From f766bd61a58e75b65559b2a3509effdea46ab5f8 Mon Sep 17 00:00:00 2001 From: Graeme Arthur Date: Mon, 15 Jan 2024 20:19:40 +0100 Subject: [PATCH] VPN Geoswitching - look and feel tweaks + Pixels (#2045) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task/Issue URL: https://app.asana.com/0/0/1206317271028889/f **Description**: Second PR for the bulk of the VPN Geoswitching feature. - Makes some tweaks to the styling (font sizes, images, colours) - adds the correct separators - fixes the click area of the country items. - preloads the ViewModel so the countries are already loaded when the sheet is shown - adds the pixels listed in the [macOS Geoswitching Pixels subtask](https://app.asana.com/0/0/1206112407389115/f). **Steps to test this PR**: 1. Make sure you’re an internal user 2. Navigate to Settings -> VPN 3. Check this screen and the VPN Location that the Location item links to match [the designs](https://www.figma.com/file/d7d8ZGkWD0W5ExhBI0tESv/O-N---VPN-Settings-Templates?type=design&node-id=1342-7329&mode=design&t=Wfh4vgagnGqTtVWo-0) 4. Check that switching countries and/or cities updates the VPN location 5. Check that everything looks and feels good. 6. Check that the appropriate pixels are being sent — ###### Internal references: [Pull Request Review Checklist](https://app.asana.com/0/1202500774821704/1203764234894239/f) [Software Engineering Expectations](https://app.asana.com/0/59792373528535/199064865822552) [Technical Design Template](https://app.asana.com/0/59792373528535/184709971311943) [Pull Request Documentation](https://app.asana.com/0/1202500774821704/1204012835277482/f) --- .../Location-16-Solid.imageset/Contents.json | 15 + .../Location-16-Solid.pdf | Bin 0 -> 2495 bytes .../NetworkProtectionPixelEvent.swift | 14 + .../EventMapping+NetworkProtectionError.swift | 24 +- .../VPNLocationPreferenceItem.swift | 17 +- .../VPNLocationPreferenceItemModel.swift | 5 +- .../VPNLocation/VPNLocationView.swift | 263 ++++++++++-------- .../VPNLocation/VPNLocationViewModel.swift | 66 +++-- DuckDuckGo/Statistics/PixelEvent.swift | 12 + DuckDuckGo/Statistics/PixelParameters.swift | 4 + 10 files changed, 279 insertions(+), 141 deletions(-) create mode 100644 DuckDuckGo/Assets.xcassets/Images/Location-16-Solid.imageset/Contents.json create mode 100644 DuckDuckGo/Assets.xcassets/Images/Location-16-Solid.imageset/Location-16-Solid.pdf diff --git a/DuckDuckGo/Assets.xcassets/Images/Location-16-Solid.imageset/Contents.json b/DuckDuckGo/Assets.xcassets/Images/Location-16-Solid.imageset/Contents.json new file mode 100644 index 0000000000..1a37f7eb94 --- /dev/null +++ b/DuckDuckGo/Assets.xcassets/Images/Location-16-Solid.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "Location-16-Solid.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/DuckDuckGo/Assets.xcassets/Images/Location-16-Solid.imageset/Location-16-Solid.pdf b/DuckDuckGo/Assets.xcassets/Images/Location-16-Solid.imageset/Location-16-Solid.pdf new file mode 100644 index 0000000000000000000000000000000000000000..ddcabe468b93c60f767774dd5febb091afc88f18 GIT binary patch literal 2495 zcmc&#OK;Rj5We@X=u0FxVE4Oi2}L4Lt#%P%W)Db+gX1*Gurqenc2wVzqhj-UsLfHYsll~Q|+wP7l zC@u!*vZ){Lw+!NYe->?e!zKJRMb+LHO{mJRJLkv8gRAAkbr*wP)XiqV7NM;lnliL8 z#Q9@)x$KHA^lvi$lm!=w6ZG)oRbX%ee* zrv={-YMoWi5}K(@d&LP8ne))JiS%BA21qIsMre|Zu-YRgSqrPAj+#zuXP{{kFSHRo zmJ6jNVMZF~qO~vEF-|1ChppfaoiiPGugLa8^Rd z5|l|JG}&cj&_`$+>)6wEskde%RA%uRo9OgHn3Q>606Fz(JK6Ofmf5 ztncrN=UtEK&c<8Pu#u*l{GYqH18@juV}H0EriLwsX6+gJnTel*+Ozs_Z<52lO2$p0 zF#xuf>YJ*nyOw{XU4v)1(p7OA2H5 z11v&YJQ392fWD|dS2swN(+w7U%=ol^fUVrizujeAVt#MAq8qkT#QEl@MOUor+g;no zCVXaCO_KGUL;tz6R$C5ycqjlpyc(h0U^qU396DzQlE&;q4(#k|`F+D|Un`@5zhkI` zdzx2VxT99r6svV;7}5L38OVLn)|>E>zgXS8>?uwcbq#)rzF4nxx%w7}3zVttil&R~ ND482}baeji;ya&5Aff;O literal 0 HcmV?d00001 diff --git a/DuckDuckGo/NetworkProtection/AppAndExtensionAndAgentTargets/NetworkProtectionPixelEvent.swift b/DuckDuckGo/NetworkProtection/AppAndExtensionAndAgentTargets/NetworkProtectionPixelEvent.swift index 1840f30df1..3f0b9b0633 100644 --- a/DuckDuckGo/NetworkProtection/AppAndExtensionAndAgentTargets/NetworkProtectionPixelEvent.swift +++ b/DuckDuckGo/NetworkProtection/AppAndExtensionAndAgentTargets/NetworkProtectionPixelEvent.swift @@ -54,6 +54,8 @@ enum NetworkProtectionPixelEvent: PixelKitEvent { case networkProtectionClientInvalidInviteCode case networkProtectionClientFailedToRedeemInviteCode(error: Error?) case networkProtectionClientFailedToParseRedeemResponse(error: Error) + case networkProtectionClientFailedToFetchLocations(error: Error?) + case networkProtectionClientFailedToParseLocationsResponse(error: Error?) case networkProtectionClientInvalidAuthToken case networkProtectionServerListStoreFailedToEncodeServerList @@ -158,6 +160,12 @@ enum NetworkProtectionPixelEvent: PixelKitEvent { case .networkProtectionClientFailedToParseRedeemResponse: return "m_mac_netp_backend_api_error_parsing_redeem_response_failed" + case .networkProtectionClientFailedToFetchLocations: + return "m_mac_netp_backend_api_error_failed_to_fetch_location_list" + + case .networkProtectionClientFailedToParseLocationsResponse: + return "m_mac_netp_backend_api_error_parsing_location_list_response_failed" + case .networkProtectionClientInvalidAuthToken: return "m_mac_netp_backend_api_error_invalid_auth_token" @@ -251,6 +259,12 @@ enum NetworkProtectionPixelEvent: PixelKitEvent { case .networkProtectionClientFailedToRedeemInviteCode(error: let error): return error?.pixelParameters + case .networkProtectionClientFailedToFetchLocations(error: let error): + return error?.pixelParameters + + case .networkProtectionClientFailedToParseLocationsResponse(error: let error): + return error?.pixelParameters + case .networkProtectionUnhandledError(let function, let line, let error): var parameters = error.pixelParameters parameters[PixelKit.Parameters.function] = function diff --git a/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/EventMapping+NetworkProtectionError.swift b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/EventMapping+NetworkProtectionError.swift index e9201fa310..b0b7edd33b 100644 --- a/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/EventMapping+NetworkProtectionError.swift +++ b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/EventMapping+NetworkProtectionError.swift @@ -26,28 +26,45 @@ import PixelKit extension EventMapping where Event == NetworkProtectionError { static var networkProtectionAppDebugEvents: EventMapping = .init { event, _, _, _ in let domainEvent: NetworkProtectionPixelEvent + let frequency: PixelKit.Frequency switch event { case .failedToEncodeRedeemRequest: domainEvent = .networkProtectionClientFailedToEncodeRedeemRequest + frequency = .standard case .invalidInviteCode: domainEvent = .networkProtectionClientInvalidInviteCode + frequency = .standard case .failedToRedeemInviteCode(let error): domainEvent = .networkProtectionClientFailedToRedeemInviteCode(error: error) + frequency = .standard case .failedToParseRedeemResponse(let error): domainEvent = .networkProtectionClientFailedToParseRedeemResponse(error: error) + frequency = .standard case .invalidAuthToken: domainEvent = .networkProtectionClientInvalidAuthToken + frequency = .standard case .failedToCastKeychainValueToData(field: let field): domainEvent = .networkProtectionKeychainErrorFailedToCastKeychainValueToData(field: field) + frequency = .standard case .keychainReadError(field: let field, status: let status): domainEvent = .networkProtectionKeychainReadError(field: field, status: status) + frequency = .standard case .keychainWriteError(field: let field, status: let status): domainEvent = .networkProtectionKeychainWriteError(field: field, status: status) + frequency = .standard case .keychainDeleteError(status: let status): domainEvent = .networkProtectionKeychainDeleteError(status: status) + frequency = .standard case .noAuthTokenFound: domainEvent = .networkProtectionNoAuthTokenFoundError + frequency = .standard + case .failedToFetchLocationList(let error): + domainEvent = .networkProtectionClientFailedToFetchLocations(error: error) + frequency = .dailyAndContinuous + case .failedToParseLocationListResponse(let error): + domainEvent = .networkProtectionClientFailedToParseLocationsResponse(error: error) + frequency = .dailyAndContinuous case .noServerRegistrationInfo, .couldNotSelectClosestServer, .couldNotGetPeerPublicKey, @@ -70,14 +87,13 @@ extension EventMapping where Event == NetworkProtectionError { .wireGuardDnsResolution, .wireGuardSetNetworkSettings, .startWireGuardBackend, - .failedToRetrieveAuthToken, - .failedToFetchLocationList, - .failedToParseLocationListResponse: + .failedToRetrieveAuthToken: domainEvent = .networkProtectionUnhandledError(function: #function, line: #line, error: event) + frequency = .standard return case .unhandledError(function: let function, line: let line, error: let error): domainEvent = .networkProtectionUnhandledError(function: function, line: line, error: error) - + frequency = .standard return } diff --git a/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/VPNLocation/VPNLocationPreferenceItem.swift b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/VPNLocation/VPNLocationPreferenceItem.swift index 0b6cfe5069..4279088dce 100644 --- a/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/VPNLocation/VPNLocationPreferenceItem.swift +++ b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/VPNLocation/VPNLocationPreferenceItem.swift @@ -22,7 +22,7 @@ import Foundation import SwiftUI struct VPNLocationPreferenceItem: View { - let model: VPNLocationPreferenceItemModel + @ObservedObject var model: VPNLocationPreferenceItemModel @State private var isShowingLocationSheet: Bool = false var body: some View { @@ -30,14 +30,13 @@ struct VPNLocationPreferenceItem: View { HStack(spacing: 10) { switch model.icon { case .defaultIcon: - Image(systemName: "location.fill") - .resizable() - .frame(width: 18, height: 18) + Image("Location-16-Solid") + .foregroundColor(Color("BlackWhite100").opacity(0.9)) case .emoji(let string): - Text(string).font(.system(size: 20)) + Text(string).font(.system(size: 16)) } - VStack(alignment: .leading) { + VStack(alignment: .leading, spacing: 2) { Text(model.title) .font(.system(size: 13)) .foregroundColor(.primary) @@ -52,13 +51,15 @@ struct VPNLocationPreferenceItem: View { isShowingLocationSheet = true } .sheet(isPresented: $isShowingLocationSheet) { - VPNLocationView(isPresented: $isShowingLocationSheet) + VPNLocationView(model: model.locationsViewModel, isPresented: $isShowingLocationSheet) } } } .frame(idealWidth: .infinity, maxWidth: .infinity, alignment: .topLeading) - .padding(10) + .frame(height: 52) + .padding(.horizontal, 10) .background(Color("BlackWhite1")) + .animation(.default) .roundedBorder() } diff --git a/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/VPNLocation/VPNLocationPreferenceItemModel.swift b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/VPNLocation/VPNLocationPreferenceItemModel.swift index dc0ea89271..dc8bda6885 100644 --- a/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/VPNLocation/VPNLocationPreferenceItemModel.swift +++ b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/VPNLocation/VPNLocationPreferenceItemModel.swift @@ -21,7 +21,7 @@ import Foundation import NetworkProtection -struct VPNLocationPreferenceItemModel { +final class VPNLocationPreferenceItemModel: ObservableObject { enum LocationIcon { case defaultIcon case emoji(String) @@ -31,6 +31,9 @@ struct VPNLocationPreferenceItemModel { let subtitle: String? let icon: LocationIcon + // This is preloaded so the user doesn't have to wait for the list to load on presentation + let locationsViewModel = VPNLocationViewModel() + init(selectedLocation: VPNSettings.SelectedLocation) { switch selectedLocation { case .nearest: diff --git a/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/VPNLocation/VPNLocationView.swift b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/VPNLocation/VPNLocationView.swift index 27cc2a8ea2..76f86ee12b 100644 --- a/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/VPNLocation/VPNLocationView.swift +++ b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/VPNLocation/VPNLocationView.swift @@ -23,106 +23,121 @@ import SwiftUI import SwiftUIExtensions struct VPNLocationView: View { - @StateObject var model = VPNLocationViewModel() + @StateObject var model: VPNLocationViewModel @Binding var isPresented: Bool var body: some View { - VStack(alignment: .leading) { - Text(UserText.vpnLocationListTitle) - .font(.system(size: 17, weight: .bold)) - .foregroundColor(.primary) - VStack(alignment: .leading, spacing: 16) { - nearest(isSelected: model.isNearestSelected) - countries() + VStack(spacing: 0) { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + Text(UserText.vpnLocationListTitle) + .font(.system(size: 17, weight: .bold)) + .foregroundColor(.primary) + VStack(alignment: .leading, spacing: 20) { + nearestSection + countriesSection + } + .animation(.default, value: model.state.isLoading) + .padding(0) + } + .padding(.horizontal, 56) + .padding(.top, 32) + .padding(.bottom, 20) } - .padding(0) - } - .padding(.horizontal, 56) - .padding(.top, 32) - .padding(.bottom, 20) - .frame(minWidth: 624, maxWidth: .infinity, minHeight: 514, maxHeight: 514, alignment: .top) - Spacer() - Group { VPNLocationViewButtons( onDone: { model.onSubmit() isPresented = false }, onCancel: { isPresented = false - }) - .navigationTitle(UserText.vpnFeedbackFormTitle) + } + ) .onAppear { Task { await model.onViewAppeared() } } } - .background(Color.secondary.opacity(0.1)) + .frame(minWidth: 624, maxWidth: .infinity, minHeight: 514, maxHeight: 514, alignment: .top) } @ViewBuilder - private func nearest(isSelected: Bool) -> some View { - PreferencePaneSection(verticalPadding: 12) { + private var nearestSection: some View { + PreferencePaneSection(verticalPadding: 0) { Text(UserText.vpnLocationRecommendedSectionTitle) .font(.system(size: 15)) .foregroundColor(.primary) - ChecklistItem( - isSelected: isSelected, - action: { - Task { - await model.onNearestItemSelection() - } - }, label: { - Image(systemName: "location.fill") - .resizable() - .frame(width: 18, height: 18) - VStack(alignment: .leading, spacing: 4) { - Text(UserText.vpnLocationNearestAvailable) - .foregroundColor(.primary) - Text(UserText.vpnLocationNearestAvailableSubtitle) - .font(.system(size: 11)) - .foregroundColor(.secondary) - } - } - ) - .frame(idealWidth: .infinity, maxWidth: .infinity) - .padding(10) - .background(Color("BlackWhite1")) - .roundedBorder() + nearestItem } } @ViewBuilder - private func countries() -> some View { - switch model.state { - case .loading: - EmptyView() - .listRowBackground(Color.clear) - case .loaded(let countryItems): - PreferencePaneSection(verticalPadding: 12) { - Text(UserText.vpnLocationCustomSectionTitle) - .font(.system(size: 15)) - .foregroundColor(.primary) - LazyVStack(alignment: .leading) { - ForEach(countryItems) { item in - CountryItem( - itemModel: item, - action: { - Task { - await model.onCountryItemSelection(id: item.id) - } - }, cityPickerAction: { selection in - Task { - await model.onCountryItemSelection(id: item.id, cityId: selection) - } - }) - .padding(10) - } + private var nearestItem: some View { + ChecklistItem( + isSelected: model.isNearestSelected, + action: { + Task { + await model.onNearestItemSelection() + } + }, label: { + Image("Location-16-Solid") + .padding(4) + .foregroundColor(Color("BlackWhite100").opacity(0.9)) + VStack(alignment: .leading, spacing: 2) { + Text(UserText.vpnLocationNearestAvailable) + .font(.system(size: 13)) + .foregroundColor(.primary) + Text(UserText.vpnLocationNearestAvailableSubtitle) + .font(.system(size: 11)) + .foregroundColor(.secondary) } - .roundedBorder() + } + ) + .roundedBorder() + } + + @ViewBuilder + private var countriesSection: some View { + PreferencePaneSection(verticalPadding: 0) { + Text(UserText.vpnLocationCustomSectionTitle) + .font(.system(size: 15)) + .foregroundColor(.primary) + switch model.state { + case .loading: + EmptyView() + case .loaded(let countryItems): + countriesList(countries: countryItems) } } } + + private func countriesList(countries: [VPNCountryItemModel]) -> some View { + VStack(spacing: 0) { + ForEach(countries) { country in + if !country.isFirstItem { + Rectangle() + .fill(Color("BlackWhite10")) + .frame(height: 1) + .padding(.init(top: 0, leading: 10, bottom: 0, trailing: 10)) + } + + CountryItem( + itemModel: country, + action: { + Task { + await model.onCountryItemSelection(id: country.id) + } + }, + cityPickerAction: { selection in + Task { + await model.onCountryItemSelection(id: country.id, cityId: selection) + } + } + ) + } + } + .roundedBorder() + } } private struct CountryItem: View { @@ -150,30 +165,51 @@ private struct CountryItem: View { action: action, label: { Text(itemModel.emoji) - VStack(alignment: .leading, spacing: 4) { - Text(itemModel.title) - .foregroundColor(.primary) - if let subtitle = itemModel.subtitle { - Text(subtitle) - .foregroundColor(.secondary) - } - } + .font(.system(size: 16)) + .padding(4) + labels if itemModel.shouldShowPicker { Spacer() - Picker("", selection: selectedCityItemBinding) { - Text(itemModel.nearestCityPickerItem.name) - .tag(itemModel.nearestCityPickerItem) - Divider() - ForEach(itemModel.cityPickerItems) { cityItem in - Text(cityItem.name) - .tag(cityItem) - } - } - .pickerStyle(.menu) - .frame(width: 90) + picker } } ) + .frame(idealWidth: .infinity, maxWidth: .infinity) + .background(Color("BlackWhite1")) + } + + @ViewBuilder + private var labels: some View { + VStack(alignment: .leading, spacing: 2) { + Text(itemModel.title) + .font(.system(size: 13)) + .foregroundColor(.primary) + .background(Color.clear) + if let subtitle = itemModel.subtitle { + Text(subtitle) + .font(.system(size: 11)) + .foregroundColor(.secondary) + .background(Color.clear) + } + } + .background(Color.clear) + } + + @ViewBuilder + private var picker: some View { + Picker("", selection: selectedCityItemBinding) { + Text(itemModel.nearestCityPickerItem.name) + .tag(itemModel.nearestCityPickerItem) + Divider() + ForEach(itemModel.cityPickerItems) { cityItem in + Text(cityItem.name) + .tag(cityItem) + } + } + .foregroundColor(.accentColor) + .pickerStyle(.menu) + .frame(width: 90) + .background(Color.clear) } } @@ -183,17 +219,20 @@ private struct ChecklistItem: View where Content: View { @ViewBuilder let label: () -> Content var body: some View { - HStack(spacing: 12) { - Image(systemName: "checkmark") - .foregroundColor(Color.accentColor) - .if(!isSelected) { - $0.hidden() - } - label() + HStack(alignment: .center, spacing: 10) { + HStack { + Image(systemName: "checkmark") + .foregroundColor(.accentColor) + .if(!isSelected) { + $0.hidden() + } + label() + } + .padding(10) } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + .frame(height: 52) .contentShape(Rectangle()) - .background(Color("BlackWhite1")) .onTapGesture { action() } @@ -205,18 +244,24 @@ private struct VPNLocationViewButtons: View { let onCancel: () -> Void var body: some View { - HStack { - Spacer() - button(text: UserText.vpnLocationCancelButtonTitle, action: onCancel) - .keyboardShortcut(.cancelAction) - .buttonStyle(DismissActionButtonStyle()) - - button(text: UserText.vpnLocationSubmitButtonTitle, action: onDone) - .keyboardShortcut(.defaultAction) - .buttonStyle(DefaultActionButtonStyle(enabled: true)) + VStack(spacing: 0) { + Rectangle() + .fill(Color("BlackWhite10")) + .frame(height: 1) + HStack { + Spacer() + button(text: UserText.vpnLocationCancelButtonTitle, action: onCancel) + .keyboardShortcut(.cancelAction) + .buttonStyle(DismissActionButtonStyle()) + + button(text: UserText.vpnLocationSubmitButtonTitle, action: onDone) + .keyboardShortcut(.defaultAction) + .buttonStyle(DefaultActionButtonStyle(enabled: true)) + } + .padding(.vertical, 16) + .padding(.horizontal, 20) + .background(Color("BlackWhite1")) } - .padding(.vertical, 16) - .padding(.horizontal, 20) } @ViewBuilder diff --git a/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/VPNLocation/VPNLocationViewModel.swift b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/VPNLocation/VPNLocationViewModel.swift index 09a3af829c..074337cb59 100644 --- a/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/VPNLocation/VPNLocationViewModel.swift +++ b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/VPNLocation/VPNLocationViewModel.swift @@ -51,21 +51,27 @@ final class VPNLocationViewModel: ObservableObject { init(locationListRepository: NetworkProtectionLocationListRepository, settings: VPNSettings) { self.locationListRepository = locationListRepository self.settings = settings - state = .loading selectedLocation = settings.selectedLocation self.isNearestSelected = selectedLocation == .nearest + state = .loading + Task { + await reloadList() + } } func onViewAppeared() async { + Pixel.fire(.networkProtectionGeoswitchingOpened) await reloadList() } func onNearestItemSelection() async { + DailyPixel.fire(pixel: .networkProtectionGeoswitchingSetNearest, frequency: .dailyAndCount, includeAppVersionParameter: true) selectedLocation = .nearest await reloadList() } func onCountryItemSelection(id: String, cityId: String? = nil) async { + DailyPixel.fire(pixel: .networkProtectionGeoswitchingSetCustom, frequency: .dailyAndCount, includeAppVersionParameter: true) let location = NetworkProtectionSelectedLocation(country: id, city: cityId) selectedLocation = .location(location) await reloadList() @@ -77,44 +83,50 @@ final class VPNLocationViewModel: ObservableObject { @MainActor private func reloadList() async { - guard let list = try? await locationListRepository.fetchLocationList() else { return } + guard let locations = try? await locationListRepository.fetchLocationList().sortedByName() else { return } + if locations.isEmpty { + DailyPixel.fire(pixel: .networkProtectionGeoswitchingNoLocations, frequency: .dailyAndCount, includeAppVersionParameter: true) + } let isNearestSelected = selectedLocation == .nearest + self.isNearestSelected = isNearestSelected + var countryItems = [VPNCountryItemModel]() - let countryItems = list.map { currentLocation in + for i in 0.. Self { + sorted(by: { lhs, rhs in + lhs.country.localizedLocationFromCountryCode < rhs.country.localizedLocationFromCountryCode + }) + } +} + +private extension String { + var localizedLocationFromCountryCode: String { + Locale.current.localizedString(forRegionCode: self) ?? "" + } +} + #endif diff --git a/DuckDuckGo/Statistics/PixelEvent.swift b/DuckDuckGo/Statistics/PixelEvent.swift index 3e5cfe1d09..8d1312b603 100644 --- a/DuckDuckGo/Statistics/PixelEvent.swift +++ b/DuckDuckGo/Statistics/PixelEvent.swift @@ -178,6 +178,10 @@ extension Pixel { case networkProtectionRemoteMessageDismissed(messageID: String) case networkProtectionRemoteMessageOpened(messageID: String) case networkProtectionEnabledOnSearch + case networkProtectionGeoswitchingOpened + case networkProtectionGeoswitchingSetNearest + case networkProtectionGeoswitchingSetCustom + case networkProtectionGeoswitchingNoLocations // Sync case syncSignupDirect @@ -566,6 +570,14 @@ extension Pixel.Event { case .dailyPixel(let pixel, isFirst: let isFirst): return pixel.name + (isFirst ? "_d" : "_c") + case .networkProtectionGeoswitchingOpened: + return "m_mac_netp_imp_geoswitching_c" + case .networkProtectionGeoswitchingSetNearest: + return "m_mac_netp_ev_geoswitching_set_nearest" + case .networkProtectionGeoswitchingSetCustom: + return "m_mac_netp_ev_geoswitching_set_custom" + case .networkProtectionGeoswitchingNoLocations: + return "m_mac_netp_ev_geoswitching_no_locations" } } } diff --git a/DuckDuckGo/Statistics/PixelParameters.swift b/DuckDuckGo/Statistics/PixelParameters.swift index 25ed00fb87..4aa86c7cca 100644 --- a/DuckDuckGo/Statistics/PixelParameters.swift +++ b/DuckDuckGo/Statistics/PixelParameters.swift @@ -144,6 +144,10 @@ extension Pixel.Event { .networkProtectionRemoteMessageDismissed, .networkProtectionRemoteMessageOpened, .networkProtectionEnabledOnSearch, + .networkProtectionGeoswitchingOpened, + .networkProtectionGeoswitchingSetNearest, + .networkProtectionGeoswitchingSetCustom, + .networkProtectionGeoswitchingNoLocations, .syncSignupDirect, .syncSignupConnect, .syncLogin,