passepartout-apple/Passepartout/Library/Sources/AppUI/Views/Modules/OnDemandView.swift
Davide ee8ef34f06
Avoid nested module navigation (#749)
A NavigationLink in VPNProviderContentModifier raised a few questions
about the navigation approach in module views. It turned out that having
a Binding to a local ObservedObject (ProfileEditor) is a recipe for
disaster.

Therefore:

- We don't need a binding to the editor module (the draft), because by
doing so we end up _observing_ the same changes from two properties, the
binding and the editor. This seems to drive SwiftUI crazy and freezes
the app once we navigate from the module to another view (e.g. in
OpenVPN the credentials or the provider server). Use the module binding
as a shortcut, but do not assign the binding to the view to avoid
unnecessary observation.
- Keep .navigationDestination() in the module view, and pass a known
destination to VPNProviderContentModifier. This will save the modifier
from creating a nested NavigationLink destination. The
VPNProviderServerView is now openly instantiated by the module view when
such destination is triggered by the NavigationLink in the modifier.
- Do not implicitly dismiss VPNProviderServerView on selection, let the
presenter take care. In order to do so, we add a .navigationPath
environment key through which the module view can modify the current
navigation stack.
2024-10-23 15:42:54 +02:00

262 lines
7.4 KiB
Swift

//
// OnDemandView.swift
// Passepartout
//
// Created by Davide De Rosa on 2/23/24.
// Copyright (c) 2024 Davide De Rosa. All rights reserved.
//
// https://github.com/passepartoutvpn
//
// This file is part of Passepartout.
//
// Passepartout is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Passepartout is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
//
import PassepartoutKit
import SwiftUI
import UtilsLibrary
struct OnDemandView: View, ModuleDraftEditing {
@EnvironmentObject
private var theme: Theme
@EnvironmentObject
private var iapManager: IAPManager
@ObservedObject
var editor: ProfileEditor
let module: OnDemandModule.Builder
private let wifi: Wifi
@State
private var paywallReason: PaywallReason?
init(
editor: ProfileEditor,
module: OnDemandModule.Builder,
observer: WifiObserver? = nil
) {
self.editor = editor
self.module = module
wifi = Wifi(observer: observer ?? CoreLocationWifiObserver())
}
var body: some View {
Group {
enabledSection
restrictedArea
}
.moduleView(editor: editor, draft: draft.wrappedValue)
.modifier(PaywallModifier(reason: $paywallReason))
}
}
private extension OnDemandView {
static let allPolicies: [OnDemandModule.Policy] = [
.any,
.excluding,
.including
]
var enabledSection: some View {
Section {
Toggle(Strings.Global.enabled, isOn: draft.isEnabled)
}
}
@ViewBuilder
var restrictedArea: some View {
switch iapManager.paywallReason(forFeature: .onDemand) {
case .purchase(let appFeature):
Button(Strings.Modules.OnDemand.purchase) {
paywallReason = .purchase(appFeature)
}
case .restricted:
EmptyView()
default:
if draft.wrappedValue.isEnabled {
policySection
if draft.wrappedValue.policy != .any {
networkSection
wifiSection
}
}
}
}
var policySection: some View {
Picker(Strings.Modules.OnDemand.policy, selection: draft.policy) {
ForEach(Self.allPolicies, id: \.self) {
Text($0.localizedDescription)
}
}
.themeSection(footer: policyFooterDescription)
}
var policyFooterDescription: String {
guard draft.wrappedValue.isEnabled else {
return "" // better animation than removing footer completely
}
let suffix: String
switch draft.wrappedValue.policy {
case .any:
suffix = Strings.Modules.OnDemand.Policy.Footer.any
case .including, .excluding:
if draft.wrappedValue.policy == .including {
suffix = Strings.Modules.OnDemand.Policy.Footer.including
} else {
suffix = Strings.Modules.OnDemand.Policy.Footer.excluding
}
}
return Strings.Modules.OnDemand.Policy.footer(suffix)
}
var networkSection: some View {
Group {
if Utils.hasCellularData() {
Toggle(Strings.Modules.OnDemand.mobile, isOn: draft.withMobileNetwork)
} else if Utils.hasEthernet() {
Toggle(Strings.Modules.OnDemand.ethernet, isOn: draft.withEthernetNetwork)
}
}
.themeSection(header: Strings.Global.networks)
}
var wifiSection: some View {
theme.listSection(
Strings.Unlocalized.wifi,
addTitle: Strings.Modules.OnDemand.Ssid.add,
originalItems: allSSIDs,
emptyValue: {
do {
return try await wifi.currentSSID()
} catch {
return ""
}
},
itemLabel: { isEditing, binding in
if isEditing {
Text(binding.wrappedValue)
} else {
HStack {
ThemeTextField("", text: binding, placeholder: Strings.Placeholders.OnDemand.ssid)
.frame(maxWidth: .infinity)
.themeManualInput()
Spacer()
Toggle("", isOn: isSSIDOn(binding.wrappedValue))
}
.labelsHidden()
}
}
)
}
}
private extension OnDemandView {
var allSSIDs: Binding<[String]> {
.init {
Array(draft.wrappedValue.withSSIDs.keys)
} set: { newValue in
draft.wrappedValue.withSSIDs.forEach {
guard newValue.contains($0.key) else {
draft.wrappedValue.withSSIDs.removeValue(forKey: $0.key)
return
}
}
newValue.forEach {
guard draft.wrappedValue.withSSIDs[$0] == nil else {
return
}
draft.wrappedValue.withSSIDs[$0] = false
}
}
}
var onSSIDs: Binding<Set<String>> {
.init {
Set(draft.wrappedValue.withSSIDs.filter {
$0.value
}.map(\.key))
} set: { newValue in
draft.wrappedValue.withSSIDs.forEach {
guard newValue.contains($0.key) else {
if draft.wrappedValue.withSSIDs[$0.key] != nil {
draft.wrappedValue.withSSIDs[$0.key] = false
} else {
draft.wrappedValue.withSSIDs.removeValue(forKey: $0.key)
}
return
}
}
newValue.forEach {
guard !(draft.wrappedValue.withSSIDs[$0] ?? false) else {
return
}
draft.wrappedValue.withSSIDs[$0] = true
}
}
}
func isSSIDOn(_ ssid: String) -> Binding<Bool> {
.init {
draft.wrappedValue.withSSIDs[ssid] ?? false
} set: {
draft.wrappedValue.withSSIDs[ssid] = $0
}
}
}
private extension OnDemandView {
func requestSSID(_ text: Binding<String>) {
Task { @MainActor in
let ssid = try await wifi.currentSSID()
if !draft.wrappedValue.withSSIDs.keys.contains(ssid) {
text.wrappedValue = ssid
}
}
}
}
// MARK: - Previews
#Preview {
var module = OnDemandModule.Builder()
module.policy = .excluding
module.withMobileNetwork = true
module.withSSIDs = [
"One": true,
"Two": false,
"Three": false
]
return module.preview {
OnDemandView(
editor: $0,
module: $1,
observer: MockWifi()
)
}
}
private class MockWifi: WifiObserver {
func currentSSID() async throws -> String {
""
}
}