diff --git a/PIA VPN-tvOS/Dashboard/UI/DashboardView.swift b/PIA VPN-tvOS/Dashboard/UI/DashboardView.swift index dbeac530..84e18a75 100644 --- a/PIA VPN-tvOS/Dashboard/UI/DashboardView.swift +++ b/PIA VPN-tvOS/Dashboard/UI/DashboardView.swift @@ -39,9 +39,6 @@ struct DashboardView: View { } .frame(width: viewWidth, height: viewHeight) - .background(Color.pia_background) - - } } diff --git a/PIA VPN-tvOS/Navigation/UI/TopNavigationView.swift b/PIA VPN-tvOS/Navigation/UI/TopNavigationView.swift index c102ee56..9127a6f3 100644 --- a/PIA VPN-tvOS/Navigation/UI/TopNavigationView.swift +++ b/PIA VPN-tvOS/Navigation/UI/TopNavigationView.swift @@ -45,11 +45,11 @@ struct LeadingSegmentedNavigationView: View { ForEach(viewModel.leadingSections, id: \.self) { section in button(for: section) .padding(6) + } } .background(Capsule().fill(Color.pia_surface_container_secondary).shadow(radius: 3)) } - .padding() .onChange(of: focusedSection) { _, newValue in viewModel.sectionDidUpdateFocus(to: newValue) } diff --git a/PIA VPN-tvOS/PIA_VPN_tvOSApp.swift b/PIA VPN-tvOS/PIA_VPN_tvOSApp.swift index 10d57391..6ff3c1c1 100644 --- a/PIA VPN-tvOS/PIA_VPN_tvOSApp.swift +++ b/PIA VPN-tvOS/PIA_VPN_tvOSApp.swift @@ -13,7 +13,8 @@ struct PIA_VPN_tvOSApp: App { var body: some Scene { WindowGroup { RootContainerFactory.makeRootContainerView() - .preferredColorScheme(.dark) + .background(Color.pia_background) + .preferredColorScheme(.dark) //Sets the UI to Dark Mode } } } diff --git a/PIA VPN-tvOS/RegionsSelection/CompositionRoot/RegionsSelectionFactory.swift b/PIA VPN-tvOS/RegionsSelection/CompositionRoot/RegionsSelectionFactory.swift index 340af37f..0f09e721 100644 --- a/PIA VPN-tvOS/RegionsSelection/CompositionRoot/RegionsSelectionFactory.swift +++ b/PIA VPN-tvOS/RegionsSelection/CompositionRoot/RegionsSelectionFactory.swift @@ -17,18 +17,18 @@ class RegionsSelectionFactory { } static func makeRegionsContainerViewModel() -> RegionsContainerViewModel { - return RegionsContainerViewModel(onSearchSelectedAction: .navigate(router: AppRouterFactory.makeAppRouter(), destination: RegionsDestinations.search)) + return RegionsContainerViewModel(favoritesUseCase: makeFavoriteRegionUseCase, onSearchSelectedAction: .navigate(router: AppRouterFactory.makeAppRouter(), destination: RegionsDestinations.search)) } static func makeRegionsListViewModel(with filter: RegionsListFilter) -> RegionsListViewModel { return RegionsListViewModel(filter: filter, listUseCase: makeRegionsListUseCase(), - favoriteUseCase: makeFavoriteRegionUseCase(), regionsFilterUseCase: makeRegionsFilterUseCase(), + favoriteUseCase: makeFavoriteRegionUseCase, regionsFilterUseCase: makeRegionsFilterUseCase(), regionsDisplayNameUseCase: RegionsDisplayNameUseCase(), onServerSelectedRouterAction: .goBackToRoot(router: AppRouterFactory.makeAppRouter())) } static func makeRegionsFilterUseCase() -> RegionsFilterUseCaseType { - return RegionsFilterUseCase(serversUseCase: makeRegionsListUseCase(), favoritesUseCase: makeFavoriteRegionUseCase(), searchedRegionsAvailability: makeSearchedRegionsAvailability()) + return RegionsFilterUseCase(serversUseCase: makeRegionsListUseCase(), favoritesUseCase: makeFavoriteRegionUseCase, searchedRegionsAvailability: makeSearchedRegionsAvailability()) } static func makeSearchedRegionsAvailability() -> SearchedRegionsAvailabilityType { @@ -64,8 +64,10 @@ class RegionsSelectionFactory { return RegionsListUseCase(serverProvider: DashboardFactory.makeServerProvider(), clientPreferences: Client.preferences) } - static func makeFavoriteRegionUseCase() -> FavoriteRegionUseCaseType { + /// FavoritesUseCase is the same instance across the whole app + /// in order to be able to publish updates to the favorites collection + static var makeFavoriteRegionUseCase: FavoriteRegionUseCaseType = { return FavoriteRegionUseCase(keychain: KeychainFactory.makeKeychain()) - } + }() } diff --git a/PIA VPN-tvOS/RegionsSelection/Presentation/RegionsContainerViewModel.swift b/PIA VPN-tvOS/RegionsSelection/Presentation/RegionsContainerViewModel.swift index 43f3bdad..58e4c001 100644 --- a/PIA VPN-tvOS/RegionsSelection/Presentation/RegionsContainerViewModel.swift +++ b/PIA VPN-tvOS/RegionsSelection/Presentation/RegionsContainerViewModel.swift @@ -8,6 +8,7 @@ import Foundation import SwiftUI +import Combine class RegionsContainerViewModel: ObservableObject { enum RegionsNavigationItems: CaseIterable, Identifiable { @@ -31,18 +32,22 @@ class RegionsContainerViewModel: ObservableObject { } } - @Published var sideMenuItems: [RegionsNavigationItems] = RegionsNavigationItems.allCases + @Published var sideMenuItems: [RegionsNavigationItems] = [.all, .search] @Published var selectedSection: RegionsNavigationItems = .all var searchButtonTitle: String { - L10n.Localizable.Region.Search.placeholder + L10n.Localizable.Regions.Search.Button.title } + private let favoritesUseCase: FavoriteRegionUseCaseType private let onSearchSelectedAction: AppRouter.Actions + private var cancellables = Set() - init(onSearchSelectedAction: AppRouter.Actions) { + init(favoritesUseCase: FavoriteRegionUseCaseType, onSearchSelectedAction: AppRouter.Actions) { + self.favoritesUseCase = favoritesUseCase self.onSearchSelectedAction = onSearchSelectedAction + subscribeToFavoritesUpdates() } func navigate(to route: RegionsNavigationItems) { @@ -54,5 +59,20 @@ class RegionsContainerViewModel: ObservableObject { } - +} + +// MARK: - Private + +extension RegionsContainerViewModel { + private func subscribeToFavoritesUpdates() { + favoritesUseCase.favoriteIdentifiersPublisher + .receive(on: RunLoop.main) + .sink { newFavorites in + if newFavorites.isEmpty { + self.sideMenuItems = [.all, .search] + } else { + self.sideMenuItems = [.favorites, .all, .search] + } + }.store(in: &cancellables) + } } diff --git a/PIA VPN-tvOS/RegionsSelection/Presentation/RegionsListViewModel.swift b/PIA VPN-tvOS/RegionsSelection/Presentation/RegionsListViewModel.swift index be236687..3dc1c89c 100644 --- a/PIA VPN-tvOS/RegionsSelection/Presentation/RegionsListViewModel.swift +++ b/PIA VPN-tvOS/RegionsSelection/Presentation/RegionsListViewModel.swift @@ -15,6 +15,8 @@ class RegionsListViewModel: ObservableObject { private let listUseCase: RegionsListUseCaseType private let favoriteUseCase: FavoriteRegionUseCaseType private let regionsFilterUseCase: RegionsFilterUseCaseType + private let regionsDisplayNameUseCase: RegionsDisplayNameUseCaseType + private let onServerSelectedRouterAction: AppRouter.Actions internal var filter: RegionsListFilter @@ -27,21 +29,31 @@ class RegionsListViewModel: ObservableObject { private var cancellables = Set() internal var favoriteToggleError: Error? = nil + private var allServers: [ServerType] = [] + init(filter: RegionsListFilter, listUseCase: RegionsListUseCaseType, favoriteUseCase: FavoriteRegionUseCaseType, regionsFilterUseCase: RegionsFilterUseCaseType, + regionsDisplayNameUseCase: RegionsDisplayNameUseCaseType, onServerSelectedRouterAction: AppRouter.Actions) { self.filter = filter self.listUseCase = listUseCase self.favoriteUseCase = favoriteUseCase self.regionsFilterUseCase = regionsFilterUseCase + self.regionsDisplayNameUseCase = regionsDisplayNameUseCase self.onServerSelectedRouterAction = onServerSelectedRouterAction - + + allServers = listUseCase.getCurrentServers() refreshRegionsList() subscribeToSearchUpdates() } + func getDisplayName(for server: ServerType) -> (title: String, subtitle: String) { + regionsDisplayNameUseCase.getDisplayName(for: server, amongst: allServers) + } + + func didSelectRegionServer(_ server: ServerType) { listUseCase.select(server: server) onServerSelectedRouterAction() diff --git a/PIA VPN-tvOS/RegionsSelection/UI/RegionsContainerView.swift b/PIA VPN-tvOS/RegionsSelection/UI/RegionsContainerView.swift index c969ea14..162532e8 100644 --- a/PIA VPN-tvOS/RegionsSelection/UI/RegionsContainerView.swift +++ b/PIA VPN-tvOS/RegionsSelection/UI/RegionsContainerView.swift @@ -23,54 +23,29 @@ struct RegionsContainerView: View { } label: { HStack { Text(menuItem.text) - .font(.headline) - .padding(.leading, 26) - .padding(.vertical, 12) + .font(.system(size: 38, weight: .medium)) + .foregroundColor(.pia_on_surface) + .padding(20) Spacer() } + .background(focusedFilter == menuItem ? Color.pia_surface_container_primary : Color.clear) + .cornerRadius(12) } .cornerRadius(4) - .buttonStyle(.plain) + .buttonStyle(.borderless) .focused($focusedFilter, equals: menuItem) } - } - } - - - var navigateToSearchScreenButton: some View { - Button { - viewModel.navigate(to: .search) - } label: { - HStack(alignment: .center) { - Spacer() - VStack { - Spacer() - Text(viewModel.searchButtonTitle) - .padding(.horizontal, 60) - .padding(.vertical, 18) - .background(Color.pia_primary) - .cornerRadius(8) - Spacer() - }.frame(height: 150) - - Spacer() - - } - .frame(height: 150) - .padding() - - } - .buttonStyle(.bordered) - .buttonBorderShape(ButtonBorderShape.roundedRectangle) + }.listStyle(.plain) } - + var body: some View { VStack(alignment: .trailing) { HStack(alignment: .top) { regionsFilterButtons + .frame(width: viewWidth * 0.23) VStack { switch viewModel.selectedSection { case .favorites: @@ -79,21 +54,24 @@ struct RegionsContainerView: View { RegionsSelectionFactory.makeAllRegionsListView() case .search: VStack { - navigateToSearchScreenButton + SearchControllerButton( + buttonAction: { + viewModel.navigate(to: .search) + }, + buttonTitle: viewModel.searchButtonTitle + ) RegionsSelectionFactory.makePreviouslySearchedRegionsListView() .padding(.top, 40) } } - }.frame(width: viewWidth * 0.7) + } } .onChange(of: focusedFilter) { _, newValue in guard let focusedMenuItem = newValue else { return } viewModel.selectedSection = focusedMenuItem } } - .frame(width: viewWidth) - .background(Color.pia_background) } } diff --git a/PIA VPN-tvOS/RegionsSelection/UI/RegionsListItemButton.swift b/PIA VPN-tvOS/RegionsSelection/UI/RegionsListItemButton.swift index 06104fa2..605ea1a8 100644 --- a/PIA VPN-tvOS/RegionsSelection/UI/RegionsListItemButton.swift +++ b/PIA VPN-tvOS/RegionsSelection/UI/RegionsListItemButton.swift @@ -8,9 +8,9 @@ import SwiftUI -struct RegionsListItemButton: View { - - typealias ButtonAction = () -> Void +typealias ButtonAction = () -> Void + +struct RegionsListItemButton: View { let onRegionItemSelected: ButtonAction @FocusState var buttonFocused: Bool @@ -124,20 +124,23 @@ extension RegionsListItemButton { var detailsView: some View { - VStack(alignment: .leading) { + VStack(alignment: .leading, spacing: 8) { Text(title) .font(.system(size: 31, weight: .medium)) .multilineTextAlignment(.leading) - .foregroundColor(buttonFocused ? Color.black : Color.pia_on_surface_container_secondary) + .foregroundColor(buttonFocused ? Color.pia_on_primary : Color.pia_on_surface_container_primary) .minimumScaleFactor(0.6) .fixedSize(horizontal: false, vertical: true) - .lineLimit(2, reservesSpace: true) + .lineLimit(1) if let subtitle = subtitle { Text(subtitle) - .font(.caption2) + .font(.system(size: 23, weight: .medium)) .italic() + .foregroundColor(buttonFocused ? Color.pia_on_primary : Color.pia_on_surface_container_primary) .multilineTextAlignment(.leading) + .minimumScaleFactor(0.7) + .lineLimit(2) } } } diff --git a/PIA VPN-tvOS/RegionsSelection/UI/RegionsListView.swift b/PIA VPN-tvOS/RegionsSelection/UI/RegionsListView.swift index 67736c15..ae057b3f 100644 --- a/PIA VPN-tvOS/RegionsSelection/UI/RegionsListView.swift +++ b/PIA VPN-tvOS/RegionsSelection/UI/RegionsListView.swift @@ -40,14 +40,15 @@ struct RegionsListView: View { viewModel.didSelectRegionServer(server) }, iconName: "flag-\(server.country.lowercased())", - title: server.name, + // TODO: substitute here + title: viewModel.getDisplayName(for: server).title, + subtitle: viewModel.getDisplayName(for: server).subtitle, favoriteIconName: viewModel.favoriteIconName(for: server), contextMenuItem: contextMenuItem(for: server) ) } } - .padding(.top, 40) } }.onAppear { diff --git a/PIA VPN-tvOS/RegionsSelection/UI/SearchControllerButton.swift b/PIA VPN-tvOS/RegionsSelection/UI/SearchControllerButton.swift new file mode 100644 index 00000000..27b11ee6 --- /dev/null +++ b/PIA VPN-tvOS/RegionsSelection/UI/SearchControllerButton.swift @@ -0,0 +1,49 @@ +// +// SearchControllerButton.swift +// PIA VPN-tvOS +// +// Created by Laura S on 2/8/24. +// Copyright © 2024 Private Internet Access Inc. All rights reserved. +// + +import SwiftUI + +struct SearchControllerButton: View { + let buttonAction: ButtonAction + let buttonTitle: String + + @FocusState var focusedSearchButton: Bool + + var body: some View { + Button { + buttonAction() + } label: { + HStack(alignment: .center) { + Spacer() + VStack { + Spacer() + Text(buttonTitle) + .font(.system(size: 38, weight: .medium)) + .padding(.horizontal, 90) + .padding(.vertical, 8) + .foregroundColor(focusedSearchButton ? .pia_on_primary : .pia_on_surface) + .background(focusedSearchButton ? Color.pia_primary : Color.pia_surface_container_secondary) + .cornerRadius(8) + Spacer() + } + .frame(height: 66) + + Spacer() + + } + .frame(height: 150) + .overlay( + RoundedRectangle(cornerRadius: 20).stroke(Color.pia_outline, lineWidth: 2) + ) + + } + .focused($focusedSearchButton) + .buttonStyle(.borderless) + .buttonBorderShape(ButtonBorderShape.roundedRectangle) + } +} diff --git a/PIA VPN-tvOS/RegionsSelection/UseCases/FavoriteRegionUseCase.swift b/PIA VPN-tvOS/RegionsSelection/UseCases/FavoriteRegionUseCase.swift index 82c0e6ef..e0227119 100644 --- a/PIA VPN-tvOS/RegionsSelection/UseCases/FavoriteRegionUseCase.swift +++ b/PIA VPN-tvOS/RegionsSelection/UseCases/FavoriteRegionUseCase.swift @@ -7,9 +7,11 @@ // import Foundation +import Combine protocol FavoriteRegionUseCaseType { var favoriteIdentifiers: [String] { get } + var favoriteIdentifiersPublisher: Published<[String]>.Publisher { get } @discardableResult func addToFavorites(_ id: String) throws -> [String] @discardableResult @@ -18,12 +20,15 @@ protocol FavoriteRegionUseCaseType { class FavoriteRegionUseCase: FavoriteRegionUseCaseType { + private let keychain: KeychainType init(keychain: KeychainType) { self.keychain = keychain + self.favorites = favoriteIdentifiers } + var favoriteIdentifiers: [String] { if let favorites = try? keychain.getFavorites() { return favorites @@ -32,11 +37,18 @@ class FavoriteRegionUseCase: FavoriteRegionUseCaseType { return [] } + @Published private var favorites: [String] = [] + + var favoriteIdentifiersPublisher: Published<[String]>.Publisher { + $favorites + } + @discardableResult func addToFavorites(_ id: String) throws -> [String] { var newFavorites = favoriteIdentifiers newFavorites.append(id) try keychain.set(favorites: newFavorites) + favorites = newFavorites return newFavorites } @@ -44,6 +56,7 @@ class FavoriteRegionUseCase: FavoriteRegionUseCaseType { func removeFromFavorites(_ id: String) throws -> [String] { var newFavorites = favoriteIdentifiers.filter { id != $0 } try keychain.set(favorites: newFavorites) + favorites = newFavorites return newFavorites } diff --git a/PIA VPN-tvOS/RegionsSelection/UseCases/RegionsDisplayNameUseCase.swift b/PIA VPN-tvOS/RegionsSelection/UseCases/RegionsDisplayNameUseCase.swift new file mode 100644 index 00000000..1096ba61 --- /dev/null +++ b/PIA VPN-tvOS/RegionsSelection/UseCases/RegionsDisplayNameUseCase.swift @@ -0,0 +1,55 @@ +// +// RegionsDisplayNameUseCase.swift +// PIA VPN-tvOS +// +// Created by Laura S on 2/8/24. +// Copyright © 2024 Private Internet Access Inc. All rights reserved. +// + +import Foundation + +protocol RegionsDisplayNameUseCaseType { + func getDisplayName(for server: ServerType, amongst servers: [ServerType]) -> (title: String, subtitle: String) +} + + +class RegionsDisplayNameUseCase: RegionsDisplayNameUseCaseType { + + func getDisplayName(for server: ServerType, amongst servers: [ServerType]) -> (title: String, subtitle: String) { + + if isTheDefaultServer(server, amongst: servers) { + // TODO: Localize "Default" + return (title: server.name, subtitle: L10n.Localizable.Regions.ListItem.Default.title) + } else { + return (title: server.country, subtitle: getDisplaySubtitleForNonDefault(server: server)) + } + } + +} + +// MARK: - Private + +extension RegionsDisplayNameUseCase { + + private func isTheDefaultServer(_ server: ServerType, amongst servers: [ServerType]) -> Bool { + + let serversInSameCountry = servers.filter { + $0.country == server.country + } + + return serversInSameCountry.count == 1 + } + + private func getDisplaySubtitleForNonDefault(server: ServerType) -> String { + var nameWords = server.name.split(separator: " ") + if let firstWord = nameWords.first, + firstWord == server.country { + nameWords.removeFirst() + return nameWords.joined(separator: " ").capitalizedSentence + } else { + return server.name + } + + } + +} diff --git a/PIA VPN-tvOS/RootContainer/UserActivatedContainer/UI/UserActivatedContainerView.swift b/PIA VPN-tvOS/RootContainer/UserActivatedContainer/UI/UserActivatedContainerView.swift index b462500f..40618dc6 100644 --- a/PIA VPN-tvOS/RootContainer/UserActivatedContainer/UI/UserActivatedContainerView.swift +++ b/PIA VPN-tvOS/RootContainer/UserActivatedContainer/UI/UserActivatedContainerView.swift @@ -20,6 +20,7 @@ struct UserActivatedContainerView: View { switch destination { case .serversList: RegionsSelectionFactory.makeRegionsContainerView() + .padding(.top, Spacing.screenTopPadding) // TODO: Check .toolbar() to add the navigation bar because // these APIs will be deprecated .navigationBarItems(leading: TopNavigationFactory.makeLeadingSegmentedNavigationView()) diff --git a/PIA VPN-tvOS/Shared/UI/Colors.xcassets/pia_surface.colorset/Contents.json b/PIA VPN-tvOS/Shared/UI/Colors.xcassets/pia_surface.colorset/Contents.json new file mode 100644 index 00000000..354842cd --- /dev/null +++ b/PIA VPN-tvOS/Shared/UI/Colors.xcassets/pia_surface.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x42", + "green" : "0x36", + "red" : "0x32" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x42", + "green" : "0x36", + "red" : "0x32" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/PIA VPN-tvOS/Shared/UI/Colors.xcassets/pia_surface_container_primary.colorset/Contents.json b/PIA VPN-tvOS/Shared/UI/Colors.xcassets/pia_surface_container_primary.colorset/Contents.json new file mode 100644 index 00000000..6b8bd2d3 --- /dev/null +++ b/PIA VPN-tvOS/Shared/UI/Colors.xcassets/pia_surface_container_primary.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x57", + "green" : "0x45", + "red" : "0x45" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x57", + "green" : "0x45", + "red" : "0x45" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/PIA VPN-tvOS/Shared/UI/PIAColors+SwitUI.swift b/PIA VPN-tvOS/Shared/UI/PIAColors+SwitUI.swift index c08d623b..82b4bb07 100644 --- a/PIA VPN-tvOS/Shared/UI/PIAColors+SwitUI.swift +++ b/PIA VPN-tvOS/Shared/UI/PIAColors+SwitUI.swift @@ -18,6 +18,8 @@ extension Color { static var pia_on_primary = Color("pia_on_primary") /// Other Greys + static var pia_surface = Color("pia_surface") + static var pia_surface_container_primary = Color("pia_surface_container_primary") static var pia_surface_container_secondary = Color("pia_surface_container_secondary") static var pia_on_surface = Color("pia_on_surface") static var pia_on_surface_container_secondary = Color("pia_on_surface_container_secondary") diff --git a/PIA VPN-tvOS/Shared/UI/PIAStyles.swift b/PIA VPN-tvOS/Shared/UI/PIAStyles.swift new file mode 100644 index 00000000..9c8d3a30 --- /dev/null +++ b/PIA VPN-tvOS/Shared/UI/PIAStyles.swift @@ -0,0 +1,13 @@ +// +// PIAStyles.swift +// PIA VPN-tvOS +// +// Created by Laura S on 2/8/24. +// Copyright © 2024 Private Internet Access Inc. All rights reserved. +// + +import Foundation + +struct Spacing { + static let screenTopPadding: CGFloat = 100 +} diff --git a/PIA VPN-tvOS/Shared/Utils/PIA+String.swift b/PIA VPN-tvOS/Shared/Utils/PIA+String.swift index 5380ae5b..099adce9 100644 --- a/PIA VPN-tvOS/Shared/Utils/PIA+String.swift +++ b/PIA VPN-tvOS/Shared/Utils/PIA+String.swift @@ -4,3 +4,14 @@ import Foundation extension String { static let kOnboardingVpnProfileInstalled = "kOnboardingVpnProfileInstalled" } + +extension String { + var capitalizedSentence: String { + // 1 + let firstLetter = self.prefix(1).capitalized + // 2 + let remainingLetters = self.dropFirst().lowercased() + // 3 + return firstLetter + remainingLetters + } +} diff --git a/PIA VPN-tvOSTests/RegionsList/FavoriteRegionsUseCaseTests.swift b/PIA VPN-tvOSTests/RegionsList/FavoriteRegionsUseCaseTests.swift index 57662c70..fbc627bd 100644 --- a/PIA VPN-tvOSTests/RegionsList/FavoriteRegionsUseCaseTests.swift +++ b/PIA VPN-tvOSTests/RegionsList/FavoriteRegionsUseCaseTests.swift @@ -43,7 +43,7 @@ class FavoriteRegionsUseCaseTests: XCTestCase { // WHEN adding a new item to the favorites list let newFavorites = try sut.addToFavorites("server-id-two") - // THEN the new item is added to the favorites list + XCTAssertEqual(newFavorites.count, 2) XCTAssertEqual(["server-id-one", "server-id-two"], newFavorites) diff --git a/PIA VPN-tvOSTests/RegionsList/Mocks/FavoriteRegionUseCaseMock.swift b/PIA VPN-tvOSTests/RegionsList/Mocks/FavoriteRegionUseCaseMock.swift index 2082b9d5..a1ddeba8 100644 --- a/PIA VPN-tvOSTests/RegionsList/Mocks/FavoriteRegionUseCaseMock.swift +++ b/PIA VPN-tvOSTests/RegionsList/Mocks/FavoriteRegionUseCaseMock.swift @@ -7,13 +7,17 @@ // import Foundation - +import Combine @testable import PIA_VPN_tvOS class FavoriteRegionUseCaseMock: FavoriteRegionUseCaseType { var favoriteIdentifiers: [String] = [] + @Published private var favorites: [String] = [] + var favoriteIdentifiersPublisher: Published<[String]>.Publisher { + $favorites + } var addToFavoritesCalled = false var addToFavoritesCalledAttempt = 0 diff --git a/PIA VPN-tvOSTests/RegionsList/Mocks/RegionsDisplayNameUseCaseMock.swift b/PIA VPN-tvOSTests/RegionsList/Mocks/RegionsDisplayNameUseCaseMock.swift new file mode 100644 index 00000000..97d1f41f --- /dev/null +++ b/PIA VPN-tvOSTests/RegionsList/Mocks/RegionsDisplayNameUseCaseMock.swift @@ -0,0 +1,25 @@ +// +// RegionsDisplayNameUseCaseMock.swift +// PIA VPN-tvOSTests +// +// Created by Laura S on 2/9/24. +// Copyright © 2024 Private Internet Access Inc. All rights reserved. +// + +import Foundation + +@testable import PIA_VPN_tvOS + +class RegionsDisplayNameUseCaseMock: RegionsDisplayNameUseCaseType { + + var getDisplayNameResult: (title: String, subtitle: String) = (title: "", subtitle: "") + var getDisplayNameCalled = false + var getDisplayNameCalledWithParamerters: (server: ServerType, servers: [ServerType])! + + func getDisplayName(for server: ServerType, amongst servers: [ServerType]) -> (title: String, subtitle: String) { + getDisplayNameCalled = true + getDisplayNameCalledWithParamerters = (server: server, servers: servers) + return getDisplayNameResult + + } +} diff --git a/PIA VPN-tvOSTests/RegionsList/RegionsDisplayNameUseCaseTests.swift b/PIA VPN-tvOSTests/RegionsList/RegionsDisplayNameUseCaseTests.swift new file mode 100644 index 00000000..63432df0 --- /dev/null +++ b/PIA VPN-tvOSTests/RegionsList/RegionsDisplayNameUseCaseTests.swift @@ -0,0 +1,84 @@ +// +// File.swift +// PIA VPN-tvOSTests +// +// Created by Laura S on 2/9/24. +// Copyright © 2024 Private Internet Access Inc. All rights reserved. +// + +import Foundation +import XCTest +@testable import PIA_VPN_tvOS + +class RegionsDisplayNameUseCaseTests: XCTestCase { + class Fixture { + static let barcelona = ServerMock(name: "Barcelona-1", identifier: "es-server-barcelona", regionIdentifier: "es-region", country: "ES", geo: false, pingTime: 25) + static let madrid = ServerMock(name: "Madrid", identifier: "es-server-madrid", regionIdentifier: "es-region2", country: "ES", geo: false, pingTime: 12) + static let toronto = ServerMock(name: "CA Toronto", identifier: "ca-server", regionIdentifier: "canada", country: "CA", geo: false, pingTime: 30) + static let montreal = ServerMock(name: "CA Montreal", identifier: "ca-server2", regionIdentifier: "canada2", country: "CA", geo: false, pingTime: 42) + static let france = ServerMock(name: "France", identifier: "fr-server", regionIdentifier: "france-region", country: "FR", geo: false, pingTime: 18) + + var allServers: [ServerMock] = [ + toronto, + montreal, + barcelona, + madrid, + france + ] + + } + + var fixture: Fixture! + var sut: RegionsDisplayNameUseCase! + + func instantiateSut() { + sut = RegionsDisplayNameUseCase() + } + + override func setUp() { + fixture = Fixture() + } + + override func tearDown() { + fixture = nil + sut = nil + } + + + func test_displayNameForDefaultServer() { + // GIVEN that we have 5 servers (2 in ES, 2 in CA and 1 in FR) + instantiateSut() + + // WHEN calculating the display name for FR (only one server) + let displayName = sut.getDisplayName(for: Fixture.france, amongst: fixture.allServers) + // THEN the title is the server name + XCTAssertEqual(displayName.title, Fixture.france.name) + // AND the subtitle is 'Default' + XCTAssertEqual(displayName.subtitle, "Default") + } + + func test_displayNameForNonDefaultServer_containingCountryCodeInName() { + // GIVEN that we have 5 servers (2 in ES, 2 in CA and 1 in FR) + instantiateSut() + + // WHEN calculating the display name for 'CA Toronto' + let displayName = sut.getDisplayName(for: Fixture.toronto, amongst: fixture.allServers) + // THEN the title is the server country code + XCTAssertEqual(displayName.title, "CA") + // AND the subtitle is the server name without the country code at the beginning + XCTAssertEqual(displayName.subtitle, "Toronto") + } + + func test_displayNameForNonDefaultServer_notContainingCountryCodeInName() { + // GIVEN that we have 5 servers (2 in ES, 2 in CA and 1 in FR) + instantiateSut() + + // WHEN calculating the display name for 'Madrid' + let displayName = sut.getDisplayName(for: Fixture.madrid, amongst: fixture.allServers) + // THEN the title is the server country code + XCTAssertEqual(displayName.title, "ES") + // AND the subtitle is the server name + XCTAssertEqual(displayName.subtitle, "Madrid") + } + +} diff --git a/PIA VPN-tvOSTests/RegionsList/RegionsListViewModelTests.swift b/PIA VPN-tvOSTests/RegionsList/RegionsListViewModelTests.swift index 62645d0a..a3fcc5b1 100644 --- a/PIA VPN-tvOSTests/RegionsList/RegionsListViewModelTests.swift +++ b/PIA VPN-tvOSTests/RegionsList/RegionsListViewModelTests.swift @@ -15,6 +15,7 @@ class RegionsListViewModelTests: XCTestCase { let regionsListUseCaseMock = RegionsListUseCaseMock() let favoriteRegionsUseCaseMock = FavoriteRegionUseCaseMock() let regionsFilterUseCaseMock = RegionsFilterUseCaseMock() + let regionsDisplayNameUseCaseMock = RegionsDisplayNameUseCaseMock() let appRouterSpy = AppRouterSpy() static let barcelona = ServerMock(name: "Barcelona-1", identifier: "es-server-barcelona", regionIdentifier: "es-region", country: "ES", geo: false, pingTime: 25) static let madrid = ServerMock(name: "Madrid", identifier: "es-server-madrid", regionIdentifier: "es-region2", country: "ES", geo: false, pingTime: 12) @@ -38,7 +39,7 @@ class RegionsListViewModelTests: XCTestCase { func instantiateSut(with filter: RegionsListFilter = .all, routerAction: AppRouter.Actions? = nil) { let routerAction = routerAction ?? AppRouter.Actions.pop(router: fixture.appRouterSpy) - sut = RegionsListViewModel(filter: filter, listUseCase: fixture.regionsListUseCaseMock, favoriteUseCase: fixture.favoriteRegionsUseCaseMock, regionsFilterUseCase: fixture.regionsFilterUseCaseMock, onServerSelectedRouterAction: routerAction) + sut = RegionsListViewModel(filter: filter, listUseCase: fixture.regionsListUseCaseMock, favoriteUseCase: fixture.favoriteRegionsUseCaseMock, regionsFilterUseCase: fixture.regionsFilterUseCaseMock, regionsDisplayNameUseCase: fixture.regionsDisplayNameUseCaseMock, onServerSelectedRouterAction: routerAction) } override func setUp() { @@ -49,6 +50,20 @@ class RegionsListViewModelTests: XCTestCase { fixture = nil } + func test_displayName_forServer() { + // GIVEN that we have 4 servers (2 in ES and 2 in CA) + fixture.stubGetServers(for: .all, result: fixture.allServers) + // AND GIVEN that the regions display use case for Barcelona returns the country(as the title) and the server name(as the subtitle) + fixture.regionsDisplayNameUseCaseMock.getDisplayNameResult = (title: Fixture.barcelona.country, subtitle: Fixture.barcelona.name) + instantiateSut() + + // WHEN asking for the display name for the server Barcelona + let displayName = sut.getDisplayName(for: Fixture.barcelona) + // THEN the title of the display name is 'ES' (country) and the subtitle is 'Barcelona-1' (server name) + XCTAssertEqual(displayName.title, "ES") + XCTAssertEqual(displayName.subtitle, "Barcelona-1") + } + func test_regionServer_didSelect() { // GIVEN that the Regions list is created diff --git a/PIA VPN.xcodeproj/project.pbxproj b/PIA VPN.xcodeproj/project.pbxproj index fe3cbb8c..461d46ce 100644 --- a/PIA VPN.xcodeproj/project.pbxproj +++ b/PIA VPN.xcodeproj/project.pbxproj @@ -187,6 +187,11 @@ 693474CA2B6B8F710061F788 /* SearchedRegionsAvailabilityMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 693474C92B6B8F710061F788 /* SearchedRegionsAvailabilityMock.swift */; }; 693474CD2B6BC84C0061F788 /* FavoriteRegionsUseCaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 693474CC2B6BC84C0061F788 /* FavoriteRegionsUseCaseTests.swift */; }; 693622152B698B9200AE3C51 /* SearchedRegionsAvailability.swift in Sources */ = {isa = PBXBuildFile; fileRef = 693622142B698B9200AE3C51 /* SearchedRegionsAvailability.swift */; }; + 693B9A3C2B754C0600757A41 /* PIAStyles.swift in Sources */ = {isa = PBXBuildFile; fileRef = 693B9A3B2B754C0600757A41 /* PIAStyles.swift */; }; + 693B9A3E2B75592D00757A41 /* RegionsDisplayNameUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 693B9A3D2B75592D00757A41 /* RegionsDisplayNameUseCase.swift */; }; + 693B9A402B7571ED00757A41 /* SearchControllerButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 693B9A3F2B7571ED00757A41 /* SearchControllerButton.swift */; }; + 693B9A422B7615C700757A41 /* RegionsDisplayNameUseCaseMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 693B9A412B7615C700757A41 /* RegionsDisplayNameUseCaseMock.swift */; }; + 693B9A442B7622B600757A41 /* RegionsDisplayNameUseCaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 693B9A432B7622B600757A41 /* RegionsDisplayNameUseCaseTests.swift */; }; 693CA5AB2B3ED67A00D38378 /* AppPreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EFDC1DF1FE4A450007C0B9B /* AppPreferences.swift */; }; 693CA5AC2B3ED69B00D38378 /* AppConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EFDC1EB1FE4B9DC007C0B9B /* AppConstants.swift */; }; 693CA5AD2B3ED74100D38378 /* AppConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EFDC1EE1FE4B9E6007C0B9B /* AppConfiguration.swift */; }; @@ -1008,6 +1013,11 @@ 693474C92B6B8F710061F788 /* SearchedRegionsAvailabilityMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchedRegionsAvailabilityMock.swift; sourceTree = ""; }; 693474CC2B6BC84C0061F788 /* FavoriteRegionsUseCaseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteRegionsUseCaseTests.swift; sourceTree = ""; }; 693622142B698B9200AE3C51 /* SearchedRegionsAvailability.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchedRegionsAvailability.swift; sourceTree = ""; }; + 693B9A3B2B754C0600757A41 /* PIAStyles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PIAStyles.swift; sourceTree = ""; }; + 693B9A3D2B75592D00757A41 /* RegionsDisplayNameUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegionsDisplayNameUseCase.swift; sourceTree = ""; }; + 693B9A3F2B7571ED00757A41 /* SearchControllerButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchControllerButton.swift; sourceTree = ""; }; + 693B9A412B7615C700757A41 /* RegionsDisplayNameUseCaseMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegionsDisplayNameUseCaseMock.swift; sourceTree = ""; }; + 693B9A432B7622B600757A41 /* RegionsDisplayNameUseCaseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegionsDisplayNameUseCaseTests.swift; sourceTree = ""; }; 693CA5AF2B3ED9E100D38378 /* PIA VPN-tvOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "PIA VPN-tvOS.entitlements"; sourceTree = ""; }; 693CA5B62B3F268300D38378 /* PIA-VPN-tvOS-NetworkExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "PIA-VPN-tvOS-NetworkExtension.appex"; sourceTree = BUILT_PRODUCTS_DIR; }; 693CA5B92B3F268400D38378 /* PacketTunnelProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PacketTunnelProvider.swift; sourceTree = ""; }; @@ -2008,6 +2018,7 @@ 69793DA02B59B96B000CB845 /* RegionsListViewModelTests.swift */, 693474CC2B6BC84C0061F788 /* FavoriteRegionsUseCaseTests.swift */, 6963465C2B7109DA0051F8BC /* RegionsFilterUseCaseTests.swift */, + 693B9A432B7622B600757A41 /* RegionsDisplayNameUseCaseTests.swift */, ); path = RegionsList; sourceTree = ""; @@ -2019,6 +2030,7 @@ 693474C52B6B8ABA0061F788 /* FavoriteRegionUseCaseMock.swift */, 693474C92B6B8F710061F788 /* SearchedRegionsAvailabilityMock.swift */, 6963465A2B70F6A80051F8BC /* RegionsFilterUseCaseMock.swift */, + 693B9A412B7615C700757A41 /* RegionsDisplayNameUseCaseMock.swift */, ); path = Mocks; sourceTree = ""; @@ -2222,6 +2234,7 @@ 69E61DE72B55643500085648 /* RegionsListView.swift */, 69793DA32B5A9B27000CB845 /* RegionsContainerView.swift */, 698615A92B62F99700A1EA54 /* RegionsListItemButton.swift */, + 693B9A3F2B7571ED00757A41 /* SearchControllerButton.swift */, ); path = UI; sourceTree = ""; @@ -2241,6 +2254,7 @@ 696346562B6D0EDC0051F8BC /* RegionsFilterUseCase.swift */, 69E61DEE2B556FDA00085648 /* RegionsListUseCase.swift */, 69AB492B2B63C73000C780CD /* FavoriteRegionUseCase.swift */, + 693B9A3D2B75592D00757A41 /* RegionsDisplayNameUseCase.swift */, ); path = UseCases; sourceTree = ""; @@ -2263,6 +2277,7 @@ 69F053492B1F1D2800AE0665 /* Colors.xcassets */, 69F1C2942B2B216100E924AE /* PIA+Animation.swift */, 69FF0B0E2B3AE8F60074AA04 /* PIAImages+SwiftUI.swift */, + 693B9A3B2B754C0600757A41 /* PIAStyles.swift */, ); path = UI; sourceTree = ""; @@ -4093,6 +4108,7 @@ 696346572B6D0EDC0051F8BC /* RegionsFilterUseCase.swift in Sources */, E5AB286F2B27977000744E5F /* LoginFactory.swift in Sources */, 699311A22B318F6B00D316C8 /* DashboardViewModel.swift in Sources */, + 693B9A402B7571ED00757A41 /* SearchControllerButton.swift in Sources */, 69AB492C2B63C73000C780CD /* FavoriteRegionUseCase.swift in Sources */, 69FF0B042B3AD1D40074AA04 /* SelectedServerView.swift in Sources */, 69A226B82B3079EA0065EDDB /* PIA+String.swift in Sources */, @@ -4136,6 +4152,7 @@ E52E69152B5DB05600471913 /* BootstraperFactory.swift in Sources */, E5AB28CB2B48AFA900744E5F /* VPNConfigurationAvailability.swift in Sources */, E5AB28712B279B4000744E5F /* LoginProviderType.swift in Sources */, + 693B9A3E2B75592D00757A41 /* RegionsDisplayNameUseCase.swift in Sources */, 693CA5B02B3EDB4900D38378 /* RegionFilter.swift in Sources */, E5AB287B2B28B4C400744E5F /* Credentials.swift in Sources */, E52E68FE2B55E47600471913 /* Routes.swift in Sources */, @@ -4155,6 +4172,7 @@ 69F1C2972B2B239000E924AE /* PIAConnectionButtonViewModel.swift in Sources */, 693CA5B12B3EDBEF00D38378 /* ThemeCode.swift in Sources */, E52E691D2B5DC2B200471913 /* StateMonitorsFactory.swift in Sources */, + 693B9A3C2B754C0600757A41 /* PIAStyles.swift in Sources */, E5C507B32B17E5AD00200A6A /* LoginError.swift in Sources */, E5AB28BB2B48815300744E5F /* VPNConfigurationInstallingStatus.swift in Sources */, E5C507CA2B1FAC3600200A6A /* LoginDomainErrorMapper.swift in Sources */, @@ -4180,6 +4198,7 @@ E52E69252B5E92CC00471913 /* VPNStatusMonitorMock.swift in Sources */, 696E8F0D2B31A8760080BB31 /* NotificationCenterMock.swift in Sources */, E5C507A82B153E6B00200A6A /* LoginViewModelTests.swift in Sources */, + 693B9A422B7615C700757A41 /* RegionsDisplayNameUseCaseMock.swift in Sources */, E5AB286C2B2796E700744E5F /* LoginProviderMock.swift in Sources */, 693474CD2B6BC84C0061F788 /* FavoriteRegionsUseCaseTests.swift in Sources */, E5AB28872B2911C900744E5F /* AccountProviderMock.swift in Sources */, @@ -4200,6 +4219,7 @@ 690FC4822B3C5A4300F6DCC8 /* ServerMock.swift in Sources */, 690FC4842B3C5B0500F6DCC8 /* QuickConnectButtonViewModelDelegateMock.swift in Sources */, 693474C82B6B8BDD0061F788 /* KeychainTypeMock.swift in Sources */, + 693B9A442B7622B600757A41 /* RegionsDisplayNameUseCaseTests.swift in Sources */, E5AB287F2B28F6EF00744E5F /* Stubs.swift in Sources */, 69E61DF12B56990E00085648 /* DashboardViewModelTests.swift in Sources */, E5AB28972B2C782C00744E5F /* Stubs+PIALibrary.swift in Sources */, diff --git a/PIA VPN/SwiftGen+Strings.swift b/PIA VPN/SwiftGen+Strings.swift index a3ba2b99..76718a92 100644 --- a/PIA VPN/SwiftGen+Strings.swift +++ b/PIA VPN/SwiftGen+Strings.swift @@ -755,7 +755,17 @@ internal enum L10n { internal static let title = L10n.tr("Localizable", "regions.filter.search.title", fallback: "Search") } } + internal enum ListItem { + internal enum Default { + /// Default + internal static let title = L10n.tr("Localizable", "regions.list_item.default.title", fallback: "Default") + } + } internal enum Search { + internal enum Button { + /// Search for a Location + internal static let title = L10n.tr("Localizable", "regions.search.button.title", fallback: "Search for a Location") + } internal enum InputField { /// Search for city or country internal static let placeholder = L10n.tr("Localizable", "regions.search.input_field.placeholder", fallback: "Search for city or country") diff --git a/PIA VPN/en.lproj/Localizable.strings b/PIA VPN/en.lproj/Localizable.strings index cc2b5445..0231ccb0 100644 --- a/PIA VPN/en.lproj/Localizable.strings +++ b/PIA VPN/en.lproj/Localizable.strings @@ -445,10 +445,12 @@ "regions.filter.favorites.title" = "Favourite"; "regions.search.recommended_locations.title" = "Recommended Locations"; "regions.search.results.title" = "Search Results"; +"regions.search.button.title" = "Search for a Location"; "regions.search.input_field.placeholder" = "Search for city or country"; "regions.search.previous_results.title" = "Last Searched Locations"; "regions.context_menu.favorites.add.text" = "Add to Favorites"; "regions.context_menu.favorites.remove.text" = "Remove from Favorites"; +"regions.list_item.default.title" = "Default"; // Top Navigation bar "top_navigation_bar.vpn_item.title" = "PIA VPN";