Refactor profile management
This commit is contained in:
@@ -62,7 +62,7 @@ import SwiftUI
|
||||
.toolbar {
|
||||
toolbar
|
||||
}
|
||||
#if os(tvOS)
|
||||
#if os(tvOS)
|
||||
.navigationDestination(isPresented: $showGroups) {
|
||||
GroupListView()
|
||||
.navigationTitle("Groups")
|
||||
@@ -92,60 +92,60 @@ import SwiftUI
|
||||
}
|
||||
}
|
||||
}
|
||||
#else
|
||||
#else
|
||||
.sheet(isPresented: $showGroups) {
|
||||
groupsSheetContent
|
||||
}.sheet(isPresented: $showConnections) {
|
||||
connectionsSheetContent
|
||||
}.sheet(isPresented: $showCardManagement, onDismiss: {
|
||||
cardConfigurationVersion += 1
|
||||
}, content: {
|
||||
if #available(iOS 16.0, *) {
|
||||
CardManagementSheet().presentationDetents([.large]).presentationDragIndicator(.visible)
|
||||
} else {
|
||||
CardManagementSheet()
|
||||
}
|
||||
})
|
||||
#endif
|
||||
groupsSheetContent
|
||||
}.sheet(isPresented: $showConnections) {
|
||||
connectionsSheetContent
|
||||
}.sheet(isPresented: $showCardManagement, onDismiss: {
|
||||
cardConfigurationVersion += 1
|
||||
}, content: {
|
||||
if #available(iOS 16.0, *) {
|
||||
CardManagementSheet().presentationDetents([.large]).presentationDragIndicator(.visible)
|
||||
} else {
|
||||
CardManagementSheet()
|
||||
}
|
||||
})
|
||||
#endif
|
||||
#endif
|
||||
.onAppear {
|
||||
if ApplicationLibrary.inPreview {
|
||||
environments.commandClient.connect()
|
||||
} else {
|
||||
if ApplicationLibrary.inPreview {
|
||||
environments.commandClient.connect()
|
||||
} else {
|
||||
environments.connect()
|
||||
}
|
||||
}.onChangeCompat(of: scenePhase) { phase in
|
||||
guard phase == .active else {
|
||||
return
|
||||
}
|
||||
environments.connect()
|
||||
}
|
||||
}.onChangeCompat(of: scenePhase) { phase in
|
||||
guard phase == .active else {
|
||||
return
|
||||
}
|
||||
environments.connect()
|
||||
}.onChangeCompat(of: profile.status) { status in
|
||||
guard status.isConnected else {
|
||||
return
|
||||
}
|
||||
environments.connect()
|
||||
}.onReceive(environments.profileUpdate) { _ in
|
||||
Task {
|
||||
await coordinator.reload()
|
||||
}
|
||||
}.onReceive(environments.selectedProfileUpdate) { _ in
|
||||
Task {
|
||||
await coordinator.updateSelectedProfile()
|
||||
if profile.status.isConnected {
|
||||
await coordinator.reloadSystemProxy()
|
||||
}.onChangeCompat(of: profile.status) { status in
|
||||
guard status.isConnected else {
|
||||
return
|
||||
}
|
||||
environments.connect()
|
||||
}.onReceive(environments.profileUpdate) { _ in
|
||||
Task {
|
||||
await coordinator.reload()
|
||||
}
|
||||
}.onReceive(environments.selectedProfileUpdate) { _ in
|
||||
Task {
|
||||
await coordinator.updateSelectedProfile()
|
||||
if profile.status.isConnected {
|
||||
await coordinator.reloadSystemProxy()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#if os(iOS) || os(tvOS)
|
||||
.onReceive(environments.commandClient.$groups) { _ in
|
||||
updateButtonVisibility()
|
||||
}.onReceive(environments.commandClient.$connections) { _ in
|
||||
updateButtonVisibility()
|
||||
}.onReceive(profile.$status) { _ in
|
||||
updateButtonVisibility()
|
||||
}.onAppear {
|
||||
updateButtonVisibility()
|
||||
}
|
||||
.onReceive(environments.commandClient.$groups) { _ in
|
||||
updateButtonVisibility()
|
||||
}.onReceive(environments.commandClient.$connections) { _ in
|
||||
updateButtonVisibility()
|
||||
}.onReceive(profile.$status) { _ in
|
||||
updateButtonVisibility()
|
||||
}.onAppear {
|
||||
updateButtonVisibility()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
|
||||
@@ -98,52 +98,52 @@ import SwiftUI
|
||||
}
|
||||
|
||||
#if os(tvOS)
|
||||
@MainActor public struct CardManagementView: View {
|
||||
@StateObject private var configuration = DashboardCardConfiguration()
|
||||
private let onDisappear: (() -> Void)?
|
||||
@MainActor public struct CardManagementView: View {
|
||||
@StateObject private var configuration = DashboardCardConfiguration()
|
||||
private let onDisappear: (() -> Void)?
|
||||
|
||||
public init(onDisappear: (() -> Void)? = nil) {
|
||||
self.onDisappear = onDisappear
|
||||
}
|
||||
public init(onDisappear: (() -> Void)? = nil) {
|
||||
self.onDisappear = onDisappear
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
Group {
|
||||
if configuration.isLoading {
|
||||
ProgressView()
|
||||
} else {
|
||||
List {
|
||||
ForEach(configuration.cardOrder) { card in
|
||||
CardRow(
|
||||
card: card,
|
||||
isEnabled: configuration.isEnabled(card),
|
||||
onToggle: {
|
||||
configuration.toggleCard(card)
|
||||
public var body: some View {
|
||||
Group {
|
||||
if configuration.isLoading {
|
||||
ProgressView()
|
||||
} else {
|
||||
List {
|
||||
ForEach(configuration.cardOrder) { card in
|
||||
CardRow(
|
||||
card: card,
|
||||
isEnabled: configuration.isEnabled(card),
|
||||
onToggle: {
|
||||
configuration.toggleCard(card)
|
||||
}
|
||||
)
|
||||
}
|
||||
.onMove { source, destination in
|
||||
Task {
|
||||
await configuration.moveCard(from: source, to: destination)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
.onMove { source, destination in
|
||||
.applyContentMargins()
|
||||
}
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("Reset", role: .destructive) {
|
||||
Task {
|
||||
await configuration.moveCard(from: source, to: destination)
|
||||
await configuration.resetToDefault()
|
||||
}
|
||||
}
|
||||
}
|
||||
.applyContentMargins()
|
||||
}
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("Reset", role: .destructive) {
|
||||
Task {
|
||||
await configuration.resetToDefault()
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
onDisappear?()
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
onDisappear?()
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
private struct CardRow: View {
|
||||
|
||||
@@ -3,6 +3,11 @@ import Libbox
|
||||
import Library
|
||||
import QRCode
|
||||
import SwiftUI
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#elseif canImport(AppKit)
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
@MainActor
|
||||
public struct ProfileCard: View {
|
||||
@@ -33,64 +38,68 @@ public struct ProfileCard: View {
|
||||
}
|
||||
.disabled(viewModel.isUpdating)
|
||||
#if os(tvOS)
|
||||
.navigationDestination(isPresented: $viewModel.showNewProfile) {
|
||||
NewProfileContentView(onDisappear: {
|
||||
environments.profileUpdate.send()
|
||||
})
|
||||
.environmentObject(environments)
|
||||
.toolbar {
|
||||
ToolbarItemGroup(placement: .topBarLeading) {
|
||||
BackButton()
|
||||
}
|
||||
.navigationDestination(isPresented: $viewModel.showNewProfile) {
|
||||
NewProfileMenuView()
|
||||
.environmentObject(environments)
|
||||
.onDisappear {
|
||||
environments.profileUpdate.send()
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItemGroup(placement: .topBarLeading) {
|
||||
BackButton()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationDestination(isPresented: $viewModel.showManageProfiles) {
|
||||
ManageProfilesView()
|
||||
.navigationDestination(isPresented: $viewModel.showProfilePicker) {
|
||||
ProfilePickerSheet(
|
||||
profileList: $profileList,
|
||||
selectedProfileID: $selectedProfileID
|
||||
)
|
||||
.environmentObject(environments)
|
||||
.navigationTitle("Manage profiles")
|
||||
.navigationTitle("Profiles")
|
||||
.toolbar {
|
||||
ToolbarItemGroup(placement: .topBarLeading) {
|
||||
BackButton()
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationDestination(item: $viewModel.profileToEdit) { profile in
|
||||
EditProfileView()
|
||||
.environmentObject(profile)
|
||||
.environmentObject(environments)
|
||||
.toolbar {
|
||||
ToolbarItemGroup(placement: .topBarLeading) {
|
||||
BackButton()
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $viewModel.showQRCode) {
|
||||
if let profile = selectedProfile, let remoteURL = profile.remoteURL {
|
||||
QRCodeSheet(profileName: profile.name, remoteURL: remoteURL)
|
||||
}
|
||||
}
|
||||
.navigationDestination(item: $viewModel.profileToEdit) { profile in
|
||||
EditProfileView()
|
||||
.environmentObject(profile)
|
||||
.environmentObject(environments)
|
||||
.toolbar {
|
||||
ToolbarItemGroup(placement: .topBarLeading) {
|
||||
BackButton()
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $viewModel.showQRCode) {
|
||||
if let profile = selectedProfile, let remoteURL = profile.remoteURL {
|
||||
QRCodeSheet(profileName: profile.name, remoteURL: remoteURL)
|
||||
}
|
||||
}
|
||||
#else
|
||||
.sheet(isPresented: $viewModel.showNewProfile, onDismiss: {
|
||||
environments.profileUpdate.send()
|
||||
}, content: {
|
||||
NewProfileNavigationView()
|
||||
.environmentObject(environments)
|
||||
})
|
||||
.sheet(isPresented: $viewModel.showManageProfiles) {
|
||||
manageProfilesSheet
|
||||
}
|
||||
.sheet(item: $viewModel.profileToEdit) { profile in
|
||||
editProfileSheet(for: profile)
|
||||
}
|
||||
#if os(iOS)
|
||||
.sheet(isPresented: $viewModel.showQRCode) {
|
||||
if let profile = selectedProfile, let remoteURL = profile.remoteURL {
|
||||
QRCodeSheet(profileName: profile.name, remoteURL: remoteURL)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $viewModel.showNewProfile, onDismiss: {
|
||||
environments.profileUpdate.send()
|
||||
}, content: {
|
||||
NewProfileNavigationView()
|
||||
.environmentObject(environments)
|
||||
})
|
||||
.sheet(isPresented: $viewModel.showProfilePicker) {
|
||||
profilePickerSheet
|
||||
}
|
||||
.sheet(item: $viewModel.profileToEdit) { profile in
|
||||
editProfileSheet(for: profile)
|
||||
}
|
||||
#if os(iOS)
|
||||
.sheet(isPresented: $viewModel.showQRCode) {
|
||||
if let profile = selectedProfile, let remoteURL = profile.remoteURL {
|
||||
QRCodeSheet(profileName: profile.name, remoteURL: remoteURL)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
#endif
|
||||
.alert($viewModel.alert)
|
||||
.alert($viewModel.alert)
|
||||
}
|
||||
|
||||
private var headerView: some View {
|
||||
@@ -101,27 +110,14 @@ public struct ProfileCard: View {
|
||||
|
||||
Spacer()
|
||||
|
||||
HStack(spacing: actionButtonSpacing) {
|
||||
Button {
|
||||
viewModel.showNewProfile = true
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
.font(.system(size: 16))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.actionButtonStyle()
|
||||
|
||||
if !profileList.isEmpty {
|
||||
Button {
|
||||
viewModel.showManageProfiles = true
|
||||
} label: {
|
||||
Image(systemName: "line.3.horizontal")
|
||||
.font(.system(size: 16))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.actionButtonStyle()
|
||||
}
|
||||
Button {
|
||||
viewModel.showNewProfile = true
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
.font(.system(size: 16))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.actionButtonStyle()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,9 +129,8 @@ public struct ProfileCard: View {
|
||||
.padding(.vertical, 8)
|
||||
} else {
|
||||
ProfileSelectorButton(
|
||||
items: profileList,
|
||||
selectedItem: selectedProfile,
|
||||
onSelect: { selectedProfileID = $0 }
|
||||
isPickerPresented: $viewModel.showProfilePicker
|
||||
)
|
||||
|
||||
if let profile = selectedProfile {
|
||||
@@ -150,9 +145,9 @@ public struct ProfileCard: View {
|
||||
|
||||
private var actionButtonSpacing: CGFloat {
|
||||
#if os(tvOS)
|
||||
24
|
||||
24
|
||||
#else
|
||||
12
|
||||
12
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -163,17 +158,9 @@ public struct ProfileCard: View {
|
||||
|
||||
if profile.type == .remote {
|
||||
updateButton(for: profile)
|
||||
qrCodeButton(for: profile)
|
||||
}
|
||||
|
||||
#if !os(tvOS)
|
||||
ProfileShareButton($viewModel.alert, profile.origin) {
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
.font(.system(size: 16))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.actionButtonStyle()
|
||||
#endif
|
||||
shareMenu(for: profile)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -240,6 +227,112 @@ public struct ProfileCard: View {
|
||||
#endif
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func shareMenu(for profile: ProfilePreview) -> some View {
|
||||
#if os(tvOS)
|
||||
if profile.type == .remote {
|
||||
Menu {
|
||||
Button {
|
||||
viewModel.showQRCode = true
|
||||
} label: {
|
||||
Label("Share URL as QR Code", systemImage: "qrcode")
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
.font(.system(size: 16))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.actionButtonStyle()
|
||||
}
|
||||
#else
|
||||
Menu {
|
||||
Button {
|
||||
viewModel.shareItemType = .file
|
||||
} label: {
|
||||
Label("Share File", systemImage: "doc")
|
||||
}
|
||||
|
||||
if profile.type == .remote {
|
||||
Button {
|
||||
viewModel.showQRCode = true
|
||||
} label: {
|
||||
Label("Share URL as QR Code", systemImage: "qrcode")
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
viewModel.shareItemType = .json
|
||||
} label: {
|
||||
Label("Share Content JSON File", systemImage: "curlybraces")
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
.font(.system(size: 16))
|
||||
}
|
||||
.menuIndicator(.hidden)
|
||||
.foregroundStyle(.primary)
|
||||
.menuStyle(.borderlessButton)
|
||||
.actionButtonStyle()
|
||||
.onChange(of: viewModel.shareItemType) { shareItemType in
|
||||
guard let shareItemType else { return }
|
||||
viewModel.shareItemType = nil
|
||||
shareProfile(profile, type: shareItemType)
|
||||
}
|
||||
#if os(macOS)
|
||||
.background(ViewAnchor { viewModel.shareButtonView = $0 })
|
||||
.popover(isPresented: $viewModel.showQRCode, arrowEdge: .bottom) {
|
||||
if let remoteURL = profile.remoteURL {
|
||||
QRCodeContentView(profileName: profile.name, remoteURL: remoteURL)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
}
|
||||
|
||||
#if !os(tvOS)
|
||||
private func shareProfile(_ profile: ProfilePreview, type: ShareItemType) {
|
||||
do {
|
||||
let url: URL
|
||||
switch type {
|
||||
case .file:
|
||||
url = try profile.origin.toContent().generateShareFile()
|
||||
case .json:
|
||||
url = try profile.origin.read().generateShareFile(name: "\(profile.name).json")
|
||||
}
|
||||
#if os(iOS)
|
||||
presentShareController(url)
|
||||
#elseif os(macOS)
|
||||
let anchorView = viewModel.shareButtonView ?? NSApp.keyWindow?.contentView ?? NSView()
|
||||
NSSharingServicePicker(items: [url]).show(
|
||||
relativeTo: .zero,
|
||||
of: anchorView,
|
||||
preferredEdge: .minY
|
||||
)
|
||||
#endif
|
||||
} catch {
|
||||
viewModel.alert = AlertState(error: error)
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
private func presentShareController(_ item: URL) {
|
||||
guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
||||
let rootViewController = windowScene.keyWindow?.rootViewController
|
||||
else {
|
||||
return
|
||||
}
|
||||
var topViewController = rootViewController
|
||||
while let presented = topViewController.presentedViewController {
|
||||
topViewController = presented
|
||||
}
|
||||
topViewController.present(
|
||||
UIActivityViewController(activityItems: [item], applicationActivities: nil),
|
||||
animated: true
|
||||
)
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
|
||||
@ViewBuilder
|
||||
private func profileInfo(for profile: ProfilePreview) -> some View {
|
||||
HStack(spacing: 8) {
|
||||
@@ -265,48 +358,65 @@ public struct ProfileCard: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var manageProfilesSheet: some View {
|
||||
NavigationSheet(
|
||||
title: String(localized: "Manage profiles"),
|
||||
showDoneButton: true,
|
||||
onDismiss: { viewModel.showManageProfiles = false },
|
||||
content: {
|
||||
ManageProfilesView()
|
||||
#if !os(tvOS)
|
||||
private var profilePickerSheet: some View {
|
||||
NavigationSheet(
|
||||
title: String(localized: "Profiles"),
|
||||
size: .large,
|
||||
content: {
|
||||
ProfilePickerSheet(
|
||||
profileList: $profileList,
|
||||
selectedProfileID: $selectedProfileID
|
||||
)
|
||||
.environmentObject(environments)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
#if os(macOS)
|
||||
.frame(minWidth: 400, minHeight: 300)
|
||||
#endif
|
||||
.modifier(OpaqueSheetBackground())
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func editProfileSheet(for profile: Profile) -> some View {
|
||||
#if os(macOS)
|
||||
NavigationSheet {
|
||||
EditProfileView()
|
||||
.environmentObject(profile)
|
||||
.environmentObject(environments)
|
||||
}
|
||||
.frame(minWidth: 500, minHeight: 400)
|
||||
#else
|
||||
NavigationSheet(title: "Edit Profile") {
|
||||
EditProfileView()
|
||||
.environmentObject(profile)
|
||||
.environmentObject(environments)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@ViewBuilder
|
||||
private func editProfileSheet(for profile: Profile) -> some View {
|
||||
#if os(macOS)
|
||||
NavigationSheet {
|
||||
EditProfileView()
|
||||
.environmentObject(profile)
|
||||
.environmentObject(environments)
|
||||
}
|
||||
.frame(minWidth: 500, minHeight: 400)
|
||||
#else
|
||||
NavigationSheet(title: "Edit Profile") {
|
||||
EditProfileView()
|
||||
.environmentObject(profile)
|
||||
.environmentObject(environments)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - ViewModel
|
||||
|
||||
extension ProfileCard {
|
||||
enum ShareItemType {
|
||||
case file
|
||||
case json
|
||||
}
|
||||
|
||||
@MainActor
|
||||
class ViewModel: ObservableObject {
|
||||
@Published var showNewProfile = false
|
||||
@Published var showManageProfiles = false
|
||||
@Published var showProfilePicker = false
|
||||
@Published var showQRCode = false
|
||||
@Published var isUpdating = false
|
||||
@Published var alert: AlertState?
|
||||
@Published var profileToEdit: Profile?
|
||||
@Published var shareItemType: ShareItemType?
|
||||
#if os(macOS)
|
||||
var shareButtonView: NSView?
|
||||
#endif
|
||||
|
||||
func updateProfile(_ profile: Profile, environments: ExtensionEnvironments) async {
|
||||
defer { isUpdating = false }
|
||||
@@ -330,253 +440,61 @@ extension ProfileCard {
|
||||
@MainActor
|
||||
struct NewProfileNavigationView: View {
|
||||
@EnvironmentObject private var environments: ExtensionEnvironments
|
||||
@State private var createdProfile: Profile?
|
||||
|
||||
var body: some View {
|
||||
#if os(macOS)
|
||||
macOSBody
|
||||
#else
|
||||
iOSBody
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
@ViewBuilder
|
||||
private var macOSBody: some View {
|
||||
if let profile = createdProfile {
|
||||
EditProfileView()
|
||||
.environmentObject(profile)
|
||||
NavigationSheet {
|
||||
NewProfileMenuView()
|
||||
.environmentObject(environments)
|
||||
} else {
|
||||
NewProfileView { profile in
|
||||
createdProfile = profile
|
||||
}
|
||||
.environmentObject(environments)
|
||||
}
|
||||
}
|
||||
#else
|
||||
private var iOSBody: some View {
|
||||
#else
|
||||
NavigationStackCompat {
|
||||
if let profile = createdProfile {
|
||||
EditProfileView()
|
||||
.environmentObject(profile)
|
||||
.environmentObject(environments)
|
||||
} else {
|
||||
NewProfileView { profile in
|
||||
createdProfile = profile
|
||||
}
|
||||
NewProfileMenuView()
|
||||
.environmentObject(environments)
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
.presentationDetentsIfAvailable()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - NewProfileContentView (tvOS)
|
||||
|
||||
#if os(tvOS)
|
||||
extension ProfileCard {
|
||||
@MainActor
|
||||
struct NewProfileContentView: View {
|
||||
@EnvironmentObject private var environments: ExtensionEnvironments
|
||||
@State private var createdProfile: Profile?
|
||||
private let onDisappear: (() -> Void)?
|
||||
|
||||
init(onDisappear: (() -> Void)? = nil) {
|
||||
self.onDisappear = onDisappear
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let profile = createdProfile {
|
||||
EditProfileView()
|
||||
.environmentObject(profile)
|
||||
.environmentObject(environments)
|
||||
} else {
|
||||
NewProfileView { profile in
|
||||
createdProfile = profile
|
||||
}
|
||||
.environmentObject(environments)
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
onDisappear?()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - OpaqueSheetBackground
|
||||
|
||||
#if os(iOS)
|
||||
private struct OpaqueSheetBackground: ViewModifier {
|
||||
func body(content: Content) -> some View {
|
||||
if #available(iOS 16.4, *) {
|
||||
content.presentationBackground(.regularMaterial)
|
||||
} else {
|
||||
content
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#elseif os(macOS)
|
||||
private struct OpaqueSheetBackground: ViewModifier {
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
}
|
||||
}
|
||||
|
||||
private struct ViewAnchor: NSViewRepresentable {
|
||||
let callback: (NSView) -> Void
|
||||
|
||||
func makeNSView(context _: Context) -> NSView {
|
||||
let view = NSView()
|
||||
DispatchQueue.main.async {
|
||||
callback(view)
|
||||
}
|
||||
return view
|
||||
}
|
||||
|
||||
func updateNSView(_: NSView, context _: Context) {}
|
||||
}
|
||||
#else
|
||||
private struct OpaqueSheetBackground: ViewModifier {
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
// MARK: - ManageProfilesView
|
||||
|
||||
extension ProfileCard {
|
||||
@MainActor
|
||||
struct ManageProfilesView: View {
|
||||
@EnvironmentObject private var environments: ExtensionEnvironments
|
||||
@StateObject private var viewModel = ProfileViewModel()
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
if viewModel.isLoading {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.onAppear {
|
||||
viewModel.setEnvironments(environments)
|
||||
Task {
|
||||
await viewModel.doReload()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
FormView {
|
||||
if viewModel.profileList.isEmpty {
|
||||
Text("Empty profiles")
|
||||
} else {
|
||||
List {
|
||||
ForEach(viewModel.profileList, id: \.id) { profile in
|
||||
ManageProfileItem(viewModel, profile)
|
||||
}
|
||||
.onMove(perform: moveProfile)
|
||||
#if os(macOS)
|
||||
.onDelete(perform: deleteProfile)
|
||||
#endif
|
||||
}
|
||||
#if os(iOS) || os(tvOS)
|
||||
.environment(\.editMode, .constant(.active))
|
||||
.deleteDisabled(true)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled(viewModel.isUpdating)
|
||||
.alert($viewModel.alert, isLoading: $viewModel.isLoading)
|
||||
.onReceive(environments.profileUpdate) { _ in
|
||||
Task {
|
||||
await viewModel.doReload()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func moveProfile(from source: IndexSet, to destination: Int) {
|
||||
viewModel.moveProfile(from: source, to: destination)
|
||||
}
|
||||
|
||||
private func deleteProfile(where profileIndex: IndexSet) {
|
||||
viewModel.deleteProfile(where: profileIndex)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
struct ManageProfileItem: View {
|
||||
@EnvironmentObject private var environments: ExtensionEnvironments
|
||||
@ObservedObject private var viewModel: ProfileViewModel
|
||||
@State private var profile: ProfilePreview
|
||||
@State private var shareLinkPresented = false
|
||||
@State private var isUpdating = false
|
||||
|
||||
init(_ viewModel: ProfileViewModel, _ profile: ProfilePreview) {
|
||||
self.viewModel = viewModel
|
||||
_profile = State(initialValue: profile)
|
||||
}
|
||||
|
||||
private var actionButtonSpacing: CGFloat {
|
||||
#if os(tvOS)
|
||||
24
|
||||
#else
|
||||
8
|
||||
#endif
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Image(systemName: "line.3.horizontal")
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
VStack(alignment: .leading) {
|
||||
Text(profile.name)
|
||||
#if os(macOS)
|
||||
if profile.type == .remote {
|
||||
Spacer(minLength: 4)
|
||||
Text("Last Updated: \(profile.origin.lastUpdated!.myFormat)").font(.caption)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
HStack(spacing: actionButtonSpacing) {
|
||||
if profile.type == .remote {
|
||||
Button {
|
||||
isUpdating = true
|
||||
Task {
|
||||
await viewModel.updateProfile(profile.origin)
|
||||
profile = ProfilePreview(profile.origin)
|
||||
isUpdating = false
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "arrow.clockwise")
|
||||
.font(.system(size: 16))
|
||||
.rotationEffect(.degrees(isUpdating ? 360 : 0))
|
||||
.animation(
|
||||
isUpdating
|
||||
? .linear(duration: 1).repeatForever(autoreverses: false)
|
||||
: .default,
|
||||
value: isUpdating
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.actionButtonStyle()
|
||||
.disabled(isUpdating)
|
||||
|
||||
Button {
|
||||
shareLinkPresented = true
|
||||
} label: {
|
||||
Image(systemName: "qrcode")
|
||||
.font(.system(size: 16))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.actionButtonStyle()
|
||||
#if os(macOS)
|
||||
.popover(isPresented: $shareLinkPresented, arrowEdge: .bottom) {
|
||||
QRCodeContentView(profileName: profile.name, remoteURL: profile.remoteURL!)
|
||||
}
|
||||
#elseif os(iOS) || os(tvOS)
|
||||
.sheet(isPresented: $shareLinkPresented) {
|
||||
QRCodeSheet(profileName: profile.name, remoteURL: profile.remoteURL!)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
#if !os(tvOS)
|
||||
ShareButtonCompat($viewModel.alert) {
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
.font(.system(size: 16))
|
||||
} itemURL: {
|
||||
try profile.origin.toContent().generateShareFile()
|
||||
}
|
||||
.actionButtonStyle()
|
||||
#endif
|
||||
|
||||
Button {
|
||||
Task {
|
||||
await viewModel.deleteProfile(profile.origin)
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "trash")
|
||||
.font(.system(size: 16))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.actionButtonStyle()
|
||||
}
|
||||
}
|
||||
#if os(macOS)
|
||||
.padding(.vertical, 4)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,699 @@
|
||||
import Library
|
||||
import QRCode
|
||||
import SwiftUI
|
||||
#if canImport(AppKit)
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
@MainActor
|
||||
struct ProfilePickerSheet: View {
|
||||
@EnvironmentObject private var environments: ExtensionEnvironments
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@Binding var profileList: [ProfilePreview]
|
||||
@Binding var selectedProfileID: Int64
|
||||
|
||||
#if os(iOS) || os(tvOS)
|
||||
@State private var editMode: EditMode = .inactive
|
||||
#else
|
||||
@State private var isEditing = false
|
||||
@State private var rowWidth: CGFloat = 400
|
||||
#endif
|
||||
@State private var profileToEdit: Profile?
|
||||
@State private var alert: AlertState?
|
||||
#if os(tvOS)
|
||||
@FocusState private var focusedProfileID: Int64?
|
||||
#endif
|
||||
|
||||
private var isEditingActive: Bool {
|
||||
#if os(iOS) || os(tvOS)
|
||||
editMode.isEditing
|
||||
#else
|
||||
isEditing
|
||||
#endif
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
listContent
|
||||
#if os(iOS) || os(tvOS)
|
||||
.environment(\.editMode, $editMode)
|
||||
#endif
|
||||
#if os(tvOS)
|
||||
.navigationDestination(item: $profileToEdit) { profile in
|
||||
EditProfileView()
|
||||
.environmentObject(profile)
|
||||
.environmentObject(environments)
|
||||
.toolbar {
|
||||
ToolbarItemGroup(placement: .topBarLeading) {
|
||||
BackButton()
|
||||
}
|
||||
}
|
||||
}
|
||||
#elseif os(macOS)
|
||||
.safeAreaInset(edge: .bottom) {
|
||||
VStack(spacing: 0) {
|
||||
Divider()
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Cancel") {
|
||||
dismiss()
|
||||
}
|
||||
.keyboardShortcut(.escape, modifiers: [])
|
||||
if isEditing {
|
||||
Button("Done") {
|
||||
withAnimation {
|
||||
isEditing = false
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
} else {
|
||||
Button("Edit") {
|
||||
withAnimation {
|
||||
isEditing = true
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(NSColor.controlBackgroundColor))
|
||||
}
|
||||
}
|
||||
.sheet(item: $profileToEdit) { profile in
|
||||
NavigationSheet {
|
||||
EditProfileView()
|
||||
.environmentObject(profile)
|
||||
.environmentObject(environments)
|
||||
}
|
||||
.frame(minWidth: 500, minHeight: 400)
|
||||
}
|
||||
#else
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button(editMode.isEditing ? "Done" : "Edit") {
|
||||
withAnimation {
|
||||
editMode = editMode.isEditing ? .inactive : .active
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(item: $profileToEdit) { profile in
|
||||
NavigationSheet(title: "Edit Profile") {
|
||||
EditProfileView()
|
||||
.environmentObject(profile)
|
||||
.environmentObject(environments)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
.alert($alert)
|
||||
}
|
||||
|
||||
private var listContent: some View {
|
||||
#if os(tvOS)
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 12) {
|
||||
ForEach(profileList, id: \.id) { profile in
|
||||
ProfilePickerRow(
|
||||
profile: profile,
|
||||
isSelected: profile.id == selectedProfileID,
|
||||
isEditing: isEditingActive,
|
||||
alert: $alert,
|
||||
focusedProfileID: $focusedProfileID,
|
||||
onSelect: {
|
||||
selectedProfileID = profile.id
|
||||
dismiss()
|
||||
},
|
||||
onEdit: {
|
||||
profileToEdit = profile.origin
|
||||
},
|
||||
onUpdate: {
|
||||
await updateProfile(profile)
|
||||
}
|
||||
)
|
||||
.environmentObject(environments)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.onAppear {
|
||||
focusedProfileID = selectedProfileID
|
||||
}
|
||||
#elseif os(macOS)
|
||||
List {
|
||||
ForEach(profileList, id: \.id) { profile in
|
||||
macOSProfileRow(profile)
|
||||
}
|
||||
}
|
||||
#else
|
||||
List {
|
||||
ForEach(profileList, id: \.id) { profile in
|
||||
ProfilePickerRow(
|
||||
profile: profile,
|
||||
isSelected: profile.id == selectedProfileID,
|
||||
isEditing: isEditingActive,
|
||||
alert: $alert,
|
||||
onSelect: {
|
||||
selectedProfileID = profile.id
|
||||
dismiss()
|
||||
},
|
||||
onEdit: {
|
||||
profileToEdit = profile.origin
|
||||
},
|
||||
onUpdate: {
|
||||
await updateProfile(profile)
|
||||
}
|
||||
)
|
||||
.environmentObject(environments)
|
||||
}
|
||||
.onMove(perform: moveProfile)
|
||||
.onDelete(perform: deleteProfile)
|
||||
.moveDisabled(!isEditingActive)
|
||||
.deleteDisabled(!isEditingActive)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private func updateProfile(_ profile: ProfilePreview) async {
|
||||
do {
|
||||
try await profile.origin.updateRemoteProfile()
|
||||
environments.profileUpdate.send()
|
||||
} catch {
|
||||
alert = AlertState(
|
||||
title: String(localized: "Update Failed"),
|
||||
message: error.localizedDescription
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func moveProfile(from source: IndexSet, to destination: Int) {
|
||||
profileList.move(fromOffsets: source, toOffset: destination)
|
||||
for (index, profile) in profileList.enumerated() {
|
||||
profileList[index].order = UInt32(index)
|
||||
profile.origin.order = UInt32(index)
|
||||
}
|
||||
Task {
|
||||
do {
|
||||
try await ProfileManager.update(profileList.map(\.origin))
|
||||
environments.profileUpdate.send()
|
||||
} catch {
|
||||
// Handle error silently
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteProfile(at offsets: IndexSet) {
|
||||
let profilesToDelete = offsets.map { profileList[$0].origin }
|
||||
profileList.remove(atOffsets: offsets)
|
||||
Task {
|
||||
do {
|
||||
_ = try await ProfileManager.delete(profilesToDelete)
|
||||
environments.emptyProfiles = profileList.isEmpty
|
||||
environments.profileUpdate.send()
|
||||
} catch {
|
||||
// Handle error silently
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
@ViewBuilder
|
||||
private func macOSProfileRow(_ profile: ProfilePreview) -> some View {
|
||||
ProfilePickerRow(
|
||||
profile: profile,
|
||||
isSelected: profile.id == selectedProfileID,
|
||||
isEditing: isEditingActive,
|
||||
alert: $alert,
|
||||
onSelect: {
|
||||
selectedProfileID = profile.id
|
||||
dismiss()
|
||||
},
|
||||
onEdit: {
|
||||
profileToEdit = profile.origin
|
||||
},
|
||||
onUpdate: {
|
||||
await updateProfile(profile)
|
||||
},
|
||||
onDelete: {
|
||||
if let index = profileList.firstIndex(where: { $0.id == profile.id }) {
|
||||
deleteProfile(at: IndexSet(integer: index))
|
||||
}
|
||||
}
|
||||
)
|
||||
.environmentObject(environments)
|
||||
.background {
|
||||
GeometryReader { geometry in
|
||||
Color.clear.preference(key: RowWidthKey.self, value: geometry.size.width)
|
||||
}
|
||||
}
|
||||
.onPreferenceChange(RowWidthKey.self) { rowWidth = $0 }
|
||||
.draggable(String(profile.id)) {
|
||||
ProfilePickerRow.previewContent(profile: profile, width: rowWidth)
|
||||
}
|
||||
.dropDestination(for: String.self) { items, _ in
|
||||
handleDrop(items: items, targetID: profile.id)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleDrop(items: [String], targetID: Int64) -> Bool {
|
||||
guard let draggedIDString = items.first,
|
||||
let draggedID = Int64(draggedIDString),
|
||||
let fromIndex = profileList.firstIndex(where: { $0.id == draggedID }),
|
||||
let toIndex = profileList.firstIndex(where: { $0.id == targetID }),
|
||||
fromIndex != toIndex
|
||||
else {
|
||||
return false
|
||||
}
|
||||
moveProfile(from: IndexSet(integer: fromIndex), to: toIndex > fromIndex ? toIndex + 1 : toIndex)
|
||||
return true
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - ProfilePickerRow
|
||||
|
||||
private struct ProfilePickerRow: View {
|
||||
@EnvironmentObject private var environments: ExtensionEnvironments
|
||||
|
||||
let profile: ProfilePreview
|
||||
let isSelected: Bool
|
||||
let isEditing: Bool
|
||||
@Binding var alert: AlertState?
|
||||
#if os(tvOS)
|
||||
var focusedProfileID: FocusState<Int64?>.Binding
|
||||
#endif
|
||||
let onSelect: () -> Void
|
||||
let onEdit: () -> Void
|
||||
let onUpdate: () async -> Void
|
||||
#if os(macOS)
|
||||
let onDelete: () -> Void
|
||||
#endif
|
||||
|
||||
@State private var isUpdating = false
|
||||
@State private var showQRCode = false
|
||||
#if os(macOS)
|
||||
@State private var shareItemType: ShareItemType?
|
||||
@State private var menuAnchorView: NSView?
|
||||
#endif
|
||||
|
||||
var body: some View {
|
||||
#if os(tvOS)
|
||||
tvOSBody
|
||||
#else
|
||||
defaultBody
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(tvOS)
|
||||
private var tvOSBody: some View {
|
||||
Button(action: onSelect) {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "checkmark")
|
||||
.font(.system(size: 24, weight: .semibold))
|
||||
.foregroundStyle(.tint)
|
||||
.opacity(isSelected ? 1 : 0)
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(profile.name)
|
||||
.font(.headline)
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
profileInfo
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if !isEditing {
|
||||
Color.clear.frame(width: 44)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.buttonStyle(.card)
|
||||
.focused(focusedProfileID, equals: profile.id)
|
||||
.disabled(isEditing || isUpdating)
|
||||
.overlay(alignment: .trailing) {
|
||||
if !isEditing {
|
||||
Menu {
|
||||
Button {
|
||||
onEdit()
|
||||
} label: {
|
||||
Label("Edit", systemImage: "pencil")
|
||||
}
|
||||
|
||||
if profile.type == .remote {
|
||||
Button {
|
||||
isUpdating = true
|
||||
Task {
|
||||
await onUpdate()
|
||||
isUpdating = false
|
||||
}
|
||||
} label: {
|
||||
Label("Update", systemImage: "arrow.clockwise")
|
||||
}
|
||||
|
||||
Menu {
|
||||
Button {
|
||||
showQRCode = true
|
||||
} label: {
|
||||
Label("Share URL as QR Code", systemImage: "qrcode")
|
||||
}
|
||||
} label: {
|
||||
Label("Share", systemImage: "square.and.arrow.up")
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "ellipsis")
|
||||
.font(.system(size: 16))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.actionButtonStyle()
|
||||
.disabled(isUpdating)
|
||||
.padding(.trailing, 12)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showQRCode) {
|
||||
if let remoteURL = profile.remoteURL {
|
||||
QRCodeSheet(profileName: profile.name, remoteURL: remoteURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#if !os(tvOS)
|
||||
@ViewBuilder
|
||||
private var defaultBody: some View {
|
||||
#if os(macOS)
|
||||
if isEditing {
|
||||
macOSEditingBody
|
||||
} else {
|
||||
macOSNormalBody
|
||||
}
|
||||
#else
|
||||
iOSBody
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
private var macOSNormalBody: some View {
|
||||
Button {
|
||||
if !isUpdating {
|
||||
onSelect()
|
||||
}
|
||||
} label: {
|
||||
rowContent
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(isUpdating)
|
||||
.sheet(isPresented: $showQRCode) {
|
||||
if let remoteURL = profile.remoteURL {
|
||||
QRCodeSheet(profileName: profile.name, remoteURL: remoteURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var macOSEditingBody: some View {
|
||||
rowContent
|
||||
.overlay(alignment: .trailing) {
|
||||
Button {
|
||||
onDelete()
|
||||
} label: {
|
||||
Image(systemName: "minus.circle.fill")
|
||||
.font(.system(size: 20))
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#if os(iOS)
|
||||
private var iOSBody: some View {
|
||||
Button {
|
||||
if !isEditing, !isUpdating {
|
||||
onSelect()
|
||||
}
|
||||
} label: {
|
||||
rowContent
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(isEditing || isUpdating)
|
||||
.sheet(isPresented: $showQRCode) {
|
||||
if let remoteURL = profile.remoteURL {
|
||||
QRCodeSheet(profileName: profile.name, remoteURL: remoteURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
private var rowContent: some View {
|
||||
HStack(spacing: 12) {
|
||||
#if os(macOS)
|
||||
Group {
|
||||
if isEditing {
|
||||
Image(systemName: "line.3.horizontal")
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
Image(systemName: "checkmark")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(.tint)
|
||||
.opacity(isSelected ? 1 : 0)
|
||||
}
|
||||
}
|
||||
.frame(width: 16)
|
||||
#else
|
||||
Image(systemName: "checkmark")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(.tint)
|
||||
.opacity(isSelected ? 1 : 0)
|
||||
#endif
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(profile.name)
|
||||
.font(.body)
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
profileInfo
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if !isEditing {
|
||||
rowMenu
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
#endif
|
||||
|
||||
private var rowMenu: some View {
|
||||
Menu {
|
||||
Button {
|
||||
onEdit()
|
||||
} label: {
|
||||
Label("Edit", systemImage: "pencil")
|
||||
}
|
||||
|
||||
if profile.type == .remote {
|
||||
Button {
|
||||
isUpdating = true
|
||||
Task {
|
||||
await onUpdate()
|
||||
isUpdating = false
|
||||
}
|
||||
} label: {
|
||||
Label("Update", systemImage: "arrow.clockwise")
|
||||
}
|
||||
}
|
||||
|
||||
#if !os(tvOS)
|
||||
shareMenu
|
||||
#endif
|
||||
} label: {
|
||||
Group {
|
||||
if isUpdating {
|
||||
ProgressView()
|
||||
.scaleEffect(0.8)
|
||||
} else {
|
||||
Image(systemName: "ellipsis")
|
||||
.font(.system(size: 16))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.frame(width: 32, height: 32)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
#if os(macOS)
|
||||
.background(ViewAnchor { menuAnchorView = $0 })
|
||||
.onChange(of: shareItemType) { shareItemType in
|
||||
guard let shareItemType else { return }
|
||||
self.shareItemType = nil
|
||||
shareProfile(type: shareItemType)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
#if !os(tvOS)
|
||||
@ViewBuilder
|
||||
private var shareMenu: some View {
|
||||
Menu {
|
||||
#if os(macOS)
|
||||
Button {
|
||||
shareItemType = .file
|
||||
} label: {
|
||||
Label("Share File", systemImage: "doc")
|
||||
}
|
||||
#else
|
||||
ShareButtonCompat($alert) {
|
||||
Label("Share File", systemImage: "doc")
|
||||
} itemURL: {
|
||||
try profile.origin.toContent().generateShareFile()
|
||||
}
|
||||
#endif
|
||||
|
||||
if profile.type == .remote {
|
||||
Button {
|
||||
showQRCode = true
|
||||
} label: {
|
||||
Label("Share URL as QR Code", systemImage: "qrcode")
|
||||
}
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
Button {
|
||||
shareItemType = .json
|
||||
} label: {
|
||||
Label("Share Content JSON File", systemImage: "curlybraces")
|
||||
}
|
||||
#else
|
||||
ShareButtonCompat($alert) {
|
||||
Label("Share Content JSON File", systemImage: "curlybraces")
|
||||
} itemURL: {
|
||||
try profile.origin.read().generateShareFile(name: "\(profile.name).json")
|
||||
}
|
||||
#endif
|
||||
} label: {
|
||||
Label("Share", systemImage: "square.and.arrow.up")
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
private var profileInfo: some View {
|
||||
HStack(spacing: 8) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: profile.type == .remote ? "cloud.fill" : "doc.fill")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(.secondary)
|
||||
Text(profile.type == .remote ? "Remote" : "Local")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if profile.type == .remote, let lastUpdated = profile.lastUpdated {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "clock.fill")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(.secondary)
|
||||
Text(lastUpdated.myFormat)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
private func shareProfile(type: ShareItemType) {
|
||||
do {
|
||||
let url: URL
|
||||
switch type {
|
||||
case .file:
|
||||
url = try profile.origin.toContent().generateShareFile()
|
||||
case .json:
|
||||
url = try profile.origin.read().generateShareFile(name: "\(profile.name).json")
|
||||
}
|
||||
let anchorView = menuAnchorView ?? NSApp.keyWindow?.contentView ?? NSView()
|
||||
NSSharingServicePicker(items: [url]).show(
|
||||
relativeTo: .zero,
|
||||
of: anchorView,
|
||||
preferredEdge: .minY
|
||||
)
|
||||
} catch {
|
||||
alert = AlertState(error: error)
|
||||
}
|
||||
}
|
||||
|
||||
static func previewContent(profile: ProfilePreview, width: CGFloat) -> some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "line.3.horizontal")
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 16)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(profile.name)
|
||||
.font(.body)
|
||||
|
||||
HStack(spacing: 8) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: profile.type == .remote ? "cloud.fill" : "doc.fill")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(.secondary)
|
||||
Text(profile.type == .remote ? "Remote" : "Local")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if profile.type == .remote, let lastUpdated = profile.lastUpdated {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "clock.fill")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(.secondary)
|
||||
Text(lastUpdated.myFormat)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.frame(width: width)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(.background, in: RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - macOS Helpers
|
||||
|
||||
#if os(macOS)
|
||||
private struct RowWidthKey: PreferenceKey {
|
||||
static var defaultValue: CGFloat = 400
|
||||
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
|
||||
value = nextValue()
|
||||
}
|
||||
}
|
||||
|
||||
private enum ShareItemType {
|
||||
case file
|
||||
case json
|
||||
}
|
||||
|
||||
private struct ViewAnchor: NSViewRepresentable {
|
||||
let callback: (NSView) -> Void
|
||||
|
||||
func makeNSView(context _: Context) -> NSView {
|
||||
let view = NSView()
|
||||
DispatchQueue.main.async {
|
||||
callback(view)
|
||||
}
|
||||
return view
|
||||
}
|
||||
|
||||
func updateNSView(_: NSView, context _: Context) {}
|
||||
}
|
||||
#endif
|
||||
@@ -1,274 +1,80 @@
|
||||
import Library
|
||||
import SwiftUI
|
||||
#if os(iOS) || os(tvOS)
|
||||
import UIKit
|
||||
#elseif os(macOS)
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
/// A custom view that looks like a navigation link but opens a dropdown menu
|
||||
struct ProfileSelectorButton: View {
|
||||
let items: [ProfilePreview]
|
||||
let selectedItem: ProfilePreview?
|
||||
let onSelect: (Int64) -> Void
|
||||
|
||||
init(
|
||||
items: [ProfilePreview],
|
||||
selectedItem: ProfilePreview?,
|
||||
onSelect: @escaping (Int64) -> Void
|
||||
) {
|
||||
self.items = items
|
||||
self.selectedItem = selectedItem
|
||||
self.onSelect = onSelect
|
||||
}
|
||||
@Binding var isPickerPresented: Bool
|
||||
|
||||
var body: some View {
|
||||
#if os(iOS) || os(tvOS)
|
||||
ProfileSelectorUIButton(
|
||||
items: items,
|
||||
selectedItem: selectedItem,
|
||||
onSelect: onSelect
|
||||
)
|
||||
.frame(height: 44)
|
||||
.selectorBackground()
|
||||
Button {
|
||||
isPickerPresented = true
|
||||
} label: {
|
||||
HStack {
|
||||
Text(selectedItem?.name ?? "Select Profile")
|
||||
.font(.system(size: buttonFontSize, weight: .medium))
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "chevron.up.chevron.down")
|
||||
.font(.system(size: chevronSize, weight: .semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.frame(height: buttonHeight)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
#if os(tvOS)
|
||||
.buttonStyle(SelectorButtonStyle())
|
||||
#else
|
||||
.buttonStyle(.plain)
|
||||
.selectorBackground()
|
||||
#endif
|
||||
}
|
||||
|
||||
private var buttonHeight: CGFloat {
|
||||
#if os(tvOS)
|
||||
60
|
||||
#elseif os(macOS)
|
||||
ProfileSelectorNSButton(
|
||||
items: items,
|
||||
selectedItem: selectedItem,
|
||||
onSelect: onSelect
|
||||
)
|
||||
.frame(height: 32)
|
||||
.selectorBackground()
|
||||
32
|
||||
#else
|
||||
44
|
||||
#endif
|
||||
}
|
||||
|
||||
private var buttonFontSize: CGFloat {
|
||||
#if os(macOS)
|
||||
13
|
||||
#else
|
||||
17
|
||||
#endif
|
||||
}
|
||||
|
||||
private var chevronSize: CGFloat {
|
||||
#if os(macOS)
|
||||
10
|
||||
#else
|
||||
12
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UIKit Implementation (iOS/tvOS)
|
||||
#if os(tvOS)
|
||||
private struct SelectorButtonStyle: ButtonStyle {
|
||||
@Environment(\.isFocused) private var isFocused
|
||||
|
||||
#if os(iOS) || os(tvOS)
|
||||
/// Custom UIButton subclass that allows customizing menu attachment point
|
||||
private class MenuAttachmentButton: UIButton {
|
||||
override func menuAttachmentPoint(for _: UIContextMenuConfiguration) -> CGPoint {
|
||||
// Attach menu to bottom-leading corner of the button
|
||||
CGPoint(x: 0, y: bounds.height)
|
||||
}
|
||||
}
|
||||
|
||||
/// UIViewRepresentable wrapper for UIButton with UIMenu
|
||||
private struct ProfileSelectorUIButton: UIViewRepresentable {
|
||||
let items: [ProfilePreview]
|
||||
let selectedItem: ProfilePreview?
|
||||
let onSelect: (Int64) -> Void
|
||||
|
||||
func makeUIView(context _: Context) -> MenuAttachmentButton {
|
||||
let button = MenuAttachmentButton(type: .system)
|
||||
button.showsMenuAsPrimaryAction = true
|
||||
button.contentHorizontalAlignment = .fill
|
||||
|
||||
// Configure button appearance
|
||||
var config = UIButton.Configuration.plain()
|
||||
config.contentInsets = NSDirectionalEdgeInsets(top: 10, leading: 14, bottom: 10, trailing: 14)
|
||||
button.configuration = config
|
||||
|
||||
updateButtonContent(button)
|
||||
updateMenu(button)
|
||||
|
||||
return button
|
||||
}
|
||||
|
||||
func updateUIView(_ button: MenuAttachmentButton, context _: Context) {
|
||||
updateButtonContent(button)
|
||||
updateMenu(button)
|
||||
}
|
||||
|
||||
private func updateButtonContent(_ button: MenuAttachmentButton) {
|
||||
// Remove existing subviews
|
||||
for subview in button.subviews where subview is UIStackView {
|
||||
subview.removeFromSuperview()
|
||||
}
|
||||
|
||||
// Create content stack
|
||||
let stackView = UIStackView()
|
||||
stackView.axis = .horizontal
|
||||
stackView.alignment = .center
|
||||
stackView.distribution = .fill
|
||||
stackView.spacing = 8
|
||||
stackView.isUserInteractionEnabled = false
|
||||
stackView.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
// Title label
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.text = selectedItem?.name ?? "Select Profile"
|
||||
titleLabel.font = .systemFont(ofSize: 17, weight: .medium)
|
||||
titleLabel.textColor = .label
|
||||
titleLabel.setContentHuggingPriority(.defaultLow, for: .horizontal)
|
||||
|
||||
// Chevron image
|
||||
let chevronConfig = UIImage.SymbolConfiguration(pointSize: 12, weight: .semibold)
|
||||
let chevronImage = UIImage(systemName: "chevron.up.chevron.down", withConfiguration: chevronConfig)
|
||||
let chevronView = UIImageView(image: chevronImage)
|
||||
chevronView.tintColor = .secondaryLabel
|
||||
chevronView.setContentHuggingPriority(.required, for: .horizontal)
|
||||
|
||||
stackView.addArrangedSubview(titleLabel)
|
||||
stackView.addArrangedSubview(chevronView)
|
||||
|
||||
button.addSubview(stackView)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
stackView.leadingAnchor.constraint(equalTo: button.leadingAnchor, constant: 14),
|
||||
stackView.trailingAnchor.constraint(equalTo: button.trailingAnchor, constant: -14),
|
||||
stackView.centerYAnchor.constraint(equalTo: button.centerYAnchor),
|
||||
])
|
||||
}
|
||||
|
||||
private func updateMenu(_ button: MenuAttachmentButton) {
|
||||
let actions = items.map { item in
|
||||
UIAction(
|
||||
title: item.name,
|
||||
image: UIImage(systemName: item.type.iconName)
|
||||
) { [item] _ in
|
||||
onSelect(item.id)
|
||||
}
|
||||
}
|
||||
|
||||
button.menu = UIMenu(children: actions)
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
configuration.label
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(isFocused ? Color.white : Color.secondary.opacity(0.1))
|
||||
)
|
||||
.foregroundStyle(isFocused ? .black : .primary)
|
||||
.animation(.easeInOut(duration: 0.15), value: isFocused)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
// MARK: - AppKit Implementation (macOS)
|
||||
|
||||
#if os(macOS)
|
||||
/// NSViewRepresentable wrapper for a clickable view with NSMenu
|
||||
private struct ProfileSelectorNSButton: NSViewRepresentable {
|
||||
let items: [ProfilePreview]
|
||||
let selectedItem: ProfilePreview?
|
||||
let onSelect: (Int64) -> Void
|
||||
|
||||
func makeNSView(context: Context) -> NSView {
|
||||
let containerView = MenuContainerView()
|
||||
containerView.coordinator = context.coordinator
|
||||
updateContent(containerView)
|
||||
context.coordinator.updateMenu(items: items, onSelect: onSelect)
|
||||
return containerView
|
||||
}
|
||||
|
||||
func updateNSView(_ nsView: NSView, context: Context) {
|
||||
guard let containerView = nsView as? MenuContainerView else { return }
|
||||
updateContent(containerView)
|
||||
context.coordinator.updateMenu(items: items, onSelect: onSelect)
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator()
|
||||
}
|
||||
|
||||
private func updateContent(_ containerView: MenuContainerView) {
|
||||
containerView.titleText = selectedItem?.name ?? "Select Profile"
|
||||
}
|
||||
|
||||
private class MenuContainerView: NSView {
|
||||
weak var coordinator: Coordinator?
|
||||
private let titleLabel = NSTextField(labelWithString: "")
|
||||
private let chevronView = NSImageView()
|
||||
|
||||
var titleText: String = "" {
|
||||
didSet {
|
||||
titleLabel.stringValue = titleText
|
||||
}
|
||||
}
|
||||
|
||||
override init(frame frameRect: NSRect) {
|
||||
super.init(frame: frameRect)
|
||||
setupViews()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
super.init(coder: coder)
|
||||
setupViews()
|
||||
}
|
||||
|
||||
private func setupViews() {
|
||||
titleLabel.font = .systemFont(ofSize: 13, weight: .medium)
|
||||
titleLabel.textColor = .labelColor
|
||||
titleLabel.alignment = .left
|
||||
titleLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
addSubview(titleLabel)
|
||||
|
||||
let chevronConfig = NSImage.SymbolConfiguration(pointSize: 10, weight: .semibold)
|
||||
chevronView.image = NSImage(systemSymbolName: "chevron.up.chevron.down", accessibilityDescription: nil)?
|
||||
.withSymbolConfiguration(chevronConfig)
|
||||
chevronView.contentTintColor = .secondaryLabelColor
|
||||
chevronView.translatesAutoresizingMaskIntoConstraints = false
|
||||
addSubview(chevronView)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 14),
|
||||
titleLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
|
||||
chevronView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -14),
|
||||
chevronView.centerYAnchor.constraint(equalTo: centerYAnchor),
|
||||
titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: chevronView.leadingAnchor, constant: -8),
|
||||
])
|
||||
}
|
||||
|
||||
override func mouseDown(with _: NSEvent) {
|
||||
coordinator?.showMenu(in: self)
|
||||
}
|
||||
|
||||
override func resetCursorRects() {
|
||||
addCursorRect(bounds, cursor: .pointingHand)
|
||||
}
|
||||
}
|
||||
|
||||
class Coordinator: NSObject {
|
||||
private var menu = NSMenu()
|
||||
private var onSelectCallback: ((Int64) -> Void)?
|
||||
|
||||
func updateMenu(items: [ProfilePreview], onSelect: @escaping (Int64) -> Void) {
|
||||
menu.removeAllItems()
|
||||
onSelectCallback = onSelect
|
||||
|
||||
for item in items {
|
||||
let menuItem = NSMenuItem(
|
||||
title: item.name,
|
||||
action: #selector(menuItemSelected(_:)),
|
||||
keyEquivalent: ""
|
||||
)
|
||||
menuItem.target = self
|
||||
menuItem.tag = Int(item.id)
|
||||
menuItem.image = NSImage(systemSymbolName: item.type.iconName, accessibilityDescription: nil)
|
||||
menu.addItem(menuItem)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func menuItemSelected(_ sender: NSMenuItem) {
|
||||
onSelectCallback?(Int64(sender.tag))
|
||||
}
|
||||
|
||||
func showMenu(in view: NSView) {
|
||||
let location = NSPoint(x: 0, y: view.bounds.height + 4)
|
||||
menu.popUp(positioning: nil, at: location, in: view)
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
// MARK: - ProfileType Extension
|
||||
|
||||
private extension ProfileType {
|
||||
var iconName: String {
|
||||
switch self {
|
||||
case .local:
|
||||
"doc.fill"
|
||||
case .icloud:
|
||||
"icloud.fill"
|
||||
case .remote:
|
||||
"cloud.fill"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - View Extension
|
||||
|
||||
private extension View {
|
||||
|
||||
@@ -35,7 +35,7 @@ public struct DashboardView: View {
|
||||
.onChangeCompat(of: importRemoteProfile.wrappedValue) { _ in
|
||||
handleImportRemoteProfile()
|
||||
}
|
||||
#if os(tvOS)
|
||||
#if os(tvOS)
|
||||
.navigationDestination(item: $importRemoteProfileRequest) { request in
|
||||
NewProfileView(request)
|
||||
.environmentObject(environments)
|
||||
@@ -48,16 +48,16 @@ public struct DashboardView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
#else
|
||||
#else
|
||||
.sheet(item: $importRemoteProfileRequest) { request in
|
||||
importRemoteProfileSheet(for: request)
|
||||
}
|
||||
#endif
|
||||
importRemoteProfileSheet(for: request)
|
||||
}
|
||||
#endif
|
||||
#if os(macOS)
|
||||
.onChangeCompat(of: controlActiveState) { state in
|
||||
guard state != .inactive, Variant.useSystemExtension, !coordinator.isLoading else { return }
|
||||
Task { await coordinator.reload() }
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user