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:
Davide 2024-11-02 15:24:41 +01:00 committed by GitHub
parent 454efb8e50
commit aba5081450
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 262 additions and 134 deletions

View File

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

View File

@ -226,10 +226,14 @@ private extension OpenVPNView {
}
case .credentials:
OpenVPNCredentialsView(
isInteractive: draft.isInteractive,
credentials: draft.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))
}

View File

@ -80,7 +80,7 @@ private extension ProfileEditView {
@ToolbarContentBuilder
func toolbarContent() -> some ToolbarContent {
ToolbarItem {
ToolbarItem(placement: .confirmationAction) {
ProfileSaveButton(
title: Strings.Global.save,
errorModuleIds: $malformedModuleIds

View File

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

View File

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

View File

@ -71,12 +71,21 @@ struct ProfileView: View, TunnelInstallationProviding {
.focusSection()
}
.frame(maxWidth: .infinity)
.disabled(interactiveManager.isPresented)
if isSwitching {
listView
.padding(.horizontal)
.frame(width: geo.size.width * 0.5)
.focusSection()
ZStack {
listView
.padding(.horizontal)
.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,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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