Create basic UI for TV app (#798)
Start with the profile tab. Left to do: search and settings. Fixes and refactoring: - Listen to changes in current profile in ExtendedTunnel - Externalize style from TunnelToggleButton and ConnectionStatusText (renamed from View) - Add ThemeCountryText for convenience
This commit is contained in:
parent
357c505cc0
commit
72e784272a
|
@ -19,6 +19,7 @@
|
|||
0EC066D12C7DC47600D88A94 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 0EC066D02C7DC47600D88A94 /* LaunchScreen.storyboard */; platformFilter = ios; };
|
||||
0EC332CA2B8A1808000B9C2F /* NetworkExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0EC332C92B8A1808000B9C2F /* NetworkExtension.framework */; };
|
||||
0EC332D22B8A1808000B9C2F /* PassepartoutTunnel.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 0EC332C82B8A1808000B9C2F /* PassepartoutTunnel.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
0EC4A7132CD597EF00B7CAAD /* AppUIPlatform in Frameworks */ = {isa = PBXBuildFile; productRef = 0EC4A7122CD597EF00B7CAAD /* AppUIPlatform */; };
|
||||
0EC797422B9378E000C093B7 /* Shared+App.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EC797402B9378E000C093B7 /* Shared+App.swift */; };
|
||||
0EC797432B9378E000C093B7 /* Shared.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EC797412B9378E000C093B7 /* Shared.swift */; };
|
||||
0EC797442B93790600C093B7 /* Shared.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EC797412B9378E000C093B7 /* Shared.swift */; };
|
||||
|
@ -27,8 +28,6 @@
|
|||
0EDE56EA2CABE40D0082D21C /* Intents.plist in Resources */ = {isa = PBXBuildFile; fileRef = 0EDE56E62CABE40D0082D21C /* Intents.plist */; };
|
||||
0EDE56FA2CABE42E0082D21C /* PassepartoutIntents.appex in Embed ExtensionKit Extensions */ = {isa = PBXBuildFile; fileRef = 0EDE56F02CABE42E0082D21C /* PassepartoutIntents.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
0EDE57002CABE4B50082D21C /* IntentsExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EDE56E72CABE40D0082D21C /* IntentsExtension.swift */; };
|
||||
0EE8D7DD2CD1107E00F6600C /* AppUIMain in Frameworks */ = {isa = PBXBuildFile; platformFilters = (ios, macos, ); productRef = 0EE8D7DC2CD1107E00F6600C /* AppUIMain */; };
|
||||
0EE8D7DF2CD1108900F6600C /* AppUITV in Frameworks */ = {isa = PBXBuildFile; platformFilters = (tvos, ); productRef = 0EE8D7DE2CD1108900F6600C /* AppUITV */; };
|
||||
0EE8D7E12CD112C200F6600C /* App+tvOS.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EE8D7E02CD112C200F6600C /* App+tvOS.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
|
@ -145,8 +144,7 @@
|
|||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
0EE8D7DF2CD1108900F6600C /* AppUITV in Frameworks */,
|
||||
0EE8D7DD2CD1107E00F6600C /* AppUIMain in Frameworks */,
|
||||
0EC4A7132CD597EF00B7CAAD /* AppUIPlatform in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
@ -292,8 +290,7 @@
|
|||
);
|
||||
name = Passepartout;
|
||||
packageProductDependencies = (
|
||||
0EE8D7DC2CD1107E00F6600C /* AppUIMain */,
|
||||
0EE8D7DE2CD1108900F6600C /* AppUITV */,
|
||||
0EC4A7122CD597EF00B7CAAD /* AppUIPlatform */,
|
||||
);
|
||||
productName = PassepartoutKit;
|
||||
productReference = 0E06D18F2B87629100176E1D /* Passepartout.app */;
|
||||
|
@ -998,13 +995,9 @@
|
|||
isa = XCSwiftPackageProductDependency;
|
||||
productName = TunnelLibrary;
|
||||
};
|
||||
0EE8D7DC2CD1107E00F6600C /* AppUIMain */ = {
|
||||
0EC4A7122CD597EF00B7CAAD /* AppUIPlatform */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = AppUIMain;
|
||||
};
|
||||
0EE8D7DE2CD1108900F6600C /* AppUITV */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = AppUITV;
|
||||
productName = AppUIPlatform;
|
||||
};
|
||||
/* End XCSwiftPackageProductDependency section */
|
||||
};
|
||||
|
|
|
@ -25,6 +25,10 @@ let package = Package(
|
|||
name: "AppUIMain",
|
||||
targets: ["AppUIMain"]
|
||||
),
|
||||
.library(
|
||||
name: "AppUIPlatform",
|
||||
targets: ["AppUIPlatform"]
|
||||
),
|
||||
.library(
|
||||
name: "AppUITV",
|
||||
targets: ["AppUITV"]
|
||||
|
@ -109,6 +113,13 @@ let package = Package(
|
|||
.process("Resources")
|
||||
]
|
||||
),
|
||||
.target(
|
||||
name: "AppUIPlatform",
|
||||
dependencies: [
|
||||
.target(name: "AppUIMain", condition: .when(platforms: [.iOS, .macOS])),
|
||||
.target(name: "AppUITV", condition: .when(platforms: [.tvOS]))
|
||||
]
|
||||
),
|
||||
.target(
|
||||
name: "AppUITV",
|
||||
dependencies: ["AppUI"]
|
||||
|
|
|
@ -84,6 +84,14 @@ public final class ExtendedTunnel: ObservableObject {
|
|||
}
|
||||
.store(in: &subscriptions)
|
||||
|
||||
tunnel
|
||||
.$currentProfile
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.objectWillChange.send()
|
||||
}
|
||||
.store(in: &subscriptions)
|
||||
|
||||
Timer
|
||||
.publish(every: interval, on: .main, in: .common)
|
||||
.autoconnect()
|
||||
|
|
|
@ -297,6 +297,8 @@ public enum Strings {
|
|||
public static let routing = Strings.tr("Localizable", "global.routing", fallback: "Routing")
|
||||
/// Save
|
||||
public static let save = Strings.tr("Localizable", "global.save", fallback: "Save")
|
||||
/// Select
|
||||
public static let select = Strings.tr("Localizable", "global.select", fallback: "Select")
|
||||
/// Server
|
||||
public static let server = Strings.tr("Localizable", "global.server", fallback: "Server")
|
||||
/// Servers
|
||||
|
|
|
@ -62,6 +62,7 @@
|
|||
"global.routes" = "Routes";
|
||||
"global.routing" = "Routing";
|
||||
"global.save" = "Save";
|
||||
"global.select" = "Select";
|
||||
"global.server" = "Server";
|
||||
"global.servers" = "Servers";
|
||||
"global.settings" = "Settings";
|
||||
|
|
|
@ -148,13 +148,13 @@ extension ThemeTappableText {
|
|||
|
||||
extension ThemeTextField {
|
||||
public var body: some View {
|
||||
commonView
|
||||
labeledView
|
||||
}
|
||||
}
|
||||
|
||||
extension ThemeSecureField {
|
||||
public var body: some View {
|
||||
commonView
|
||||
labeledView
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -108,15 +108,13 @@ extension ThemeTappableText {
|
|||
|
||||
extension ThemeTextField {
|
||||
public var body: some View {
|
||||
commonView
|
||||
.labelsHidden()
|
||||
fieldView
|
||||
}
|
||||
}
|
||||
|
||||
extension ThemeSecureField {
|
||||
public var body: some View {
|
||||
commonView
|
||||
.labelsHidden()
|
||||
fieldView
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -67,13 +67,13 @@ extension ThemeSectionWithHeaderFooterModifier {
|
|||
|
||||
extension ThemeTextField {
|
||||
public var body: some View {
|
||||
commonView
|
||||
TextField(placeholder, text: $text)
|
||||
}
|
||||
}
|
||||
|
||||
extension ThemeSecureField {
|
||||
public var body: some View {
|
||||
commonView
|
||||
SecureField(placeholder, text: $text)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -50,6 +50,7 @@ extension Theme {
|
|||
case profilesGrid
|
||||
case profilesList
|
||||
case remove
|
||||
case search
|
||||
case settings
|
||||
case share
|
||||
case show
|
||||
|
@ -90,6 +91,7 @@ extension Theme.ImageName {
|
|||
case .profilesGrid: return "square.grid.2x2"
|
||||
case .profilesList: return "rectangle.grid.1x2"
|
||||
case .remove: return "minus"
|
||||
case .search: return "magnifyingglass"
|
||||
case .settings: return "gearshape"
|
||||
case .share: return "square.and.arrow.up"
|
||||
case .show: return "eye"
|
||||
|
|
|
@ -91,6 +91,25 @@ public struct ThemeImageLabel: View {
|
|||
}
|
||||
}
|
||||
|
||||
public struct ThemeCountryText: View {
|
||||
private let code: String
|
||||
|
||||
private let title: String?
|
||||
|
||||
public init(_ code: String, title: String? = nil) {
|
||||
self.code = code
|
||||
self.title = title ?? code.localizedAsRegionCode
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
Text(
|
||||
[code, title]
|
||||
.compactMap { $0 }
|
||||
.joined(separator: " ")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
public struct ThemeCountryFlag: View {
|
||||
private let code: String?
|
||||
|
||||
|
@ -124,12 +143,12 @@ public struct ThemeCountryFlag: View {
|
|||
}
|
||||
|
||||
public struct ThemeTextField: View {
|
||||
private let title: String?
|
||||
let title: String?
|
||||
|
||||
@Binding
|
||||
private var text: String
|
||||
var text: String
|
||||
|
||||
private let placeholder: String
|
||||
let placeholder: String
|
||||
|
||||
public init(_ title: String, text: Binding<String>, placeholder: String) {
|
||||
self.title = title
|
||||
|
@ -138,7 +157,7 @@ public struct ThemeTextField: View {
|
|||
}
|
||||
|
||||
@ViewBuilder
|
||||
var commonView: some View {
|
||||
var labeledView: some View {
|
||||
if let title {
|
||||
LabeledContent {
|
||||
fieldView
|
||||
|
@ -150,18 +169,18 @@ public struct ThemeTextField: View {
|
|||
}
|
||||
}
|
||||
|
||||
private var fieldView: some View {
|
||||
var fieldView: some View {
|
||||
TextField(title ?? "", text: $text, prompt: Text(placeholder))
|
||||
}
|
||||
}
|
||||
|
||||
public struct ThemeSecureField: View {
|
||||
private let title: String?
|
||||
let title: String?
|
||||
|
||||
@Binding
|
||||
private var text: String
|
||||
var text: String
|
||||
|
||||
private let placeholder: String
|
||||
let placeholder: String
|
||||
|
||||
public init(title: String?, text: Binding<String>, placeholder: String) {
|
||||
self.title = title
|
||||
|
@ -170,7 +189,7 @@ public struct ThemeSecureField: View {
|
|||
}
|
||||
|
||||
@ViewBuilder
|
||||
var commonView: some View {
|
||||
var labeledView: some View {
|
||||
if let title {
|
||||
LabeledContent {
|
||||
fieldView
|
||||
|
@ -182,7 +201,7 @@ public struct ThemeSecureField: View {
|
|||
}
|
||||
}
|
||||
|
||||
private var fieldView: some View {
|
||||
var fieldView: some View {
|
||||
RevealingSecureField(title ?? "", text: $text, prompt: Text(placeholder), imageWidth: 30.0) {
|
||||
ThemeImage(.hide)
|
||||
.foregroundStyle(Color.accentColor)
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
//
|
||||
// ConnectionStatusView.swift
|
||||
// ConnectionStatusText.swift
|
||||
// Passepartout
|
||||
//
|
||||
// Created by Davide De Rosa on 9/4/24.
|
||||
|
@ -28,7 +28,7 @@ import Foundation
|
|||
import PassepartoutKit
|
||||
import SwiftUI
|
||||
|
||||
public struct ConnectionStatusView: View, ThemeProviding {
|
||||
public struct ConnectionStatusText: View, ThemeProviding {
|
||||
|
||||
@EnvironmentObject
|
||||
public var theme: Theme
|
||||
|
@ -42,12 +42,10 @@ public struct ConnectionStatusView: View, ThemeProviding {
|
|||
|
||||
public var body: some View {
|
||||
Text(statusDescription)
|
||||
.font(.headline)
|
||||
.foregroundStyle(tunnel.statusColor(theme))
|
||||
}
|
||||
}
|
||||
|
||||
private extension ConnectionStatusView {
|
||||
private extension ConnectionStatusText {
|
||||
var statusDescription: String {
|
||||
if let lastErrorCode = tunnel.lastErrorCode {
|
||||
return lastErrorCode.localizedDescription
|
||||
|
@ -76,7 +74,7 @@ private extension ConnectionStatusView {
|
|||
}
|
||||
|
||||
#Preview("Connected") {
|
||||
ConnectionStatusView(tunnel: .mock)
|
||||
ConnectionStatusText(tunnel: .mock)
|
||||
.task {
|
||||
try? await ExtendedTunnel.mock.connect(with: .mock, processor: .mock)
|
||||
}
|
||||
|
@ -95,7 +93,7 @@ private extension ConnectionStatusView {
|
|||
} catch {
|
||||
fatalError()
|
||||
}
|
||||
return ConnectionStatusView(tunnel: .mock)
|
||||
return ConnectionStatusText(tunnel: .mock)
|
||||
.task {
|
||||
try? await ExtendedTunnel.mock.connect(with: profile, processor: .mock)
|
||||
}
|
|
@ -29,11 +29,6 @@ import SwiftUI
|
|||
import UtilsLibrary
|
||||
|
||||
public struct TunnelToggleButton<Label>: View, ThemeProviding where Label: View {
|
||||
public enum Style {
|
||||
case plain
|
||||
|
||||
case color
|
||||
}
|
||||
|
||||
@EnvironmentObject
|
||||
public var theme: Theme
|
||||
|
@ -44,8 +39,6 @@ public struct TunnelToggleButton<Label>: View, ThemeProviding where Label: View
|
|||
@EnvironmentObject
|
||||
private var profileProcessor: ProfileProcessor
|
||||
|
||||
private let style: Style
|
||||
|
||||
@ObservedObject
|
||||
private var tunnel: ExtendedTunnel
|
||||
|
||||
|
@ -63,7 +56,6 @@ public struct TunnelToggleButton<Label>: View, ThemeProviding where Label: View
|
|||
private let label: (Bool) -> Label
|
||||
|
||||
public init(
|
||||
style: Style = .plain,
|
||||
tunnel: ExtendedTunnel,
|
||||
profile: Profile?,
|
||||
nextProfileId: Binding<Profile.ID?>,
|
||||
|
@ -72,7 +64,6 @@ public struct TunnelToggleButton<Label>: View, ThemeProviding where Label: View
|
|||
onProviderEntityRequired: ((Profile) -> Void)? = nil,
|
||||
label: @escaping (Bool) -> Label
|
||||
) {
|
||||
self.style = style
|
||||
self.tunnel = tunnel
|
||||
self.profile = profile
|
||||
_nextProfileId = nextProfileId
|
||||
|
@ -86,7 +77,6 @@ public struct TunnelToggleButton<Label>: View, ThemeProviding where Label: View
|
|||
Button(action: tryPerform) {
|
||||
label(canConnect)
|
||||
}
|
||||
.foregroundStyle(color)
|
||||
#if os(macOS)
|
||||
.buttonStyle(.plain)
|
||||
.cursor(.hand)
|
||||
|
@ -103,16 +93,6 @@ private extension TunnelToggleButton {
|
|||
var canConnect: Bool {
|
||||
!isInstalled || (tunnel.status == .inactive && tunnel.currentProfile?.onDemand != true)
|
||||
}
|
||||
|
||||
var color: Color {
|
||||
switch style {
|
||||
case .plain:
|
||||
return .primary
|
||||
|
||||
case .color:
|
||||
return tunnel.statusColor(theme)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension TunnelToggleButton {
|
||||
|
|
|
@ -100,14 +100,15 @@ private extension InstalledProfileView {
|
|||
var statusView: some View {
|
||||
HStack {
|
||||
providerSelectorButton
|
||||
ConnectionStatusView(tunnel: tunnel)
|
||||
ConnectionStatusText(tunnel: tunnel)
|
||||
.font(.body)
|
||||
.foregroundStyle(tunnel.statusColor(theme))
|
||||
.opacity(installedOpacity)
|
||||
}
|
||||
}
|
||||
|
||||
var toggleButton: some View {
|
||||
TunnelToggleButton(
|
||||
style: .color,
|
||||
tunnel: tunnel,
|
||||
profile: profile,
|
||||
nextProfileId: $nextProfileId,
|
||||
|
@ -121,6 +122,7 @@ private extension InstalledProfileView {
|
|||
)
|
||||
// TODO: #584, necessary to avoid cell selection
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(tunnel.statusColor(theme))
|
||||
.opacity(installedOpacity)
|
||||
}
|
||||
|
||||
|
|
|
@ -104,6 +104,7 @@ private extension ProfileRowView {
|
|||
.contentShape(.rect)
|
||||
}
|
||||
)
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
|
||||
var sharingView: some View {
|
||||
|
|
|
@ -81,7 +81,7 @@ private extension VPNFiltersView {
|
|||
Text(Strings.Global.any)
|
||||
.tag(nil as String?)
|
||||
ForEach(model.countries, id: \.code) {
|
||||
Text("\($0.code.asCountryCodeEmoji) \($0.description)")
|
||||
ThemeCountryText($0.code, title: $0.description)
|
||||
.tag($0.code as String?)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -166,7 +166,7 @@ private extension VPNProviderServerView.ServersSubview {
|
|||
DisclosureGroup {
|
||||
ForEach(servers, id: \.id, content: serverView)
|
||||
} label: {
|
||||
Text("\(code.asCountryCodeEmoji) \(code.localizedAsRegionCode ?? code)")
|
||||
ThemeCountryText(code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -72,7 +72,7 @@ extension VPNProviderServerView {
|
|||
.width(10.0)
|
||||
|
||||
TableColumn(Strings.Global.region) { server in
|
||||
Text("\(server.provider.countryCode.asCountryCodeEmoji) \(server.region)")
|
||||
ThemeCountryText(server.provider.countryCode, title: server.region)
|
||||
.help(server.region)
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,26 @@
|
|||
//
|
||||
// Dummy.swift
|
||||
// Passepartout
|
||||
//
|
||||
// Created by Davide De Rosa on 11/2/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 Foundation
|
|
@ -27,8 +27,6 @@ import AppLibrary
|
|||
import PassepartoutKit
|
||||
import SwiftUI
|
||||
|
||||
// FIXME: #788, UI for Apple TV
|
||||
|
||||
public struct AppCoordinator: View, AppCoordinatorConforming {
|
||||
private let profileManager: ProfileManager
|
||||
|
||||
|
@ -43,18 +41,42 @@ public struct AppCoordinator: View, AppCoordinatorConforming {
|
|||
}
|
||||
|
||||
public var body: some View {
|
||||
ProfilesView(profileManager: profileManager)
|
||||
debugChanges()
|
||||
return TabView {
|
||||
profileView
|
||||
.tabItem {
|
||||
Text(Strings.Global.profile)
|
||||
}
|
||||
|
||||
searchView
|
||||
.tabItem {
|
||||
ThemeImage(.search)
|
||||
}
|
||||
|
||||
settingsView
|
||||
.tabItem {
|
||||
ThemeImage(.settings)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ProfilesView: View {
|
||||
private extension AppCoordinator {
|
||||
var profileView: some View {
|
||||
ProfileView(profileManager: profileManager, tunnel: tunnel)
|
||||
}
|
||||
|
||||
@ObservedObject
|
||||
var profileManager: ProfileManager
|
||||
// FIXME: #788, UI for TV
|
||||
var searchView: some View {
|
||||
VStack {
|
||||
Text("Search")
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ForEach(profileManager.headers, id: \.id) {
|
||||
Text($0.name)
|
||||
// FIXME: #788, UI for TV
|
||||
var settingsView: some View {
|
||||
VStack {
|
||||
Text("Settings")
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,108 @@
|
|||
//
|
||||
// ActiveProfileView.swift
|
||||
// Passepartout
|
||||
//
|
||||
// Created by Davide De Rosa on 11/1/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 AppLibrary
|
||||
import PassepartoutKit
|
||||
import SwiftUI
|
||||
import UtilsLibrary
|
||||
|
||||
struct ActiveProfileView: View {
|
||||
let profile: Profile?
|
||||
|
||||
let firstProfileId: Profile.ID?
|
||||
|
||||
@ObservedObject
|
||||
var tunnel: ExtendedTunnel
|
||||
|
||||
@Binding
|
||||
var isSwitching: Bool
|
||||
|
||||
@FocusState.Binding
|
||||
var focusedField: ProfileView.Field?
|
||||
|
||||
@ObservedObject
|
||||
var interactiveManager: InteractiveManager
|
||||
|
||||
@ObservedObject
|
||||
var errorHandler: ErrorHandler
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
VStack {
|
||||
currentProfileView
|
||||
statusView
|
||||
Group {
|
||||
toggleConnectionButton
|
||||
switchProfileButton
|
||||
}
|
||||
.padding(.horizontal, 100)
|
||||
}
|
||||
.padding(.top, 100)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension ActiveProfileView {
|
||||
var currentProfileView: some View {
|
||||
Text(profile?.name ?? Strings.Views.Profiles.Rows.notInstalled)
|
||||
.font(.title)
|
||||
.fontWeight(.bold)
|
||||
}
|
||||
|
||||
var statusView: some View {
|
||||
ConnectionStatusText(tunnel: tunnel)
|
||||
.font(.title2)
|
||||
}
|
||||
|
||||
var toggleConnectionButton: some View {
|
||||
TunnelToggleButton(
|
||||
tunnel: tunnel,
|
||||
profile: profile,
|
||||
nextProfileId: .constant(nil),
|
||||
interactiveManager: interactiveManager,
|
||||
errorHandler: errorHandler,
|
||||
label: {
|
||||
Text($0 ? Strings.Global.connect : Strings.Global.disconnect)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
)
|
||||
.focused($focusedField, equals: .connect)
|
||||
}
|
||||
|
||||
var switchProfileButton: some View {
|
||||
Button {
|
||||
if let focus = tunnel.currentProfile?.id ?? firstProfileId {
|
||||
focusedField = .profile(focus)
|
||||
}
|
||||
isSwitching.toggle()
|
||||
} label: {
|
||||
Text(Strings.Global.select)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.focused($focusedField, equals: .switchProfile)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,86 @@
|
|||
//
|
||||
// ProfileListView.swift
|
||||
// Passepartout
|
||||
//
|
||||
// Created by Davide De Rosa on 11/1/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 AppLibrary
|
||||
import PassepartoutKit
|
||||
import SwiftUI
|
||||
import UtilsLibrary
|
||||
|
||||
struct ProfileListView: View {
|
||||
|
||||
@ObservedObject
|
||||
var profileManager: ProfileManager
|
||||
|
||||
@ObservedObject
|
||||
var tunnel: ExtendedTunnel
|
||||
|
||||
@FocusState.Binding
|
||||
var focusedField: ProfileView.Field?
|
||||
|
||||
@ObservedObject
|
||||
var interactiveManager: InteractiveManager
|
||||
|
||||
@ObservedObject
|
||||
var errorHandler: ErrorHandler
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section {
|
||||
ForEach(profileManager.headers, id: \.id, content: toggleButton(for:))
|
||||
} header: {
|
||||
Text(Strings.Views.Profiles.Folders.default)
|
||||
}
|
||||
}
|
||||
.listStyle(.grouped)
|
||||
.scrollClipDisabled()
|
||||
}
|
||||
}
|
||||
|
||||
private extension ProfileListView {
|
||||
func toggleButton(for header: ProfileHeader) -> some View {
|
||||
TunnelToggleButton(
|
||||
tunnel: tunnel,
|
||||
profile: profileManager.profile(withId: header.id),
|
||||
nextProfileId: .constant(nil),
|
||||
interactiveManager: interactiveManager,
|
||||
errorHandler: errorHandler,
|
||||
label: { _ in
|
||||
toggleView(for: header)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
func toggleView(for header: ProfileHeader) -> some View {
|
||||
HStack {
|
||||
Text(header.name)
|
||||
Spacer()
|
||||
if header.id == tunnel.currentProfile?.id {
|
||||
ThemeImage(.marked)
|
||||
}
|
||||
}
|
||||
.font(.headline)
|
||||
.focused($focusedField, equals: .profile(header.id))
|
||||
}
|
||||
}
|
|
@ -0,0 +1,160 @@
|
|||
//
|
||||
// ProfileView.swift
|
||||
// Passepartout
|
||||
//
|
||||
// Created by Davide De Rosa on 10/31/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 AppLibrary
|
||||
import AppUI
|
||||
import PassepartoutKit
|
||||
import SwiftUI
|
||||
import UtilsLibrary
|
||||
|
||||
// FIXME: #788, UI for TV
|
||||
|
||||
struct ProfileView: View, TunnelInstallationProviding {
|
||||
enum Field: Hashable {
|
||||
case connect
|
||||
|
||||
case switchProfile
|
||||
|
||||
case profile(Profile.ID)
|
||||
}
|
||||
|
||||
@EnvironmentObject
|
||||
private var theme: Theme
|
||||
|
||||
@ObservedObject
|
||||
var profileManager: ProfileManager
|
||||
|
||||
@ObservedObject
|
||||
var tunnel: ExtendedTunnel
|
||||
|
||||
@State
|
||||
private var isSwitching = false
|
||||
|
||||
@FocusState
|
||||
private var focusedField: Field?
|
||||
|
||||
@StateObject
|
||||
private var interactiveManager = InteractiveManager()
|
||||
|
||||
@StateObject
|
||||
private var errorHandler: ErrorHandler = .default()
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
HStack(spacing: .zero) {
|
||||
VStack {
|
||||
activeView
|
||||
.padding(.horizontal)
|
||||
.frame(width: geo.size.width * 0.5)
|
||||
.focusSection()
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
if isSwitching {
|
||||
listView
|
||||
.padding(.horizontal)
|
||||
.frame(width: geo.size.width * 0.5)
|
||||
.focusSection()
|
||||
}
|
||||
}
|
||||
}
|
||||
.ignoresSafeArea(edges: .horizontal)
|
||||
.background(theme.primaryColor.gradient)
|
||||
.animation(.default, value: isSwitching)
|
||||
.withErrorHandler(errorHandler)
|
||||
.themeModal(isPresented: $interactiveManager.isPresented) {
|
||||
InteractiveView(manager: interactiveManager) {
|
||||
errorHandler.handle(
|
||||
$0,
|
||||
title: Strings.Global.connection,
|
||||
message: Strings.Views.Profiles.Errors.tunnel
|
||||
)
|
||||
}
|
||||
}
|
||||
.onLoad {
|
||||
focusedField = .switchProfile
|
||||
}
|
||||
.onChange(of: tunnel.status) { _, new in
|
||||
if new == .activating {
|
||||
isSwitching = false
|
||||
}
|
||||
}
|
||||
.onChange(of: tunnel.currentProfile) { _, new in
|
||||
if focusedField == .connect && new == nil {
|
||||
focusedField = .switchProfile
|
||||
}
|
||||
}
|
||||
.onChange(of: focusedField) { _, new in
|
||||
switch new {
|
||||
case .connect:
|
||||
isSwitching = false
|
||||
|
||||
case .switchProfile:
|
||||
isSwitching = true
|
||||
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension ProfileView {
|
||||
var currentProfile: Profile? {
|
||||
guard let id = tunnel.currentProfile?.id else {
|
||||
return nil
|
||||
}
|
||||
return profileManager.profile(withId: id)
|
||||
}
|
||||
|
||||
var activeView: some View {
|
||||
ActiveProfileView(
|
||||
profile: currentProfile,
|
||||
firstProfileId: profileManager.headers.first?.id,
|
||||
tunnel: tunnel,
|
||||
isSwitching: $isSwitching,
|
||||
focusedField: $focusedField,
|
||||
interactiveManager: interactiveManager,
|
||||
errorHandler: errorHandler
|
||||
)
|
||||
}
|
||||
|
||||
var listView: some View {
|
||||
ProfileListView(
|
||||
profileManager: profileManager,
|
||||
tunnel: tunnel,
|
||||
focusedField: $focusedField,
|
||||
interactiveManager: interactiveManager,
|
||||
errorHandler: errorHandler
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ProfileView(
|
||||
profileManager: .mock,
|
||||
tunnel: .mock
|
||||
)
|
||||
}
|
Loading…
Reference in New Issue