Add CI/CD configuration and API documentation
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
public extension Alert {
|
||||
init(_ error: Error, _ dismissAction: (() -> Void)? = nil) {
|
||||
self.init(
|
||||
errorMessage: error.localizedDescription,
|
||||
dismissAction
|
||||
)
|
||||
}
|
||||
|
||||
init(errorMessage: String, _ dismissAction: (() -> Void)? = nil) {
|
||||
self.init(
|
||||
title: Text("错误提示"),
|
||||
message: Text(errorMessage),
|
||||
dismissButton: .default(Text("确定")) {
|
||||
dismissAction?()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
init(title: String = "提示", okMessage: String, _ dismissAction: (() -> Void)? = nil) {
|
||||
self.init(
|
||||
title: Text(title),
|
||||
message: Text(okMessage),
|
||||
dismissButton: .default(Text("确定")) {
|
||||
dismissAction?()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
init(OKORNOtitle: String = "提示", YesOrNOMessage: String, _ primaryAction: (() -> Void)? = nil) {
|
||||
self.init(
|
||||
title: Text(OKORNOtitle),
|
||||
message: Text(YesOrNOMessage),
|
||||
primaryButton: .default(Text("确定"), action: {
|
||||
primaryAction?()
|
||||
}),
|
||||
secondaryButton: .cancel(Text("取消"), action: {
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public extension View {
|
||||
func alertBinding(_ binding: Binding<Alert?>) -> some View {
|
||||
|
||||
alert(isPresented: Binding(get: {
|
||||
binding.wrappedValue != nil
|
||||
}, set: { newValue, _ in
|
||||
if !newValue {
|
||||
binding.wrappedValue = nil
|
||||
}
|
||||
})) {
|
||||
binding.wrappedValue!
|
||||
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
func alertBinding(_ binding: Binding<Alert?>, _ isLoading: Binding<Bool>) -> some View {
|
||||
alert(isPresented: Binding(get: {
|
||||
binding.wrappedValue != nil
|
||||
}, set: { newValue, _ in
|
||||
if !newValue, !isLoading.wrappedValue {
|
||||
binding.wrappedValue = nil
|
||||
}
|
||||
})) {
|
||||
binding.wrappedValue!
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
public struct BackButton: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
public var body: some View {
|
||||
Button {
|
||||
dismiss()
|
||||
} label: {
|
||||
Image(systemName: "chevron.backward")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
public extension Binding {
|
||||
func withSetter(_ setter: @escaping (Value) -> Void) -> Binding<Value> {
|
||||
Binding {
|
||||
wrappedValue
|
||||
} set: { [setter] newValue, _ in
|
||||
setter(newValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import SwiftUI
|
||||
|
||||
public extension Binding {
|
||||
func unwrapped<T>(_ defaultValue: T) -> Binding<T> where Value == T? {
|
||||
Binding<T>(get: {
|
||||
wrappedValue ?? defaultValue
|
||||
}, set: { newValue in
|
||||
wrappedValue = newValue
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
public extension Binding where Value == Int32 {
|
||||
func stringBinding(defaultValue: Int32) -> Binding<String> {
|
||||
Binding<String> {
|
||||
var intValue = wrappedValue
|
||||
if intValue == 0 {
|
||||
intValue = defaultValue
|
||||
}
|
||||
return String(intValue)
|
||||
} set: { newValue in
|
||||
var newIntValue = Int32(newValue) ?? defaultValue
|
||||
if newIntValue == 0 {
|
||||
newIntValue = defaultValue
|
||||
}
|
||||
wrappedValue = newIntValue
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
public struct DeleteButton<Label>: View where Label: View {
|
||||
private let action: () async -> Void
|
||||
private let label: Label
|
||||
|
||||
@State private var performDelete = false
|
||||
@State private var timer: Timer?
|
||||
@State private var isLoading = false
|
||||
|
||||
public init(action: @escaping () async -> Void, @ViewBuilder label: () -> Label) {
|
||||
self.action = action
|
||||
self.label = label()
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
Button(role: .destructive) {
|
||||
isLoading = true
|
||||
if let timer {
|
||||
timer.invalidate()
|
||||
}
|
||||
if !performDelete {
|
||||
performDelete = true
|
||||
timer = Timer.scheduledTimer(withTimeInterval: 3, repeats: false) { _ in
|
||||
timer = nil
|
||||
performDelete = false
|
||||
}
|
||||
isLoading = true
|
||||
} else {
|
||||
Task {
|
||||
await action()
|
||||
isLoading = false
|
||||
performDelete = false
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
if !performDelete {
|
||||
label
|
||||
} else {
|
||||
label.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
.disabled(isLoading)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import Foundation
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#elseif canImport(AppKit)
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
public enum DeviceCensorship {
|
||||
public static func isChinaDevice() -> Bool {
|
||||
let bannedCharacter = "\u{1F1F9}\u{1F1FC}" as NSString
|
||||
var imageData: Data
|
||||
#if canImport(UIKit)
|
||||
let attributes = [NSAttributedString.Key.font:
|
||||
UIFont.systemFont(ofSize: 8)]
|
||||
UIGraphicsBeginImageContext(bannedCharacter.size(withAttributes: attributes))
|
||||
bannedCharacter.draw(at: CGPoint(x: 0, y: 0), withAttributes: attributes)
|
||||
var imagePNG: Data?
|
||||
if let charImage = UIGraphicsGetImageFromCurrentImageContext() {
|
||||
imagePNG = charImage.pngData()
|
||||
}
|
||||
UIGraphicsEndImageContext()
|
||||
guard let imagePNG else {
|
||||
return false
|
||||
}
|
||||
guard let uiImage = UIImage(data: imagePNG) else {
|
||||
return false
|
||||
}
|
||||
guard let cgImage = uiImage.cgImage else { return false }
|
||||
guard let cgImageData = cgImage.dataProvider?.data as Data? else { return false }
|
||||
imageData = cgImageData
|
||||
#elseif canImport(AppKit)
|
||||
let attributes = [NSAttributedString.Key.font:
|
||||
NSFont.systemFont(ofSize: 8)]
|
||||
let characterSize = bannedCharacter.size(withAttributes: attributes)
|
||||
let characterRect = NSRect(origin: .zero, size: characterSize)
|
||||
|
||||
let characterBitmap = NSBitmapImageRep(bitmapDataPlanes: nil,
|
||||
pixelsWide: Int(characterSize.width),
|
||||
pixelsHigh: Int(characterSize.height),
|
||||
bitsPerSample: 8,
|
||||
samplesPerPixel: 4,
|
||||
hasAlpha: true,
|
||||
isPlanar: false,
|
||||
colorSpaceName: NSColorSpaceName.calibratedRGB,
|
||||
bytesPerRow: 0,
|
||||
bitsPerPixel: 0)
|
||||
|
||||
NSGraphicsContext.saveGraphicsState()
|
||||
NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: characterBitmap!)
|
||||
NSGraphicsContext.current?.imageInterpolation = .high
|
||||
|
||||
bannedCharacter.draw(in: characterRect, withAttributes: attributes)
|
||||
|
||||
NSGraphicsContext.restoreGraphicsState()
|
||||
|
||||
guard let imagePNG = characterBitmap?.representation(using: .png, properties: [:]) else {
|
||||
return false
|
||||
}
|
||||
|
||||
guard let nsImage = NSImage(data: imagePNG) else {
|
||||
return false
|
||||
}
|
||||
guard let cgImage = nsImage.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
|
||||
return false
|
||||
}
|
||||
guard let cgImageData = cgImage.dataProvider?.data as Data? else { return false }
|
||||
imageData = cgImageData
|
||||
#endif
|
||||
let rawData: UnsafePointer<UInt8> = CFDataGetBytePtr(imageData as CFData)
|
||||
for index in stride(from: 0, to: imageData.count, by: 4) {
|
||||
let r = rawData[index]
|
||||
let g = rawData[index + 1]
|
||||
let b = rawData[index + 2]
|
||||
if !(r == g && g == b) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
public func FormView(@ViewBuilder content: () -> some View) -> some View {
|
||||
Form {
|
||||
content()
|
||||
}
|
||||
#if os(macOS)
|
||||
.formStyle(.grouped)
|
||||
#endif
|
||||
}
|
||||
|
||||
public func FormTextItem(_ name: LocalizedStringKey, _ value: String) -> some View {
|
||||
HStack {
|
||||
Text(name)
|
||||
Spacer()
|
||||
Text(value)
|
||||
.multilineTextAlignment(.trailing)
|
||||
.font(Font.system(.caption, design: .monospaced))
|
||||
#if os(iOS) || os(macOS)
|
||||
.textSelection(.enabled)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
public func FormTextItem(_ name: LocalizedStringKey, _ systemImage: String, @ViewBuilder _ value: () -> some View) -> some View {
|
||||
HStack {
|
||||
Label(name, systemImage: systemImage)
|
||||
Spacer()
|
||||
value()
|
||||
.multilineTextAlignment(.trailing)
|
||||
.font(Font.system(.caption, design: .monospaced))
|
||||
#if os(iOS) || os(macOS)
|
||||
.textSelection(.enabled)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
public func FormItem(_ title: String, @ViewBuilder content: () -> some View) -> some View {
|
||||
#if os(iOS) || os(tvOS)
|
||||
HStack {
|
||||
Text(title)
|
||||
.lineLimit(1)
|
||||
.layoutPriority(1)
|
||||
Spacer()
|
||||
Spacer()
|
||||
content()
|
||||
}
|
||||
#elseif os(macOS)
|
||||
content()
|
||||
#endif
|
||||
}
|
||||
|
||||
public func FormToggle(_ titleKey: LocalizedStringKey, _ subtitleKey: LocalizedStringKey, _ isOn: Binding<Bool>, _ action: @escaping (_ newValue: Bool) async -> Void) -> some View {
|
||||
#if os(macOS)
|
||||
Toggle(isOn: isOn) {
|
||||
VStack(alignment: .leading) {
|
||||
Text(titleKey)
|
||||
Spacer()
|
||||
Text(subtitleKey)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.onChangeCompat(of: isOn.wrappedValue) { newValue in
|
||||
Task {
|
||||
await action(newValue)
|
||||
}
|
||||
}
|
||||
#else
|
||||
Section {
|
||||
Toggle(titleKey, isOn: isOn)
|
||||
.onChangeCompat(of: isOn.wrappedValue) { newValue in
|
||||
Task {
|
||||
await action(newValue)
|
||||
}
|
||||
}
|
||||
} footer: {
|
||||
Text(subtitleKey)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
public func FormButton(action: @escaping () -> Void, @ViewBuilder label: () -> some View) -> some View {
|
||||
Button(action: action, label: label)
|
||||
#if os(macOS)
|
||||
.buttonStyle(.plain)
|
||||
.foregroundColor(.accentColor)
|
||||
#endif
|
||||
}
|
||||
|
||||
public func FormButton(_ titleKey: some StringProtocol, action: @escaping () -> Void) -> some View {
|
||||
Button(titleKey, action: action)
|
||||
#if os(macOS)
|
||||
.buttonStyle(.plain)
|
||||
.foregroundColor(.accentColor)
|
||||
#endif
|
||||
}
|
||||
|
||||
public func FormButton(role: ButtonRole?, action: @escaping () -> Void, @ViewBuilder label: () -> some View) -> some View {
|
||||
Button(role: role, action: action, label: label)
|
||||
#if os(macOS)
|
||||
.buttonStyle(.plain)
|
||||
.foregroundColor(.accentColor)
|
||||
#endif
|
||||
}
|
||||
|
||||
public func FormNavigationLink(@ViewBuilder destination: () -> some View, @ViewBuilder label: () -> some View) -> some View {
|
||||
#if !os(tvOS)
|
||||
return NavigationLink(destination: destination, label: label)
|
||||
#else
|
||||
return NavigationLink(destination: {
|
||||
destination()
|
||||
.toolbar {
|
||||
ToolbarItemGroup(placement: .topBarLeading) {
|
||||
BackButton()
|
||||
}
|
||||
}
|
||||
}, label: label)
|
||||
#endif
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import SwiftUI
|
||||
|
||||
func NavigationDestinationCompat(isPresented: Binding<Bool>, @ViewBuilder destination: () -> some View) -> some View {
|
||||
NavigationLink(
|
||||
destination: destination(),
|
||||
isActive: isPresented,
|
||||
label: {
|
||||
EmptyView()
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import SwiftUI
|
||||
|
||||
public func NavigationStackCompat(@ViewBuilder content: () -> some View) -> some View {
|
||||
viewBuilder {
|
||||
if #available(iOS 17.0, *) {
|
||||
// view not updating in iOS 16, but why?
|
||||
NavigationStack {
|
||||
content()
|
||||
}
|
||||
} else {
|
||||
NavigationView(content: content)
|
||||
#if !os(macOS)
|
||||
.navigationViewStyle(.stack)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
#if !os(tvOS)
|
||||
|
||||
import StoreKit
|
||||
import SwiftUI
|
||||
|
||||
public func RequestReviewButton(label: @escaping () -> some View) -> some View {
|
||||
viewBuilder {
|
||||
if #available(iOS 16.0, macOS 13.0, visionOS 1.0, *) {
|
||||
RequestReviewButton0(label: label)
|
||||
} else {
|
||||
#if os(iOS)
|
||||
RequestReviewButton1(label: label)
|
||||
#else
|
||||
EmptyView()
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 16.0, macOS 13.0, visionOS 1.0, *)
|
||||
struct RequestReviewButton0<Label: View>: View {
|
||||
@Environment(\.requestReview) private var requestReview
|
||||
|
||||
private let label: () -> Label
|
||||
init(label: @escaping () -> Label) {
|
||||
self.label = label
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
FormButton(action: {
|
||||
requestReview()
|
||||
}, label: label)
|
||||
}
|
||||
}
|
||||
|
||||
struct RequestReviewButton1<Label: View>: View {
|
||||
private let label: () -> Label
|
||||
init(label: @escaping () -> Label) {
|
||||
self.label = label
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Button(action: {
|
||||
SKStoreReviewController.requestReview()
|
||||
}, label: label)
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,147 @@
|
||||
import Foundation
|
||||
import Library
|
||||
import SwiftUI
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#elseif canImport(AppKit)
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
@MainActor
|
||||
public struct ProfileShareButton<Label>: View where Label: View {
|
||||
private let alert: Binding<Alert?>
|
||||
private let profile: Profile
|
||||
private let label: () -> Label
|
||||
|
||||
public init(_ alert: Binding<Alert?>, _ profile: Profile, label: @escaping () -> Label) {
|
||||
self.alert = alert
|
||||
self.profile = profile
|
||||
self.label = label
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
#if os(iOS)
|
||||
if #available(iOS 17.4, *) {
|
||||
bodyCompat
|
||||
} else if #available(iOS 16.0, *) {
|
||||
ShareLink(item: profile, subject: Text(profile.name), preview: SharePreview("Share profile"), label: label)
|
||||
} else if UIDevice.current.userInterfaceIdiom != .pad {
|
||||
bodyCompat
|
||||
}
|
||||
#else
|
||||
bodyCompat
|
||||
#endif
|
||||
}
|
||||
|
||||
private var bodyCompat: some View {
|
||||
ShareButtonCompat(alert, label: label) {
|
||||
try profile.toContent().generateShareFile()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct ShareButtonCompat<Label>: View where Label: View {
|
||||
private let label: () -> Label
|
||||
private let itemURL: () throws -> URL
|
||||
|
||||
@Binding private var alert: Alert?
|
||||
|
||||
#if os(macOS)
|
||||
@State private var sharePresented = false
|
||||
#endif
|
||||
|
||||
public init(_ alert: Binding<Alert?>, @ViewBuilder label: @escaping () -> Label, itemURL: @escaping () throws -> URL) {
|
||||
_alert = alert
|
||||
self.label = label
|
||||
self.itemURL = itemURL
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
Button(action: shareItem, label: label)
|
||||
#if os(macOS)
|
||||
.background(SharingServicePicker($sharePresented, $alert, itemURL))
|
||||
#endif
|
||||
}
|
||||
|
||||
private func shareItem() {
|
||||
#if os(iOS)
|
||||
Task {
|
||||
await shareItem0()
|
||||
}
|
||||
#elseif os(macOS)
|
||||
sharePresented = true
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
private nonisolated func shareItem0() async {
|
||||
do {
|
||||
let shareItem = try itemURL()
|
||||
await MainActor.run {
|
||||
shareItem1(shareItem)
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
alert = Alert(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func shareItem1(_ item: URL) {
|
||||
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene {
|
||||
windowScene.keyWindow?.rootViewController?.present(UIActivityViewController(activityItems: [item], applicationActivities: nil), animated: true, completion: nil)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
private struct SharingServicePicker: NSViewRepresentable {
|
||||
@Binding private var isPresented: Bool
|
||||
@Binding private var alert: Alert?
|
||||
private let item: () throws -> URL
|
||||
|
||||
init(_ isPresented: Binding<Bool>, _ alert: Binding<Alert?>, _ item: @escaping () throws -> URL) {
|
||||
_isPresented = isPresented
|
||||
_alert = alert
|
||||
self.item = item
|
||||
}
|
||||
|
||||
func makeNSView(context _: Context) -> NSView {
|
||||
let view = NSView()
|
||||
return view
|
||||
}
|
||||
|
||||
func updateNSView(_ nsView: NSView, context: Context) {
|
||||
if isPresented {
|
||||
do {
|
||||
let picker = try NSSharingServicePicker(items: [item()])
|
||||
picker.delegate = context.coordinator
|
||||
DispatchQueue.main.async {
|
||||
picker.show(relativeTo: .zero, of: nsView, preferredEdge: .minY)
|
||||
}
|
||||
} catch {
|
||||
alert = Alert(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(self)
|
||||
}
|
||||
|
||||
class Coordinator: NSObject, NSSharingServicePickerDelegate {
|
||||
private let parent: SharingServicePicker
|
||||
|
||||
init(_ parent: SharingServicePicker) {
|
||||
self.parent = parent
|
||||
}
|
||||
|
||||
func sharingServicePicker(_ sharingServicePicker: NSSharingServicePicker, didChoose _: NSSharingService?) {
|
||||
sharingServicePicker.delegate = nil
|
||||
parent.isPresented = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,6 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
public func viewBuilder(@ViewBuilder _ builder: () -> some View) -> some View {
|
||||
builder()
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import SwiftUI
|
||||
|
||||
public extension View {
|
||||
func onChangeCompat(of value: some Equatable, _ action: @escaping () -> Void) -> some View {
|
||||
// if #available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *) {
|
||||
// return onChange(of: value, action)
|
||||
// } else {
|
||||
onChange(of: value) { _ in
|
||||
action()
|
||||
}
|
||||
// }
|
||||
}
|
||||
|
||||
func onChangeCompat<V>(of value: V, _ action: @escaping (_ newValue: V) -> Void) -> some View where V: Equatable {
|
||||
// if #available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *) {
|
||||
// return onChange(of: value) { _, newValue in
|
||||
// action(newValue)
|
||||
// }
|
||||
// } else {
|
||||
onChange(of: value, perform: action)
|
||||
// }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user