diff --git a/PIA VPN-tvOS/Navigation/AppRouter.swift b/PIA VPN-tvOS/Navigation/AppRouter.swift index 0b06d850..891238c6 100644 --- a/PIA VPN-tvOS/Navigation/AppRouter.swift +++ b/PIA VPN-tvOS/Navigation/AppRouter.swift @@ -2,7 +2,7 @@ import Foundation import SwiftUI - +import Combine protocol AppRouterType { var stackCount: Int { get } @@ -15,28 +15,44 @@ protocol AppRouterType { // AppRouter enables any component to navigate the user to any screen defined within Destinations class AppRouter: ObservableObject, AppRouterType { - static let shared: AppRouter = AppRouter(with: NavigationPath()) + static let shared: AppRouter = AppRouter() @Published public var path: NavigationPath + /// Returns the current Destinations. Useful to find out the exact current route + @Published private(set) var pathDestinations: [any Destinations] = [] + + private var cancellables = Set() + /// Returns the amount of stacked views. Useful during unit test validation var stackCount: Int { path.count } - init(with path: NavigationPath) { - self.path = path + init(with destinations: [any Destinations] = []) { + self.pathDestinations = destinations + var pathWithDestinations = NavigationPath() + + for destination in destinations { + pathWithDestinations.append(destination) + } + self.path = pathWithDestinations + + subscribeToPathUpdates() } func navigate(to destination: any Destinations) { + pathDestinations.append(destination) path.append(destination) } func pop() { + pathDestinations.removeLast() path.removeLast() } func goBackToRoot() { + pathDestinations.removeAll() path.removeLast(path.count) } } @@ -74,3 +90,17 @@ extension AppRouter { } } + +// MARK: - Path updates subscription + +extension AppRouter { + private func subscribeToPathUpdates() { + $path.sink { newPath in + + // Update the current destinations when navigating back + if newPath.count < self.pathDestinations.count { + self.pathDestinations.removeLast() + } + }.store(in: &cancellables) + } +} diff --git a/PIA VPN-tvOS/Navigation/CompositionRoot/TopNavigationFactory.swift b/PIA VPN-tvOS/Navigation/CompositionRoot/TopNavigationFactory.swift new file mode 100644 index 00000000..f58a3135 --- /dev/null +++ b/PIA VPN-tvOS/Navigation/CompositionRoot/TopNavigationFactory.swift @@ -0,0 +1,26 @@ +// +// TopNavigationFactory.swift +// PIA VPN-tvOS +// +// Created by Laura S on 2/6/24. +// Copyright © 2024 Private Internet Access Inc. All rights reserved. +// + +import Foundation + + +class TopNavigationFactory { + + static func makeLeadingSegmentedNavigationView() -> LeadingSegmentedNavigationView { + return LeadingSegmentedNavigationView(viewModel: makeTopNavigationViewModel()) + } + + static func makeTrailingNavigationView() -> TrailingNavigationView { + return TrailingNavigationView(viewModel: makeTopNavigationViewModel()) + } + + static func makeTopNavigationViewModel() -> TopNavigationViewModel { + return TopNavigationViewModel(appRouter: AppRouterFactory.makeAppRouter()) + } + +} diff --git a/PIA VPN-tvOS/Navigation/Destinations.swift b/PIA VPN-tvOS/Navigation/Destinations.swift index 0a5b5c6f..8a61ce71 100644 --- a/PIA VPN-tvOS/Navigation/Destinations.swift +++ b/PIA VPN-tvOS/Navigation/Destinations.swift @@ -10,26 +10,22 @@ import Foundation typealias Destinations = Hashable -enum DashboardDestinations: Destinations { +enum DashboardDestinations: Destinations, Codable { case home } -enum RegionSelectionDestinations: Destinations { - case search -} - -enum RegionsDestinations: Destinations { +enum RegionsDestinations: Destinations, Codable { case serversList - case selectServer(_: ServerType) + case search static func == (lhs: RegionsDestinations, rhs: RegionsDestinations) -> Bool { switch (lhs, rhs) { case(.serversList, .serversList): return true - case(.serversList, .selectServer): return false - case(.selectServer, .serversList): return false - case (.selectServer(let lhsServer), .selectServer(let rhsServer)): - return lhsServer.identifier == rhsServer.identifier + case(.search, .search): + return true + default: + return false } } @@ -38,8 +34,8 @@ enum RegionsDestinations: Destinations { switch self { case .serversList: return hasher.combine("serversList") - case .selectServer(let server): - return hasher.combine("selectedServer\(server.identifier)") + case .search: + return hasher.combine("searchRegions") } } } diff --git a/PIA VPN-tvOS/Navigation/Presentation/TopNavigationViewModel.swift b/PIA VPN-tvOS/Navigation/Presentation/TopNavigationViewModel.swift new file mode 100644 index 00000000..2918400d --- /dev/null +++ b/PIA VPN-tvOS/Navigation/Presentation/TopNavigationViewModel.swift @@ -0,0 +1,125 @@ +// +// LeadingSegmentedNavigationViewModel.swift +// PIA VPN-tvOS +// +// Created by Laura S on 2/6/24. +// Copyright © 2024 Private Internet Access Inc. All rights reserved. +// + +import Foundation +import Combine +import SwiftUI + +class TopNavigationViewModel: ObservableObject { + + let appRouter: AppRouter + + private var cancellables = Set() + + let leadingSections: [Sections] = [.vpn, .locations] + let trailingSections: [Sections] = [.settings, .help] + + enum Sections: Equatable, Hashable, Identifiable { + var id: Self { + return self + } + + case vpn + case locations + case settings + case help + + var title: String { + switch self { + case .vpn: + return L10n.Localizable.TopNavigationBar.VpnItem.title + case .locations: + return L10n.Localizable.TopNavigationBar.LocationItem.title + case .settings: + // The settings section does not have title (just an icon image) + return "" + case .help: + // The help section does not have title (just an icon image) + return "" + } + } + + var systemIconName: String { + switch self { + case .help: + return "questionmark.circle" + case .settings: + return "gearshape" + default: + return "" + } + + } + } + + init(appRouter: AppRouter) { + self.appRouter = appRouter + self.selectedSection = calculateSelectedSection(for: appRouter.pathDestinations) + subscribeToAppRouterDestinationsUpdates() + } + + @Published var selectedSection: Sections = .vpn + @Published var highlightedSection: Sections? = nil + + func sectionDidUpdateSelection(to section: Sections) { + guard section != selectedSection else { return } + selectedSection = section + + switch section { + case .vpn: + appRouter.goBackToRoot() + case .locations: + appRouter.navigate(to: RegionsDestinations.serversList) + case .settings: + // TODO: Implement me + break + case .help: + // TODO: Implement me + break + } + } + + func sectionDidUpdateFocus(to section: Sections?) { + highlightedSection = section + } + + func viewDidAppear() { + // Remove the focus when the view appears + sectionDidUpdateFocus(to: nil) + } + + private func calculateSelectedSection(for pathDestinations: [any Destinations]) -> Sections { + + if let currentPath = pathDestinations.last { + switch currentPath { + case .serversList as RegionsDestinations: + return .locations + case .search as RegionsDestinations: + return .locations + case .home as DashboardDestinations: + return .vpn + // TODO: Add settings and help destinations when implemented + default: + return .vpn + } + } else { + // The path is empty, so we are in the root + return .vpn + } + + } + + private func subscribeToAppRouterDestinationsUpdates() { + appRouter.$pathDestinations + .sink { [weak self] newPathDestinations in + guard let self = self else { return } + self.selectedSection = self.calculateSelectedSection(for: newPathDestinations) + }.store(in: &cancellables) + } + +} diff --git a/PIA VPN-tvOS/Navigation/UI/TopNavigationView.swift b/PIA VPN-tvOS/Navigation/UI/TopNavigationView.swift new file mode 100644 index 00000000..ed220f9e --- /dev/null +++ b/PIA VPN-tvOS/Navigation/UI/TopNavigationView.swift @@ -0,0 +1,97 @@ +// +// LeadingSegmentedNavigationView.swift +// PIA VPN-tvOS +// +// Created by Laura S on 2/6/24. +// Copyright © 2024 Private Internet Access Inc. All rights reserved. +// + +import Foundation +import SwiftUI + +/// View displayed in the Leading part of the Navigation top bar +struct LeadingSegmentedNavigationView: View { + + @ObservedObject var viewModel: TopNavigationViewModel + @FocusState var focusedSection: TopNavigationViewModel.Sections? + + func button(for section: TopNavigationViewModel.Sections) -> some View { + Button { + viewModel.sectionDidUpdateSelection(to: section) + + } label: { + Text(section.title) + .font(.system(size: 29, weight: .medium)) + .padding(.vertical, 14) + .padding(.horizontal, 28) + .cornerRadius(32) + .foregroundColor(section == viewModel.highlightedSection ? Color.black : Color.white) + .background(Capsule().fill( + section == viewModel.highlightedSection ? Color.pia_green : + section == viewModel.selectedSection ? + Color.pia_selection_background : Color.clear + ).shadow(radius: 3)) + } + .buttonBorderShape(.capsule) + .buttonStyle(.borderless) + .focused($focusedSection, equals: section) + + } + + + var body: some View { + VStack(alignment: .leading) { + HStack(spacing: 2) { + ForEach(viewModel.leadingSections, id: \.self) { section in + button(for: section) + } + } + .background(Capsule().fill(Color.pia_region_tile_background).shadow(radius: 3)) + } + .padding() + .onChange(of: focusedSection) { _, newValue in + viewModel.sectionDidUpdateFocus(to: newValue) + } + .onAppear { + viewModel.viewDidAppear() + } + + } + +} + +/// View displayed in the Trailing part of the Navigation top bar +struct TrailingNavigationView: View { + @ObservedObject var viewModel: TopNavigationViewModel + @FocusState var focusedSection: TopNavigationViewModel.Sections? + + var body: some View { + VStack(alignment: .trailing) { + HStack(spacing: 16) { + ForEach(viewModel.trailingSections, id: \.self) { section in + Button { + viewModel.sectionDidUpdateSelection(to: section) + } label: { + Image(systemName: section.systemIconName) + .padding(12) + .foregroundColor(section == viewModel.highlightedSection ? Color.black : Color.white) + .background( + section == viewModel.highlightedSection + ? Color.pia_green : Color.pia_region_tile_background) + .clipShape(Circle()) + } + .focused($focusedSection, equals: section) + .buttonBorderShape(.circle) + .buttonStyle(.card) + } + } + + } + .onChange(of: focusedSection) { _, newValue in + viewModel.sectionDidUpdateFocus(to: newValue) + } + .onAppear { + viewModel.viewDidAppear() + } + } +} diff --git a/PIA VPN-tvOS/PIA_VPN_tvOSApp.swift b/PIA VPN-tvOS/PIA_VPN_tvOSApp.swift index 28d5324a..10d57391 100644 --- a/PIA VPN-tvOS/PIA_VPN_tvOSApp.swift +++ b/PIA VPN-tvOS/PIA_VPN_tvOSApp.swift @@ -13,6 +13,7 @@ struct PIA_VPN_tvOSApp: App { var body: some Scene { WindowGroup { RootContainerFactory.makeRootContainerView() + .preferredColorScheme(.dark) } } } diff --git a/PIA VPN-tvOS/RegionsSelection/CompositionRoot/RegionsSelectionFactory.swift b/PIA VPN-tvOS/RegionsSelection/CompositionRoot/RegionsSelectionFactory.swift index c6f4e707..340af37f 100644 --- a/PIA VPN-tvOS/RegionsSelection/CompositionRoot/RegionsSelectionFactory.swift +++ b/PIA VPN-tvOS/RegionsSelection/CompositionRoot/RegionsSelectionFactory.swift @@ -8,6 +8,7 @@ import Foundation import PIALibrary +import SwiftUI class RegionsSelectionFactory { @@ -16,7 +17,7 @@ class RegionsSelectionFactory { } static func makeRegionsContainerViewModel() -> RegionsContainerViewModel { - return RegionsContainerViewModel(onSearchSelectedAction: .navigate(router: AppRouterFactory.makeAppRouter(), destination: RegionSelectionDestinations.search)) + return RegionsContainerViewModel(onSearchSelectedAction: .navigate(router: AppRouterFactory.makeAppRouter(), destination: RegionsDestinations.search)) } @@ -42,8 +43,12 @@ class RegionsSelectionFactory { return RegionsListView(viewModel: makeRegionsListViewModel(with: .favorites)) } - static func makeSearchRegionsListView() -> RegionsListView { - return RegionsListView(viewModel: makeRegionsListViewModel(with: .searchResults(""))) + static func makeSearchRegionsListView() -> some View { + let viewModel = makeRegionsListViewModel(with: .searchResults("")) + let searchableRegions = RegionsListView(viewModel: viewModel) + + return searchableRegions.searchable(text: searchableRegions.$viewModel.search, prompt: L10n.Localizable.Regions.Search.InputField.placeholder) + } static func makeRecommendedRegionsListView() -> RegionsListView { diff --git a/PIA VPN-tvOS/RegionsSelection/Presentation/RegionsContainerViewModel.swift b/PIA VPN-tvOS/RegionsSelection/Presentation/RegionsContainerViewModel.swift index 04866a4a..43f3bdad 100644 --- a/PIA VPN-tvOS/RegionsSelection/Presentation/RegionsContainerViewModel.swift +++ b/PIA VPN-tvOS/RegionsSelection/Presentation/RegionsContainerViewModel.swift @@ -38,10 +38,6 @@ class RegionsContainerViewModel: ObservableObject { var searchButtonTitle: String { L10n.Localizable.Region.Search.placeholder } - - var searchFieldPrompt: String { - L10n.Localizable.Regions.Search.InputField.placeholder - } private let onSearchSelectedAction: AppRouter.Actions diff --git a/PIA VPN-tvOS/RegionsSelection/Presentation/RegionsListViewModel.swift b/PIA VPN-tvOS/RegionsSelection/Presentation/RegionsListViewModel.swift index 80a7dbae..be236687 100644 --- a/PIA VPN-tvOS/RegionsSelection/Presentation/RegionsListViewModel.swift +++ b/PIA VPN-tvOS/RegionsSelection/Presentation/RegionsListViewModel.swift @@ -145,10 +145,9 @@ extension RegionsListViewModel { func favoriteContextMenuTitle(for server: ServerType) -> String { if isFavorite(server: server) { - // TODO: Localize - return "Remove from Favorites" + return L10n.Localizable.Regions.ContextMenu.Favorites.Remove.text } else { - return "Add to Favorites" + return L10n.Localizable.Regions.ContextMenu.Favorites.Add.text } } diff --git a/PIA VPN-tvOS/RegionsSelection/UI/RegionsContainerView.swift b/PIA VPN-tvOS/RegionsSelection/UI/RegionsContainerView.swift index 7cae2507..32d4805b 100644 --- a/PIA VPN-tvOS/RegionsSelection/UI/RegionsContainerView.swift +++ b/PIA VPN-tvOS/RegionsSelection/UI/RegionsContainerView.swift @@ -39,14 +39,6 @@ struct RegionsContainerView: View { } - var searchableRegionsList: some View { - let searchableRegions = RegionsSelectionFactory.makeSearchRegionsListView() - - return searchableRegions - .searchable(text: searchableRegions.$viewModel.search, prompt: viewModel.searchFieldPrompt) - - } - var navigateToSearchScreenButton: some View { Button { viewModel.navigate(to: .search) @@ -100,14 +92,8 @@ struct RegionsContainerView: View { viewModel.selectedSection = focusedMenuItem } } - .frame(width: viewWidth, height: viewHeight) + .frame(width: viewWidth) .background(Color.app_background) - .navigationDestination(for: RegionSelectionDestinations.self) { route in - switch route { - case .search: - searchableRegionsList - } - } } } diff --git a/PIA VPN-tvOS/RegionsSelection/UI/RegionsListView.swift b/PIA VPN-tvOS/RegionsSelection/UI/RegionsListView.swift index dc05ba37..c1f0986d 100644 --- a/PIA VPN-tvOS/RegionsSelection/UI/RegionsListView.swift +++ b/PIA VPN-tvOS/RegionsSelection/UI/RegionsListView.swift @@ -49,6 +49,7 @@ struct RegionsListView: View { } .padding(.top, 40) } + }.onAppear { viewModel.viewDidAppear() } diff --git a/PIA VPN-tvOS/RootContainer/UserActivatedContainer/CompositionRoot/UserActivatedContainerFactory.swift b/PIA VPN-tvOS/RootContainer/UserActivatedContainer/CompositionRoot/UserActivatedContainerFactory.swift index 57ce36a7..a0b088be 100644 --- a/PIA VPN-tvOS/RootContainer/UserActivatedContainer/CompositionRoot/UserActivatedContainerFactory.swift +++ b/PIA VPN-tvOS/RootContainer/UserActivatedContainer/CompositionRoot/UserActivatedContainerFactory.swift @@ -10,6 +10,6 @@ import Foundation class UserActivatedContainerFactory { static func makeUSerActivatedContainerView() -> UserActivatedContainerView { - return UserActivatedContainerView(router: AppRouterFactory.makeAppRouter()) + return UserActivatedContainerView() } } diff --git a/PIA VPN-tvOS/RootContainer/UserActivatedContainer/UI/UserActivatedContainerView.swift b/PIA VPN-tvOS/RootContainer/UserActivatedContainer/UI/UserActivatedContainerView.swift index c9848580..74e175a5 100644 --- a/PIA VPN-tvOS/RootContainer/UserActivatedContainer/UI/UserActivatedContainerView.swift +++ b/PIA VPN-tvOS/RootContainer/UserActivatedContainer/UI/UserActivatedContainerView.swift @@ -8,23 +8,30 @@ import Foundation import SwiftUI +import Combine + + struct UserActivatedContainerView: View { - @ObservedObject var router: AppRouter - var body: some View { DashboardFactory.makeDashboardView() .navigationDestination(for: RegionsDestinations.self) { destination in switch destination { case .serversList: RegionsSelectionFactory.makeRegionsContainerView() - case .selectServer(let selectedServer): - VStack { - Text("Selected server: \(selectedServer.name)") - } + .navigationBarItems(leading: TopNavigationFactory.makeLeadingSegmentedNavigationView()) + .navigationTitle(L10n.Localizable.TopNavigationBar.LocationSelectionScreen.title) + .navigationBarItems(trailing: TopNavigationFactory.makeTrailingNavigationView()) + case .search: + RegionsSelectionFactory.makeSearchRegionsListView() + .navigationBarHidden(true) } } + .navigationBarItems(leading: TopNavigationFactory.makeLeadingSegmentedNavigationView()) + .navigationBarItems(trailing: TopNavigationFactory.makeTrailingNavigationView()) + .navigationTitle("") // TODO: Inject the VPN connection status here + } } diff --git a/PIA VPN-tvOS/Shared/UI/PIAColors+SwitUI.swift b/PIA VPN-tvOS/Shared/UI/PIAColors+SwitUI.swift index cb054629..5ff7d2bd 100644 --- a/PIA VPN-tvOS/Shared/UI/PIAColors+SwitUI.swift +++ b/PIA VPN-tvOS/Shared/UI/PIAColors+SwitUI.swift @@ -16,5 +16,7 @@ extension Color { static var pia_primary_text = Color("pia_primary_text") static var pia_primary_title = Color("pia_primary_title") static var pia_secondary_title = Color("pia_secondary_title") + static var pia_selection_background = Color("pia_selection_background") + } diff --git a/PIA VPN-tvOSTests/Dashboard/DashboardViewModelTests.swift b/PIA VPN-tvOSTests/Dashboard/DashboardViewModelTests.swift index c649e0d4..5d9e6b1f 100644 --- a/PIA VPN-tvOSTests/Dashboard/DashboardViewModelTests.swift +++ b/PIA VPN-tvOSTests/Dashboard/DashboardViewModelTests.swift @@ -14,7 +14,7 @@ import SwiftUI class DashboardViewModelTests: XCTestCase { class Fixture { let accountProviderMock = AccountProviderTypeMock() - let appRouter = AppRouter(with: NavigationPath()) + let appRouter = AppRouter() } var fixture: Fixture! @@ -48,7 +48,10 @@ class DashboardViewModelTests: XCTestCase { // THEN the app router navigates to the Regions list XCTAssertFalse(fixture.appRouter.path.isEmpty) - XCTAssertEqual(fixture.appRouter.path, regionsListNavigationPath) + XCTAssertEqual(fixture.appRouter.stackCount, 1) + XCTAssertEqual(fixture.appRouter.pathDestinations.count, 1) + XCTAssertTrue(fixture.appRouter.pathDestinations.last! is RegionsDestinations) + XCTAssertEqual(fixture.appRouter.pathDestinations.last! as! RegionsDestinations, .serversList) } diff --git a/PIA VPN-tvOSTests/Login/LoginIntegrationTests.swift b/PIA VPN-tvOSTests/Login/LoginIntegrationTests.swift index 69a3feb0..781e751a 100644 --- a/PIA VPN-tvOSTests/Login/LoginIntegrationTests.swift +++ b/PIA VPN-tvOSTests/Login/LoginIntegrationTests.swift @@ -26,7 +26,7 @@ final class LoginIntegrationTests: XCTestCase { let loginWithCredentialsUseCase = LoginWithCredentialsUseCase(loginProvider: loginProvider, errorMapper: LoginDomainErrorMapper()) - let appRouter = AppRouter(with: NavigationPath()) + let appRouter = AppRouter() let sut = LoginViewModel(loginWithCredentialsUseCase: loginWithCredentialsUseCase, checkLoginAvailability: CheckLoginAvailability(), @@ -98,7 +98,7 @@ final class LoginIntegrationTests: XCTestCase { let loginWithCredentialsUseCase = LoginWithCredentialsUseCase(loginProvider: loginProvider, errorMapper: LoginDomainErrorMapper()) - let appRouter = AppRouter(with: NavigationPath()) + let appRouter = AppRouter() let sut = LoginViewModel(loginWithCredentialsUseCase: loginWithCredentialsUseCase, checkLoginAvailability: CheckLoginAvailability(), diff --git a/PIA VPN-tvOSTests/Shared/AppRouterTests.swift b/PIA VPN-tvOSTests/Shared/AppRouterTests.swift new file mode 100644 index 00000000..3d9103f4 --- /dev/null +++ b/PIA VPN-tvOSTests/Shared/AppRouterTests.swift @@ -0,0 +1,112 @@ +// +// AppRouterTests.swift +// PIA VPN-tvOSTests +// +// Created by Laura S on 2/7/24. +// Copyright © 2024 Private Internet Access Inc. All rights reserved. +// + +import Foundation + +import XCTest +@testable import PIA_VPN_tvOS + +class AppRouterTests: XCTestCase { + class Fixture { + var initialDestinations: [any Destinations] = [] + } + + var fixture: Fixture! + var sut: AppRouter! + + func instantiateSubject() { + sut = AppRouter(with: fixture.initialDestinations) + } + + override func setUp() { + fixture = Fixture() + } + + override func tearDown() { + fixture = nil + } + + func test_startAppRouter_WithoutAnyDestination() { + // GIVEN that the initial destinations of the AppRouter is empty + fixture.initialDestinations = [] + instantiateSubject() + + // THEN the path and the path destinations are empty + XCTAssertTrue(sut.path.isEmpty) + XCTAssertTrue(sut.pathDestinations.isEmpty) + + } + + func test_startAppRouter_withAGivenDestination() { + // GIVEN that the initial destinations of the AppRouter is Regions List + fixture.initialDestinations = [RegionsDestinations.serversList] + instantiateSubject() + + // THEN the path and the path destinations contain one Destination item + XCTAssertFalse(sut.path.isEmpty) + XCTAssertFalse(sut.pathDestinations.isEmpty) + XCTAssertEqual(sut.stackCount, 1) + XCTAssertEqual(sut.pathDestinations.count, 1) + + // AND the current Destination of the router is set to the servers list + XCTAssertEqual(sut.pathDestinations.last! as! RegionsDestinations, .serversList) + + } + + func test_navigateToDestination() { + // GIVEN that the current path is empty + instantiateSubject() + XCTAssertTrue(sut.path.isEmpty) + XCTAssertTrue(sut.pathDestinations.isEmpty) + + // WHEN navigating to the RegionsDestination search + sut.navigate(to: RegionsDestinations.search) + + // THEN the current path destination is updated to RegionsDestinations search + XCTAssertEqual(sut.pathDestinations.last! as! RegionsDestinations, .search) + XCTAssertEqual(sut.stackCount, 1) + XCTAssertEqual(sut.pathDestinations.count, 1) + + } + + func test_navigateBack() { + // GIVEN that the AppRouter has navigated to the Regions search destination from the regions list destination + fixture.initialDestinations = [RegionsDestinations.serversList, RegionsDestinations.search] + instantiateSubject() + XCTAssertEqual(sut.stackCount, 2) + XCTAssertEqual(sut.pathDestinations.count, 2) + XCTAssertEqual(sut.pathDestinations.last! as! RegionsDestinations, .search) + + // WHEN navigating back to the previous destination + sut.pop() + + // THEN the path stack count decreases to 1 and the current destination becomes the Regions List + XCTAssertEqual(sut.stackCount, 1) + XCTAssertEqual(sut.pathDestinations.count, 1) + XCTAssertEqual(sut.pathDestinations.last! as! RegionsDestinations, .serversList) + + } + + func test_navigateBackToRoot() { + // GIVEN that the AppRouter has navigated to the Regions search destination from the regions list destination + fixture.initialDestinations = [RegionsDestinations.serversList, RegionsDestinations.search] + instantiateSubject() + XCTAssertEqual(sut.stackCount, 2) + XCTAssertEqual(sut.pathDestinations.count, 2) + XCTAssertEqual(sut.pathDestinations.last! as! RegionsDestinations, .search) + + // WHEN navigating back to the root + sut.goBackToRoot() + + // THEN the path and path destinations become empty + XCTAssertTrue(sut.path.isEmpty) + XCTAssertTrue(sut.pathDestinations.isEmpty) + + } + +} diff --git a/PIA VPN-tvOSTests/Shared/TopNavigationViewModelTests.swift b/PIA VPN-tvOSTests/Shared/TopNavigationViewModelTests.swift new file mode 100644 index 00000000..abb16012 --- /dev/null +++ b/PIA VPN-tvOSTests/Shared/TopNavigationViewModelTests.swift @@ -0,0 +1,92 @@ +// +// TopNavigationViewModelTests.swift +// PIA VPN-tvOSTests +// +// Created by Laura S on 2/7/24. +// Copyright © 2024 Private Internet Access Inc. All rights reserved. +// + +import Foundation +import XCTest +@testable import PIA_VPN_tvOS + +class TopNavigationViewModelTests: XCTestCase { + class Fixture { + var appRouter: AppRouter = AppRouter() + + func stubPathDestinations(_ destinations: [any Destinations]) { + self.appRouter = AppRouter(with: destinations) + } + } + + var fixture: Fixture! + var sut: TopNavigationViewModel! + + func instantiateSut() { + sut = TopNavigationViewModel(appRouter: fixture.appRouter) + } + + override func setUp() { + fixture = Fixture() + } + + override func tearDown() { + fixture = nil + } + + func test_selectedSectionForEmptyDestinationPath() { + // GIVEN that the app router path is empty + instantiateSut() + XCTAssertTrue(fixture.appRouter.path.isEmpty) + XCTAssertTrue(fixture.appRouter.pathDestinations.isEmpty) + + // THEN the selected section is vpn + XCTAssertEqual(sut.selectedSection, .vpn) + } + + func test_selectedSectionForRegionsListDestination() { + // GIVEN that the app path destination is Regions + fixture.stubPathDestinations([RegionsDestinations.serversList]) + instantiateSut() + + // THEN the selected section is locations + XCTAssertEqual(sut.selectedSection, .locations) + } + + func test_selectedSectionForRegionsSearchDestination() { + // GIVEN that the app path destination is Regions + fixture.stubPathDestinations([RegionsDestinations.search]) + instantiateSut() + + // THEN the selected section is locations + XCTAssertEqual(sut.selectedSection, .locations) + } + + func test_updateSelectedSection() { + // GIVEN that the selected section is vpn + instantiateSut() + XCTAssertEqual(sut.selectedSection, .vpn) + XCTAssertTrue(fixture.appRouter.pathDestinations.isEmpty) + + // WHEN updating the selected section to Locations + sut.sectionDidUpdateSelection(to: .locations) + + // THEN the selected section becomes locations + XCTAssertEqual(sut.selectedSection, .locations) + // AND the router path destination is updated to Regions servers list + XCTAssertEqual(fixture.appRouter.pathDestinations.last! as! RegionsDestinations, .serversList) + + } + + func test_updateHighlightedSection() { + instantiateSut() + XCTAssertNil(sut.highlightedSection) + + // WHEN the focus becomes the Locations buttons + sut.sectionDidUpdateFocus(to: .locations) + + // THEN the highlighted section becomes the locations section + XCTAssertEqual(sut.highlightedSection, .locations) + } + +} diff --git a/PIA VPN.xcodeproj/project.pbxproj b/PIA VPN.xcodeproj/project.pbxproj index 668a7ec2..fe3cbb8c 100644 --- a/PIA VPN.xcodeproj/project.pbxproj +++ b/PIA VPN.xcodeproj/project.pbxproj @@ -180,6 +180,8 @@ 692483202AB05F18002A0407 /* PIAConnectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6924831F2AB05F18002A0407 /* PIAConnectionView.swift */; }; 692483222AB05F37002A0407 /* PIACircleIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = 692483212AB05F37002A0407 /* PIACircleIcon.swift */; }; 692483262AB05F85002A0407 /* PIAConnectionActivityWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 692483252AB05F85002A0407 /* PIAConnectionActivityWidget.swift */; }; + 692DE7DA2B739AF8000A2F1A /* AppRouterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 692DE7D92B739AF8000A2F1A /* AppRouterTests.swift */; }; + 692DE7DC2B73AE32000A2F1A /* TopNavigationViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 692DE7DB2B73AE32000A2F1A /* TopNavigationViewModelTests.swift */; }; 693474C62B6B8ABA0061F788 /* FavoriteRegionUseCaseMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 693474C52B6B8ABA0061F788 /* FavoriteRegionUseCaseMock.swift */; }; 693474C82B6B8BDD0061F788 /* KeychainTypeMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 693474C72B6B8BDD0061F788 /* KeychainTypeMock.swift */; }; 693474CA2B6B8F710061F788 /* SearchedRegionsAvailabilityMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 693474C92B6B8F710061F788 /* SearchedRegionsAvailabilityMock.swift */; }; @@ -245,6 +247,9 @@ 69B70ABF2ACC2CFE0072A09D /* AccessibilityId.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69B70ABD2ACC2CFE0072A09D /* AccessibilityId.swift */; }; 69B70AC02ACC2CFE0072A09D /* AccessibilityId.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69B70ABD2ACC2CFE0072A09D /* AccessibilityId.swift */; }; 69C587FD2AD00C6300B95EF9 /* PIAExampleWithAuthenticatedAppTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69C587FC2AD00C6300B95EF9 /* PIAExampleWithAuthenticatedAppTest.swift */; }; + 69C5E7CC2B72945F00FE3126 /* TopNavigationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69C5E7CB2B72945F00FE3126 /* TopNavigationView.swift */; }; + 69C5E7CE2B72949000FE3126 /* TopNavigationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69C5E7CD2B72949000FE3126 /* TopNavigationViewModel.swift */; }; + 69C5E7D02B72962700FE3126 /* TopNavigationFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69C5E7CF2B72962700FE3126 /* TopNavigationFactory.swift */; }; 69CA26B22B59668700E78894 /* RegionsListUseCaseMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69CA26B12B59668700E78894 /* RegionsListUseCaseMock.swift */; }; 69E61DE82B55643500085648 /* RegionsListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69E61DE72B55643500085648 /* RegionsListView.swift */; }; 69E61DEA2B55644900085648 /* RegionsListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69E61DE92B55644900085648 /* RegionsListViewModel.swift */; }; @@ -996,6 +1001,8 @@ 6924831F2AB05F18002A0407 /* PIAConnectionView.swift */ = {isa = PBXFileReference; indentWidth = 4; lastKnownFileType = sourcecode.swift; path = PIAConnectionView.swift; sourceTree = ""; tabWidth = 4; }; 692483212AB05F37002A0407 /* PIACircleIcon.swift */ = {isa = PBXFileReference; indentWidth = 4; lastKnownFileType = sourcecode.swift; path = PIACircleIcon.swift; sourceTree = ""; tabWidth = 4; }; 692483252AB05F85002A0407 /* PIAConnectionActivityWidget.swift */ = {isa = PBXFileReference; indentWidth = 4; lastKnownFileType = sourcecode.swift; path = PIAConnectionActivityWidget.swift; sourceTree = ""; tabWidth = 4; }; + 692DE7D92B739AF8000A2F1A /* AppRouterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppRouterTests.swift; sourceTree = ""; }; + 692DE7DB2B73AE32000A2F1A /* TopNavigationViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TopNavigationViewModelTests.swift; sourceTree = ""; }; 693474C52B6B8ABA0061F788 /* FavoriteRegionUseCaseMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteRegionUseCaseMock.swift; sourceTree = ""; }; 693474C72B6B8BDD0061F788 /* KeychainTypeMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainTypeMock.swift; sourceTree = ""; }; 693474C92B6B8F710061F788 /* SearchedRegionsAvailabilityMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchedRegionsAvailabilityMock.swift; sourceTree = ""; }; @@ -1047,6 +1054,9 @@ 69B70AB42ACBF51C0072A09D /* LoginScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginScreen.swift; sourceTree = ""; }; 69B70ABD2ACC2CFE0072A09D /* AccessibilityId.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessibilityId.swift; sourceTree = ""; }; 69C587FC2AD00C6300B95EF9 /* PIAExampleWithAuthenticatedAppTest.swift */ = {isa = PBXFileReference; indentWidth = 2; lastKnownFileType = sourcecode.swift; path = PIAExampleWithAuthenticatedAppTest.swift; sourceTree = ""; tabWidth = 2; }; + 69C5E7CB2B72945F00FE3126 /* TopNavigationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TopNavigationView.swift; sourceTree = ""; }; + 69C5E7CD2B72949000FE3126 /* TopNavigationViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TopNavigationViewModel.swift; sourceTree = ""; }; + 69C5E7CF2B72962700FE3126 /* TopNavigationFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TopNavigationFactory.swift; sourceTree = ""; }; 69CA26B12B59668700E78894 /* RegionsListUseCaseMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegionsListUseCaseMock.swift; sourceTree = ""; }; 69E61DE72B55643500085648 /* RegionsListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegionsListView.swift; sourceTree = ""; }; 69E61DE92B55644900085648 /* RegionsListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegionsListViewModel.swift; sourceTree = ""; }; @@ -2017,6 +2027,7 @@ isa = PBXGroup; children = ( 697A5F442B514B5700661977 /* AppRouterFactory.swift */, + 69C5E7CF2B72962700FE3126 /* TopNavigationFactory.swift */, ); path = CompositionRoot; sourceTree = ""; @@ -2084,6 +2095,8 @@ 69A226AF2B3074110065EDDB /* Navigation */ = { isa = PBXGroup; children = ( + 69C5E7CA2B7293FE00FE3126 /* Presentation */, + 69C5E7C92B7293F700FE3126 /* UI */, 697A5F432B514B3D00661977 /* CompositionRoot */, 69A226B22B3074D30065EDDB /* AppRouter.swift */, 69816C3C2B4EBB3C00E3C86B /* Destinations.swift */, @@ -2158,6 +2171,22 @@ path = "PIA-VPN_E2E_Tests"; sourceTree = ""; }; + 69C5E7C92B7293F700FE3126 /* UI */ = { + isa = PBXGroup; + children = ( + 69C5E7CB2B72945F00FE3126 /* TopNavigationView.swift */, + ); + path = UI; + sourceTree = ""; + }; + 69C5E7CA2B7293FE00FE3126 /* Presentation */ = { + isa = PBXGroup; + children = ( + 69C5E7CD2B72949000FE3126 /* TopNavigationViewModel.swift */, + ); + path = Presentation; + sourceTree = ""; + }; 69D12D152ACC75140053A81B /* Util */ = { isa = PBXGroup; children = ( @@ -2593,6 +2622,8 @@ E52E69202B5DCF1800471913 /* Mocks */, E52E690E2B5D695A00471913 /* VPNStatusMonitorTests.swift */, E52E69102B5D696E00471913 /* UserAuthenticationStatusMonitorTests.swift */, + 692DE7D92B739AF8000A2F1A /* AppRouterTests.swift */, + 692DE7DB2B73AE32000A2F1A /* TopNavigationViewModelTests.swift */, ); path = Shared; sourceTree = ""; @@ -4088,6 +4119,7 @@ 698615AA2B62F99700A1EA54 /* RegionsListItemButton.swift in Sources */, E5C507B12B17E48900200A6A /* LoginWithCredentialsUseCase.swift in Sources */, E5C507B92B17E7A400200A6A /* ValidateCredentialsFormat.swift in Sources */, + 69C5E7CC2B72945F00FE3126 /* TopNavigationView.swift in Sources */, E5AB28C92B48AF9000744E5F /* VPNConfigurationAvailabilityType.swift in Sources */, 69F1C2992B2B28DA00E924AE /* PIAConnectionButton.swift in Sources */, 69AB492E2B63CB6100C780CD /* AppConstants+Protocols.swift in Sources */, @@ -4098,6 +4130,7 @@ 69E61DEA2B55644900085648 /* RegionsListViewModel.swift in Sources */, 69816C3D2B4EBB3C00E3C86B /* Destinations.swift in Sources */, 69FF0B0D2B3AD57C0074AA04 /* SelectedServerUseCase.swift in Sources */, + 69C5E7CE2B72949000FE3126 /* TopNavigationViewModel.swift in Sources */, 69FF0B112B3AF1F00074AA04 /* QuickConnectButton.swift in Sources */, E5AB28AD2B32339C00744E5F /* VPNConfigurationInstallingView.swift in Sources */, E52E69152B5DB05600471913 /* BootstraperFactory.swift in Sources */, @@ -4108,6 +4141,7 @@ E52E68FE2B55E47600471913 /* Routes.swift in Sources */, E5C5077C2B0E144E00200A6A /* ContentView.swift in Sources */, 69A226BE2B307D5F0065EDDB /* RootContainerView.swift in Sources */, + 69C5E7D02B72962700FE3126 /* TopNavigationFactory.swift in Sources */, 694AC74E2B17AB9C007E7B56 /* DashboardView.swift in Sources */, 69F1C2932B2B10D400E924AE /* DashboardFactory.swift in Sources */, E5AB28B62B361E6C00744E5F /* VPNConfigurationInstallingErrorMapper.swift in Sources */, @@ -4176,7 +4210,9 @@ 693474C62B6B8ABA0061F788 /* FavoriteRegionUseCaseMock.swift in Sources */, E5AB28E12B4C107F00744E5F /* VpnConfigurationProviderTypeMock.swift in Sources */, E5C507C42B1F72E700200A6A /* CheckLoginAvailabilityTests.swift in Sources */, + 692DE7DC2B73AE32000A2F1A /* TopNavigationViewModelTests.swift in Sources */, 69793DA12B59B96B000CB845 /* RegionsListViewModelTests.swift in Sources */, + 692DE7DA2B739AF8000A2F1A /* AppRouterTests.swift in Sources */, E5C507C02B1F700C00200A6A /* CheckLoginAvailabilityMock.swift in Sources */, 691317A12B32ED76009B4E85 /* AccountProviderTypeMock.swift in Sources */, E52E69222B5DCF1F00471913 /* VPNStatusProviderMock.swift in Sources */, diff --git a/PIA VPN/SwiftGen+Strings.swift b/PIA VPN/SwiftGen+Strings.swift index bcc73e12..a3ba2b99 100644 --- a/PIA VPN/SwiftGen+Strings.swift +++ b/PIA VPN/SwiftGen+Strings.swift @@ -729,6 +729,18 @@ internal enum L10n { } } internal enum Regions { + internal enum ContextMenu { + internal enum Favorites { + internal enum Add { + /// Add to Favorites + internal static let text = L10n.tr("Localizable", "regions.context_menu.favorites.add.text", fallback: "Add to Favorites") + } + internal enum Remove { + /// Remove from Favorites + internal static let text = L10n.tr("Localizable", "regions.context_menu.favorites.remove.text", fallback: "Remove from Favorites") + } + } + } internal enum Filter { internal enum All { /// All @@ -1304,6 +1316,20 @@ internal enum L10n { internal static let login = L10n.tr("Localizable", "today.widget.login", fallback: "Login") } } + internal enum TopNavigationBar { + internal enum LocationItem { + /// Location + internal static let title = L10n.tr("Localizable", "top_navigation_bar.location_item.title", fallback: "Location") + } + internal enum LocationSelectionScreen { + /// Location Selection + internal static let title = L10n.tr("Localizable", "top_navigation_bar.location_selection_screen.title", fallback: "Location Selection") + } + internal enum VpnItem { + /// PIA VPN + internal static let title = L10n.tr("Localizable", "top_navigation_bar.vpn_item.title", fallback: "PIA VPN") + } + } internal enum VpnPermission { /// PIA internal static let title = L10n.tr("Localizable", "vpn_permission.title", fallback: "PIA") diff --git a/PIA VPN/en.lproj/Localizable.strings b/PIA VPN/en.lproj/Localizable.strings index ff3cd61d..cc2b5445 100644 --- a/PIA VPN/en.lproj/Localizable.strings +++ b/PIA VPN/en.lproj/Localizable.strings @@ -447,3 +447,11 @@ "regions.search.results.title" = "Search Results"; "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"; + +// Top Navigation bar +"top_navigation_bar.vpn_item.title" = "PIA VPN"; +"top_navigation_bar.location_item.title" = "Location"; +"top_navigation_bar.location_selection_screen.title" = "Location Selection"; +