Refactor AppUI/AppUIMain to accomodate TV (#797)
- Move InteractiveView to AppUI for use in TV, with OpenVPNCredentialsView - Move non-UI entities to AppLibrary (IAP, ExtendedTunnel, ProfileProcessor) - Take API out of CommonLibrary (tunnel extension does not need it) - Reorganize theme views/modifiers into separate files
This commit is contained in:
parent
ca18aadddf
commit
357c505cc0
|
@ -12,7 +12,7 @@
|
|||
{
|
||||
"identity" : "generic-json-swift",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/zoul/generic-json-swift",
|
||||
"location" : "https://github.com/iwill/generic-json-swift",
|
||||
"state" : {
|
||||
"revision" : "0a06575f4038b504e78ac330913d920f1630f510",
|
||||
"version" : "2.0.2"
|
||||
|
@ -41,7 +41,7 @@
|
|||
"kind" : "remoteSourceControl",
|
||||
"location" : "git@github.com:passepartoutvpn/passepartoutkit-source",
|
||||
"state" : {
|
||||
"revision" : "31aff403169c7cebe91a07fb8d225ab844a9a9ff"
|
||||
"revision" : "e95c7b54dc11e744d9b40a722fccf752436ac0ef"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
|
@ -49,6 +49,13 @@ let package = Package(
|
|||
targets: [
|
||||
// Targets are the basic building blocks of a package, defining a module or a test suite.
|
||||
// Targets can depend on other targets in this package and products from dependencies.
|
||||
.target(
|
||||
name: "APILibrary",
|
||||
dependencies: ["CommonLibrary"],
|
||||
resources: [
|
||||
.copy("API")
|
||||
]
|
||||
),
|
||||
.target(
|
||||
name: "AppData",
|
||||
dependencies: []
|
||||
|
@ -57,8 +64,7 @@ let package = Package(
|
|||
name: "AppDataProfiles",
|
||||
dependencies: [
|
||||
"AppData",
|
||||
"AppLibrary",
|
||||
"UtilsLibrary"
|
||||
"AppLibrary"
|
||||
],
|
||||
resources: [
|
||||
.process("Profiles.xcdatamodeld")
|
||||
|
@ -68,8 +74,7 @@ let package = Package(
|
|||
name: "AppDataProviders",
|
||||
dependencies: [
|
||||
"AppData",
|
||||
"AppLibrary",
|
||||
"UtilsLibrary"
|
||||
"AppLibrary"
|
||||
],
|
||||
resources: [
|
||||
.process("Providers.xcdatamodeld")
|
||||
|
@ -77,16 +82,18 @@ let package = Package(
|
|||
),
|
||||
.target(
|
||||
name: "AppLibrary",
|
||||
dependencies: ["CommonLibrary"]
|
||||
dependencies: [
|
||||
"APILibrary",
|
||||
"Kvitto",
|
||||
"UtilsLibrary"
|
||||
]
|
||||
),
|
||||
.target(
|
||||
name: "AppUI",
|
||||
dependencies: [
|
||||
"AppDataProfiles",
|
||||
"AppDataProviders",
|
||||
"AppLibrary",
|
||||
"Kvitto",
|
||||
"UtilsLibrary"
|
||||
"AppLibrary"
|
||||
],
|
||||
resources: [
|
||||
.process("Resources")
|
||||
|
@ -114,7 +121,6 @@ let package = Package(
|
|||
.product(name: "PassepartoutWireGuardGo", package: "passepartoutkit-source-wireguard-go")
|
||||
],
|
||||
resources: [
|
||||
.copy("API"),
|
||||
.process("Resources")
|
||||
]
|
||||
),
|
||||
|
|
|
@ -0,0 +1,63 @@
|
|||
//
|
||||
// Shared.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 CommonLibrary
|
||||
import Foundation
|
||||
import PassepartoutKit
|
||||
|
||||
// TODO: #716, move to Environment
|
||||
extension API {
|
||||
public static var shared: [APIMapper] {
|
||||
#if DEBUG
|
||||
[API.bundled]
|
||||
#else
|
||||
API.remoteThenBundled
|
||||
#endif
|
||||
}
|
||||
|
||||
private static let remoteThenBundled: [APIMapper] = [
|
||||
Self.remote,
|
||||
Self.bundled
|
||||
]
|
||||
|
||||
public static let bundled: APIMapper = {
|
||||
guard let url = Bundle.module.url(forResource: "API", withExtension: nil) else {
|
||||
fatalError("Unable to find bundled API")
|
||||
}
|
||||
let ws = API.V5.DefaultWebServices(
|
||||
url,
|
||||
timeout: Constants.shared.api.timeoutInterval
|
||||
)
|
||||
return API.V5.Mapper(webServices: ws)
|
||||
}()
|
||||
|
||||
public static let remote: APIMapper = {
|
||||
let ws = API.V5.DefaultWebServices(
|
||||
Constants.shared.websites.api,
|
||||
timeout: Constants.shared.api.timeoutInterval
|
||||
)
|
||||
return API.V5.Mapper(webServices: ws)
|
||||
}()
|
||||
}
|
|
@ -50,7 +50,7 @@ extension AppProduct: InAppIdentifierProviding {
|
|||
}
|
||||
|
||||
extension AppProduct {
|
||||
static var all: [Self] {
|
||||
public static var all: [Self] {
|
||||
Features.all + Full.all + Donations.all
|
||||
}
|
||||
|
|
@ -26,18 +26,18 @@
|
|||
import Foundation
|
||||
import UtilsLibrary
|
||||
|
||||
actor MockAppProductHelper: AppProductHelper {
|
||||
private(set) var products: [AppProduct: InAppProduct]
|
||||
public actor MockAppProductHelper: AppProductHelper {
|
||||
public private(set) var products: [AppProduct: InAppProduct]
|
||||
|
||||
init() {
|
||||
public init() {
|
||||
products = [:]
|
||||
}
|
||||
|
||||
nonisolated var canMakePurchases: Bool {
|
||||
public nonisolated var canMakePurchases: Bool {
|
||||
true
|
||||
}
|
||||
|
||||
func fetchProducts() async throws {
|
||||
public func fetchProducts() async throws {
|
||||
products = AppProduct.all.reduce(into: [:]) {
|
||||
$0[$1] = InAppProduct(
|
||||
productIdentifier: $1.rawValue,
|
||||
|
@ -48,10 +48,10 @@ actor MockAppProductHelper: AppProductHelper {
|
|||
}
|
||||
}
|
||||
|
||||
func purchase(productWithIdentifier productIdentifier: AppProduct) async throws -> InAppPurchaseResult {
|
||||
public func purchase(productWithIdentifier productIdentifier: AppProduct) async throws -> InAppPurchaseResult {
|
||||
.done
|
||||
}
|
||||
|
||||
func restorePurchases() async throws {
|
||||
public func restorePurchases() async throws {
|
||||
}
|
||||
}
|
|
@ -26,14 +26,14 @@
|
|||
import Foundation
|
||||
import UtilsLibrary
|
||||
|
||||
actor MockReceiptReader: AppReceiptReader {
|
||||
public actor MockAppReceiptReader: AppReceiptReader {
|
||||
private var receipt: InAppReceipt?
|
||||
|
||||
init(receipt: InAppReceipt? = nil) {
|
||||
public init(receipt: InAppReceipt? = nil) {
|
||||
self.receipt = receipt
|
||||
}
|
||||
|
||||
func setReceipt(withBuild build: Int, products: [AppProduct], cancelledProducts: Set<AppProduct> = []) {
|
||||
public func setReceipt(withBuild build: Int, products: [AppProduct], cancelledProducts: Set<AppProduct> = []) {
|
||||
receipt = InAppReceipt(originalBuildNumber: build, purchaseReceipts: products.map {
|
||||
.init(productIdentifier: $0.rawValue,
|
||||
cancellationDate: cancelledProducts.contains($0) ? Date() : nil,
|
||||
|
@ -41,7 +41,7 @@ actor MockReceiptReader: AppReceiptReader {
|
|||
})
|
||||
}
|
||||
|
||||
func receipt(for userLevel: AppUserLevel) -> InAppReceipt? {
|
||||
public func receipt(for userLevel: AppUserLevel) -> InAppReceipt? {
|
||||
receipt
|
||||
}
|
||||
}
|
|
@ -23,6 +23,7 @@
|
|||
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
|
||||
//
|
||||
|
||||
import APILibrary
|
||||
import CommonLibrary
|
||||
import Foundation
|
||||
import PassepartoutKit
|
||||
|
|
|
@ -37,7 +37,7 @@ extension AppContext {
|
|||
return AppContext(
|
||||
iapManager: IAPManager(
|
||||
customUserLevel: nil,
|
||||
receiptReader: MockReceiptReader(),
|
||||
receiptReader: MockAppReceiptReader(),
|
||||
unrestrictedFeatures: [
|
||||
.interactiveLogin,
|
||||
.onDemand,
|
||||
|
|
|
@ -25,7 +25,7 @@
|
|||
|
||||
import SwiftUI
|
||||
|
||||
protocol InteractiveViewProviding {
|
||||
public protocol InteractiveViewProviding {
|
||||
associatedtype InteractiveContent: View
|
||||
|
||||
@MainActor
|
|
@ -26,6 +26,7 @@
|
|||
#if os(iOS)
|
||||
|
||||
import SwiftUI
|
||||
import UtilsLibrary
|
||||
|
||||
extension Theme {
|
||||
public convenience init() {
|
||||
|
@ -35,7 +36,62 @@ extension Theme {
|
|||
}
|
||||
}
|
||||
|
||||
// MARK: - Modifiers
|
||||
// MARK: - Shortcuts
|
||||
|
||||
extension View {
|
||||
public func themePopover<Content>(
|
||||
isPresented: Binding<Bool>,
|
||||
content: @escaping () -> Content
|
||||
) -> some View where Content: View {
|
||||
modifier(ThemeBooleanPopoverModifier(
|
||||
isPresented: isPresented,
|
||||
popover: content
|
||||
))
|
||||
}
|
||||
|
||||
public func themeLockScreen() -> some View {
|
||||
modifier(ThemeLockScreenModifier(lockedContent: LogoView.init))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Presentation modifiers
|
||||
|
||||
struct ThemeBooleanPopoverModifier<Popover>: ViewModifier, SizeClassProviding where Popover: View {
|
||||
|
||||
@EnvironmentObject
|
||||
private var theme: Theme
|
||||
|
||||
@Environment(\.horizontalSizeClass)
|
||||
var hsClass
|
||||
|
||||
@Environment(\.verticalSizeClass)
|
||||
var vsClass
|
||||
|
||||
@Binding
|
||||
var isPresented: Bool
|
||||
|
||||
@ViewBuilder
|
||||
let popover: Popover
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
if isBigDevice {
|
||||
content
|
||||
.popover(isPresented: $isPresented) {
|
||||
popover
|
||||
.frame(minWidth: theme.popoverSize?.width, minHeight: theme.popoverSize?.height)
|
||||
.themeLockScreen()
|
||||
}
|
||||
} else {
|
||||
content
|
||||
.sheet(isPresented: $isPresented) {
|
||||
popover
|
||||
.themeLockScreen()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Content modifiers
|
||||
|
||||
extension ThemeWindowModifier {
|
||||
func body(content: Content) -> some View {
|
||||
|
|
|
@ -36,6 +36,14 @@ extension Theme {
|
|||
}
|
||||
}
|
||||
|
||||
// MARK: - Shortcuts
|
||||
|
||||
extension View {
|
||||
public func themeLockScreen() -> some View {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Modifiers
|
||||
|
||||
extension ThemeWindowModifier {
|
||||
|
|
|
@ -33,4 +33,48 @@ extension Theme {
|
|||
}
|
||||
}
|
||||
|
||||
// MARK: - Shortcuts
|
||||
|
||||
extension View {
|
||||
public func themeLockScreen() -> some View {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Modifiers
|
||||
|
||||
extension ThemeManualInputModifier {
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.never)
|
||||
}
|
||||
}
|
||||
|
||||
extension ThemeSectionWithHeaderFooterModifier {
|
||||
func body(content: Content) -> some View {
|
||||
Section {
|
||||
content
|
||||
} header: {
|
||||
header.map(Text.init)
|
||||
} footer: {
|
||||
footer.map(Text.init)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Views
|
||||
|
||||
extension ThemeTextField {
|
||||
public var body: some View {
|
||||
commonView
|
||||
}
|
||||
}
|
||||
|
||||
extension ThemeSecureField {
|
||||
public var body: some View {
|
||||
commonView
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
//
|
||||
// ExtendedTunnel+Theme.swift
|
||||
// Theme+Extensions.swift
|
||||
// Passepartout
|
||||
//
|
||||
// Created by Davide De Rosa on 9/6/24.
|
||||
|
@ -23,6 +23,7 @@
|
|||
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
|
||||
//
|
||||
|
||||
import AppLibrary
|
||||
import PassepartoutKit
|
||||
import SwiftUI
|
||||
|
|
@ -28,6 +28,10 @@ import UtilsLibrary
|
|||
|
||||
@MainActor
|
||||
public final class Theme: ObservableObject {
|
||||
private var animation: Animation = .spring
|
||||
|
||||
public internal(set) var animationCategories: Set<ThemeAnimationCategory> = Set(ThemeAnimationCategory.allCases)
|
||||
|
||||
public internal(set) var rootModalSize: CGSize?
|
||||
|
||||
public internal(set) var secondaryModalSize: CGSize?
|
||||
|
@ -64,10 +68,6 @@ public final class Theme: ObservableObject {
|
|||
|
||||
public internal(set) var errorColor: Color = .red
|
||||
|
||||
private var animation: Animation = .spring
|
||||
|
||||
public internal(set) var animationCategories: Set<ThemeAnimationCategory> = Set(ThemeAnimationCategory.allCases)
|
||||
|
||||
public internal(set) var logoImage = "Logo"
|
||||
|
||||
public internal(set) var systemImageName: (ImageName) -> String = Theme.ImageName.defaultSystemName
|
||||
|
@ -81,163 +81,3 @@ public final class Theme: ObservableObject {
|
|||
animationCategories.contains(category) ? animation : nil
|
||||
}
|
||||
}
|
||||
|
||||
#if !os(tvOS)
|
||||
|
||||
// MARK: - Modifiers
|
||||
|
||||
extension View {
|
||||
public func themeWindow(width: CGFloat, height: CGFloat) -> some View {
|
||||
modifier(ThemeWindowModifier(size: .init(width: width, height: height)))
|
||||
}
|
||||
|
||||
public func themeNavigationDetail() -> some View {
|
||||
modifier(ThemeNavigationDetailModifier())
|
||||
}
|
||||
|
||||
public func themeForm() -> some View {
|
||||
modifier(ThemeFormModifier())
|
||||
}
|
||||
|
||||
public func themeModal<Content>(
|
||||
isPresented: Binding<Bool>,
|
||||
isRoot: Bool = false,
|
||||
isInteractive: Bool = true,
|
||||
content: @escaping () -> Content
|
||||
) -> some View where Content: View {
|
||||
modifier(ThemeBooleanModalModifier(
|
||||
isPresented: isPresented,
|
||||
isRoot: isRoot,
|
||||
isInteractive: isInteractive,
|
||||
modal: content
|
||||
))
|
||||
}
|
||||
|
||||
public func themeModal<Content, T>(
|
||||
item: Binding<T?>,
|
||||
isRoot: Bool = false,
|
||||
isInteractive: Bool = true,
|
||||
content: @escaping (T) -> Content
|
||||
) -> some View where Content: View, T: Identifiable {
|
||||
modifier(ThemeItemModalModifier(
|
||||
item: item,
|
||||
isRoot: isRoot,
|
||||
isInteractive: isInteractive,
|
||||
modal: content
|
||||
))
|
||||
}
|
||||
|
||||
public func themePopover<Content>(
|
||||
isPresented: Binding<Bool>,
|
||||
content: @escaping () -> Content
|
||||
) -> some View where Content: View {
|
||||
modifier(ThemeBooleanPopoverModifier(
|
||||
isPresented: isPresented,
|
||||
popover: content
|
||||
))
|
||||
}
|
||||
|
||||
public func themeConfirmation(
|
||||
isPresented: Binding<Bool>,
|
||||
title: String,
|
||||
isDestructive: Bool = false,
|
||||
action: @escaping () -> Void
|
||||
) -> some View {
|
||||
modifier(ThemeConfirmationModifier(
|
||||
isPresented: isPresented,
|
||||
title: title,
|
||||
isDestructive: isDestructive,
|
||||
action: action
|
||||
))
|
||||
}
|
||||
|
||||
public func themeNavigationStack(if condition: Bool, closable: Bool = false, path: Binding<NavigationPath>) -> some View {
|
||||
modifier(ThemeNavigationStackModifier(condition: condition, closable: closable, path: path))
|
||||
}
|
||||
|
||||
public func themePlainButton(action: @escaping () -> Void) -> some View {
|
||||
modifier(ThemePlainButtonModifier(action: action))
|
||||
}
|
||||
|
||||
public func themeManualInput() -> some View {
|
||||
modifier(ThemeManualInputModifier())
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
public func themeMultiLine(_ isMultiLine: Bool) -> some View {
|
||||
if isMultiLine {
|
||||
multilineTextAlignment(.leading)
|
||||
} else {
|
||||
themeTruncating()
|
||||
}
|
||||
}
|
||||
|
||||
public func themeTruncating(_ mode: Text.TruncationMode = .middle) -> some View {
|
||||
lineLimit(1)
|
||||
.truncationMode(mode)
|
||||
}
|
||||
|
||||
public func themeEmptyMessage() -> some View {
|
||||
modifier(ThemeEmptyMessageModifier())
|
||||
}
|
||||
|
||||
public func themeError(_ isError: Bool) -> some View {
|
||||
modifier(ThemeErrorModifier(isError: isError))
|
||||
}
|
||||
|
||||
public func themeAnimation<T>(on value: T, category: ThemeAnimationCategory) -> some View where T: Equatable {
|
||||
modifier(ThemeAnimationModifier(value: value, category: category))
|
||||
}
|
||||
|
||||
public func themeTrailingValue(_ value: CustomStringConvertible?, truncationMode: Text.TruncationMode = .tail) -> some View {
|
||||
modifier(ThemeTrailingValueModifier(value: value, truncationMode: truncationMode))
|
||||
}
|
||||
|
||||
public func themeSection(header: String? = nil, footer: String? = nil) -> some View {
|
||||
modifier(ThemeSectionWithHeaderFooterModifier(header: header, footer: footer))
|
||||
}
|
||||
|
||||
public func themeGridHeader(title: String?) -> some View {
|
||||
modifier(ThemeGridSectionModifier(title: title))
|
||||
}
|
||||
|
||||
public func themeGridCell(isSelected: Bool) -> some View {
|
||||
modifier(ThemeGridCellModifier(isSelected: isSelected))
|
||||
}
|
||||
|
||||
public func themeHoverListRow() -> some View {
|
||||
modifier(ThemeHoverListRowModifier())
|
||||
}
|
||||
|
||||
public func themeLockScreen() -> some View {
|
||||
modifier(ThemeLockScreenModifier(lockedContent: LogoView.init))
|
||||
}
|
||||
|
||||
public func themeTip(_ text: String, edge: Edge) -> some View {
|
||||
modifier(ThemeTipModifier(text: text, edge: edge))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Views
|
||||
|
||||
extension Theme {
|
||||
public func listSection<ItemView: View, T: EditableValue>(
|
||||
_ title: String,
|
||||
addTitle: String,
|
||||
originalItems: Binding<[T]>,
|
||||
emptyValue: (() async -> T)? = nil,
|
||||
@ViewBuilder itemLabel: @escaping (Bool, Binding<T>) -> ItemView
|
||||
) -> some View {
|
||||
EditableListSection(
|
||||
title,
|
||||
addTitle: addTitle,
|
||||
originalItems: originalItems,
|
||||
emptyValue: emptyValue,
|
||||
itemLabel: itemLabel,
|
||||
removeLabel: ThemeEditableListSection.RemoveLabel.init(action:),
|
||||
editLabel: ThemeEditableListSection.EditLabel.init
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
|
|
|
@ -0,0 +1,38 @@
|
|||
//
|
||||
// ThemeAnimationCategory.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 Foundation
|
||||
|
||||
public enum ThemeAnimationCategory: CaseIterable {
|
||||
case diagnostics
|
||||
|
||||
case modules
|
||||
|
||||
case profiles
|
||||
|
||||
case profilesLayout
|
||||
|
||||
case providers
|
||||
}
|
|
@ -1,8 +1,8 @@
|
|||
//
|
||||
// Theme+UI.swift
|
||||
// Theme+Modifiers.swift
|
||||
// Passepartout
|
||||
//
|
||||
// Created by Davide De Rosa on 8/28/24.
|
||||
// Created by Davide De Rosa on 11/1/24.
|
||||
// Copyright (c) 2024 Davide De Rosa. All rights reserved.
|
||||
//
|
||||
// https://github.com/passepartoutvpn
|
||||
|
@ -30,24 +30,130 @@ import LocalAuthentication
|
|||
import SwiftUI
|
||||
import UtilsLibrary
|
||||
|
||||
// MARK: - Modifiers
|
||||
// MARK: Shortcuts
|
||||
|
||||
extension View {
|
||||
public func themeModal<Content>(
|
||||
isPresented: Binding<Bool>,
|
||||
isRoot: Bool = false,
|
||||
isInteractive: Bool = true,
|
||||
content: @escaping () -> Content
|
||||
) -> some View where Content: View {
|
||||
modifier(ThemeBooleanModalModifier(
|
||||
isPresented: isPresented,
|
||||
isRoot: isRoot,
|
||||
isInteractive: isInteractive,
|
||||
modal: content
|
||||
))
|
||||
}
|
||||
|
||||
public func themeModal<Content, T>(
|
||||
item: Binding<T?>,
|
||||
isRoot: Bool = false,
|
||||
isInteractive: Bool = true,
|
||||
content: @escaping (T) -> Content
|
||||
) -> some View where Content: View, T: Identifiable {
|
||||
modifier(ThemeItemModalModifier(
|
||||
item: item,
|
||||
isRoot: isRoot,
|
||||
isInteractive: isInteractive,
|
||||
modal: content
|
||||
))
|
||||
}
|
||||
|
||||
public func themeConfirmation(
|
||||
isPresented: Binding<Bool>,
|
||||
title: String,
|
||||
isDestructive: Bool = false,
|
||||
action: @escaping () -> Void
|
||||
) -> some View {
|
||||
modifier(ThemeConfirmationModifier(
|
||||
isPresented: isPresented,
|
||||
title: title,
|
||||
isDestructive: isDestructive,
|
||||
action: action
|
||||
))
|
||||
}
|
||||
|
||||
public func themeNavigationStack(if condition: Bool, closable: Bool = false, path: Binding<NavigationPath>) -> some View {
|
||||
modifier(ThemeNavigationStackModifier(condition: condition, closable: closable, path: path))
|
||||
}
|
||||
|
||||
public func themeForm() -> some View {
|
||||
modifier(ThemeFormModifier())
|
||||
}
|
||||
|
||||
public func themeManualInput() -> some View {
|
||||
modifier(ThemeManualInputModifier())
|
||||
}
|
||||
|
||||
public func themeSection(header: String? = nil, footer: String? = nil) -> some View {
|
||||
modifier(ThemeSectionWithHeaderFooterModifier(header: header, footer: footer))
|
||||
}
|
||||
|
||||
#if !os(tvOS)
|
||||
|
||||
struct ThemeWindowModifier: ViewModifier {
|
||||
let size: CGSize
|
||||
}
|
||||
|
||||
struct ThemeNavigationDetailModifier: ViewModifier {
|
||||
}
|
||||
|
||||
struct ThemeFormModifier: ViewModifier {
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.formStyle(.grouped)
|
||||
public func themeWindow(width: CGFloat, height: CGFloat) -> some View {
|
||||
modifier(ThemeWindowModifier(size: .init(width: width, height: height)))
|
||||
}
|
||||
|
||||
public func themeNavigationDetail() -> some View {
|
||||
modifier(ThemeNavigationDetailModifier())
|
||||
}
|
||||
|
||||
public func themePlainButton(action: @escaping () -> Void) -> some View {
|
||||
modifier(ThemePlainButtonModifier(action: action))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
public func themeMultiLine(_ isMultiLine: Bool) -> some View {
|
||||
if isMultiLine {
|
||||
multilineTextAlignment(.leading)
|
||||
} else {
|
||||
themeTruncating()
|
||||
}
|
||||
}
|
||||
|
||||
public func themeTruncating(_ mode: Text.TruncationMode = .middle) -> some View {
|
||||
lineLimit(1)
|
||||
.truncationMode(mode)
|
||||
}
|
||||
|
||||
public func themeEmptyMessage() -> some View {
|
||||
modifier(ThemeEmptyMessageModifier())
|
||||
}
|
||||
|
||||
public func themeError(_ isError: Bool) -> some View {
|
||||
modifier(ThemeErrorModifier(isError: isError))
|
||||
}
|
||||
|
||||
public func themeAnimation<T>(on value: T, category: ThemeAnimationCategory) -> some View where T: Equatable {
|
||||
modifier(ThemeAnimationModifier(value: value, category: category))
|
||||
}
|
||||
|
||||
public func themeTrailingValue(_ value: CustomStringConvertible?, truncationMode: Text.TruncationMode = .tail) -> some View {
|
||||
modifier(ThemeTrailingValueModifier(value: value, truncationMode: truncationMode))
|
||||
}
|
||||
|
||||
public func themeGridHeader(title: String?) -> some View {
|
||||
modifier(ThemeGridSectionModifier(title: title))
|
||||
}
|
||||
|
||||
public func themeGridCell(isSelected: Bool) -> some View {
|
||||
modifier(ThemeGridCellModifier(isSelected: isSelected))
|
||||
}
|
||||
|
||||
public func themeHoverListRow() -> some View {
|
||||
modifier(ThemeHoverListRowModifier())
|
||||
}
|
||||
|
||||
public func themeTip(_ text: String, edge: Edge) -> some View {
|
||||
modifier(ThemeTipModifier(text: text, edge: edge))
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - Presentation modifiers
|
||||
|
||||
struct ThemeBooleanModalModifier<Modal>: ViewModifier where Modal: View {
|
||||
|
||||
@EnvironmentObject
|
||||
|
@ -106,41 +212,6 @@ struct ThemeItemModalModifier<Modal, T>: ViewModifier where Modal: View, T: Iden
|
|||
}
|
||||
}
|
||||
|
||||
struct ThemeBooleanPopoverModifier<Popover>: ViewModifier, SizeClassProviding where Popover: View {
|
||||
|
||||
@EnvironmentObject
|
||||
private var theme: Theme
|
||||
|
||||
@Environment(\.horizontalSizeClass)
|
||||
var hsClass
|
||||
|
||||
@Environment(\.verticalSizeClass)
|
||||
var vsClass
|
||||
|
||||
@Binding
|
||||
var isPresented: Bool
|
||||
|
||||
@ViewBuilder
|
||||
let popover: Popover
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
if isBigDevice {
|
||||
content
|
||||
.popover(isPresented: $isPresented) {
|
||||
popover
|
||||
.frame(minWidth: theme.popoverSize?.width, minHeight: theme.popoverSize?.height)
|
||||
.themeLockScreen()
|
||||
}
|
||||
} else {
|
||||
content
|
||||
.sheet(isPresented: $isPresented) {
|
||||
popover
|
||||
.themeLockScreen()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ThemeConfirmationModifier: ViewModifier {
|
||||
|
||||
@Binding
|
||||
|
@ -197,13 +268,37 @@ struct ThemeNavigationStackModifier: ViewModifier {
|
|||
}
|
||||
}
|
||||
|
||||
struct ThemePlainButtonModifier: ViewModifier {
|
||||
let action: () -> Void
|
||||
// MARK: - Content modifiers
|
||||
|
||||
struct ThemeFormModifier: ViewModifier {
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.formStyle(.grouped)
|
||||
}
|
||||
}
|
||||
|
||||
struct ThemeManualInputModifier: ViewModifier {
|
||||
}
|
||||
|
||||
struct ThemeSectionWithHeaderFooterModifier: ViewModifier {
|
||||
let header: String?
|
||||
|
||||
let footer: String?
|
||||
}
|
||||
|
||||
#if !os(tvOS)
|
||||
|
||||
struct ThemeWindowModifier: ViewModifier {
|
||||
let size: CGSize
|
||||
}
|
||||
|
||||
struct ThemeNavigationDetailModifier: ViewModifier {
|
||||
}
|
||||
|
||||
struct ThemePlainButtonModifier: ViewModifier {
|
||||
let action: () -> Void
|
||||
}
|
||||
|
||||
struct ThemeEmptyMessageModifier: ViewModifier {
|
||||
|
||||
@EnvironmentObject
|
||||
|
@ -268,12 +363,6 @@ struct ThemeTrailingValueModifier: ViewModifier {
|
|||
}
|
||||
}
|
||||
|
||||
struct ThemeSectionWithHeaderFooterModifier: ViewModifier {
|
||||
let header: String?
|
||||
|
||||
let footer: String?
|
||||
}
|
||||
|
||||
struct ThemeGridSectionModifier: ViewModifier {
|
||||
|
||||
@EnvironmentObject
|
||||
|
@ -394,312 +483,3 @@ struct ThemeTipModifier: ViewModifier {
|
|||
}
|
||||
|
||||
#endif
|
||||
|
||||
// MARK: - Views
|
||||
|
||||
public enum ThemeAnimationCategory: CaseIterable {
|
||||
case diagnostics
|
||||
|
||||
case modules
|
||||
|
||||
case profiles
|
||||
|
||||
case profilesLayout
|
||||
|
||||
case providers
|
||||
}
|
||||
|
||||
public struct ThemeImage: View {
|
||||
|
||||
@EnvironmentObject
|
||||
private var theme: Theme
|
||||
|
||||
private let name: Theme.ImageName
|
||||
|
||||
public init(_ name: Theme.ImageName) {
|
||||
self.name = name
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
Image(systemName: theme.systemImageName(name))
|
||||
}
|
||||
}
|
||||
|
||||
public struct ThemeImageLabel: View {
|
||||
|
||||
@EnvironmentObject
|
||||
private var theme: Theme
|
||||
|
||||
private let title: String
|
||||
|
||||
private let name: Theme.ImageName
|
||||
|
||||
public init(_ title: String, _ name: Theme.ImageName) {
|
||||
self.title = title
|
||||
self.name = name
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
Label {
|
||||
Text(title)
|
||||
} icon: {
|
||||
ThemeImage(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct ThemeCountryFlag: View {
|
||||
private let code: String?
|
||||
|
||||
private let placeholderTip: String?
|
||||
|
||||
private let countryTip: ((String) -> String?)?
|
||||
|
||||
public init(_ code: String?, placeholderTip: String? = nil, countryTip: ((String) -> String?)? = nil) {
|
||||
self.code = code
|
||||
self.placeholderTip = placeholderTip
|
||||
self.countryTip = countryTip
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
if let code {
|
||||
text(withString: code.asCountryCodeEmoji, tip: countryTip?(code))
|
||||
} else {
|
||||
text(withString: "🌐", tip: placeholderTip)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func text(withString string: String, tip: String?) -> some View {
|
||||
if let tip {
|
||||
Text(verbatim: string)
|
||||
.help(tip)
|
||||
} else {
|
||||
Text(verbatim: string)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if !os(tvOS)
|
||||
|
||||
public struct ThemeMenuImage: View {
|
||||
|
||||
@EnvironmentObject
|
||||
private var theme: Theme
|
||||
|
||||
private let name: Theme.MenuImageName
|
||||
|
||||
public init(_ name: Theme.MenuImageName) {
|
||||
self.name = name
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
Image(theme.menuImageName(name))
|
||||
}
|
||||
}
|
||||
|
||||
public struct ThemeDisclosableMenu<Content, Label>: View where Content: View, Label: View {
|
||||
|
||||
@ViewBuilder
|
||||
private let content: () -> Content
|
||||
|
||||
@ViewBuilder
|
||||
private let label: () -> Label
|
||||
|
||||
public init(content: @escaping () -> Content, label: @escaping () -> Label) {
|
||||
self.content = content
|
||||
self.label = label
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
Menu(content: content) {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
label()
|
||||
ThemeImage(.disclose)
|
||||
}
|
||||
.contentShape(.rect)
|
||||
}
|
||||
.foregroundStyle(.primary)
|
||||
#if os(macOS)
|
||||
.buttonStyle(.plain)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
public struct ThemeCopiableText<Value, ValueView>: View where Value: CustomStringConvertible, ValueView: View {
|
||||
|
||||
@EnvironmentObject
|
||||
private var theme: Theme
|
||||
|
||||
private let title: String?
|
||||
|
||||
private let value: Value
|
||||
|
||||
private let isMultiLine: Bool
|
||||
|
||||
private let valueView: (Value) -> ValueView
|
||||
|
||||
public init(
|
||||
title: String? = nil,
|
||||
value: Value,
|
||||
isMultiLine: Bool = true,
|
||||
valueView: @escaping (Value) -> ValueView
|
||||
) {
|
||||
self.title = title
|
||||
self.value = value
|
||||
self.isMultiLine = isMultiLine
|
||||
self.valueView = valueView
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
HStack {
|
||||
if let title {
|
||||
Text(title)
|
||||
Spacer()
|
||||
}
|
||||
valueView(value)
|
||||
.foregroundStyle(title == nil ? theme.titleColor : theme.valueColor)
|
||||
.themeMultiLine(isMultiLine)
|
||||
if title == nil {
|
||||
Spacer()
|
||||
}
|
||||
Button {
|
||||
copyToPasteboard(value.description)
|
||||
} label: {
|
||||
ThemeImage(.copy)
|
||||
}
|
||||
// TODO: #584, necessary to avoid cell selection
|
||||
.buttonStyle(.borderless)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct ThemeTappableText: View {
|
||||
private let title: String
|
||||
|
||||
private let action: () -> Void
|
||||
|
||||
public init(title: String, action: @escaping () -> Void) {
|
||||
self.title = title
|
||||
self.action = action
|
||||
}
|
||||
|
||||
var commonView: some View {
|
||||
Button(action: action) {
|
||||
Text(title)
|
||||
.themeTruncating()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct ThemeTextField: View {
|
||||
private let title: String?
|
||||
|
||||
@Binding
|
||||
private var text: String
|
||||
|
||||
private let placeholder: String
|
||||
|
||||
public init(_ title: String, text: Binding<String>, placeholder: String) {
|
||||
self.title = title
|
||||
_text = text
|
||||
self.placeholder = placeholder
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
var commonView: some View {
|
||||
if let title {
|
||||
LabeledContent {
|
||||
fieldView
|
||||
} label: {
|
||||
Text(title)
|
||||
}
|
||||
} else {
|
||||
fieldView
|
||||
}
|
||||
}
|
||||
|
||||
private var fieldView: some View {
|
||||
TextField(title ?? "", text: $text, prompt: Text(placeholder))
|
||||
}
|
||||
}
|
||||
|
||||
public struct ThemeSecureField: View {
|
||||
private let title: String?
|
||||
|
||||
@Binding
|
||||
private var text: String
|
||||
|
||||
private let placeholder: String
|
||||
|
||||
public init(title: String?, text: Binding<String>, placeholder: String) {
|
||||
self.title = title
|
||||
_text = text
|
||||
self.placeholder = placeholder
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
var commonView: some View {
|
||||
if let title {
|
||||
LabeledContent {
|
||||
fieldView
|
||||
} label: {
|
||||
Text(title)
|
||||
}
|
||||
} else {
|
||||
fieldView
|
||||
}
|
||||
}
|
||||
|
||||
private var fieldView: some View {
|
||||
RevealingSecureField(title ?? "", text: $text, prompt: Text(placeholder), imageWidth: 30.0) {
|
||||
ThemeImage(.hide)
|
||||
.foregroundStyle(Color.accentColor)
|
||||
} revealImage: {
|
||||
ThemeImage(.show)
|
||||
.foregroundStyle(Color.accentColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct ThemeRemovableItemRow<ItemView>: View where ItemView: View {
|
||||
private let isEditing: Bool
|
||||
|
||||
@ViewBuilder
|
||||
private let itemView: () -> ItemView
|
||||
|
||||
let removeAction: () -> Void
|
||||
|
||||
public init(
|
||||
isEditing: Bool,
|
||||
@ViewBuilder itemView: @escaping () -> ItemView,
|
||||
removeAction: @escaping () -> Void
|
||||
) {
|
||||
self.isEditing = isEditing
|
||||
self.itemView = itemView
|
||||
self.removeAction = removeAction
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
RemovableItemRow(
|
||||
isEditing: isEditing,
|
||||
itemView: itemView,
|
||||
removeView: removeView
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
public enum ThemeEditableListSection {
|
||||
public struct RemoveLabel: View {
|
||||
let action: () -> Void
|
||||
|
||||
public init(action: @escaping () -> Void) {
|
||||
self.action = action
|
||||
}
|
||||
}
|
||||
|
||||
public struct EditLabel: View {
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
|
@ -0,0 +1,348 @@
|
|||
//
|
||||
// Theme+Views.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 SwiftUI
|
||||
import UtilsLibrary
|
||||
|
||||
// MARK: Shortcuts
|
||||
|
||||
#if !os(tvOS)
|
||||
extension Theme {
|
||||
public func listSection<ItemView: View, T: EditableValue>(
|
||||
_ title: String,
|
||||
addTitle: String,
|
||||
originalItems: Binding<[T]>,
|
||||
emptyValue: (() async -> T)? = nil,
|
||||
@ViewBuilder itemLabel: @escaping (Bool, Binding<T>) -> ItemView
|
||||
) -> some View {
|
||||
EditableListSection(
|
||||
title,
|
||||
addTitle: addTitle,
|
||||
originalItems: originalItems,
|
||||
emptyValue: emptyValue,
|
||||
itemLabel: itemLabel,
|
||||
removeLabel: ThemeEditableListSection.RemoveLabel.init(action:),
|
||||
editLabel: ThemeEditableListSection.EditLabel.init
|
||||
)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
// MARK: - Views
|
||||
|
||||
public struct ThemeImage: View {
|
||||
|
||||
@EnvironmentObject
|
||||
private var theme: Theme
|
||||
|
||||
private let name: Theme.ImageName
|
||||
|
||||
public init(_ name: Theme.ImageName) {
|
||||
self.name = name
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
Image(systemName: theme.systemImageName(name))
|
||||
}
|
||||
}
|
||||
|
||||
public struct ThemeImageLabel: View {
|
||||
|
||||
@EnvironmentObject
|
||||
private var theme: Theme
|
||||
|
||||
private let title: String
|
||||
|
||||
private let name: Theme.ImageName
|
||||
|
||||
public init(_ title: String, _ name: Theme.ImageName) {
|
||||
self.title = title
|
||||
self.name = name
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
Label {
|
||||
Text(title)
|
||||
} icon: {
|
||||
ThemeImage(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct ThemeCountryFlag: View {
|
||||
private let code: String?
|
||||
|
||||
private let placeholderTip: String?
|
||||
|
||||
private let countryTip: ((String) -> String?)?
|
||||
|
||||
public init(_ code: String?, placeholderTip: String? = nil, countryTip: ((String) -> String?)? = nil) {
|
||||
self.code = code
|
||||
self.placeholderTip = placeholderTip
|
||||
self.countryTip = countryTip
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
if let code {
|
||||
text(withString: code.asCountryCodeEmoji, tip: countryTip?(code))
|
||||
} else {
|
||||
text(withString: "🌐", tip: placeholderTip)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func text(withString string: String, tip: String?) -> some View {
|
||||
if let tip {
|
||||
Text(verbatim: string)
|
||||
.help(tip)
|
||||
} else {
|
||||
Text(verbatim: string)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct ThemeTextField: View {
|
||||
private let title: String?
|
||||
|
||||
@Binding
|
||||
private var text: String
|
||||
|
||||
private let placeholder: String
|
||||
|
||||
public init(_ title: String, text: Binding<String>, placeholder: String) {
|
||||
self.title = title
|
||||
_text = text
|
||||
self.placeholder = placeholder
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
var commonView: some View {
|
||||
if let title {
|
||||
LabeledContent {
|
||||
fieldView
|
||||
} label: {
|
||||
Text(title)
|
||||
}
|
||||
} else {
|
||||
fieldView
|
||||
}
|
||||
}
|
||||
|
||||
private var fieldView: some View {
|
||||
TextField(title ?? "", text: $text, prompt: Text(placeholder))
|
||||
}
|
||||
}
|
||||
|
||||
public struct ThemeSecureField: View {
|
||||
private let title: String?
|
||||
|
||||
@Binding
|
||||
private var text: String
|
||||
|
||||
private let placeholder: String
|
||||
|
||||
public init(title: String?, text: Binding<String>, placeholder: String) {
|
||||
self.title = title
|
||||
_text = text
|
||||
self.placeholder = placeholder
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
var commonView: some View {
|
||||
if let title {
|
||||
LabeledContent {
|
||||
fieldView
|
||||
} label: {
|
||||
Text(title)
|
||||
}
|
||||
} else {
|
||||
fieldView
|
||||
}
|
||||
}
|
||||
|
||||
private var fieldView: some View {
|
||||
RevealingSecureField(title ?? "", text: $text, prompt: Text(placeholder), imageWidth: 30.0) {
|
||||
ThemeImage(.hide)
|
||||
.foregroundStyle(Color.accentColor)
|
||||
} revealImage: {
|
||||
ThemeImage(.show)
|
||||
.foregroundStyle(Color.accentColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if !os(tvOS)
|
||||
|
||||
public struct ThemeMenuImage: View {
|
||||
|
||||
@EnvironmentObject
|
||||
private var theme: Theme
|
||||
|
||||
private let name: Theme.MenuImageName
|
||||
|
||||
public init(_ name: Theme.MenuImageName) {
|
||||
self.name = name
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
Image(theme.menuImageName(name))
|
||||
}
|
||||
}
|
||||
|
||||
public struct ThemeDisclosableMenu<Content, Label>: View where Content: View, Label: View {
|
||||
|
||||
@ViewBuilder
|
||||
private let content: () -> Content
|
||||
|
||||
@ViewBuilder
|
||||
private let label: () -> Label
|
||||
|
||||
public init(content: @escaping () -> Content, label: @escaping () -> Label) {
|
||||
self.content = content
|
||||
self.label = label
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
Menu(content: content) {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
label()
|
||||
ThemeImage(.disclose)
|
||||
}
|
||||
.contentShape(.rect)
|
||||
}
|
||||
.foregroundStyle(.primary)
|
||||
#if os(macOS)
|
||||
.buttonStyle(.plain)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
public struct ThemeCopiableText<Value, ValueView>: View where Value: CustomStringConvertible, ValueView: View {
|
||||
|
||||
@EnvironmentObject
|
||||
private var theme: Theme
|
||||
|
||||
private let title: String?
|
||||
|
||||
private let value: Value
|
||||
|
||||
private let isMultiLine: Bool
|
||||
|
||||
private let valueView: (Value) -> ValueView
|
||||
|
||||
public init(
|
||||
title: String? = nil,
|
||||
value: Value,
|
||||
isMultiLine: Bool = true,
|
||||
valueView: @escaping (Value) -> ValueView
|
||||
) {
|
||||
self.title = title
|
||||
self.value = value
|
||||
self.isMultiLine = isMultiLine
|
||||
self.valueView = valueView
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
HStack {
|
||||
if let title {
|
||||
Text(title)
|
||||
Spacer()
|
||||
}
|
||||
valueView(value)
|
||||
.foregroundStyle(title == nil ? theme.titleColor : theme.valueColor)
|
||||
.themeMultiLine(isMultiLine)
|
||||
if title == nil {
|
||||
Spacer()
|
||||
}
|
||||
Button {
|
||||
copyToPasteboard(value.description)
|
||||
} label: {
|
||||
ThemeImage(.copy)
|
||||
}
|
||||
// TODO: #584, necessary to avoid cell selection
|
||||
.buttonStyle(.borderless)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct ThemeTappableText: View {
|
||||
private let title: String
|
||||
|
||||
private let action: () -> Void
|
||||
|
||||
public init(title: String, action: @escaping () -> Void) {
|
||||
self.title = title
|
||||
self.action = action
|
||||
}
|
||||
|
||||
var commonView: some View {
|
||||
Button(action: action) {
|
||||
Text(title)
|
||||
.themeTruncating()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct ThemeRemovableItemRow<ItemView>: View where ItemView: View {
|
||||
private let isEditing: Bool
|
||||
|
||||
@ViewBuilder
|
||||
private let itemView: () -> ItemView
|
||||
|
||||
let removeAction: () -> Void
|
||||
|
||||
public init(
|
||||
isEditing: Bool,
|
||||
@ViewBuilder itemView: @escaping () -> ItemView,
|
||||
removeAction: @escaping () -> Void
|
||||
) {
|
||||
self.isEditing = isEditing
|
||||
self.itemView = itemView
|
||||
self.removeAction = removeAction
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
RemovableItemRow(
|
||||
isEditing: isEditing,
|
||||
itemView: itemView,
|
||||
removeView: removeView
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
public enum ThemeEditableListSection {
|
||||
public struct RemoveLabel: View {
|
||||
let action: () -> Void
|
||||
|
||||
public init(action: @escaping () -> Void) {
|
||||
self.action = action
|
||||
}
|
||||
}
|
||||
|
||||
public struct EditLabel: View {
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
|
@ -0,0 +1,39 @@
|
|||
//
|
||||
// OpenVPNModule+Extensions.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 PassepartoutKit
|
||||
import SwiftUI
|
||||
|
||||
extension OpenVPNModule.Builder: InteractiveViewProviding {
|
||||
public func interactiveView(with editor: ProfileEditor) -> some View {
|
||||
let draft = editor[self]
|
||||
|
||||
return OpenVPNCredentialsView(
|
||||
isInteractive: draft.isInteractive,
|
||||
credentials: draft.credentials,
|
||||
isAuthenticating: true
|
||||
)
|
||||
}
|
||||
}
|
|
@ -23,59 +23,66 @@
|
|||
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
|
||||
//
|
||||
|
||||
import AppLibrary
|
||||
import PassepartoutKit
|
||||
import SwiftUI
|
||||
import UtilsLibrary
|
||||
|
||||
extension OpenVPNView {
|
||||
struct CredentialsView: View {
|
||||
public struct OpenVPNCredentialsView: View {
|
||||
|
||||
@EnvironmentObject
|
||||
private var iapManager: IAPManager
|
||||
@EnvironmentObject
|
||||
private var iapManager: IAPManager
|
||||
|
||||
@Binding
|
||||
var isInteractive: Bool
|
||||
@Binding
|
||||
private var isInteractive: Bool
|
||||
|
||||
@Binding
|
||||
var credentials: OpenVPN.Credentials?
|
||||
@Binding
|
||||
private var credentials: OpenVPN.Credentials?
|
||||
|
||||
var isAuthenticating = false
|
||||
private var isAuthenticating = false
|
||||
|
||||
@State
|
||||
private var builder = OpenVPN.Credentials.Builder()
|
||||
@State
|
||||
private var builder = OpenVPN.Credentials.Builder()
|
||||
|
||||
@State
|
||||
private var paywallReason: PaywallReason?
|
||||
@State
|
||||
private var paywallReason: PaywallReason?
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
restrictedArea
|
||||
inputSection
|
||||
public init(
|
||||
isInteractive: Binding<Bool>,
|
||||
credentials: Binding<OpenVPN.Credentials?>,
|
||||
isAuthenticating: Bool = false
|
||||
) {
|
||||
_isInteractive = isInteractive
|
||||
_credentials = credentials
|
||||
self.isAuthenticating = isAuthenticating
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
Form {
|
||||
restrictedArea
|
||||
inputSection
|
||||
}
|
||||
.themeManualInput()
|
||||
.themeForm()
|
||||
.navigationTitle(Strings.Modules.Openvpn.credentials)
|
||||
.onLoad {
|
||||
builder = credentials?.builder() ?? OpenVPN.Credentials.Builder()
|
||||
builder.otp = nil
|
||||
}
|
||||
.onChange(of: builder) {
|
||||
var copy = $0
|
||||
if isEligibleForInteractiveLogin {
|
||||
copy.otp = copy.otp ?? ""
|
||||
} else {
|
||||
copy.otpMethod = .none
|
||||
copy.otp = nil
|
||||
}
|
||||
.themeAnimation(on: isInteractive, category: .modules)
|
||||
.themeManualInput()
|
||||
.themeForm()
|
||||
.navigationTitle(Strings.Modules.Openvpn.credentials)
|
||||
.onLoad {
|
||||
builder = credentials?.builder() ?? OpenVPN.Credentials.Builder()
|
||||
builder.otp = nil
|
||||
}
|
||||
.onChange(of: builder) {
|
||||
var copy = $0
|
||||
if isEligibleForInteractiveLogin {
|
||||
copy.otp = copy.otp ?? ""
|
||||
} else {
|
||||
copy.otpMethod = .none
|
||||
copy.otp = nil
|
||||
}
|
||||
credentials = copy.build()
|
||||
}
|
||||
.modifier(PaywallModifier(reason: $paywallReason))
|
||||
credentials = copy.build()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension OpenVPNView.CredentialsView {
|
||||
private extension OpenVPNCredentialsView {
|
||||
var isEligibleForInteractiveLogin: Bool {
|
||||
iapManager.isEligible(for: .interactiveLogin)
|
||||
}
|
||||
|
@ -164,7 +171,7 @@ private extension OpenVPNView.CredentialsView {
|
|||
var isInteractive = true
|
||||
|
||||
return NavigationStack {
|
||||
OpenVPNView.CredentialsView(
|
||||
OpenVPNCredentialsView(
|
||||
isInteractive: $isInteractive,
|
||||
credentials: $credentials
|
||||
)
|
|
@ -23,6 +23,7 @@
|
|||
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
|
||||
//
|
||||
|
||||
import AppLibrary
|
||||
import Foundation
|
||||
import PassepartoutKit
|
||||
import SwiftUI
|
|
@ -1,5 +1,5 @@
|
|||
//
|
||||
// ThemeFavoriteToggle.swift
|
||||
// FavoriteToggle.swift
|
||||
// Passepartout
|
||||
//
|
||||
// Created by Davide De Rosa on 10/25/24.
|
||||
|
@ -25,16 +25,21 @@
|
|||
|
||||
import SwiftUI
|
||||
|
||||
struct FavoriteToggle<ID>: View where ID: Hashable {
|
||||
let value: ID
|
||||
public struct FavoriteToggle<ID>: View where ID: Hashable {
|
||||
private let value: ID
|
||||
|
||||
@Binding
|
||||
var selection: Set<ID>
|
||||
private var selection: Set<ID>
|
||||
|
||||
@State
|
||||
private var hover: ID?
|
||||
|
||||
var body: some View {
|
||||
public init(value: ID, selection: Binding<Set<ID>>) {
|
||||
self.value = value
|
||||
_selection = selection
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
Button {
|
||||
if selection.contains(value) {
|
||||
selection.remove(value)
|
||||
|
@ -45,18 +50,20 @@ struct FavoriteToggle<ID>: View where ID: Hashable {
|
|||
ThemeImage(selection.contains(value) ? .favoriteOn : .favoriteOff)
|
||||
.opacity(opacity)
|
||||
}
|
||||
#if os(macOS)
|
||||
.onHover {
|
||||
hover = $0 ? value : nil
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
private extension FavoriteToggle {
|
||||
var opacity: Double {
|
||||
#if os(iOS)
|
||||
1.0
|
||||
#else
|
||||
#if os(macOS)
|
||||
selection.contains(value) || value == hover ? 1.0 : 0.0
|
||||
#else
|
||||
1.0
|
||||
#endif
|
||||
}
|
||||
}
|
|
@ -26,14 +26,19 @@
|
|||
import PassepartoutKit
|
||||
import SwiftUI
|
||||
|
||||
struct InteractiveView: View {
|
||||
public struct InteractiveView: View {
|
||||
|
||||
@ObservedObject
|
||||
var manager: InteractiveManager
|
||||
private var manager: InteractiveManager
|
||||
|
||||
let onError: (Error) -> Void
|
||||
private let onError: (Error) -> Void
|
||||
|
||||
var body: some View {
|
||||
public init(manager: InteractiveManager, onError: @escaping (Error) -> Void) {
|
||||
self.manager = manager
|
||||
self.onError = onError
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
manager
|
||||
.editor
|
||||
.interactiveProvider
|
|
@ -23,6 +23,7 @@
|
|||
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
|
||||
//
|
||||
|
||||
import AppLibrary
|
||||
import PassepartoutKit
|
||||
import SwiftUI
|
||||
import UtilsLibrary
|
|
@ -23,6 +23,7 @@
|
|||
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
|
||||
//
|
||||
|
||||
import AppLibrary
|
||||
import CommonLibrary
|
||||
import Foundation
|
||||
import PassepartoutKit
|
||||
|
|
|
@ -25,6 +25,7 @@
|
|||
|
||||
#if os(iOS)
|
||||
|
||||
import AppLibrary
|
||||
import CommonLibrary
|
||||
import Foundation
|
||||
import PassepartoutKit
|
||||
|
@ -33,6 +34,7 @@ import UIKit
|
|||
#else
|
||||
|
||||
import AppKit
|
||||
import AppLibrary
|
||||
import CommonLibrary
|
||||
import Foundation
|
||||
import PassepartoutKit
|
||||
|
|
|
@ -23,6 +23,7 @@
|
|||
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
|
||||
//
|
||||
|
||||
import AppLibrary
|
||||
import PassepartoutKit
|
||||
import SwiftUI
|
||||
import UtilsLibrary
|
|
@ -25,6 +25,7 @@
|
|||
|
||||
#if os(macOS)
|
||||
|
||||
import AppLibrary
|
||||
import PassepartoutKit
|
||||
import SwiftUI
|
||||
|
||||
|
|
|
@ -23,6 +23,7 @@
|
|||
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
|
||||
//
|
||||
|
||||
import AppLibrary
|
||||
import CommonLibrary
|
||||
import PassepartoutKit
|
||||
import SwiftUI
|
||||
|
|
|
@ -32,18 +32,6 @@ extension OpenVPNModule.Builder: ModuleViewProviding {
|
|||
}
|
||||
}
|
||||
|
||||
extension OpenVPNModule.Builder: InteractiveViewProviding {
|
||||
func interactiveView(with editor: ProfileEditor) -> some View {
|
||||
let draft = editor[self]
|
||||
|
||||
return OpenVPNView.CredentialsView(
|
||||
isInteractive: draft.isInteractive,
|
||||
credentials: draft.credentials,
|
||||
isAuthenticating: true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension OpenVPNModule: ProviderEntityViewProviding {
|
||||
func providerEntityView(
|
||||
with provider: ModuleMetadata.Provider,
|
||||
|
|
|
@ -23,6 +23,7 @@
|
|||
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
|
||||
//
|
||||
|
||||
import AppLibrary
|
||||
import PassepartoutKit
|
||||
import SwiftUI
|
||||
import UtilsLibrary
|
||||
|
|
|
@ -23,6 +23,7 @@
|
|||
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
|
||||
//
|
||||
|
||||
import AppLibrary
|
||||
import CPassepartoutOpenVPNOpenSSL
|
||||
import PassepartoutKit
|
||||
import SwiftUI
|
||||
|
@ -225,10 +226,12 @@ private extension OpenVPNView {
|
|||
}
|
||||
|
||||
case .credentials:
|
||||
CredentialsView(
|
||||
OpenVPNCredentialsView(
|
||||
isInteractive: draft.isInteractive,
|
||||
credentials: draft.credentials
|
||||
)
|
||||
.themeAnimation(on: draft.wrappedValue.isInteractive, category: .modules)
|
||||
.modifier(PaywallModifier(reason: $paywallReason))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -23,6 +23,7 @@
|
|||
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
|
||||
//
|
||||
|
||||
import AppLibrary
|
||||
import SwiftUI
|
||||
|
||||
struct PaywallModifier: ViewModifier {
|
||||
|
|
|
@ -23,6 +23,7 @@
|
|||
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
|
||||
//
|
||||
|
||||
import AppLibrary
|
||||
import SwiftUI
|
||||
|
||||
struct PaywallView: View {
|
||||
|
|
|
@ -23,7 +23,7 @@
|
|||
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import AppLibrary
|
||||
import SwiftUI
|
||||
|
||||
struct StorageSection: View {
|
|
@ -23,6 +23,7 @@
|
|||
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
|
||||
//
|
||||
|
||||
import AppLibrary
|
||||
import PassepartoutKit
|
||||
import SwiftUI
|
||||
|
||||
|
|
|
@ -25,6 +25,8 @@
|
|||
|
||||
#if os(iOS)
|
||||
|
||||
import AppLibrary
|
||||
import CommonLibrary
|
||||
import PassepartoutKit
|
||||
import SwiftUI
|
||||
|
||||
|
|
|
@ -25,6 +25,7 @@
|
|||
|
||||
#if os(macOS)
|
||||
|
||||
import CommonLibrary
|
||||
import PassepartoutKit
|
||||
import SwiftUI
|
||||
|
||||
|
|
|
@ -23,16 +23,15 @@
|
|||
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
|
||||
//
|
||||
|
||||
import CommonLibrary
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
final class ProviderFavoritesManager: ObservableObject {
|
||||
public final class ProviderFavoritesManager: ObservableObject {
|
||||
private let defaults: UserDefaults
|
||||
|
||||
private var allFavorites: ProviderFavoriteServers
|
||||
|
||||
var moduleId: UUID {
|
||||
public var moduleId: UUID {
|
||||
didSet {
|
||||
guard let rawValue = defaults.string(forKey: AppPreference.providerFavoriteServers.key) else {
|
||||
allFavorites = ProviderFavoriteServers()
|
||||
|
@ -42,7 +41,7 @@ final class ProviderFavoritesManager: ObservableObject {
|
|||
}
|
||||
}
|
||||
|
||||
var serverIds: Set<String> {
|
||||
public var serverIds: Set<String> {
|
||||
get {
|
||||
allFavorites.servers(forModuleWithID: moduleId)
|
||||
}
|
||||
|
@ -52,13 +51,13 @@ final class ProviderFavoritesManager: ObservableObject {
|
|||
}
|
||||
}
|
||||
|
||||
init(defaults: UserDefaults = .standard) {
|
||||
public init(defaults: UserDefaults = .standard) {
|
||||
self.defaults = defaults
|
||||
allFavorites = ProviderFavoriteServers()
|
||||
moduleId = UUID()
|
||||
}
|
||||
|
||||
func save() {
|
||||
public func save() {
|
||||
defaults.set(allFavorites.rawValue, forKey: AppPreference.providerFavoriteServers.key)
|
||||
}
|
||||
}
|
|
@ -100,8 +100,6 @@ public struct Constants: Decodable, Sendable {
|
|||
}
|
||||
|
||||
public struct API: Decodable, Sendable {
|
||||
public let bundlePath: String
|
||||
|
||||
public let timeoutInterval: TimeInterval
|
||||
}
|
||||
|
||||
|
|
|
@ -26,7 +26,6 @@
|
|||
"refreshInterval": 3.0
|
||||
},
|
||||
"api": {
|
||||
"bundlePath": "API",
|
||||
"timeoutInterval": 5.0
|
||||
},
|
||||
"log": {
|
||||
|
|
|
@ -55,38 +55,3 @@ extension UserDefaults {
|
|||
return defaults
|
||||
}()
|
||||
}
|
||||
|
||||
// TODO: #716, move to Environment
|
||||
extension API {
|
||||
public static var shared: [APIMapper] {
|
||||
#if DEBUG
|
||||
[API.bundled]
|
||||
#else
|
||||
API.remoteThenBundled
|
||||
#endif
|
||||
}
|
||||
|
||||
private static let remoteThenBundled: [APIMapper] = [
|
||||
Self.remote,
|
||||
Self.bundled
|
||||
]
|
||||
|
||||
public static let bundled: APIMapper = {
|
||||
guard let url = Bundle.module.url(forResource: Constants.shared.api.bundlePath, withExtension: nil) else {
|
||||
fatalError("Unable to find bundled API")
|
||||
}
|
||||
let ws = API.V5.DefaultWebServices(
|
||||
url,
|
||||
timeout: Constants.shared.api.timeoutInterval
|
||||
)
|
||||
return API.V5.Mapper(webServices: ws)
|
||||
}()
|
||||
|
||||
public static let remote: APIMapper = {
|
||||
let ws = API.V5.DefaultWebServices(
|
||||
Constants.shared.websites.api,
|
||||
timeout: Constants.shared.api.timeoutInterval
|
||||
)
|
||||
return API.V5.Mapper(webServices: ws)
|
||||
}()
|
||||
}
|
||||
|
|
|
@ -23,7 +23,7 @@
|
|||
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
|
||||
//
|
||||
|
||||
@testable import AppUI
|
||||
@testable import AppLibrary
|
||||
import Foundation
|
||||
import PassepartoutKit
|
||||
import XCTest
|
|
@ -23,7 +23,7 @@
|
|||
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
|
||||
//
|
||||
|
||||
@testable import AppUI
|
||||
@testable import AppLibrary
|
||||
import Foundation
|
||||
import XCTest
|
||||
|
||||
|
@ -43,7 +43,7 @@ extension IAPManagerTests {
|
|||
// MARK: Build products
|
||||
|
||||
func test_givenBuildProducts_whenOlder_thenFullVersion() async {
|
||||
let reader = MockReceiptReader()
|
||||
let reader = MockAppReceiptReader()
|
||||
await reader.setReceipt(withBuild: olderBuildNumber, products: [])
|
||||
let sut = IAPManager(receiptReader: reader) { build in
|
||||
if build <= self.defaultBuildNumber {
|
||||
|
@ -56,7 +56,7 @@ extension IAPManagerTests {
|
|||
}
|
||||
|
||||
func test_givenBuildProducts_whenNewer_thenFreeVersion() async {
|
||||
let reader = MockReceiptReader()
|
||||
let reader = MockAppReceiptReader()
|
||||
await reader.setReceipt(withBuild: newerBuildNumber, products: [])
|
||||
let sut = IAPManager(receiptReader: reader) { build in
|
||||
if build <= self.defaultBuildNumber {
|
||||
|
@ -71,7 +71,7 @@ extension IAPManagerTests {
|
|||
// MARK: Eligibility
|
||||
|
||||
func test_givenPurchasedFeature_whenReloadReceipt_thenIsEligible() async {
|
||||
let reader = MockReceiptReader()
|
||||
let reader = MockAppReceiptReader()
|
||||
let sut = IAPManager(receiptReader: reader)
|
||||
|
||||
XCTAssertFalse(sut.isEligible(for: AppFeature.fullVersionFeaturesV2))
|
||||
|
@ -84,7 +84,7 @@ extension IAPManagerTests {
|
|||
}
|
||||
|
||||
func test_givenPurchasedFeatures_thenIsOnlyEligibleForFeatures() async {
|
||||
let reader = MockReceiptReader()
|
||||
let reader = MockAppReceiptReader()
|
||||
await reader.setReceipt(withBuild: defaultBuildNumber, products: [
|
||||
.Features.siriShortcuts,
|
||||
.Features.networkSettings
|
||||
|
@ -102,7 +102,7 @@ extension IAPManagerTests {
|
|||
}
|
||||
|
||||
func test_givenPurchasedAndCancelledFeature_thenIsNotEligible() async {
|
||||
let reader = MockReceiptReader()
|
||||
let reader = MockAppReceiptReader()
|
||||
await reader.setReceipt(
|
||||
withBuild: defaultBuildNumber,
|
||||
products: [.Full.allPlatforms],
|
||||
|
@ -115,7 +115,7 @@ extension IAPManagerTests {
|
|||
}
|
||||
|
||||
func test_givenFreeVersion_thenIsNotEligibleForAnyFeature() async {
|
||||
let reader = MockReceiptReader()
|
||||
let reader = MockAppReceiptReader()
|
||||
await reader.setReceipt(withBuild: defaultBuildNumber, products: [])
|
||||
let sut = IAPManager(receiptReader: reader)
|
||||
|
||||
|
@ -127,7 +127,7 @@ extension IAPManagerTests {
|
|||
}
|
||||
|
||||
func test_givenFreeVersion_thenIsNotEligibleForAppleTV() async {
|
||||
let reader = MockReceiptReader()
|
||||
let reader = MockAppReceiptReader()
|
||||
await reader.setReceipt(withBuild: defaultBuildNumber, products: [])
|
||||
let sut = IAPManager(receiptReader: reader)
|
||||
|
||||
|
@ -136,7 +136,7 @@ extension IAPManagerTests {
|
|||
}
|
||||
|
||||
func test_givenFullVersion_thenIsEligibleForAnyFeatureExceptAppleTV() async {
|
||||
let reader = MockReceiptReader()
|
||||
let reader = MockAppReceiptReader()
|
||||
await reader.setReceipt(withBuild: defaultBuildNumber, products: [.Full.allPlatforms])
|
||||
let sut = IAPManager(receiptReader: reader)
|
||||
|
||||
|
@ -148,7 +148,7 @@ extension IAPManagerTests {
|
|||
}
|
||||
|
||||
func test_givenAppleTV_thenIsEligibleForAppleTV() async {
|
||||
let reader = MockReceiptReader()
|
||||
let reader = MockAppReceiptReader()
|
||||
await reader.setReceipt(withBuild: defaultBuildNumber, products: [.Features.appleTV])
|
||||
let sut = IAPManager(receiptReader: reader)
|
||||
|
||||
|
@ -157,7 +157,7 @@ extension IAPManagerTests {
|
|||
}
|
||||
|
||||
func test_givenPlatformVersion_thenIsFullVersionForPlatform() async {
|
||||
let reader = MockReceiptReader()
|
||||
let reader = MockAppReceiptReader()
|
||||
let sut = IAPManager(receiptReader: reader)
|
||||
|
||||
#if os(macOS)
|
||||
|
@ -172,7 +172,7 @@ extension IAPManagerTests {
|
|||
}
|
||||
|
||||
func test_givenPlatformVersion_thenIsNotFullVersionForOtherPlatform() async {
|
||||
let reader = MockReceiptReader()
|
||||
let reader = MockAppReceiptReader()
|
||||
let sut = IAPManager(receiptReader: reader)
|
||||
|
||||
#if os(macOS)
|
||||
|
@ -193,7 +193,7 @@ extension IAPManagerTests {
|
|||
// MARK: App level
|
||||
|
||||
func test_givenBetaApp_thenIsRestricted() async {
|
||||
let reader = MockReceiptReader()
|
||||
let reader = MockAppReceiptReader()
|
||||
let sut = IAPManager(customUserLevel: .beta, receiptReader: reader)
|
||||
|
||||
await sut.reloadReceipt()
|
||||
|
@ -201,7 +201,7 @@ extension IAPManagerTests {
|
|||
}
|
||||
|
||||
func test_givenBetaApp_thenIsNotEligibleForAnyFeature() async {
|
||||
let reader = MockReceiptReader()
|
||||
let reader = MockAppReceiptReader()
|
||||
let sut = IAPManager(customUserLevel: .beta, receiptReader: reader)
|
||||
|
||||
await sut.reloadReceipt()
|
||||
|
@ -209,7 +209,7 @@ extension IAPManagerTests {
|
|||
}
|
||||
|
||||
func test_givenBetaApp_thenIsEligibleForUnrestrictedFeature() async {
|
||||
let reader = MockReceiptReader()
|
||||
let reader = MockAppReceiptReader()
|
||||
let sut = IAPManager(customUserLevel: .beta, receiptReader: reader, unrestrictedFeatures: [.onDemand])
|
||||
|
||||
await sut.reloadReceipt()
|
||||
|
@ -223,7 +223,7 @@ extension IAPManagerTests {
|
|||
}
|
||||
|
||||
func test_givenFullApp_thenIsFullVersion() async {
|
||||
let reader = MockReceiptReader()
|
||||
let reader = MockAppReceiptReader()
|
||||
let sut = IAPManager(customUserLevel: .fullVersion, receiptReader: reader)
|
||||
|
||||
await sut.reloadReceipt()
|
||||
|
@ -231,7 +231,7 @@ extension IAPManagerTests {
|
|||
}
|
||||
|
||||
func test_givenFullPlusTVApp_thenIsFullVersion() async {
|
||||
let reader = MockReceiptReader()
|
||||
let reader = MockAppReceiptReader()
|
||||
let sut = IAPManager(customUserLevel: .fullVersionPlusTV, receiptReader: reader)
|
||||
|
||||
await sut.reloadReceipt()
|
||||
|
@ -239,7 +239,7 @@ extension IAPManagerTests {
|
|||
}
|
||||
|
||||
func test_givenFullApp_thenIsEligibleForAnyFeatureExceptAppleTV() async {
|
||||
let reader = MockReceiptReader()
|
||||
let reader = MockAppReceiptReader()
|
||||
let sut = IAPManager(customUserLevel: .fullVersion, receiptReader: reader)
|
||||
|
||||
await sut.reloadReceipt()
|
||||
|
@ -250,7 +250,7 @@ extension IAPManagerTests {
|
|||
}
|
||||
|
||||
func test_givenFullPlusTVApp_thenIsEligibleForAnyFeature() async {
|
||||
let reader = MockReceiptReader()
|
||||
let reader = MockAppReceiptReader()
|
||||
let sut = IAPManager(customUserLevel: .fullVersionPlusTV, receiptReader: reader)
|
||||
|
||||
await sut.reloadReceipt()
|
|
@ -23,6 +23,7 @@
|
|||
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
|
||||
//
|
||||
|
||||
import AppLibrary
|
||||
@testable import AppUIMain
|
||||
import Foundation
|
||||
import XCTest
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
#!/bin/bash
|
||||
DESTINATION="Passepartout/Library/Sources/CommonLibrary/Resources/API"
|
||||
DESTINATION="Passepartout/Library/Sources/APILibrary/API"
|
||||
API_VERSION="v5"
|
||||
|
||||
mkdir tmp
|
||||
|
|
Loading…
Reference in New Issue