Add more view modifiers (#838)
1. ThemeProgressViewModifier to replace content with a progress view while a condition is active 2. ThemeEmptyContentModifier to replace content with a message if an empty condition is met 3. Replace .opacity(bool ? 1.0 : 0.0) with .opaque(bool) Reuse: - 1 in PaywallView and DonateView - 2 in ProfileContainerView
This commit is contained in:
parent
e07833b2a4
commit
3a5e3889d3
|
@ -51,27 +51,19 @@ struct DonateView: View {
|
||||||
private var errorHandler: ErrorHandler = .default()
|
private var errorHandler: ErrorHandler = .default()
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Form {
|
donationsView
|
||||||
if isFetchingProducts {
|
.themeProgress(if: isFetchingProducts)
|
||||||
ProgressView()
|
.navigationTitle(title)
|
||||||
.id(UUID())
|
.alert(
|
||||||
} else if !availableProducts.isEmpty {
|
title,
|
||||||
donationsView
|
isPresented: $isThankYouPresented,
|
||||||
.disabled(purchasingIdentifier != nil)
|
actions: thankYouActions,
|
||||||
|
message: thankYouMessage
|
||||||
|
)
|
||||||
|
.task {
|
||||||
|
await fetchAvailableProducts()
|
||||||
}
|
}
|
||||||
}
|
.withErrorHandler(errorHandler)
|
||||||
.themeForm()
|
|
||||||
.navigationTitle(title)
|
|
||||||
.alert(
|
|
||||||
title,
|
|
||||||
isPresented: $isThankYouPresented,
|
|
||||||
actions: thankYouActions,
|
|
||||||
message: thankYouMessage
|
|
||||||
)
|
|
||||||
.task {
|
|
||||||
await fetchAvailableProducts()
|
|
||||||
}
|
|
||||||
.withErrorHandler(errorHandler)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -80,24 +72,27 @@ private extension DonateView {
|
||||||
Strings.Views.Donate.title
|
Strings.Views.Donate.title
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
var donationsView: some View {
|
var donationsView: some View {
|
||||||
|
Form {
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
Section {
|
Section {
|
||||||
Text(Strings.Views.Donate.Sections.Main.footer)
|
Text(Strings.Views.Donate.Sections.Main.footer)
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
ForEach(availableProducts, id: \.productIdentifier) {
|
ForEach(availableProducts, id: \.productIdentifier) {
|
||||||
PaywallProductView(
|
PaywallProductView(
|
||||||
iapManager: iapManager,
|
iapManager: iapManager,
|
||||||
style: .donation,
|
style: .donation,
|
||||||
product: $0,
|
product: $0,
|
||||||
purchasingIdentifier: $purchasingIdentifier,
|
purchasingIdentifier: $purchasingIdentifier,
|
||||||
onComplete: onComplete,
|
onComplete: onComplete,
|
||||||
onError: onError
|
onError: onError
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
.themeSection(footer: Strings.Views.Donate.Sections.Main.footer)
|
||||||
}
|
}
|
||||||
.themeSection(footer: Strings.Views.Donate.Sections.Main.footer)
|
.themeForm()
|
||||||
|
.disabled(purchasingIdentifier != nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func thankYouActions() -> some View {
|
func thankYouActions() -> some View {
|
||||||
|
|
|
@ -62,8 +62,8 @@ struct InstalledProfileView: View, Routable {
|
||||||
}
|
}
|
||||||
|
|
||||||
private extension InstalledProfileView {
|
private extension InstalledProfileView {
|
||||||
var installedOpacity: CGFloat {
|
var isOpaque: Bool {
|
||||||
profile != nil ? 1.0 : 0.0
|
profile != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var cardView: some View {
|
var cardView: some View {
|
||||||
|
@ -102,7 +102,7 @@ private extension InstalledProfileView {
|
||||||
StatusText(
|
StatusText(
|
||||||
theme: theme,
|
theme: theme,
|
||||||
tunnel: tunnel,
|
tunnel: tunnel,
|
||||||
opacity: installedOpacity
|
isOpaque: isOpaque
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -115,7 +115,7 @@ private extension InstalledProfileView {
|
||||||
nextProfileId: $nextProfileId,
|
nextProfileId: $nextProfileId,
|
||||||
interactiveManager: interactiveManager,
|
interactiveManager: interactiveManager,
|
||||||
errorHandler: errorHandler,
|
errorHandler: errorHandler,
|
||||||
opacity: installedOpacity,
|
isOpaque: isOpaque,
|
||||||
flow: flow
|
flow: flow
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -160,13 +160,13 @@ private struct StatusText: View {
|
||||||
@ObservedObject
|
@ObservedObject
|
||||||
var tunnel: ExtendedTunnel
|
var tunnel: ExtendedTunnel
|
||||||
|
|
||||||
let opacity: Double
|
let isOpaque: Bool
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ConnectionStatusText(tunnel: tunnel)
|
ConnectionStatusText(tunnel: tunnel)
|
||||||
.font(.body)
|
.font(.body)
|
||||||
.foregroundStyle(tunnel.statusColor(theme))
|
.foregroundStyle(tunnel.statusColor(theme))
|
||||||
.opacity(opacity)
|
.opaque(isOpaque)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -189,7 +189,7 @@ private struct ToggleButton: View {
|
||||||
@ObservedObject
|
@ObservedObject
|
||||||
var errorHandler: ErrorHandler
|
var errorHandler: ErrorHandler
|
||||||
|
|
||||||
let opacity: Double
|
let isOpaque: Bool
|
||||||
|
|
||||||
let flow: ProfileFlow?
|
let flow: ProfileFlow?
|
||||||
|
|
||||||
|
@ -209,7 +209,7 @@ private struct ToggleButton: View {
|
||||||
// TODO: #584, necessary to avoid cell selection
|
// TODO: #584, necessary to avoid cell selection
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.foregroundStyle(tunnel.statusColor(theme))
|
.foregroundStyle(tunnel.statusColor(theme))
|
||||||
.opacity(opacity)
|
.opaque(isOpaque)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -113,20 +113,13 @@ private struct ContainerModifier: ViewModifier {
|
||||||
|
|
||||||
func body(content: Content) -> some View {
|
func body(content: Content) -> some View {
|
||||||
debugChanges()
|
debugChanges()
|
||||||
return ZStack {
|
return content
|
||||||
content
|
.themeEmptyContent(if: !profileManager.hasProfiles, message: Strings.Views.Profiles.Folders.noProfiles)
|
||||||
.opacity(profileManager.hasProfiles ? 1.0 : 0.0)
|
.searchable(text: $search)
|
||||||
|
.onChange(of: search) {
|
||||||
if !profileManager.hasProfiles {
|
profileManager.search(byName: $0)
|
||||||
Text(Strings.Views.Profiles.Folders.noProfiles)
|
|
||||||
.themeEmptyMessage()
|
|
||||||
}
|
}
|
||||||
}
|
.themeAnimation(on: profileManager.headers, category: .profiles)
|
||||||
.searchable(text: $search)
|
|
||||||
.onChange(of: search) {
|
|
||||||
profileManager.search(byName: $0)
|
|
||||||
}
|
|
||||||
.themeAnimation(on: profileManager.headers, category: .profiles)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -79,7 +79,7 @@ struct ProfileRowView: View, Routable {
|
||||||
private extension ProfileRowView {
|
private extension ProfileRowView {
|
||||||
var markerView: some View {
|
var markerView: some View {
|
||||||
ThemeImage(header.id == nextProfileId ? .pending : statusImage)
|
ThemeImage(header.id == nextProfileId ? .pending : statusImage)
|
||||||
.opacity(header.id == nextProfileId || header.id == tunnel.currentProfile?.id ? 1.0 : 0.0)
|
.opaque(header.id == nextProfileId || header.id == tunnel.currentProfile?.id)
|
||||||
.frame(width: 24.0)
|
.frame(width: 24.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -178,7 +178,7 @@ private extension VPNProviderServerView.ServersSubview {
|
||||||
} label: {
|
} label: {
|
||||||
HStack {
|
HStack {
|
||||||
ThemeImage(.marked)
|
ThemeImage(.marked)
|
||||||
.opacity(server.id == selectedServerId ? 1.0 : 0.0)
|
.opaque(server.id == selectedServerId)
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
if let area = server.provider.area {
|
if let area = server.provider.area {
|
||||||
Text(area)
|
Text(area)
|
||||||
|
|
|
@ -68,7 +68,7 @@ extension VPNProviderServerView {
|
||||||
return Table(servers) {
|
return Table(servers) {
|
||||||
TableColumn("") { server in
|
TableColumn("") { server in
|
||||||
ThemeImage(.marked)
|
ThemeImage(.marked)
|
||||||
.opacity(server.id == selectedServerId ? 1.0 : 0.0)
|
.opaque(server.id == selectedServerId)
|
||||||
}
|
}
|
||||||
.width(10.0)
|
.width(10.0)
|
||||||
|
|
||||||
|
|
|
@ -77,7 +77,7 @@ struct ProfileView: View, TunnelInstallationProviding {
|
||||||
ZStack {
|
ZStack {
|
||||||
listView
|
listView
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
.opacity(interactiveManager.isPresented ? 0.0 : 1.0)
|
.opaque(!interactiveManager.isPresented)
|
||||||
|
|
||||||
if interactiveManager.isPresented {
|
if interactiveManager.isPresented {
|
||||||
interactiveView
|
interactiveView
|
||||||
|
|
|
@ -38,6 +38,10 @@ extension View {
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public func opaque(_ condition: Bool) -> some View {
|
||||||
|
opacity(condition ? 1.0 : 0.0)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension ViewModifier {
|
extension ViewModifier {
|
||||||
|
|
|
@ -24,10 +24,10 @@
|
||||||
//
|
//
|
||||||
|
|
||||||
import CommonLibrary
|
import CommonLibrary
|
||||||
|
import CommonUtils
|
||||||
#if canImport(LocalAuthentication)
|
#if canImport(LocalAuthentication)
|
||||||
import LocalAuthentication
|
import LocalAuthentication
|
||||||
#endif
|
#endif
|
||||||
import CommonUtils
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
// MARK: Shortcuts
|
// MARK: Shortcuts
|
||||||
|
@ -143,6 +143,14 @@ extension View {
|
||||||
modifier(ThemeAnimationModifier(value: value, category: category))
|
modifier(ThemeAnimationModifier(value: value, category: category))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public func themeProgress(if isProgressing: Bool) -> some View {
|
||||||
|
modifier(ThemeProgressViewModifier(isProgressing: isProgressing))
|
||||||
|
}
|
||||||
|
|
||||||
|
public func themeEmptyContent(if isEmpty: Bool, message: String) -> some View {
|
||||||
|
modifier(ThemeEmptyContentModifier(isEmpty: isEmpty, message: message))
|
||||||
|
}
|
||||||
|
|
||||||
#if !os(tvOS)
|
#if !os(tvOS)
|
||||||
public func themeWindow(width: CGFloat, height: CGFloat) -> some View {
|
public func themeWindow(width: CGFloat, height: CGFloat) -> some View {
|
||||||
modifier(ThemeWindowModifier(size: .init(width: width, height: height)))
|
modifier(ThemeWindowModifier(size: .init(width: width, height: height)))
|
||||||
|
@ -349,6 +357,39 @@ struct ThemeAnimationModifier<T>: ViewModifier where T: Equatable {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct ThemeProgressViewModifier: ViewModifier {
|
||||||
|
let isProgressing: Bool
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
ZStack {
|
||||||
|
if isProgressing {
|
||||||
|
ThemeProgressView()
|
||||||
|
}
|
||||||
|
content
|
||||||
|
.opaque(!isProgressing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ThemeEmptyContentModifier: ViewModifier {
|
||||||
|
let isEmpty: Bool
|
||||||
|
|
||||||
|
let message: String
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
ZStack {
|
||||||
|
content
|
||||||
|
.opaque(!isEmpty)
|
||||||
|
|
||||||
|
if isEmpty {
|
||||||
|
Text(message)
|
||||||
|
.themeEmptyMessage()
|
||||||
|
.opaque(isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#if !os(tvOS)
|
#if !os(tvOS)
|
||||||
|
|
||||||
struct ThemeWindowModifier: ViewModifier {
|
struct ThemeWindowModifier: ViewModifier {
|
||||||
|
|
|
@ -225,6 +225,17 @@ public struct ThemeSecureField: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public struct ThemeProgressView: View {
|
||||||
|
public init() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public var body: some View {
|
||||||
|
ProgressView()
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.id(UUID())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#if !os(tvOS)
|
#if !os(tvOS)
|
||||||
|
|
||||||
public struct ThemeMenuImage: View {
|
public struct ThemeMenuImage: View {
|
||||||
|
|
|
@ -59,31 +59,19 @@ struct PaywallView: View {
|
||||||
private var errorHandler: ErrorHandler = .default()
|
private var errorHandler: ErrorHandler = .default()
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Form {
|
paywallView
|
||||||
if isFetchingProducts {
|
.themeProgress(if: isFetchingProducts)
|
||||||
ProgressView()
|
.toolbar(content: toolbarContent)
|
||||||
.id(UUID())
|
.alert(
|
||||||
} else if !recurringProducts.isEmpty {
|
Strings.Global.purchase,
|
||||||
Group {
|
isPresented: $isPurchasePendingConfirmation,
|
||||||
productsView
|
actions: pendingActions,
|
||||||
subscriptionFeaturesView
|
message: pendingMessage
|
||||||
restoreView
|
)
|
||||||
}
|
.task(id: feature) {
|
||||||
.disabled(purchasingIdentifier != nil)
|
await fetchAvailableProducts()
|
||||||
}
|
}
|
||||||
}
|
.withErrorHandler(errorHandler)
|
||||||
.themeForm()
|
|
||||||
.toolbar(content: toolbarContent)
|
|
||||||
.alert(
|
|
||||||
Strings.Global.purchase,
|
|
||||||
isPresented: $isPurchasePendingConfirmation,
|
|
||||||
actions: pendingActions,
|
|
||||||
message: pendingMessage
|
|
||||||
)
|
|
||||||
.task(id: feature) {
|
|
||||||
await fetchAvailableProducts()
|
|
||||||
}
|
|
||||||
.withErrorHandler(errorHandler)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -92,6 +80,16 @@ private extension PaywallView {
|
||||||
Strings.Global.purchase
|
Strings.Global.purchase
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var paywallView: some View {
|
||||||
|
Form {
|
||||||
|
productsView
|
||||||
|
subscriptionFeaturesView
|
||||||
|
restoreView
|
||||||
|
}
|
||||||
|
.themeForm()
|
||||||
|
.disabled(purchasingIdentifier != nil)
|
||||||
|
}
|
||||||
|
|
||||||
var subscriptionFeatures: [AppFeature] {
|
var subscriptionFeatures: [AppFeature] {
|
||||||
AppFeature.allCases.sorted {
|
AppFeature.allCases.sorted {
|
||||||
$0.localizedDescription.lowercased() < $1.localizedDescription.lowercased()
|
$0.localizedDescription.lowercased() < $1.localizedDescription.lowercased()
|
||||||
|
|
|
@ -48,7 +48,7 @@ public struct FavoriteToggle<ID>: View where ID: Hashable {
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
ThemeImage(selection.contains(value) ? .favoriteOn : .favoriteOff)
|
ThemeImage(selection.contains(value) ? .favoriteOn : .favoriteOff)
|
||||||
.opacity(opacity)
|
.opaque(opaque)
|
||||||
}
|
}
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
.onHover {
|
.onHover {
|
||||||
|
@ -59,11 +59,11 @@ public struct FavoriteToggle<ID>: View where ID: Hashable {
|
||||||
}
|
}
|
||||||
|
|
||||||
private extension FavoriteToggle {
|
private extension FavoriteToggle {
|
||||||
var opacity: Double {
|
var opaque: Bool {
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
selection.contains(value) || value == hover ? 1.0 : 0.0
|
selection.contains(value) || value == hover
|
||||||
#else
|
#else
|
||||||
1.0
|
true
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue