passepartout-apple/Passepartout/Library/Sources/AppUI/Theme/Theme+UI.swift

709 lines
16 KiB
Swift
Raw Normal View History

2024-09-23 13:02:26 +00:00
//
// Theme+UI.swift
// Passepartout
//
// Created by Davide De Rosa on 8/28/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
#if canImport(LocalAuthentication)
import LocalAuthentication
#endif
2024-09-23 13:02:26 +00:00
import SwiftUI
import UtilsLibrary
// MARK: - Modifiers
#if !os(tvOS)
2024-09-23 13:02:26 +00:00
struct ThemeWindowModifier: ViewModifier {
let size: CGSize
}
struct ThemeNavigationDetailModifier: ViewModifier {
}
struct ThemeFormModifier: ViewModifier {
func body(content: Content) -> some View {
content
.formStyle(.grouped)
}
}
struct ThemeBooleanModalModifier<Modal>: ViewModifier where Modal: View {
@EnvironmentObject
private var theme: Theme
@Binding
var isPresented: Bool
let isRoot: Bool
let isInteractive: Bool
let modal: () -> Modal
func body(content: Content) -> some View {
content
.sheet(isPresented: $isPresented) {
modal()
.frame(minWidth: modalSize?.width, minHeight: modalSize?.height)
.interactiveDismissDisabled(!isInteractive)
.themeLockScreen(theme)
2024-09-23 13:02:26 +00:00
}
}
private var modalSize: CGSize? {
isRoot ? theme.rootModalSize : theme.secondaryModalSize
}
}
struct ThemeItemModalModifier<Modal, T>: ViewModifier where Modal: View, T: Identifiable {
@EnvironmentObject
private var theme: Theme
@Binding
var item: T?
let isRoot: Bool
let isInteractive: Bool
let modal: (T) -> Modal
func body(content: Content) -> some View {
content
.sheet(item: $item) {
modal($0)
.frame(minWidth: modalSize?.width, minHeight: modalSize?.height)
.interactiveDismissDisabled(!isInteractive)
.themeLockScreen(theme)
2024-09-23 13:02:26 +00:00
}
}
private var modalSize: CGSize? {
isRoot ? theme.rootModalSize : theme.secondaryModalSize
}
}
struct ThemeBooleanPopoverModifier<Popover>: ViewModifier where Popover: View {
@EnvironmentObject
private var theme: Theme
@Environment(\.horizontalSizeClass)
private var hsClass
@Environment(\.verticalSizeClass)
private var vsClass
@Binding
var isPresented: Bool
@ViewBuilder
let popover: Popover
func body(content: Content) -> some View {
if hsClass == .regular && vsClass == .regular {
content
.popover(isPresented: $isPresented) {
popover
.frame(minWidth: theme.popoverSize?.width, minHeight: theme.popoverSize?.height)
.themeLockScreen(theme)
}
} else {
content
.sheet(isPresented: $isPresented) {
popover
.themeLockScreen(theme)
}
}
}
}
struct ThemeConfirmationModifier: ViewModifier {
@Binding
var isPresented: Bool
let title: String
let action: () -> Void
func body(content: Content) -> some View {
content
.confirmationDialog(title, isPresented: $isPresented) {
Button(Strings.Global.ok, action: action)
Text(Strings.Global.cancel)
} message: {
Text(Strings.Theme.Confirmation.message)
}
}
}
2024-09-28 10:47:33 +00:00
struct ThemeNavigationStackModifier: ViewModifier {
@Environment(\.dismiss)
private var dismiss
let condition: Bool
let closable: Bool
@Binding
var path: NavigationPath
func body(content: Content) -> some View {
if condition {
NavigationStack(path: $path) {
content
.toolbar {
if closable {
ToolbarItem(placement: .cancellationAction) {
Button {
dismiss()
} label: {
ThemeImage(.close)
}
}
}
}
}
} else {
content
}
}
}
2024-09-23 13:02:26 +00:00
struct ThemePlainButtonModifier: ViewModifier {
let action: () -> Void
}
struct ThemeManualInputModifier: ViewModifier {
}
struct ThemeEmptyMessageModifier: ViewModifier {
@EnvironmentObject
private var theme: Theme
func body(content: Content) -> some View {
VStack {
Spacer()
content
.font(theme.emptyMessageFont)
.foregroundStyle(theme.emptyMessageColor)
Spacer()
}
}
}
struct ThemeErrorModifier: ViewModifier {
@EnvironmentObject
private var theme: Theme
let isError: Bool
func body(content: Content) -> some View {
content
.foregroundStyle(isError ? theme.errorColor : theme.titleColor)
}
}
struct ThemeAnimationModifier<T>: ViewModifier where T: Equatable {
@EnvironmentObject
private var theme: Theme
let value: T
let category: ThemeAnimationCategory
func body(content: Content) -> some View {
content
.animation(theme.animation(for: category), value: value)
}
}
struct ThemeTrailingValueModifier: ViewModifier {
let value: CustomStringConvertible?
let truncationMode: Text.TruncationMode
func body(content: Content) -> some View {
LabeledContent {
if let value {
Spacer()
Text(value.description)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(truncationMode)
}
} label: {
content
}
}
}
struct ThemeSectionWithHeaderFooterModifier: ViewModifier {
let header: String?
2024-09-23 13:02:26 +00:00
let footer: String?
}
struct ThemeGridSectionModifier: ViewModifier {
@EnvironmentObject
private var theme: Theme
let title: String?
func body(content: Content) -> some View {
if let title {
Text(title)
.font(theme.gridHeaderStyle)
.fontWeight(theme.relevantWeight)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.leading)
.padding(.bottom, theme.gridHeaderBottom)
}
content
.padding(.bottom)
.padding(.bottom)
}
}
struct ThemeGridCellModifier: ViewModifier {
@EnvironmentObject
private var theme: Theme
let isSelected: Bool
func body(content: Content) -> some View {
content
.padding()
.background(isSelected ? theme.gridCellActiveColor : theme.gridCellColor)
.clipShape(.rect(cornerRadius: theme.gridRadius))
}
}
struct ThemeHoverListRowModifier: ViewModifier {
func body(content: Content) -> some View {
content
.frame(maxHeight: .infinity)
.listRowInsets(.init())
}
}
struct ThemeLockScreenModifier: ViewModifier {
@AppStorage(AppPreference.locksInBackground.key)
private var locksInBackground = false
@ObservedObject
var theme: Theme
func body(content: Content) -> some View {
LockableView(
locksInBackground: $locksInBackground,
content: {
content
},
lockedContent: LogoView.init,
unlockBlock: Self.unlockScreenBlock
)
.environmentObject(theme)
}
private static func unlockScreenBlock() async -> Bool {
let context = LAContext()
let policy: LAPolicy = .deviceOwnerAuthentication
var error: NSError?
guard context.canEvaluatePolicy(policy, error: &error) else {
return true
}
do {
let isAuthorized = try await context.evaluatePolicy(
policy,
localizedReason: Strings.Views.Settings.Rows.LocksInBackground.message
)
return isAuthorized
} catch {
return false
}
}
}
struct ThemeTipModifier: ViewModifier {
let text: String
let edge: Edge
@State
private var isPresenting = false
func body(content: Content) -> some View {
HStack {
content
Button {
isPresenting = true
} label: {
ThemeImage(.tip)
}
2024-10-04 00:57:09 +00:00
.imageScale(.large)
.buttonStyle(.borderless)
2024-10-03 15:53:19 +00:00
.popover(isPresented: $isPresenting, arrowEdge: edge) {
VStack {
Text(text)
.font(.body)
.foregroundStyle(.primary)
.lineLimit(nil)
.multilineTextAlignment(.leading)
.frame(width: 150.0)
}
.padding(12)
}
}
}
}
#endif
2024-09-23 13:02:26 +00:00
// MARK: - Views
public enum ThemeAnimationCategory: CaseIterable {
case diagnostics
case modules
2024-09-23 13:02:26 +00:00
case profiles
case profilesLayout
case providers
2024-09-23 13:02:26 +00:00
}
public struct ThemeImage: View {
2024-09-23 13:02:26 +00:00
@EnvironmentObject
private var theme: Theme
private let name: Theme.ImageName
public init(_ name: Theme.ImageName) {
2024-09-23 13:02:26 +00:00
self.name = name
}
public var body: some View {
2024-10-26 19:51:18 +00:00
Image(systemName: theme.systemImageName(name))
2024-09-23 13:02:26 +00:00
}
}
public struct ThemeImageLabel: View {
2024-09-23 13:02:26 +00:00
@EnvironmentObject
private var theme: Theme
private let title: String
private let name: Theme.ImageName
public init(_ title: String, _ name: Theme.ImageName) {
2024-09-23 13:02:26 +00:00
self.title = title
self.name = name
}
public var body: some View {
2024-09-23 13:02:26 +00:00
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 {
Group {
if let code {
let image = Image("flags/\(code.lowercased())")
.resizable()
if let tip = countryTip?(code) {
image
.help(tip)
} else {
image
}
} else {
let image = Image(systemName: "globe")
if let placeholderTip {
image
.help(placeholderTip)
} else {
image
}
}
}
.frame(width: 20, height: 15)
}
}
#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 {
2024-09-23 13:02:26 +00:00
@EnvironmentObject
private var theme: Theme
private let title: String?
private let value: Value
2024-09-23 13:02:26 +00:00
private let isMultiLine: Bool
2024-09-23 13:02:26 +00:00
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 {
2024-09-23 13:02:26 +00:00
HStack {
if let title {
Text(title)
Spacer()
}
valueView(value)
2024-09-23 13:02:26 +00:00
.foregroundStyle(title == nil ? theme.titleColor : theme.valueColor)
.themeMultiLine(isMultiLine)
2024-09-23 13:02:26 +00:00
if title == nil {
Spacer()
}
Button {
2024-10-11 17:45:58 +00:00
copyToPasteboard(value.description)
2024-09-23 13:02:26 +00:00
} label: {
ThemeImage(.copy)
}
2024-10-01 13:45:25 +00:00
// TODO: #584, necessary to avoid cell selection
2024-09-23 13:02:26 +00:00
.buttonStyle(.borderless)
}
}
}
public struct ThemeTappableText: View {
private let title: String
2024-09-23 13:02:26 +00:00
private let action: () -> Void
public init(title: String, action: @escaping () -> Void) {
self.title = title
self.action = action
}
2024-09-23 13:02:26 +00:00
var commonView: some View {
Button(action: action) {
Text(title)
.themeTruncating()
}
}
}
public struct ThemeTextField: View {
private let title: String?
2024-09-23 13:02:26 +00:00
@Binding
private var text: String
2024-09-23 13:02:26 +00:00
private let placeholder: String
2024-09-23 13:02:26 +00:00
public init(_ title: String, text: Binding<String>, placeholder: String) {
2024-09-23 13:02:26 +00:00
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?
2024-09-23 13:02:26 +00:00
@Binding
private var text: String
2024-09-23 13:02:26 +00:00
private let placeholder: String
public init(title: String?, text: Binding<String>, placeholder: String) {
self.title = title
_text = text
self.placeholder = placeholder
}
2024-09-23 13:02:26 +00:00
@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
2024-09-23 13:02:26 +00:00
@ViewBuilder
private let itemView: () -> ItemView
2024-09-23 13:02:26 +00:00
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 {
2024-09-23 13:02:26 +00:00
RemovableItemRow(
isEditing: isEditing,
itemView: itemView,
removeView: removeView
)
}
}
public enum ThemeEditableListSection {
public struct RemoveLabel: View {
2024-09-23 13:02:26 +00:00
let action: () -> Void
public init(action: @escaping () -> Void) {
self.action = action
}
2024-09-23 13:02:26 +00:00
}
public struct EditLabel: View {
2024-09-23 13:02:26 +00:00
}
}
#endif