Refactor and improve interactive login (#801)
Define two styles for interactive login: - Modal (iOS/macOS) - Form inside NavigationStack - Inline (tvOS) - VStack Requires OpenVPN credentials view to be container-agnostic. Play with focus to improve the overall TV experience.
This commit is contained in:
parent
454efb8e50
commit
aba5081450
|
@ -92,13 +92,14 @@ private extension ProfileContainerView {
|
|||
}
|
||||
|
||||
func interactiveDestination() -> some View {
|
||||
InteractiveView(manager: interactiveManager) {
|
||||
InteractiveCoordinator(style: .modal, manager: interactiveManager) {
|
||||
errorHandler.handle(
|
||||
$0,
|
||||
title: Strings.Global.connection,
|
||||
message: Strings.Views.Profiles.Errors.tunnel
|
||||
)
|
||||
}
|
||||
.presentationDetents([.medium])
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -226,10 +226,14 @@ private extension OpenVPNView {
|
|||
}
|
||||
|
||||
case .credentials:
|
||||
Form {
|
||||
OpenVPNCredentialsView(
|
||||
isInteractive: draft.isInteractive,
|
||||
credentials: draft.credentials
|
||||
)
|
||||
}
|
||||
.navigationTitle(Strings.Modules.Openvpn.credentials)
|
||||
.themeForm()
|
||||
.themeAnimation(on: draft.wrappedValue.isInteractive, category: .modules)
|
||||
.modifier(PaywallModifier(reason: $paywallReason))
|
||||
}
|
||||
|
|
|
@ -80,7 +80,7 @@ private extension ProfileEditView {
|
|||
|
||||
@ToolbarContentBuilder
|
||||
func toolbarContent() -> some ToolbarContent {
|
||||
ToolbarItem {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
ProfileSaveButton(
|
||||
title: Strings.Global.save,
|
||||
errorModuleIds: $malformedModuleIds
|
||||
|
|
|
@ -48,10 +48,10 @@ public struct AppCoordinator: View, AppCoordinatorConforming {
|
|||
Text(Strings.Global.profile)
|
||||
}
|
||||
|
||||
searchView
|
||||
.tabItem {
|
||||
ThemeImage(.search)
|
||||
}
|
||||
// searchView
|
||||
// .tabItem {
|
||||
// ThemeImage(.search)
|
||||
// }
|
||||
|
||||
settingsView
|
||||
.tabItem {
|
||||
|
@ -66,12 +66,11 @@ private extension AppCoordinator {
|
|||
ProfileView(profileManager: profileManager, tunnel: tunnel)
|
||||
}
|
||||
|
||||
// FIXME: #788, UI for TV
|
||||
var searchView: some View {
|
||||
VStack {
|
||||
Text("Search")
|
||||
}
|
||||
}
|
||||
// var searchView: some View {
|
||||
// VStack {
|
||||
// Text("Search")
|
||||
// }
|
||||
// }
|
||||
|
||||
// FIXME: #788, UI for TV
|
||||
var settingsView: some View {
|
||||
|
|
|
@ -70,6 +70,7 @@ private extension ProfileListView {
|
|||
toggleView(for: header)
|
||||
}
|
||||
)
|
||||
.focused($focusedField, equals: .profile(header.id))
|
||||
}
|
||||
|
||||
func toggleView(for header: ProfileHeader) -> some View {
|
||||
|
@ -81,6 +82,5 @@ private extension ProfileListView {
|
|||
}
|
||||
}
|
||||
.font(.headline)
|
||||
.focused($focusedField, equals: .profile(header.id))
|
||||
}
|
||||
}
|
||||
|
|
|
@ -71,11 +71,20 @@ struct ProfileView: View, TunnelInstallationProviding {
|
|||
.focusSection()
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.disabled(interactiveManager.isPresented)
|
||||
|
||||
if isSwitching {
|
||||
ZStack {
|
||||
listView
|
||||
.padding(.horizontal)
|
||||
.frame(width: geo.size.width * 0.5)
|
||||
.opacity(interactiveManager.isPresented ? 0.0 : 1.0)
|
||||
|
||||
if interactiveManager.isPresented {
|
||||
interactiveView
|
||||
.padding(.horizontal, 100)
|
||||
}
|
||||
}
|
||||
// .frame(width: geo.size.width * 0.5) // seems redundant
|
||||
.focusSection()
|
||||
}
|
||||
}
|
||||
|
@ -84,21 +93,11 @@ struct ProfileView: View, TunnelInstallationProviding {
|
|||
.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
|
||||
}
|
||||
.defaultFocus($focusedField, .switchProfile)
|
||||
.onChange(of: tunnel.status) { _, new in
|
||||
if new == .activating {
|
||||
isSwitching = false
|
||||
focusedField = .connect
|
||||
}
|
||||
}
|
||||
.onChange(of: tunnel.currentProfile) { _, new in
|
||||
|
@ -141,6 +140,22 @@ private extension ProfileView {
|
|||
)
|
||||
}
|
||||
|
||||
var interactiveView: some View {
|
||||
InteractiveCoordinator(style: .inline(withCancel: false), manager: interactiveManager) {
|
||||
errorHandler.handle(
|
||||
$0,
|
||||
title: Strings.Global.connection,
|
||||
message: Strings.Views.Profiles.Errors.tunnel
|
||||
)
|
||||
}
|
||||
.font(.body)
|
||||
.onExitCommand {
|
||||
let formerProfileId = interactiveManager.editor.profile.id
|
||||
focusedField = .profile(formerProfileId)
|
||||
interactiveManager.isPresented = false
|
||||
}
|
||||
}
|
||||
|
||||
var listView: some View {
|
||||
ProfileListView(
|
||||
profileManager: profileManager,
|
||||
|
|
|
@ -531,6 +531,10 @@ public enum Strings {
|
|||
/// (on-demand)
|
||||
public static let onDemandSuffix = Strings.tr("Localizable", "ui.connection_status.on_demand_suffix", fallback: " (on-demand)")
|
||||
}
|
||||
public enum InteractiveCoordinator {
|
||||
/// Interactive
|
||||
public static let title = Strings.tr("Localizable", "ui.interactive_coordinator.title", fallback: "Interactive")
|
||||
}
|
||||
public enum ProfileContext {
|
||||
/// Connect to...
|
||||
public static let connectTo = Strings.tr("Localizable", "ui.profile_context.connect_to", fallback: "Connect to...")
|
||||
|
|
|
@ -246,6 +246,7 @@
|
|||
// MARK: - Components
|
||||
|
||||
"ui.connection_status.on_demand_suffix" = " (on-demand)";
|
||||
"ui.interactive_coordinator.title" = "Interactive";
|
||||
"ui.profile_context.connect_to" = "Connect to...";
|
||||
|
||||
// MARK: - Paywalls
|
||||
|
|
|
@ -67,13 +67,13 @@ extension ThemeSectionWithHeaderFooterModifier {
|
|||
|
||||
extension ThemeTextField {
|
||||
public var body: some View {
|
||||
TextField(placeholder, text: $text)
|
||||
TextField(title ?? "", text: $text)
|
||||
}
|
||||
}
|
||||
|
||||
extension ThemeSecureField {
|
||||
public var body: some View {
|
||||
SecureField(placeholder, text: $text)
|
||||
SecureField(title ?? "", text: $text)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -58,13 +58,11 @@ public struct OpenVPNCredentialsView: View {
|
|||
}
|
||||
|
||||
public var body: some View {
|
||||
Form {
|
||||
Group {
|
||||
restrictedArea
|
||||
inputSection
|
||||
}
|
||||
.themeManualInput()
|
||||
.themeForm()
|
||||
.navigationTitle(Strings.Modules.Openvpn.credentials)
|
||||
.onLoad {
|
||||
builder = credentials?.builder() ?? OpenVPN.Credentials.Builder()
|
||||
builder.otp = nil
|
||||
|
@ -136,11 +134,12 @@ private extension OpenVPNCredentialsView {
|
|||
|
||||
var inputSection: some View {
|
||||
Group {
|
||||
if !isAuthenticating || builder.otpMethod == .none {
|
||||
ThemeTextField(Strings.Global.username, text: $builder.username, placeholder: Strings.Placeholders.username)
|
||||
.textContentType(.username)
|
||||
ThemeSecureField(title: Strings.Global.password, text: $builder.password, placeholder: Strings.Placeholders.secret)
|
||||
.textContentType(.password)
|
||||
|
||||
}
|
||||
if isEligibleForInteractiveLogin, isAuthenticating, builder.otpMethod != .none {
|
||||
ThemeSecureField(
|
||||
title: Strings.Unlocalized.otp,
|
||||
|
|
|
@ -0,0 +1,194 @@
|
|||
//
|
||||
// InteractiveCoordinator.swift
|
||||
// Passepartout
|
||||
//
|
||||
// Created by Davide De Rosa on 9/8/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 CommonUtils
|
||||
import PassepartoutKit
|
||||
import SwiftUI
|
||||
|
||||
public struct InteractiveCoordinator: View {
|
||||
public enum Style {
|
||||
case modal
|
||||
|
||||
case inline(withCancel: Bool)
|
||||
}
|
||||
|
||||
private let style: Style
|
||||
|
||||
@ObservedObject
|
||||
private var manager: InteractiveManager
|
||||
|
||||
private let onError: (Error) -> Void
|
||||
|
||||
public init(style: Style, manager: InteractiveManager, onError: @escaping (Error) -> Void) {
|
||||
self.style = style
|
||||
self.manager = manager
|
||||
self.onError = onError
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
switch style {
|
||||
case .modal:
|
||||
interactiveView
|
||||
.modifier(ModalInteractiveModifier(
|
||||
confirm: confirm,
|
||||
cancel: cancel
|
||||
))
|
||||
|
||||
case .inline(let withCancel):
|
||||
interactiveView
|
||||
.modifier(InlineInteractiveModifier(
|
||||
title: manager.editor.profile.name,
|
||||
withCancel: withCancel,
|
||||
confirm: confirm,
|
||||
cancel: cancel
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Modal
|
||||
|
||||
private extension InteractiveCoordinator {
|
||||
struct ModalInteractiveModifier: ViewModifier {
|
||||
let confirm: () -> Void
|
||||
|
||||
let cancel: () -> Void
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
content
|
||||
}
|
||||
.themeForm()
|
||||
.themeNavigationDetail()
|
||||
.navigationTitle(Strings.Ui.InteractiveCoordinator.title)
|
||||
.toolbar(content: modalToolbar)
|
||||
}
|
||||
}
|
||||
|
||||
@ToolbarContentBuilder
|
||||
func modalToolbar() -> some ToolbarContent {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button(action: confirm) {
|
||||
Text(Strings.Global.connect)
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button(action: cancel) {
|
||||
#if os(iOS)
|
||||
ThemeImage(.close)
|
||||
#else
|
||||
Text(Strings.Global.cancel)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Inline
|
||||
|
||||
private extension InteractiveCoordinator {
|
||||
struct InlineInteractiveModifier: ViewModifier {
|
||||
let title: String
|
||||
|
||||
let withCancel: Bool
|
||||
|
||||
let confirm: () -> Void
|
||||
|
||||
let cancel: () -> Void
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
VStack {
|
||||
Text(title)
|
||||
.font(.title2)
|
||||
content
|
||||
toolbar
|
||||
.padding(.top)
|
||||
Spacer()
|
||||
}
|
||||
#if os(tvOS)
|
||||
.scrollClipDisabled()
|
||||
#endif
|
||||
}
|
||||
|
||||
var toolbar: some View {
|
||||
VStack {
|
||||
Button(action: confirm) {
|
||||
Text(Strings.Global.connect)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
if withCancel {
|
||||
Button(role: .cancel, action: cancel) {
|
||||
Text(Strings.Global.cancel)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Common
|
||||
|
||||
private extension InteractiveCoordinator {
|
||||
var interactiveView: some View {
|
||||
manager
|
||||
.editor
|
||||
.interactiveProvider
|
||||
.map(innerView)
|
||||
}
|
||||
|
||||
func innerView(with provider: any InteractiveViewProviding) -> some View {
|
||||
AnyView(provider.interactiveView(with: manager.editor))
|
||||
}
|
||||
|
||||
func confirm() {
|
||||
Task {
|
||||
do {
|
||||
try await manager.complete()
|
||||
} catch {
|
||||
onError(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func cancel() {
|
||||
manager.isPresented = false
|
||||
}
|
||||
}
|
||||
|
||||
private extension ProfileEditor {
|
||||
|
||||
// in the future, multiple modules may be interactive
|
||||
// here we only intercept the first interactive module
|
||||
var interactiveProvider: (any InteractiveViewProviding)? {
|
||||
modules
|
||||
.first {
|
||||
$0 is any InteractiveViewProviding
|
||||
} as? any InteractiveViewProviding
|
||||
}
|
||||
}
|
|
@ -1,89 +0,0 @@
|
|||
//
|
||||
// InteractiveView.swift
|
||||
// Passepartout
|
||||
//
|
||||
// Created by Davide De Rosa on 9/8/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
|
||||
|
||||
public struct InteractiveView: View {
|
||||
|
||||
@ObservedObject
|
||||
private var manager: InteractiveManager
|
||||
|
||||
private let onError: (Error) -> Void
|
||||
|
||||
public init(manager: InteractiveManager, onError: @escaping (Error) -> Void) {
|
||||
self.manager = manager
|
||||
self.onError = onError
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
manager
|
||||
.editor
|
||||
.interactiveProvider
|
||||
.map(stackView)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private extension InteractiveView {
|
||||
func stackView(with provider: any InteractiveViewProviding) -> some View {
|
||||
NavigationStack {
|
||||
AnyView(provider.interactiveView(with: manager.editor))
|
||||
.toolbar(content: toolbarContent)
|
||||
}
|
||||
}
|
||||
|
||||
@ToolbarContentBuilder
|
||||
func toolbarContent() -> some ToolbarContent {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button(Strings.Global.cancel, role: .cancel) {
|
||||
manager.isPresented = false
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button(Strings.Global.ok) {
|
||||
Task {
|
||||
do {
|
||||
try await manager.complete()
|
||||
} catch {
|
||||
onError(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension ProfileEditor {
|
||||
|
||||
// in the future, multiple modules may be interactive
|
||||
// here we only intercept the first interactive module
|
||||
var interactiveProvider: (any InteractiveViewProviding)? {
|
||||
modules
|
||||
.first {
|
||||
$0 is any InteractiveViewProviding
|
||||
} as? any InteractiveViewProviding
|
||||
}
|
||||
}
|
|
@ -1,9 +1,9 @@
|
|||
strings:
|
||||
inputs:
|
||||
- Passepartout/Library/Sources/AppUI/Resources/en.lproj/Localizable.strings
|
||||
- Passepartout/Library/Sources/UILibrary/Resources/en.lproj/Localizable.strings
|
||||
outputs:
|
||||
- templateName: structured-swift5
|
||||
output: Passepartout/Library/Sources/AppUI/L10n/SwiftGen+Strings.swift
|
||||
output: Passepartout/Library/Sources/UILibrary/L10n/SwiftGen+Strings.swift
|
||||
params:
|
||||
bundle: Bundle.main
|
||||
enumName: Strings
|
||||
|
|
Loading…
Reference in New Issue