Skip to content

Commit

Permalink
NetP Status View Server Info + Share feedback (duckduckgo#1908)
Browse files Browse the repository at this point in the history
Task/Issue URL: https://app.asana.com/0/0/1205084446087078/f

**Description**:
This is the third and final design implementation task in the overarching Network Protection Status View task and covers just the server info / connection details view plus the share feedback link (just because it turned out to be way simpler than anticipated.
  • Loading branch information
graeme authored Aug 11, 2023
1 parent 6d90cb9 commit 5d62640
Show file tree
Hide file tree
Showing 8 changed files with 184 additions and 42 deletions.
1 change: 1 addition & 0 deletions DuckDuckGo.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -5815,6 +5815,7 @@
};
EE9286812A812BD2002B7818 /* Embed PacketTunnelProvider */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
Expand Down
7 changes: 7 additions & 0 deletions DuckDuckGo/NetworkProtectionConvenienceInitialisers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,13 @@ extension ConnectionStatusObserverThroughSession {
}
}

extension ConnectionServerInfoObserverThroughSession {
convenience init() {
self.init(platformNotificationCenter: .default,
platformDidWakeNotification: UIApplication.didBecomeActiveNotification)
}
}

extension NetworkProtectionKeychainTokenStore {
convenience init() {
// Error events to be added as part of https://app.asana.com/0/1203137811378537/1205112639044115/f
Expand Down
3 changes: 1 addition & 2 deletions DuckDuckGo/NetworkProtectionRootView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,7 @@ struct NetworkProtectionRootView: View {
NetworkProtectionInviteView(model: inviteViewModel)
case .status:
NetworkProtectionStatusView(
statusModel: NetworkProtectionStatusViewModel(),
inviteModel: inviteViewModel
statusModel: NetworkProtectionStatusViewModel()
)
}
}
Expand Down
125 changes: 87 additions & 38 deletions DuckDuckGo/NetworkProtectionStatusView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,28 +24,29 @@ import NetworkProtection

struct NetworkProtectionStatusView: View {
@ObservedObject public var statusModel: NetworkProtectionStatusViewModel
@ObservedObject public var inviteModel: NetworkProtectionInviteViewModel

var body: some View {
List {
toggle()
inviteCodeEntry()
if statusModel.shouldShowConnectionDetails {
connectionDetails()
}
}
.applyListStyle()
.navigationTitle(UserText.netPNavTitle)
}

@ViewBuilder
func toggle() -> some View {
private func toggle() -> some View {
Section {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(UserText.netPStatusViewTitle)
.font(.system(size: 16))
.foregroundColor(.titleText)
.foregroundColor(.primary)
Text(statusModel.statusMessage)
.font(.system(size: 13))
.foregroundColor(.messageText)
.foregroundColor(.secondary)
}

Toggle("", isOn: Binding(
Expand All @@ -59,43 +60,94 @@ struct NetworkProtectionStatusView: View {
.disabled(statusModel.shouldDisableToggle)
.toggleStyle(SwitchToggleStyle(tint: .toggleColor))
}
.background(Color.cellBackground)
.listRowBackground(Color.cellBackground)
} header: {
HStack {
Spacer()
VStack(alignment: .center, spacing: 16) {
Image(statusModel.statusImageID)
.resizable()
.scaledToFit()
.frame(height: 96)
.padding(8)
Text(statusModel.headerTitle)
.font(.system(size: 17, weight: .semibold))
.multilineTextAlignment(.center)
.foregroundColor(.titleText)
Text(UserText.netPStatusHeaderMessage)
.font(.system(size: 13))
.multilineTextAlignment(.center)
.foregroundColor(.messageText)
}
.padding(.bottom, 4)
.background(Color.viewBackground)
Spacer()
header()
} footer: {
if !statusModel.shouldShowConnectionDetails {
inviteOnlyFooter()
}
}.increaseHeaderProminence()
}

@ViewBuilder
func inviteCodeEntry() -> some View {
private func header() -> some View {
HStack {
Spacer()
VStack(alignment: .center, spacing: 16) {
Image(statusModel.statusImageID)
.resizable()
.scaledToFit()
.frame(height: 96)
.padding(8)
Text(statusModel.headerTitle)
.font(.system(size: 17, weight: .semibold))
.multilineTextAlignment(.center)
.foregroundColor(.primary)
Text(UserText.netPStatusHeaderMessage)
.font(.system(size: 13))
.multilineTextAlignment(.center)
.foregroundColor(.secondary)
}
.padding(.bottom, 4)
.background(Color.viewBackground)
Spacer()
}
}

@ViewBuilder
private func connectionDetails() -> some View {
Section {
Button("Clear Invite Code") {
Task {
await inviteModel.clear()
}
if let location = statusModel.location {
NetworkProtectionServerItemView(
imageID: "Server-Location-24",
title: UserText.netPStatusViewLocation,
value: location
)
}
if let ipAddress = statusModel.ipAddress {
NetworkProtectionServerItemView(
imageID: "IP-24",
title: UserText.netPStatusViewIPAddress,
value: ipAddress
)
}
.foregroundColor(.red)
} header: {
Text(UserText.netPStatusViewConnectionDetails).foregroundColor(.primary)
} footer: {
inviteOnlyFooter()
}
}

@ViewBuilder
private func inviteOnlyFooter() -> some View {
// Needs to be inlined like this for the markdown parsing to work
Text("\(UserText.netPInviteOnlyMessage) [\(UserText.netPStatusViewShareFeedback)](https://form.asana.com/?k=_wNLt6YcT5ILpQjDuW0Mxw&d=137249556945)")
.foregroundColor(.secondary)
.accentColor(.accentColor)
.font(.system(size: 13))
.padding(.top, 6)
}
}

private struct NetworkProtectionServerItemView: View {
let imageID: String
let title: String
let value: String

var body: some View {
HStack(spacing: 16) {
Image(imageID)
Text(title)
.font(.system(size: 16))
.foregroundColor(.primary)
Spacer()
Text(value)
.font(.system(size: 16))
.foregroundColor(.secondary)
}
.listRowBackground(Color.cellBackground)
}
}

private extension View {
Expand All @@ -116,11 +168,11 @@ private extension View {
@ViewBuilder
func applyListStyle() -> some View {
self
.listStyle(.insetGrouped)
.listStyle(.insetGrouped)
.hideScrollContentBackground()
.background(
Rectangle().ignoresSafeArea().foregroundColor(Color.viewBackground))
Rectangle().ignoresSafeArea().foregroundColor(Color.viewBackground)
)
}

@ViewBuilder
Expand All @@ -143,10 +195,7 @@ private extension Color {

struct NetworkProtectionStatusView_Previews: PreviewProvider {
static var previews: some View {
let inviteViewModel = NetworkProtectionInviteViewModel(
redemptionCoordinator: NetworkProtectionCodeRedemptionCoordinator()
) { }
NetworkProtectionStatusView(statusModel: NetworkProtectionStatusViewModel(), inviteModel: inviteViewModel)
NetworkProtectionStatusView(statusModel: NetworkProtectionStatusViewModel())
}
}

Expand Down
30 changes: 29 additions & 1 deletion DuckDuckGo/NetworkProtectionStatusViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ final class NetworkProtectionStatusViewModel: ObservableObject {

private let tunnelController: TunnelController
private let statusObserver: ConnectionStatusObserver
private let serverInfoObserver: ConnectionServerInfoObserver
private var cancellables: Set<AnyCancellable> = []

// MARK: Header
Expand All @@ -44,17 +45,44 @@ final class NetworkProtectionStatusViewModel: ObservableObject {
@Published public var statusMessage: String
@Published public var shouldDisableToggle: Bool = false

// MARK: Connection Details
@Published public var shouldShowConnectionDetails: Bool = false
@Published public var location: String?
@Published public var ipAddress: String?

public init(tunnelController: TunnelController = NetworkProtectionTunnelController(),
statusObserver: ConnectionStatusObserver = ConnectionStatusObserverThroughSession()) {
statusObserver: ConnectionStatusObserver = ConnectionStatusObserverThroughSession(),
serverInfoObserver: ConnectionServerInfoObserver = ConnectionServerInfoObserverThroughSession()) {
self.tunnelController = tunnelController
self.statusObserver = statusObserver
self.serverInfoObserver = serverInfoObserver
statusMessage = Self.message(for: statusObserver.recentValue)
self.headerTitle = Self.titleText(connected: statusObserver.recentValue.isConnected)
self.statusImageID = Self.statusImageID(connected: statusObserver.recentValue.isConnected)

setUpIsConnectedStatePublishers()
setUpStatusMessagePublishers()
setUpDisableTogglePublisher()

serverInfoObserver.publisher
.map(\.serverLocation)
.receive(on: DispatchQueue.main)
.assign(to: \.location, onWeaklyHeld: self)
.store(in: &cancellables)

serverInfoObserver.publisher
.map(\.serverAddress)
.receive(on: DispatchQueue.main)
.assign(to: \.ipAddress, onWeaklyHeld: self)
.store(in: &cancellables)

serverInfoObserver.publisher
.map {
$0.serverAddress != nil || $0.serverLocation != nil
}
.receive(on: DispatchQueue.main)
.assign(to: \.shouldShowConnectionDetails, onWeaklyHeld: self)
.store(in: &cancellables)
}

private func setUpIsConnectedStatePublishers() {
Expand Down
6 changes: 6 additions & 0 deletions DuckDuckGo/UserText.swift
Original file line number Diff line number Diff line change
Expand Up @@ -610,12 +610,14 @@ In addition to the details entered into this form, your app issue report will co
public static let netPNavTitle = NSLocalizedString("netP.title", value: "Network Protection", comment: "Title for the Network Protection feature")
public static let netPCellConnected = NSLocalizedString("netP.cell.connected", value: "Connected", comment: "String indicating NetP is connected when viewed from the settings screen")
public static let netPCellDisconnected = NSLocalizedString("netP.cell.disconnected", value: "Not connected", comment: "String indicating NetP is disconnected when viewed from the settings screen")

static let netPInviteTitle = NSLocalizedString("network.protection.invite.dialog.title", value: "You're invited to try Network Protection", comment: "Title for the network protection invite screen")
static let netPInviteMessage = NSLocalizedString("network.protection.invite.dialog.message", value: "Enter your invite code to get started.", comment: "Message for the network protection invite dialog")
static let netPInviteFieldPrompt = NSLocalizedString("network.protection.invite.field.prompt", value: "Invite Code", comment: "Prompt for the network protection invite code text field")
static let netPInviteSuccessTitle = NSLocalizedString("network.protection.invite.success.title", value: "Success! You’re in.", comment: "Title for the network protection invite success view")
static let netPInviteSuccessMessage = NSLocalizedString("network.protection.invite.success.message", value: "Hide your location from websites and conceal your online activity from Internet providers and others on your network.", comment: "Message for the network protection invite success view")
static let netPInviteOnlyMessage = NSLocalizedString("network.protection.invite.only.message", value: "DuckDuckGo Network Protection is currently invite-only.", comment: "Message explaining that netP is invite only")

static let netPStatusViewTitle = NSLocalizedString("network.protection.status.view.title", value: "Network Protection", comment: "Title label text for the status view when netP is disconnected")
static let netPStatusHeaderTitleOff = NSLocalizedString("network.protection.status.header.title.off", value: "Network Protection is Off", comment: "Header title label text for the status view when netP is disconnected")
static let netPStatusHeaderTitleOn = NSLocalizedString("network.protection.status.header.title.on", value: "Network Protection is On", comment: "Header title label text for the status view when netP is connected")
Expand All @@ -627,6 +629,10 @@ In addition to the details entered into this form, your app issue report will co
let localized = NSLocalizedString("network.protection.status.connected.format", value: "Connected - %@", comment: "The label for when NetP VPN is connected plus the length of time connected as a formatter HH:MM:SS string")
return String(format: localized, timeLapsedString)
}
static let netPStatusViewLocation = NSLocalizedString("network.protection.status.view.location", value: "Location", comment: "Location label shown in NetworkProtection's status view.")
static let netPStatusViewIPAddress = NSLocalizedString("network.protection.status.view.ip.address", value: "IP Address", comment: "IP Address label shown in NetworkProtection's status view.")
static let netPStatusViewConnectionDetails = NSLocalizedString("network.protection.status.view.connection.details", value: "Connection Details", comment: "Connection details label shown in NetworkProtection's status view.")
static let netPStatusViewShareFeedback = NSLocalizedString("network.protection.status.menu.share.feedback", value: "Share Feedback", comment: "The status view 'Share Feedback' button which is shown inline on the status view after the \(netPInviteOnlyMessage) text")

static let inviteDialogSubmitButton = NSLocalizedString("invite.dialog.submit.button", value: "Submit", comment: "Submit button on an invite dialog")
static let inviteDialogGetStartedButton = NSLocalizedString("invite.dialog.get.started.button", value: "Get Started", comment: "Get Started button on an invite dialog")
Expand Down
12 changes: 12 additions & 0 deletions DuckDuckGo/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -1354,6 +1354,18 @@ https://duckduckgo.com/mac";
/* Header title label text for the status view when netP is connected */
"network.protection.status.header.title.on" = "Network Protection is On";

/* The status view 'Share Feedback' button which is shown inline on the status view after the \(netPInviteOnlyMessage) text */
"network.protection.status.menu.share.feedback" = "Share Feedback";

/* Connection details label shown in NetworkProtection's status view. */
"network.protection.status.view.connection.details" = "Connection Details";

/* IP Address label shown in NetworkProtection's status view. */
"network.protection.status.view.ip.address" = "IP Address";

/* Location label shown in NetworkProtection's status view. */
"network.protection.status.view.location" = "Location";

/* Title label text for the status view when netP is disconnected */
"network.protection.status.view.title" = "Network Protection";

Expand Down
42 changes: 41 additions & 1 deletion DuckDuckGoTests/NetworkProtectionStatusViewModelTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import NetworkProtectionTestUtils
final class NetworkProtectionStatusViewModelTests: XCTestCase {
private var tunnelController: MockTunnelController!
private var statusObserver: MockConnectionStatusObserver!
private var serverInfoObserver: MockConnectionServerInfoObserver!
private var viewModel: NetworkProtectionStatusViewModel!

private var testError: Error {
Expand All @@ -37,10 +38,16 @@ final class NetworkProtectionStatusViewModelTests: XCTestCase {
super.setUp()
tunnelController = MockTunnelController()
statusObserver = MockConnectionStatusObserver()
viewModel = NetworkProtectionStatusViewModel(tunnelController: tunnelController, statusObserver: statusObserver)
serverInfoObserver = MockConnectionServerInfoObserver()
viewModel = NetworkProtectionStatusViewModel(
tunnelController: tunnelController,
statusObserver: statusObserver,
serverInfoObserver: serverInfoObserver
)
}

override func tearDown() {
serverInfoObserver = nil
statusObserver = nil
tunnelController = nil
viewModel = nil
Expand Down Expand Up @@ -138,6 +145,39 @@ final class NetworkProtectionStatusViewModelTests: XCTestCase {
}
}

func testStatusUpdate_publishesLocation() throws {
let location = "SomeLocation"
let serverInfo = NetworkProtectionStatusServerInfo(serverLocation: location, serverAddress: nil)
serverInfoObserver.subject.send(serverInfo)
try waitForPublisher(viewModel.$location, toEmit: location)
}

func testStatusUpdate_publishesIPAddress() throws {
let ipAddress = "123.456.789.147"
let serverInfo = NetworkProtectionStatusServerInfo(serverLocation: nil, serverAddress: ipAddress)
serverInfoObserver.subject.send(serverInfo)
try waitForPublisher(viewModel.$ipAddress, toEmit: ipAddress)
}

func testStatusUpdate_nilServerLocationAndServerAddress_hidesConnectionDetails() throws {
let serverInfo = NetworkProtectionStatusServerInfo(serverLocation: nil, serverAddress: nil)
// Wait for initial value first
try waitForPublisher(viewModel.$shouldShowConnectionDetails, toEmit: false)
serverInfoObserver.subject.send(serverInfo)
try waitForPublisher(viewModel.$shouldShowConnectionDetails, toEmit: false)
}

func testStatusUpdate_anyServerInfoPropertiesNonNil_showsConnectionDetails() throws {
for serverInfo in [
NetworkProtectionStatusServerInfo(serverLocation: nil, serverAddress: "123.123.123.123"),
NetworkProtectionStatusServerInfo(serverLocation: "Antartica", serverAddress: nil),
NetworkProtectionStatusServerInfo(serverLocation: "Your Garden", serverAddress: "111.222.333.444")
] {
serverInfoObserver.subject.send(serverInfo)
try waitForPublisher(viewModel.$shouldShowConnectionDetails, toEmit: true)
}
}

// MARK: - Helpers

private func whenStatusUpdate_connected() {
Expand Down

0 comments on commit 5d62640

Please sign in to comment.