Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

PIA-1273: Add top navigation bar on the user authenticated flow #74

Merged
merged 1 commit into from
Feb 8, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading