Merge profile to dashboard

This commit is contained in:
世界
2025-11-26 22:25:52 +08:00
parent 4f0d1098e6
commit 264c37218c
27 changed files with 1590 additions and 306 deletions
@@ -55,46 +55,16 @@ public struct EditProfileView: View {
FormTextItem("Last Updated", profile.lastUpdated!.myFormat)
}
}
Section("Action") {
if profile.type != .remote {
#if os(iOS) || os(macOS)
FormNavigationLink {
EditProfileContentView(EditProfileContentView.Context(profileID: profile.id!, readOnly: false))
} label: {
Label("Edit Content", systemImage: "pencil")
.foregroundColor(.accentColor)
}
#endif
} else {
#if os(iOS) || os(macOS)
FormNavigationLink {
EditProfileContentView(EditProfileContentView.Context(profileID: profile.id!, readOnly: true))
} label: {
Label("View Content", systemImage: "doc.fill")
.foregroundColor(.accentColor)
}
#endif
FormButton {
viewModel.isLoading = true
Task {
await viewModel.updateProfile(profile, environments: environments)
}
} label: {
Label("Update", systemImage: "arrow.clockwise")
}
.foregroundColor(.accentColor)
.disabled(viewModel.isLoading)
}
FormButton(role: .destructive) {
Task {
await viewModel.deleteProfile(profile, environments: environments, dismiss: dismiss)
}
} label: {
Label("Delete", systemImage: "trash.fill")
}
.foregroundColor(.red)
}
#if os(iOS) || os(tvOS)
ProfileActionToolbar(profile: profile, viewModel: viewModel)
#endif
}
#if os(macOS)
.safeAreaInset(edge: .bottom) {
ProfileActionToolbar(profile: profile, viewModel: viewModel)
}
#endif
.onChangeCompat(of: profile.name) {
viewModel.markAsChanged()
}
@@ -8,14 +8,20 @@ public struct NewProfileView: View {
@EnvironmentObject private var environments: ExtensionEnvironments
@Environment(\.dismiss) private var dismiss
@StateObject private var viewModel: NewProfileViewModel
private var onSuccess: ((Profile) async -> Void)?
public struct ImportRequest: Codable, Hashable {
public struct ImportRequest: Codable, Hashable, Identifiable {
public var id: String { url }
public let name: String
public let url: String
}
public init(_ importRequest: ImportRequest? = nil) {
public init(
_ importRequest: ImportRequest? = nil,
onSuccess: ((Profile) async -> Void)? = nil
) {
_viewModel = StateObject(wrappedValue: NewProfileViewModel(importRequest: importRequest))
self.onSuccess = onSuccess
}
public var body: some View {
@@ -86,23 +92,55 @@ public struct NewProfileView: View {
#endif
}
}
Section {
if !viewModel.isSaving {
FormButton {
viewModel.isSaving = true
Task {
await viewModel.createProfile(environments: environments, dismiss: dismiss)
#if os(iOS) || os(tvOS)
Section {
if !viewModel.isSaving {
FormButton {
viewModel.isSaving = true
Task {
await viewModel.createProfile(
environments: environments,
dismiss: onSuccess == nil ? dismiss : nil,
onSuccess: onSuccess
)
}
} label: {
Label("Create", systemImage: "doc.fill.badge.plus")
}
} label: {
Label("Create", systemImage: "doc.fill.badge.plus")
} else {
ProgressView()
}
} else {
ProgressView()
}
}
#endif
}
.navigationTitle("New Profile")
.alertBinding($viewModel.alert)
#if os(macOS)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
dismiss()
}
}
ToolbarItem(placement: .confirmationAction) {
if viewModel.isSaving {
ProgressView()
} else {
Button("Create") {
viewModel.isSaving = true
Task {
await viewModel.createProfile(
environments: environments,
dismiss: onSuccess == nil ? dismiss : nil,
onSuccess: onSuccess
)
}
}
}
}
}
#endif
.disabled(viewModel.isSaving)
.alertBinding($viewModel.alert)
#if os(iOS) || os(macOS)
.fileImporter(
isPresented: $viewModel.pickerPresented,
@@ -36,37 +36,52 @@ public final class NewProfileViewModel: ObservableObject {
remotePath = ""
}
public func createProfile(environments: ExtensionEnvironments, dismiss: DismissAction) async {
defer {
isSaving = false
}
if profileName.isEmpty {
public func createProfile(
environments: ExtensionEnvironments,
dismiss: DismissAction? = nil,
onSuccess: ((Profile) async -> Void)? = nil,
sendUpdateNotification: Bool = true
) async {
defer { isSaving = false }
guard !profileName.isEmpty else {
alert = Alert(errorMessage: String(localized: "Missing profile name"))
return
}
if remotePath.isEmpty {
if profileType == .icloud {
alert = Alert(errorMessage: String(localized: "Missing path"))
return
} else if profileType == .remote {
alert = Alert(errorMessage: String(localized: "Missing URL"))
return
}
if profileType == .icloud, remotePath.isEmpty {
alert = Alert(errorMessage: String(localized: "Missing path"))
return
}
if profileType == .remote, remotePath.isEmpty {
alert = Alert(errorMessage: String(localized: "Missing URL"))
return
}
let createdProfile: Profile
do {
try await createProfileBackground()
createdProfile = try await createProfileBackground()
} catch {
alert = Alert(error)
return
}
environments.profileUpdate.send()
dismiss()
if let onSuccess {
await onSuccess(createdProfile)
} else {
if sendUpdateNotification {
environments.profileUpdate.send()
}
dismiss?()
}
#if os(macOS)
resetFields()
#endif
}
private nonisolated func createProfileBackground() async throws {
private nonisolated func createProfileBackground() async throws -> Profile {
let nextProfileID = try await ProfileManager.nextID()
var savePath = ""
@@ -130,7 +145,9 @@ public final class NewProfileViewModel: ObservableObject {
remoteURL = remotePath
lastUpdated = .now
}
try await ProfileManager.create(Profile(
// Create Profile object - GRDB will set its ID after insertion
let profile = Profile(
name: profileName,
type: profileType,
path: savePath,
@@ -138,7 +155,9 @@ public final class NewProfileViewModel: ObservableObject {
autoUpdate: autoUpdate,
autoUpdateInterval: autoUpdateInterval,
lastUpdated: lastUpdated
))
)
try await ProfileManager.create(profile)
if profileType == .remote {
#if os(iOS) || os(tvOS)
try UIProfileUpdateTask.configure()
@@ -146,5 +165,8 @@ public final class NewProfileViewModel: ObservableObject {
try await ProfileUpdateTask.configure()
#endif
}
// Return the profile object which now has its ID set by GRDB
return profile
}
}
@@ -0,0 +1,109 @@
import Libbox
import Library
import SwiftUI
@MainActor
public struct ProfileActionToolbar: View {
@EnvironmentObject private var environments: ExtensionEnvironments
@Environment(\.dismiss) private var dismiss
@ObservedObject private var profile: Profile
@ObservedObject private var viewModel: EditProfileViewModel
public init(profile: Profile, viewModel: EditProfileViewModel) {
self.profile = profile
self.viewModel = viewModel
}
public var body: some View {
#if os(iOS) || os(tvOS)
iosBody
#elseif os(macOS)
macOSBody
#endif
}
#if os(iOS) || os(tvOS)
private var iosBody: some View {
Section("Action") {
if profile.type != .remote {
FormNavigationLink {
EditProfileContentView(EditProfileContentView.Context(profileID: profile.id!, readOnly: false))
} label: {
Label("Edit Content", systemImage: "pencil")
.foregroundColor(.accentColor)
}
} else {
FormNavigationLink {
EditProfileContentView(EditProfileContentView.Context(profileID: profile.id!, readOnly: true))
} label: {
Label("View Content", systemImage: "doc.fill")
.foregroundColor(.accentColor)
}
FormButton {
viewModel.isLoading = true
Task {
await viewModel.updateProfile(profile, environments: environments)
}
} label: {
Label("Update", systemImage: "arrow.clockwise")
}
.foregroundColor(.accentColor)
.disabled(viewModel.isLoading)
}
FormButton(role: .destructive) {
Task {
await viewModel.deleteProfile(profile, environments: environments, dismiss: dismiss)
}
} label: {
Label("Delete", systemImage: "trash.fill")
}
.foregroundColor(.red)
}
}
#endif
#if os(macOS)
private var macOSBody: some View {
VStack(spacing: 0) {
Divider()
HStack(spacing: 12) {
if profile.type != .remote {
NavigationLink {
EditProfileContentView(EditProfileContentView.Context(profileID: profile.id!, readOnly: false))
} label: {
Text("Edit Content")
}
} else {
NavigationLink {
EditProfileContentView(EditProfileContentView.Context(profileID: profile.id!, readOnly: true))
} label: {
Text("View Content")
}
Button {
viewModel.isLoading = true
Task {
await viewModel.updateProfile(profile, environments: environments)
}
} label: {
Text("Update")
}
.disabled(viewModel.isLoading)
}
Spacer()
Button("Delete", role: .destructive) {
Task {
await viewModel.deleteProfile(profile, environments: environments, dismiss: dismiss)
}
}
.foregroundColor(.red)
}
.padding()
.background(Color(NSColor.controlBackgroundColor))
}
}
#endif
}
@@ -0,0 +1,78 @@
import Library
import SwiftUI
public enum SheetSize {
case large
case medium
#if os(iOS) || os(tvOS)
@available(iOS 16.0, tvOS 17.0, *)
var presentationDetent: SwiftUI.PresentationDetent {
switch self {
case .large: return .large
case .medium: return .medium
}
}
#endif
}
@MainActor
public struct NavigationSheet<Content: View>: View {
private let title: String
private let size: SheetSize
private let showDoneButton: Bool
private let onDismiss: (() -> Void)?
private let content: () -> Content
public init(
title: String,
size: SheetSize = .large,
showDoneButton: Bool = false,
onDismiss: (() -> Void)? = nil,
@ViewBuilder content: @escaping () -> Content
) {
self.title = title
self.size = size
self.showDoneButton = showDoneButton
self.onDismiss = onDismiss
self.content = content
}
public var body: some View {
NavigationStackCompat {
content()
.navigationTitle(title)
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
#if os(macOS)
.toolbar {
if showDoneButton {
ToolbarItem(placement: .confirmationAction) {
Button("Done") {
onDismiss?()
}
}
}
}
#endif
}
#if os(iOS) || os(tvOS)
.sheetDetent(size)
#endif
}
}
#if os(iOS) || os(tvOS)
private extension View {
@ViewBuilder
func sheetDetent(_ size: SheetSize) -> some View {
if #available(iOS 16.0, tvOS 17.0, *) {
self.presentationDetents([size.presentationDetent])
.presentationDragIndicator(.visible)
} else {
self
}
}
}
#endif
@@ -2,7 +2,6 @@ import Foundation
import Libbox
import Library
import Network
import QRCode
import SwiftUI
@MainActor
@@ -172,17 +171,17 @@ public struct ProfileView: View {
public var body: some View {
#if os(iOS) || os(macOS)
if #available(iOS 16.0, macOS 13.0,*) {
body0.draggable(profile.origin)
if #available(iOS 16.0, macOS 13.0, *) {
draggableBody.draggable(profile.origin)
} else {
body0
draggableBody
}
#else
body0
draggableBody
#endif
}
private var body0: some View {
private var draggableBody: some View {
viewBuilder {
#if !os(macOS)
FormNavigationLink {
@@ -191,7 +190,7 @@ public struct ProfileView: View {
Text(profile.name)
}
.sheet(isPresented: $shareLinkPresented) {
shareLinkView.padding()
QRCodeSheet(profileName: profile.name, remoteURL: profile.remoteURL!)
}
.contextMenu {
ProfileShareButton($viewModel.alert, profile.origin) {
@@ -254,7 +253,7 @@ public struct ProfileView: View {
}
.padding(.leading, 4)
.popover(isPresented: $shareLinkPresented, arrowEdge: .bottom) {
shareLinkView
QRCodeContentView(profileName: profile.name, remoteURL: profile.remoteURL!)
}
}
ProfileShareButton($viewModel.alert, profile.origin) {
@@ -279,41 +278,5 @@ public struct ProfileView: View {
#endif
}
}
private var shareLinkView: some View {
#if os(iOS)
viewBuilder {
if #available(iOS 16.0, *) {
shareLinkView0
.presentationDetents([.medium])
.presentationDragIndicator(.visible)
} else {
shareLinkView0
}
}
#elseif os(macOS)
shareLinkView0
.frame(minWidth: 300, minHeight: 300)
#else
shareLinkView0
#endif
}
private var foregroundColor: CGColor {
#if canImport(UIKit)
return UIColor.label.cgColor
#elseif canImport(AppKit)
return NSColor.labelColor.cgColor
#endif
}
private var shareLinkView0: some View {
QRCodeViewUI(
content: LibboxGenerateRemoteProfileImportLink(profile.name, profile.remoteURL!),
errorCorrection: .low,
foregroundColor: foregroundColor,
backgroundColor: CGColor(gray: 1.0, alpha: 0.0)
)
}
}
}
@@ -0,0 +1,78 @@
import Foundation
import Libbox
import Library
import QRCode
import SwiftUI
private extension CGColor {
static var labelColor: CGColor {
#if canImport(UIKit)
UIColor.label.cgColor
#elseif canImport(AppKit)
NSColor.labelColor.cgColor
#endif
}
}
@MainActor
public struct QRCodeContentView: View {
private let profileName: String
private let remoteURL: String
public init(profileName: String, remoteURL: String) {
self.profileName = profileName
self.remoteURL = remoteURL
}
public var body: some View {
VStack {
Spacer()
QRCodeViewUI(
content: LibboxGenerateRemoteProfileImportLink(profileName, remoteURL),
errorCorrection: .low,
foregroundColor: .labelColor,
backgroundColor: CGColor(gray: 1.0, alpha: 0.0)
)
#if os(macOS)
.frame(minWidth: 300, minHeight: 300)
#endif
Spacer()
}
.padding()
}
}
@MainActor
public struct QRCodeSheet: View {
private let profileName: String
private let remoteURL: String
public init(profileName: String, remoteURL: String) {
self.profileName = profileName
self.remoteURL = remoteURL
}
public var body: some View {
#if os(iOS) || os(tvOS)
if #available(iOS 16.0, tvOS 17.0, *) {
NavigationStackCompat {
QRCodeContentView(profileName: profileName, remoteURL: remoteURL)
.navigationTitle("Share QR Code")
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
}
.presentationDetents([.medium])
.presentationDragIndicator(.visible)
} else {
NavigationStackCompat {
QRCodeContentView(profileName: profileName, remoteURL: remoteURL)
.navigationTitle("Share QR Code")
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
}
}
#endif
}
}