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:
Davide 2024-11-10 12:00:07 +01:00 committed by GitHub
parent e07833b2a4
commit 3a5e3889d3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 130 additions and 88 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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