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:
Davide 2024-11-02 08:41:32 +01:00 committed by GitHub
parent 357c505cc0
commit 72e784272a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 492 additions and 75 deletions

View File

@ -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 */
};

View File

@ -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"]

View File

@ -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()

View File

@ -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

View File

@ -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";

View File

@ -148,13 +148,13 @@ extension ThemeTappableText {
extension ThemeTextField {
public var body: some View {
commonView
labeledView
}
}
extension ThemeSecureField {
public var body: some View {
commonView
labeledView
}
}

View File

@ -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
}
}

View File

@ -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)
}
}

View File

@ -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"

View File

@ -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)

View File

@ -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)
}

View File

@ -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 {

View File

@ -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)
}

View File

@ -104,6 +104,7 @@ private extension ProfileRowView {
.contentShape(.rect)
}
)
.foregroundStyle(.primary)
}
var sharingView: some View {

View File

@ -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?)
}
}

View File

@ -166,7 +166,7 @@ private extension VPNProviderServerView.ServersSubview {
DisclosureGroup {
ForEach(servers, id: \.id, content: serverView)
} label: {
Text("\(code.asCountryCodeEmoji) \(code.localizedAsRegionCode ?? code)")
ThemeCountryText(code)
}
}
}

View File

@ -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)
}

View File

@ -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

View File

@ -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")
}
}
}

View File

@ -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)
}
}

View File

@ -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))
}
}

View File

@ -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
)
}