Skip to content

Commit

Permalink
PIA-1273: Add top navigation bar on the user authenticated flow
Browse files Browse the repository at this point in the history
  • Loading branch information
kp-laura-sempere committed Feb 7, 2024
1 parent 0a2d002 commit 0e16e0c
Show file tree
Hide file tree
Showing 21 changed files with 601 additions and 53 deletions.
38 changes: 34 additions & 4 deletions PIA VPN-tvOS/Navigation/AppRouter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import Foundation

import SwiftUI

import Combine

protocol AppRouterType {
var stackCount: Int { get }
Expand All @@ -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<AnyCancellable>()

/// 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)
}
}
Expand Down Expand Up @@ -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)
}
}
26 changes: 26 additions & 0 deletions PIA VPN-tvOS/Navigation/CompositionRoot/TopNavigationFactory.swift
Original file line number Diff line number Diff line change
@@ -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())
}

}
22 changes: 9 additions & 13 deletions PIA VPN-tvOS/Navigation/Destinations.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

}
}
Expand All @@ -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")
}
}
}
Expand Down
125 changes: 125 additions & 0 deletions PIA VPN-tvOS/Navigation/Presentation/TopNavigationViewModel.swift
Original file line number Diff line number Diff line change
@@ -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<AnyCancellable>()

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)
}

}
97 changes: 97 additions & 0 deletions PIA VPN-tvOS/Navigation/UI/TopNavigationView.swift
Original file line number Diff line number Diff line change
@@ -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()
}
}
}
1 change: 1 addition & 0 deletions PIA VPN-tvOS/PIA_VPN_tvOSApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ struct PIA_VPN_tvOSApp: App {
var body: some Scene {
WindowGroup {
RootContainerFactory.makeRootContainerView()
.preferredColorScheme(.dark)
}
}
}
Loading

0 comments on commit 0e16e0c

Please sign in to comment.