mirror of
https://github.com/passepartoutvpn/passepartout-apple.git
synced 2024-12-24 18:32:36 +00:00
ee8ef34f06
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.
262 lines
7.4 KiB
Swift
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 {
|
|
""
|
|
}
|
|
}
|