mirror of
https://github.com/bolucat/Archive.git
synced 2026-04-23 00:17:16 +08:00
1439 lines
49 KiB
Swift
1439 lines
49 KiB
Swift
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(macOS)
|
|
@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?
|
|
@State private var movingProfileID: Int64?
|
|
#endif
|
|
|
|
private var isEditingActive: Bool {
|
|
#if !os(macOS)
|
|
editMode.isEditing
|
|
#else
|
|
isEditing
|
|
#endif
|
|
}
|
|
|
|
var body: some View {
|
|
#if os(iOS)
|
|
if #available(iOS 26, *) {
|
|
iOSBody
|
|
.environment(\.editMode, $editMode)
|
|
} else {
|
|
legacyIOSBody
|
|
.environment(\.editMode, $editMode)
|
|
}
|
|
#else
|
|
nonIOSBody
|
|
#endif
|
|
}
|
|
|
|
#if os(iOS)
|
|
@available(iOS 26, *)
|
|
private var iOSBody: some View {
|
|
iOSListContent
|
|
.toolbar {
|
|
ToolbarItem(placement: .primaryAction) {
|
|
EditButton()
|
|
}
|
|
}
|
|
.sheet(item: $profileToEdit) { profile in
|
|
NavigationSheet(title: "Edit Profile") {
|
|
EditProfileView()
|
|
.environmentObject(profile)
|
|
.environmentObject(environments)
|
|
}
|
|
}
|
|
.alert($alert)
|
|
}
|
|
|
|
@available(iOS 26, *)
|
|
@ViewBuilder
|
|
private var iOSListContent: some View {
|
|
if editMode.isEditing {
|
|
iOSEditingList
|
|
} else {
|
|
iOSNormalList
|
|
}
|
|
}
|
|
|
|
@available(iOS 26, *)
|
|
private var iOSEditingList: some View {
|
|
listContent
|
|
}
|
|
|
|
@available(iOS 26, *)
|
|
private var iOSNormalList: some View {
|
|
List {
|
|
ForEach(profileList, id: \.id) { profile in
|
|
ProfilePickerRow(
|
|
profile: profile,
|
|
isSelected: profile.id == selectedProfileID,
|
|
alert: $alert,
|
|
onSelect: {
|
|
selectedProfileID = profile.id
|
|
dismiss()
|
|
},
|
|
onEdit: {
|
|
profileToEdit = profile.origin
|
|
},
|
|
onUpdate: {
|
|
await updateProfile(profile)
|
|
}
|
|
)
|
|
.environmentObject(environments)
|
|
.listRowBackground(Color.clear)
|
|
.listRowSeparator(.hidden)
|
|
.listRowInsets(EdgeInsets(top: 2, leading: 16, bottom: 2, trailing: 16))
|
|
}
|
|
}
|
|
.listStyle(.plain)
|
|
}
|
|
|
|
private var legacyIOSBody: some View {
|
|
legacyListContent
|
|
.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)
|
|
}
|
|
}
|
|
.alert($alert)
|
|
}
|
|
#endif
|
|
|
|
#if !os(iOS)
|
|
private var nonIOSBody: some View {
|
|
listContent
|
|
#if os(tvOS)
|
|
.environment(\.editMode, $editMode)
|
|
.toolbar {
|
|
ToolbarItem(placement: .confirmationAction) {
|
|
TVToolbarButton(title: editMode.isEditing ? String(localized: "Done") : String(localized: "Edit")) {
|
|
withAnimation {
|
|
if editMode.isEditing {
|
|
movingProfileID = nil
|
|
}
|
|
editMode = editMode.isEditing ? .inactive : .active
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.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)
|
|
}
|
|
#endif
|
|
.alert($alert)
|
|
}
|
|
#endif
|
|
|
|
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,
|
|
isMoving: movingProfileID == profile.id,
|
|
alert: $alert,
|
|
focusedProfileID: $focusedProfileID,
|
|
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))
|
|
}
|
|
},
|
|
onToggleMoving: {
|
|
withAnimation {
|
|
if movingProfileID == profile.id {
|
|
movingProfileID = nil
|
|
} else {
|
|
movingProfileID = profile.id
|
|
}
|
|
}
|
|
}
|
|
)
|
|
.environmentObject(environments)
|
|
}
|
|
}
|
|
.padding()
|
|
}
|
|
.onAppear {
|
|
focusedProfileID = selectedProfileID
|
|
}
|
|
.onMoveCommand { direction in
|
|
guard let movingID = movingProfileID,
|
|
let currentIndex = profileList.firstIndex(where: { $0.id == movingID })
|
|
else { return }
|
|
|
|
let newIndex: Int
|
|
switch direction {
|
|
case .up:
|
|
guard currentIndex > 0 else { return }
|
|
newIndex = currentIndex - 1
|
|
case .down:
|
|
guard currentIndex < profileList.count - 1 else { return }
|
|
newIndex = currentIndex + 1
|
|
default:
|
|
return
|
|
}
|
|
|
|
withAnimation {
|
|
moveProfile(from: IndexSet(integer: currentIndex), to: newIndex > currentIndex ? newIndex + 1 : newIndex)
|
|
}
|
|
focusedProfileID = movingID
|
|
}
|
|
#elseif os(macOS)
|
|
List {
|
|
ForEach(profileList, id: \.id) { profile in
|
|
macOSProfileRow(profile)
|
|
.listRowBackground(Color.clear)
|
|
.listRowSeparator(.hidden)
|
|
.listRowInsets(EdgeInsets(top: 3, leading: 16, bottom: 3, trailing: 16))
|
|
}
|
|
}
|
|
.listStyle(.plain)
|
|
#else
|
|
List {
|
|
ForEach(profileList, id: \.id) { profile in
|
|
ProfilePickerRow(
|
|
profile: profile,
|
|
isSelected: profile.id == selectedProfileID,
|
|
alert: $alert,
|
|
onSelect: {
|
|
selectedProfileID = profile.id
|
|
dismiss()
|
|
},
|
|
onEdit: {
|
|
profileToEdit = profile.origin
|
|
},
|
|
onUpdate: {
|
|
await updateProfile(profile)
|
|
}
|
|
)
|
|
.environmentObject(environments)
|
|
.listRowBackground(Color.clear)
|
|
.listRowSeparator(.hidden)
|
|
.listRowInsets(EdgeInsets(top: 2, leading: 16, bottom: 2, trailing: 16))
|
|
}
|
|
.onMove(perform: moveProfile)
|
|
.onDelete(perform: deleteProfile)
|
|
}
|
|
.listStyle(.plain)
|
|
#endif
|
|
}
|
|
|
|
#if os(iOS)
|
|
@ViewBuilder
|
|
private var legacyListContent: some View {
|
|
if editMode.isEditing {
|
|
legacyEditingList
|
|
} else {
|
|
legacyNormalList
|
|
}
|
|
}
|
|
|
|
private var legacyEditingList: some View {
|
|
List {
|
|
ForEach(profileList, id: \.id) { profile in
|
|
LegacyProfilePickerRow(
|
|
profile: profile,
|
|
isSelected: profile.id == selectedProfileID,
|
|
alert: $alert,
|
|
onSelect: {
|
|
selectedProfileID = profile.id
|
|
dismiss()
|
|
},
|
|
onEdit: {
|
|
profileToEdit = profile.origin
|
|
},
|
|
onUpdate: {
|
|
await updateProfile(profile)
|
|
}
|
|
)
|
|
.environmentObject(environments)
|
|
}
|
|
.onMove(perform: legacyMoveProfile)
|
|
.onDelete(perform: legacyDeleteProfile)
|
|
}
|
|
}
|
|
|
|
private var legacyNormalList: some View {
|
|
List {
|
|
ForEach(profileList, id: \.id) { profile in
|
|
LegacyProfilePickerRow(
|
|
profile: profile,
|
|
isSelected: profile.id == selectedProfileID,
|
|
alert: $alert,
|
|
onSelect: {
|
|
selectedProfileID = profile.id
|
|
dismiss()
|
|
},
|
|
onEdit: {
|
|
profileToEdit = profile.origin
|
|
},
|
|
onUpdate: {
|
|
await updateProfile(profile)
|
|
}
|
|
)
|
|
.environmentObject(environments)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func legacyMoveProfile(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 legacyDeleteProfile(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
|
|
}
|
|
}
|
|
}
|
|
#endif
|
|
|
|
private func updateProfile(_ profile: ProfilePreview) async {
|
|
do {
|
|
try await profile.origin.updateRemoteProfile()
|
|
environments.profileUpdate.send()
|
|
} catch {
|
|
alert = AlertState(action: "update remote profile", error: error)
|
|
}
|
|
}
|
|
|
|
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)
|
|
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
|
|
#if !os(macOS)
|
|
@Environment(\.editMode) private var editMode
|
|
#endif
|
|
|
|
let profile: ProfilePreview
|
|
let isSelected: Bool
|
|
#if os(macOS)
|
|
let isEditing: Bool
|
|
#endif
|
|
#if os(tvOS)
|
|
let isMoving: Bool
|
|
#endif
|
|
@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(tvOS) || os(macOS)
|
|
let onDelete: () -> Void
|
|
#endif
|
|
#if os(tvOS)
|
|
let onToggleMoving: () -> Void
|
|
#endif
|
|
|
|
#if !os(macOS)
|
|
private var isEditing: Bool {
|
|
editMode?.wrappedValue.isEditing ?? false
|
|
}
|
|
#endif
|
|
|
|
@State private var isUpdating = false
|
|
@State private var showQRCode = false
|
|
@State private var showQRSShare = false
|
|
@State private var qrsShareData: Data?
|
|
#if os(macOS)
|
|
@State private var shareItemType: ShareItemType?
|
|
@State private var exportItemType: ExportItemType?
|
|
@State private var menuAnchorView: NSView?
|
|
#endif
|
|
#if !os(tvOS)
|
|
@State private var exportDocument: ProfileAnyExportDocument?
|
|
@State private var showExporter = false
|
|
#endif
|
|
|
|
var body: some View {
|
|
#if os(tvOS)
|
|
tvOSBody
|
|
#else
|
|
defaultBody
|
|
#endif
|
|
}
|
|
|
|
#if os(tvOS)
|
|
private var tvOSBody: some View {
|
|
Group {
|
|
if isEditing {
|
|
tvOSEditingBody
|
|
} else {
|
|
tvOSNormalBody
|
|
}
|
|
}
|
|
.sheet(isPresented: $showQRCode) {
|
|
if let remoteURL = profile.remoteURL {
|
|
QRCodeSheet(profileName: profile.name, remoteURL: remoteURL)
|
|
}
|
|
}
|
|
.sheet(
|
|
isPresented: $showQRSShare,
|
|
onDismiss: {
|
|
qrsShareData = nil
|
|
},
|
|
content: {
|
|
if let data = qrsShareData {
|
|
QRSSheet(profileName: profile.name, profileData: data)
|
|
} else {
|
|
ProgressView()
|
|
}
|
|
}
|
|
)
|
|
}
|
|
|
|
private var tvOSNormalBody: some View {
|
|
Button(action: onSelect) {
|
|
HStack(spacing: 12) {
|
|
Image(systemName: "checkmark")
|
|
.font(.system(size: 24, weight: .semibold))
|
|
.foregroundStyle(.tint)
|
|
.opacity(isSelected ? 1 : 0)
|
|
.frame(width: 28)
|
|
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text(profile.name)
|
|
.font(.headline)
|
|
.foregroundStyle(.primary)
|
|
|
|
profileInfo
|
|
}
|
|
|
|
Spacer()
|
|
|
|
Color.clear.frame(width: 70)
|
|
}
|
|
.padding()
|
|
}
|
|
.buttonStyle(.card)
|
|
.focused(focusedProfileID, equals: profile.id)
|
|
.disabled(isUpdating)
|
|
.overlay(alignment: .trailing) {
|
|
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 {
|
|
if profile.type == .remote {
|
|
Button {
|
|
showQRCode = true
|
|
} label: {
|
|
Label("Share URL as QR Code", systemImage: "qrcode")
|
|
}
|
|
}
|
|
|
|
Button {
|
|
prepareQRSShare()
|
|
} label: {
|
|
Label("Share as QRS Code", systemImage: "barcode")
|
|
}
|
|
} label: {
|
|
Label("Share", systemImage: "square.and.arrow.up")
|
|
}
|
|
} label: {
|
|
Image(systemName: "ellipsis")
|
|
.font(.system(size: 16))
|
|
}
|
|
.buttonStyle(.plain)
|
|
.actionButtonStyle()
|
|
.disabled(isUpdating)
|
|
.padding(.trailing, 12)
|
|
}
|
|
}
|
|
|
|
private var tvOSEditingBody: some View {
|
|
HStack(spacing: 12) {
|
|
Color.clear.frame(width: 28)
|
|
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text(profile.name)
|
|
.font(.headline)
|
|
.foregroundStyle(.primary)
|
|
|
|
profileInfo
|
|
}
|
|
|
|
Spacer()
|
|
|
|
Button {
|
|
onDelete()
|
|
} label: {
|
|
Image(systemName: "minus.circle.fill")
|
|
.font(.system(size: 24))
|
|
.foregroundStyle(.red)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.actionButtonStyle()
|
|
|
|
Button {
|
|
onToggleMoving()
|
|
} label: {
|
|
Image(systemName: "line.3.horizontal")
|
|
.font(.system(size: 20))
|
|
}
|
|
.buttonStyle(.plain)
|
|
.actionButtonStyle()
|
|
.focused(focusedProfileID, equals: profile.id)
|
|
}
|
|
.padding()
|
|
.background(Color.secondary.opacity(0.2), in: RoundedRectangle(cornerRadius: 16))
|
|
.scaleEffect(isMoving ? 1.05 : 1.0)
|
|
.animation(.easeInOut(duration: 0.2), value: isMoving)
|
|
}
|
|
#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)
|
|
}
|
|
}
|
|
.sheet(
|
|
isPresented: $showQRSShare,
|
|
onDismiss: {
|
|
qrsShareData = nil
|
|
},
|
|
content: {
|
|
if let data = qrsShareData {
|
|
QRSSheet(profileName: profile.name, profileData: data)
|
|
} else {
|
|
ProgressView()
|
|
}
|
|
}
|
|
)
|
|
.fileExporter(
|
|
isPresented: $showExporter,
|
|
document: exportDocument,
|
|
contentType: exportDocument?.contentType ?? .data,
|
|
defaultFilename: exportDocument?.filename
|
|
) { result in
|
|
exportDocument = nil
|
|
if case let .failure(error) = result {
|
|
alert = AlertState(action: "export profile", error: error)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var macOSEditingBody: some View {
|
|
rowContent
|
|
}
|
|
#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)
|
|
}
|
|
}
|
|
.sheet(
|
|
isPresented: $showQRSShare,
|
|
onDismiss: {
|
|
qrsShareData = nil
|
|
},
|
|
content: {
|
|
if let data = qrsShareData {
|
|
QRSSheet(profileName: profile.name, profileData: data)
|
|
} else {
|
|
ProgressView()
|
|
}
|
|
}
|
|
)
|
|
.fileExporter(
|
|
isPresented: $showExporter,
|
|
document: exportDocument,
|
|
contentType: exportDocument?.contentType ?? .data,
|
|
defaultFilename: exportDocument?.filename
|
|
) { result in
|
|
exportDocument = nil
|
|
if case let .failure(error) = result {
|
|
alert = AlertState(action: "export profile", error: error)
|
|
}
|
|
}
|
|
}
|
|
#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
|
|
if !isEditing {
|
|
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 os(macOS)
|
|
if isEditing {
|
|
Button {
|
|
onDelete()
|
|
} label: {
|
|
Image(systemName: "minus.circle.fill")
|
|
.font(.system(size: 20))
|
|
.foregroundStyle(.red)
|
|
}
|
|
.buttonStyle(.plain)
|
|
} else {
|
|
rowMenu
|
|
}
|
|
#else
|
|
if !isEditing {
|
|
rowMenu
|
|
}
|
|
#endif
|
|
}
|
|
.contentShape(Rectangle())
|
|
.padding(16)
|
|
.cardStyle()
|
|
}
|
|
#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)
|
|
}
|
|
.onChange(of: exportItemType) { exportItemType in
|
|
guard let exportItemType else { return }
|
|
self.exportItemType = nil
|
|
exportProfileMacOS(type: exportItemType)
|
|
}
|
|
#endif
|
|
}
|
|
|
|
private func prepareQRSShare() {
|
|
qrsShareData = nil
|
|
showQRSShare = true
|
|
Task {
|
|
do {
|
|
let data = try await profile.origin.encodedContentDataAsync()
|
|
await MainActor.run {
|
|
qrsShareData = data
|
|
}
|
|
} catch {
|
|
await MainActor.run {
|
|
alert = AlertState(action: "prepare QRS share", error: error)
|
|
showQRSShare = false
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#if !os(tvOS)
|
|
private var shareMenu: some View {
|
|
Menu {
|
|
#if os(macOS)
|
|
Button {
|
|
exportItemType = .file
|
|
} label: {
|
|
Label("Save File", systemImage: "square.and.arrow.down")
|
|
}
|
|
|
|
Button {
|
|
shareItemType = .file
|
|
} label: {
|
|
Label("Share File", systemImage: "doc")
|
|
}
|
|
|
|
Button {
|
|
exportItemType = .json
|
|
} label: {
|
|
Label("Save Content JSON", systemImage: "square.and.arrow.down")
|
|
}
|
|
|
|
Button {
|
|
shareItemType = .json
|
|
} label: {
|
|
Label("Share Content JSON File", systemImage: "curlybraces")
|
|
}
|
|
#else
|
|
Button {
|
|
exportProfile(type: .file)
|
|
} label: {
|
|
Label("Save File", systemImage: "square.and.arrow.down")
|
|
}
|
|
|
|
ShareButtonCompat($alert) {
|
|
Label("Share File", systemImage: "doc")
|
|
} itemURL: {
|
|
try await profile.origin.generateShareFileAsync()
|
|
}
|
|
|
|
Button {
|
|
exportProfile(type: .json)
|
|
} label: {
|
|
Label("Save Content JSON", systemImage: "square.and.arrow.down")
|
|
}
|
|
|
|
ShareButtonCompat($alert) {
|
|
Label("Share Content JSON File", systemImage: "curlybraces")
|
|
} itemURL: {
|
|
try await profile.origin.generateJSONShareFileAsync(name: "\(profile.name).json")
|
|
}
|
|
#endif
|
|
|
|
if profile.type == .remote {
|
|
Button {
|
|
showQRCode = true
|
|
} label: {
|
|
Label("Share URL as QR Code", systemImage: "qrcode")
|
|
}
|
|
}
|
|
|
|
Button {
|
|
prepareQRSShare()
|
|
} label: {
|
|
Label("Share as QRS Code", systemImage: "barcode")
|
|
}
|
|
} label: {
|
|
Label("Share", systemImage: "square.and.arrow.up")
|
|
}
|
|
}
|
|
|
|
private func exportProfile(type: ExportItemType) {
|
|
Task {
|
|
do {
|
|
let document: ProfileAnyExportDocument
|
|
switch type {
|
|
case .file:
|
|
let data = try await profile.origin.encodedContentDataAsync()
|
|
document = ProfileAnyExportDocument(data: data, filename: "\(profile.name).bpf", contentType: .data)
|
|
case .json:
|
|
let content = try await profile.origin.readAsync()
|
|
document = ProfileAnyExportDocument(data: Data(content.utf8), filename: "\(profile.name).json", contentType: .json)
|
|
}
|
|
await MainActor.run {
|
|
exportDocument = document
|
|
showExporter = true
|
|
}
|
|
} catch {
|
|
await MainActor.run {
|
|
alert = AlertState(action: "export profile", error: error)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
#endif
|
|
|
|
private var profileInfo: some View {
|
|
HStack(spacing: 8) {
|
|
HStack(spacing: 4) {
|
|
Image(systemName: profile.type.presentationSymbol)
|
|
.font(.system(size: 12))
|
|
.foregroundStyle(.secondary)
|
|
Text(profile.type.presentationLabel)
|
|
.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.relativeFormat)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#if os(macOS)
|
|
private func shareProfile(type: ShareItemType) {
|
|
Task {
|
|
do {
|
|
let url: URL
|
|
switch type {
|
|
case .file:
|
|
url = try await profile.origin.generateShareFileAsync()
|
|
case .json:
|
|
url = try await profile.origin.generateJSONShareFileAsync(name: "\(profile.name).json")
|
|
}
|
|
await MainActor.run {
|
|
let anchorView = menuAnchorView ?? NSApp.keyWindow?.contentView ?? NSView()
|
|
NSSharingServicePicker(items: [url]).show(
|
|
relativeTo: .zero,
|
|
of: anchorView,
|
|
preferredEdge: .minY
|
|
)
|
|
}
|
|
} catch {
|
|
await MainActor.run {
|
|
alert = AlertState(action: "share profile", error: error)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func exportProfileMacOS(type: ExportItemType) {
|
|
Task {
|
|
do {
|
|
let document: ProfileAnyExportDocument
|
|
switch type {
|
|
case .file:
|
|
let data = try await profile.origin.encodedContentDataAsync()
|
|
document = ProfileAnyExportDocument(data: data, filename: "\(profile.name).bpf", contentType: .data)
|
|
case .json:
|
|
let content = try await profile.origin.readAsync()
|
|
document = ProfileAnyExportDocument(data: Data(content.utf8), filename: "\(profile.name).json", contentType: .json)
|
|
}
|
|
await MainActor.run {
|
|
exportDocument = document
|
|
showExporter = true
|
|
}
|
|
} catch {
|
|
await MainActor.run {
|
|
alert = AlertState(action: "export profile", 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.presentationSymbol)
|
|
.font(.system(size: 12))
|
|
.foregroundStyle(.secondary)
|
|
Text(profile.type.presentationLabel)
|
|
.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.relativeFormat)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Spacer()
|
|
}
|
|
.frame(width: width)
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 8)
|
|
.background(.background, in: RoundedRectangle(cornerRadius: 8))
|
|
}
|
|
#endif
|
|
}
|
|
|
|
// MARK: - Export Helpers
|
|
|
|
#if !os(tvOS)
|
|
private enum ExportItemType {
|
|
case file
|
|
case json
|
|
}
|
|
#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
|
|
|
|
// MARK: - Legacy iOS ProfilePickerRow (iOS < 26)
|
|
|
|
#if os(iOS)
|
|
private struct LegacyProfilePickerRow: View {
|
|
@EnvironmentObject private var environments: ExtensionEnvironments
|
|
@Environment(\.editMode) private var editMode
|
|
|
|
let profile: ProfilePreview
|
|
let isSelected: Bool
|
|
@Binding var alert: AlertState?
|
|
let onSelect: () -> Void
|
|
let onEdit: () -> Void
|
|
let onUpdate: () async -> Void
|
|
|
|
private var isEditing: Bool {
|
|
editMode?.wrappedValue.isEditing ?? false
|
|
}
|
|
|
|
@State private var isUpdating = false
|
|
@State private var showQRCode = false
|
|
@State private var showQRSShare = false
|
|
@State private var qrsShareData: Data?
|
|
@State private var exportDocument: ProfileAnyExportDocument?
|
|
@State private var showExporter = false
|
|
|
|
var body: some View {
|
|
Group {
|
|
if isEditing {
|
|
editingBody
|
|
} else {
|
|
normalBody
|
|
}
|
|
}
|
|
.transaction { $0.animation = nil }
|
|
}
|
|
|
|
private var editingBody: some View {
|
|
HStack(spacing: 12) {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(profile.name)
|
|
.font(.body)
|
|
.foregroundStyle(.primary)
|
|
profileInfo
|
|
}
|
|
Spacer()
|
|
}
|
|
}
|
|
|
|
private var normalBody: some View {
|
|
HStack(spacing: 12) {
|
|
Button {
|
|
if !isUpdating {
|
|
onSelect()
|
|
}
|
|
} label: {
|
|
HStack(spacing: 12) {
|
|
Image(systemName: "checkmark")
|
|
.font(.system(size: 16, weight: .semibold))
|
|
.foregroundStyle(.tint)
|
|
.opacity(isSelected ? 1 : 0)
|
|
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(profile.name)
|
|
.font(.body)
|
|
.foregroundStyle(.primary)
|
|
profileInfo
|
|
}
|
|
}
|
|
}
|
|
.buttonStyle(.plain)
|
|
.disabled(isUpdating)
|
|
|
|
Spacer()
|
|
|
|
rowMenu
|
|
}
|
|
.sheet(isPresented: $showQRCode) {
|
|
if let remoteURL = profile.remoteURL {
|
|
QRCodeSheet(profileName: profile.name, remoteURL: remoteURL)
|
|
}
|
|
}
|
|
.sheet(
|
|
isPresented: $showQRSShare,
|
|
onDismiss: {
|
|
qrsShareData = nil
|
|
},
|
|
content: {
|
|
if let data = qrsShareData {
|
|
QRSSheet(profileName: profile.name, profileData: data)
|
|
} else {
|
|
ProgressView()
|
|
}
|
|
}
|
|
)
|
|
.fileExporter(
|
|
isPresented: $showExporter,
|
|
document: exportDocument,
|
|
contentType: exportDocument?.contentType ?? .data,
|
|
defaultFilename: exportDocument?.filename
|
|
) { result in
|
|
exportDocument = nil
|
|
if case let .failure(error) = result {
|
|
alert = AlertState(action: "export profile", error: error)
|
|
}
|
|
}
|
|
}
|
|
|
|
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")
|
|
}
|
|
}
|
|
|
|
shareMenu
|
|
} 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)
|
|
}
|
|
|
|
private func prepareQRSShare() {
|
|
qrsShareData = nil
|
|
showQRSShare = true
|
|
Task {
|
|
do {
|
|
let data = try await profile.origin.encodedContentDataAsync()
|
|
await MainActor.run {
|
|
qrsShareData = data
|
|
}
|
|
} catch {
|
|
await MainActor.run {
|
|
alert = AlertState(action: "prepare QRS share", error: error)
|
|
showQRSShare = false
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private var shareMenu: some View {
|
|
Menu {
|
|
Button {
|
|
exportProfile(type: .file)
|
|
} label: {
|
|
Label("Save File", systemImage: "square.and.arrow.down")
|
|
}
|
|
|
|
ShareButtonCompat($alert) {
|
|
Label("Share File", systemImage: "doc")
|
|
} itemURL: {
|
|
try await profile.origin.generateShareFileAsync()
|
|
}
|
|
|
|
Button {
|
|
exportProfile(type: .json)
|
|
} label: {
|
|
Label("Save Content JSON", systemImage: "square.and.arrow.down")
|
|
}
|
|
|
|
ShareButtonCompat($alert) {
|
|
Label("Share Content JSON File", systemImage: "curlybraces")
|
|
} itemURL: {
|
|
try await profile.origin.generateJSONShareFileAsync(name: "\(profile.name).json")
|
|
}
|
|
|
|
if profile.type == .remote {
|
|
Button {
|
|
showQRCode = true
|
|
} label: {
|
|
Label("Share URL as QR Code", systemImage: "qrcode")
|
|
}
|
|
}
|
|
|
|
Button {
|
|
prepareQRSShare()
|
|
} label: {
|
|
Label("Share as QRS Code", systemImage: "barcode")
|
|
}
|
|
} label: {
|
|
Label("Share", systemImage: "square.and.arrow.up")
|
|
}
|
|
}
|
|
|
|
private func exportProfile(type: ExportItemType) {
|
|
Task {
|
|
do {
|
|
let document: ProfileAnyExportDocument
|
|
switch type {
|
|
case .file:
|
|
let data = try await profile.origin.encodedContentDataAsync()
|
|
document = ProfileAnyExportDocument(data: data, filename: "\(profile.name).bpf", contentType: .data)
|
|
case .json:
|
|
let content = try await profile.origin.readAsync()
|
|
document = ProfileAnyExportDocument(data: Data(content.utf8), filename: "\(profile.name).json", contentType: .json)
|
|
}
|
|
await MainActor.run {
|
|
exportDocument = document
|
|
showExporter = true
|
|
}
|
|
} catch {
|
|
await MainActor.run {
|
|
alert = AlertState(action: "export profile", error: error)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private var profileInfo: some View {
|
|
HStack(spacing: 8) {
|
|
HStack(spacing: 4) {
|
|
Image(systemName: profile.type.presentationSymbol)
|
|
.font(.system(size: 12))
|
|
.foregroundStyle(.secondary)
|
|
Text(profile.type.presentationLabel)
|
|
.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.relativeFormat)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
#endif
|