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-1058: App navigation setup #51

Merged
merged 2 commits into from
Dec 20, 2023
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
6 changes: 6 additions & 0 deletions PIA VPN-tvOS/Dashboard/CompositionRoot/DashboardFactory.swift
Original file line number Diff line number Diff line change
@@ -1,14 +1,20 @@

import Foundation
import PIALibrary

class DashboardFactory {

static func makeDashboardView() -> DashboardView {
return DashboardView(
viewModel: makeDashboardViewModel(),
connectionButton: makePIAConnectionButton()
)
}

static func makeDashboardViewModel() -> DashboardViewModel {
return DashboardViewModel(accountProvider: Client.providers.accountProvider)
}

static func makePIAConnectionButton(size: CGFloat = 160, lineWidth: CGFloat = 6) -> PIAConnectionButton {
return PIAConnectionButton(
size: size,
Expand Down
23 changes: 23 additions & 0 deletions PIA VPN-tvOS/Dashboard/Presentation/DashboardViewModel.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@


import Foundation
import SwiftUI
import PIALibrary
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think since this is presentation, we could avoid leaking framework details like SwiftUI. I'd also try to avoid importing PIALibrary on presentation and domain layers. It's true that we will be removing PIALibrary but the AccountProvider protocol exposes many different methods that this ViewModel does not care about. I'd suggest to have something in between which will only expose the methods the ViewModel will be using.
What do you think?


class DashboardViewModel: ObservableObject {

let accountProvider: AccountProvider

init(accountProvider: AccountProvider) {
self.accountProvider = accountProvider
}

func logOut() {
accountProvider.logout { error in
if let err = error {
NSLog("DashboardViewModel: logout error: \(err)")
}
}
}

}
14 changes: 13 additions & 1 deletion PIA VPN-tvOS/Dashboard/UI/DashboardView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,22 @@ struct DashboardView: View {
let viewWidth = UIScreen.main.bounds.width
let viewHeight = UIScreen.main.bounds.height

let viewModel: DashboardViewModel
let connectionButton: PIAConnectionButton

var body: some View {
VStack {
VStack(spacing: 20) {
connectionButton
.padding()

Button {
viewModel.logOut()
} label: {
Text("LogOut")
}
.padding()

}
.frame(maxWidth: (viewWidth/2))
.padding()
Expand All @@ -25,5 +34,8 @@ struct DashboardView: View {
}

#Preview {
DashboardView(connectionButton: DashboardFactory.makePIAConnectionButton())
DashboardView(
viewModel: DashboardFactory.makeDashboardViewModel(),
connectionButton: DashboardFactory.makePIAConnectionButton()
)
}
36 changes: 36 additions & 0 deletions PIA VPN-tvOS/Navigation/AppRouter.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@

import Foundation

import SwiftUI

typealias Destinations = Hashable

// AppRouter enables any component to navigate the user to any screen defined within Destinations
class AppRouter: ObservableObject {

static let shared: AppRouter = AppRouter(with: NavigationPath())

@Published public var path: NavigationPath

/// Returns the amount of stacked views. Useful during unit test validation
var stackCount: Int {
path.count
}

init(with path: NavigationPath) {
self.path = path
}

func navigate(to destination: any Destinations) {
path.append(destination)
}

func pop() {
path.removeLast()
}

func goBackToRoot() {
path.removeLast(path.count)
}

}
3 changes: 1 addition & 2 deletions PIA VPN-tvOS/PIA_VPN_tvOSApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,7 @@ import SwiftUI
struct PIA_VPN_tvOSApp: App {
var body: some Scene {
WindowGroup {
LoginFactory.makeLoginView()
// DashboardFactory.makeDashboardView()
RootContainerFactory.makeRootContainerView()
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@

import Foundation
import PIALibrary

class RootContainerFactory {
static func makeRootContainerView() -> RootContainerView {
RootContainerView(viewModel: makeRootContainerViewModel())
}

private static func makeRootContainerViewModel() -> RootContainerViewModel {
return RootContainerViewModel(accountProvider: Client.providers.accountProvider)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@

import Foundation
import Combine
import SwiftUI
import PIALibrary


class RootContainerViewModel: ObservableObject {
enum State {
case splash
case notActivated
case activatedNotOnboarded
case activated
}

@Published var state: State = .splash

// TODO: Update this value from the Vpn OnBoarding installation profile screen
@AppStorage(.kOnboardingVpnProfileInstalled) var onBoardingVpnProfileInstalled = true

let accountProvider: AccountProvider
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd suggest the same as the comment above. To have something that hides all the AccountProvider methods and it exposes only what the ViewModel needs.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I was thinking the same. I think we can create a AccountProviderType protocol that only contains the methods that the presentation layer needs.
Let me try to add it 👍

let notificationCenter: NotificationCenterType

init(accountProvider: AccountProvider, notificationCenter: NotificationCenterType = NotificationCenter.default) {

self.accountProvider = accountProvider
self.notificationCenter = notificationCenter
updateState()
subscribeToAccountUpdates()
}

deinit {
notificationCenter.removeObserver(self)
}

private func updateState() {
switch (accountProvider.isLoggedIn, onBoardingVpnProfileInstalled) {
// logged in, vpn profile installed
case (true, true):
state = .activated
// logged in, vpn profile not installed
case (true, false):
state = .activatedNotOnboarded
// not logged in, any
case (false, _):
state = .notActivated
}
}

private func subscribeToAccountUpdates() {
notificationCenter.addObserver(self, selector: #selector(handleAccountDidLogin), name: .PIAAccountDidLogin, object: nil)

notificationCenter.addObserver(self, selector: #selector(handleAccountDidLogout), name: .PIAAccountDidLogout, object: nil)
}

@objc func handleAccountDidLogin() {
updateState()
}

@objc func handleAccountDidLogout() {
updateState()
}


}
27 changes: 27 additions & 0 deletions PIA VPN-tvOS/RootContainer/UI/RootContainerView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@

import Foundation
import SwiftUI

struct RootContainerView: View {
@ObservedObject var viewModel: RootContainerViewModel

var body: some View {
switch viewModel.state {
case .splash:
VStack {
Text("Splash Screen")
}
case .notActivated:
LoginFactory.makeLoginView()
case .activatedNotOnboarded:
VStack {
Text("Show Onboarding vpn installation view")
}
case .activated:
DashboardFactory.makeDashboardView()
}
}


}

20 changes: 20 additions & 0 deletions PIA VPN-tvOS/Shared/Utils/Foundation+Protocols.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@

import Foundation

/// Expose Core Foundation APIs via protocols to the PIA app


protocol NotificationCenterType {
func addObserver(
_ observer: Any,
selector aSelector: Selector,
name aName: NSNotification.Name?,
object anObject: Any?
)

func removeObserver(_ observer: Any)

// Add methods here from NSNotificationCenter as needed
}

extension NotificationCenter: NotificationCenterType {}
6 changes: 6 additions & 0 deletions PIA VPN-tvOS/Shared/Utils/PIA+String.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@

import Foundation

extension String {
static let kOnboardingVpnProfileInstalled = "kOnboardingVpnProfileInstalled"
}
23 changes: 23 additions & 0 deletions PIA VPN-tvOSTests/Dashboard/Mocks/NotificationCenterMock.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@

import Foundation
@testable import PIA_VPN_tvOS

class NotificationCenterMock: NotificationCenterType {
private(set) var addObserverCalled = false
private(set) var addObserverCalledAttempt = 0
private(set) var addObserverCalledWithNotificationName: NSNotification.Name? = nil

func addObserver(_ observer: Any, selector aSelector: Selector, name aName: NSNotification.Name?, object anObject: Any?) {
addObserverCalled = true
addObserverCalledAttempt += 1
addObserverCalledWithNotificationName = aName
}

private(set) var removeObserverCalled = false
private(set) var remoververCalledAttempt = 0
func removeObserver(_ observer: Any) {
removeObserverCalled = true
remoververCalledAttempt += 1
}

}
79 changes: 79 additions & 0 deletions PIA VPN-tvOSTests/RootContainer/RootContainerViewModelTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
//
// RootContainerViewModelTests.swift
// PIA VPN-tvOSTests
//
// Created by Laura S on 12/19/23.
// Copyright © 2023 Private Internet Access Inc. All rights reserved.
//

import XCTest
@testable import PIA_VPN_tvOS

final class RootContainerViewModelTests: XCTestCase {

final class Fixture {
let accountProvierMock = AccountProviderMock(userResult: nil, errorResult: nil)
let notificationCenterMock = NotificationCenterMock()
}

var fixture: Fixture!
var sut: RootContainerViewModel!

override func setUp() {
fixture = Fixture()
}

override func tearDown() {
fixture = nil
sut = nil
UserDefaults.standard.removeObject(forKey: .kOnboardingVpnProfileInstalled)
}

private func initializeSut() {
sut = RootContainerViewModel(accountProvider: fixture.accountProvierMock, notificationCenter: fixture.notificationCenterMock)
}

func testState_WhenUserIsNotAuthenticated() {
// GIVEN that the user is not logged in
fixture.accountProvierMock.isLoggedIn = false

// WHEN the RootContainer is created
initializeSut()

// THEN the state becomes 'notActivated'
XCTAssertEqual(sut.state, .notActivated)
}

func testState_WhenUserIsAuthenticatedAndVpnProfileNotInstalled() {
// GIVEN that the user is logged in
fixture.accountProvierMock.isLoggedIn = true
// AND GIVEN that the Onboarding Vpn Profile is NOT installed
stubOnboardingVpnInstallation(finished: false)

// WHEN the RootContainer is created
initializeSut()

// THEN the state becomes 'activatedNotOnboarded'
XCTAssertEqual(sut.state, .activatedNotOnboarded)
}

func testState_WhenUserIsAuthenticatedAndVpnProfileInstalled() {
// GIVEN that the user is logged in
fixture.accountProvierMock.isLoggedIn = true
// AND GIVEN that the Onboarding Vpn Profile is installed
stubOnboardingVpnInstallation(finished: true)

// WHEN the RootContainer is created
initializeSut()

// THEN the state becomes 'activated'
XCTAssertEqual(sut.state, .activated)
}
}


extension RootContainerViewModelTests {
private func stubOnboardingVpnInstallation(finished: Bool) {
UserDefaults.standard.setValue(finished, forKey: .kOnboardingVpnProfileInstalled)
}
}
Loading
Loading