Merge profile to dashboard
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user