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 {
|
func interactiveDestination() -> some View {
|
||||||
InteractiveView(manager: interactiveManager) {
|
InteractiveCoordinator(style: .modal, manager: interactiveManager) {
|
||||||
errorHandler.handle(
|
errorHandler.handle(
|
||||||
$0,
|
$0,
|
||||||
title: Strings.Global.connection,
|
title: Strings.Global.connection,
|
||||||
message: Strings.Views.Profiles.Errors.tunnel
|
message: Strings.Views.Profiles.Errors.tunnel
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
.presentationDetents([.medium])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -226,10 +226,14 @@ private extension OpenVPNView {
|
||||||
}
|
}
|
||||||
|
|
||||||
case .credentials:
|
case .credentials:
|
||||||
|
Form {
|
||||||
OpenVPNCredentialsView(
|
OpenVPNCredentialsView(
|
||||||
isInteractive: draft.isInteractive,
|
isInteractive: draft.isInteractive,
|
||||||
credentials: draft.credentials
|
credentials: draft.credentials
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
.navigationTitle(Strings.Modules.Openvpn.credentials)
|
||||||
|
.themeForm()
|
||||||
.themeAnimation(on: draft.wrappedValue.isInteractive, category: .modules)
|
.themeAnimation(on: draft.wrappedValue.isInteractive, category: .modules)
|
||||||
.modifier(PaywallModifier(reason: $paywallReason))
|
.modifier(PaywallModifier(reason: $paywallReason))
|
||||||
}
|
}
|
||||||
|
|
|
@ -80,7 +80,7 @@ private extension ProfileEditView {
|
||||||
|
|
||||||
@ToolbarContentBuilder
|
@ToolbarContentBuilder
|
||||||
func toolbarContent() -> some ToolbarContent {
|
func toolbarContent() -> some ToolbarContent {
|
||||||
ToolbarItem {
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
ProfileSaveButton(
|
ProfileSaveButton(
|
||||||
title: Strings.Global.save,
|
title: Strings.Global.save,
|
||||||
errorModuleIds: $malformedModuleIds
|
errorModuleIds: $malformedModuleIds
|
||||||
|
|
|
@ -48,10 +48,10 @@ public struct AppCoordinator: View, AppCoordinatorConforming {
|
||||||
Text(Strings.Global.profile)
|
Text(Strings.Global.profile)
|
||||||
}
|
}
|
||||||
|
|
||||||
searchView
|
// searchView
|
||||||
.tabItem {
|
// .tabItem {
|
||||||
ThemeImage(.search)
|
// ThemeImage(.search)
|
||||||
}
|
// }
|
||||||
|
|
||||||
settingsView
|
settingsView
|
||||||
.tabItem {
|
.tabItem {
|
||||||
|
@ -66,12 +66,11 @@ private extension AppCoordinator {
|
||||||
ProfileView(profileManager: profileManager, tunnel: tunnel)
|
ProfileView(profileManager: profileManager, tunnel: tunnel)
|
||||||
}
|
}
|
||||||
|
|
||||||
// FIXME: #788, UI for TV
|
// var searchView: some View {
|
||||||
var searchView: some View {
|
// VStack {
|
||||||
VStack {
|
// Text("Search")
|
||||||
Text("Search")
|
// }
|
||||||
}
|
// }
|
||||||
}
|
|
||||||
|
|
||||||
// FIXME: #788, UI for TV
|
// FIXME: #788, UI for TV
|
||||||
var settingsView: some View {
|
var settingsView: some View {
|
||||||
|
|
|
@ -70,6 +70,7 @@ private extension ProfileListView {
|
||||||
toggleView(for: header)
|
toggleView(for: header)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
.focused($focusedField, equals: .profile(header.id))
|
||||||
}
|
}
|
||||||
|
|
||||||
func toggleView(for header: ProfileHeader) -> some View {
|
func toggleView(for header: ProfileHeader) -> some View {
|
||||||
|
@ -81,6 +82,5 @@ private extension ProfileListView {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
.focused($focusedField, equals: .profile(header.id))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -71,11 +71,20 @@ struct ProfileView: View, TunnelInstallationProviding {
|
||||||
.focusSection()
|
.focusSection()
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
|
.disabled(interactiveManager.isPresented)
|
||||||
|
|
||||||
if isSwitching {
|
if isSwitching {
|
||||||
|
ZStack {
|
||||||
listView
|
listView
|
||||||
.padding(.horizontal)
|
.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()
|
.focusSection()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -84,21 +93,11 @@ struct ProfileView: View, TunnelInstallationProviding {
|
||||||
.background(theme.primaryColor.gradient)
|
.background(theme.primaryColor.gradient)
|
||||||
.animation(.default, value: isSwitching)
|
.animation(.default, value: isSwitching)
|
||||||
.withErrorHandler(errorHandler)
|
.withErrorHandler(errorHandler)
|
||||||
.themeModal(isPresented: $interactiveManager.isPresented) {
|
.defaultFocus($focusedField, .switchProfile)
|
||||||
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
|
.onChange(of: tunnel.status) { _, new in
|
||||||
if new == .activating {
|
if new == .activating {
|
||||||
isSwitching = false
|
isSwitching = false
|
||||||
|
focusedField = .connect
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: tunnel.currentProfile) { _, new in
|
.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 {
|
var listView: some View {
|
||||||
ProfileListView(
|
ProfileListView(
|
||||||
profileManager: profileManager,
|
profileManager: profileManager,
|
||||||
|
|
|
@ -531,6 +531,10 @@ public enum Strings {
|
||||||
/// (on-demand)
|
/// (on-demand)
|
||||||
public static let onDemandSuffix = Strings.tr("Localizable", "ui.connection_status.on_demand_suffix", fallback: " (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 {
|
public enum ProfileContext {
|
||||||
/// Connect to...
|
/// Connect to...
|
||||||
public static let connectTo = Strings.tr("Localizable", "ui.profile_context.connect_to", fallback: "Connect to...")
|
public static let connectTo = Strings.tr("Localizable", "ui.profile_context.connect_to", fallback: "Connect to...")
|
||||||
|
|
|
@ -246,6 +246,7 @@
|
||||||
// MARK: - Components
|
// MARK: - Components
|
||||||
|
|
||||||
"ui.connection_status.on_demand_suffix" = " (on-demand)";
|
"ui.connection_status.on_demand_suffix" = " (on-demand)";
|
||||||
|
"ui.interactive_coordinator.title" = "Interactive";
|
||||||
"ui.profile_context.connect_to" = "Connect to...";
|
"ui.profile_context.connect_to" = "Connect to...";
|
||||||
|
|
||||||
// MARK: - Paywalls
|
// MARK: - Paywalls
|
||||||
|
|
|
@ -67,13 +67,13 @@ extension ThemeSectionWithHeaderFooterModifier {
|
||||||
|
|
||||||
extension ThemeTextField {
|
extension ThemeTextField {
|
||||||
public var body: some View {
|
public var body: some View {
|
||||||
TextField(placeholder, text: $text)
|
TextField(title ?? "", text: $text)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension ThemeSecureField {
|
extension ThemeSecureField {
|
||||||
public var body: some View {
|
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 {
|
public var body: some View {
|
||||||
Form {
|
Group {
|
||||||
restrictedArea
|
restrictedArea
|
||||||
inputSection
|
inputSection
|
||||||
}
|
}
|
||||||
.themeManualInput()
|
.themeManualInput()
|
||||||
.themeForm()
|
|
||||||
.navigationTitle(Strings.Modules.Openvpn.credentials)
|
|
||||||
.onLoad {
|
.onLoad {
|
||||||
builder = credentials?.builder() ?? OpenVPN.Credentials.Builder()
|
builder = credentials?.builder() ?? OpenVPN.Credentials.Builder()
|
||||||
builder.otp = nil
|
builder.otp = nil
|
||||||
|
@ -136,11 +134,12 @@ private extension OpenVPNCredentialsView {
|
||||||
|
|
||||||
var inputSection: some View {
|
var inputSection: some View {
|
||||||
Group {
|
Group {
|
||||||
|
if !isAuthenticating || builder.otpMethod == .none {
|
||||||
ThemeTextField(Strings.Global.username, text: $builder.username, placeholder: Strings.Placeholders.username)
|
ThemeTextField(Strings.Global.username, text: $builder.username, placeholder: Strings.Placeholders.username)
|
||||||
.textContentType(.username)
|
.textContentType(.username)
|
||||||
ThemeSecureField(title: Strings.Global.password, text: $builder.password, placeholder: Strings.Placeholders.secret)
|
ThemeSecureField(title: Strings.Global.password, text: $builder.password, placeholder: Strings.Placeholders.secret)
|
||||||
.textContentType(.password)
|
.textContentType(.password)
|
||||||
|
}
|
||||||
if isEligibleForInteractiveLogin, isAuthenticating, builder.otpMethod != .none {
|
if isEligibleForInteractiveLogin, isAuthenticating, builder.otpMethod != .none {
|
||||||
ThemeSecureField(
|
ThemeSecureField(
|
||||||
title: Strings.Unlocalized.otp,
|
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:
|
strings:
|
||||||
inputs:
|
inputs:
|
||||||
- Passepartout/Library/Sources/AppUI/Resources/en.lproj/Localizable.strings
|
- Passepartout/Library/Sources/UILibrary/Resources/en.lproj/Localizable.strings
|
||||||
outputs:
|
outputs:
|
||||||
- templateName: structured-swift5
|
- templateName: structured-swift5
|
||||||
output: Passepartout/Library/Sources/AppUI/L10n/SwiftGen+Strings.swift
|
output: Passepartout/Library/Sources/UILibrary/L10n/SwiftGen+Strings.swift
|
||||||
params:
|
params:
|
||||||
bundle: Bundle.main
|
bundle: Bundle.main
|
||||||
enumName: Strings
|
enumName: Strings
|
||||||
|
|
Loading…
Reference in New Issue