Merge profile to dashboard
This commit is contained in:
@@ -0,0 +1,60 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
public struct NavigationButtonsView: View {
|
||||||
|
public let showGroupsButton: Bool
|
||||||
|
public let showConnectionsButton: Bool
|
||||||
|
public let groupsCount: Int
|
||||||
|
public let connectionsCount: Int
|
||||||
|
public let onGroupsTap: () -> Void
|
||||||
|
public let onConnectionsTap: () -> Void
|
||||||
|
|
||||||
|
public init(
|
||||||
|
showGroupsButton: Bool,
|
||||||
|
showConnectionsButton: Bool,
|
||||||
|
groupsCount: Int,
|
||||||
|
connectionsCount: Int,
|
||||||
|
onGroupsTap: @escaping () -> Void,
|
||||||
|
onConnectionsTap: @escaping () -> Void
|
||||||
|
) {
|
||||||
|
self.showGroupsButton = showGroupsButton
|
||||||
|
self.showConnectionsButton = showConnectionsButton
|
||||||
|
self.groupsCount = groupsCount
|
||||||
|
self.connectionsCount = connectionsCount
|
||||||
|
self.onGroupsTap = onGroupsTap
|
||||||
|
self.onConnectionsTap = onConnectionsTap
|
||||||
|
}
|
||||||
|
|
||||||
|
public var body: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
if showGroupsButton {
|
||||||
|
Divider()
|
||||||
|
Text(verbatim: "\(groupsCount)")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.fixedSize()
|
||||||
|
Button {
|
||||||
|
onGroupsTap()
|
||||||
|
} label: {
|
||||||
|
Label("Groups", systemImage: "rectangle.3.group.fill")
|
||||||
|
}
|
||||||
|
.labelStyle(.iconOnly)
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
}
|
||||||
|
if showConnectionsButton {
|
||||||
|
Divider()
|
||||||
|
Text(verbatim: "\(connectionsCount)")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.fixedSize()
|
||||||
|
Button {
|
||||||
|
onConnectionsTap()
|
||||||
|
} label: {
|
||||||
|
Label("Connections", systemImage: "list.bullet.rectangle.portrait.fill")
|
||||||
|
}
|
||||||
|
.labelStyle(.iconOnly)
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import Library
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
public struct SheetContent<Content: View>: View {
|
||||||
|
private let title: String
|
||||||
|
private let content: Content
|
||||||
|
|
||||||
|
public init(_ title: String, @ViewBuilder content: () -> Content) {
|
||||||
|
self.title = title
|
||||||
|
self.content = content()
|
||||||
|
}
|
||||||
|
|
||||||
|
public var body: some View {
|
||||||
|
#if os(iOS) || os(tvOS)
|
||||||
|
NavigationStackCompat {
|
||||||
|
content
|
||||||
|
.navigationTitle(title)
|
||||||
|
#if os(iOS)
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
.presentationDetentsIfAvailable()
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
public struct GroupsSheetContent: View {
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
public var body: some View {
|
||||||
|
SheetContent("Groups") {
|
||||||
|
GroupListView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
public struct ConnectionsSheetContent: View {
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
public var body: some View {
|
||||||
|
SheetContent("Connections") {
|
||||||
|
ConnectionListView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -58,6 +58,7 @@ public struct ShareButtonCompat<Label>: View where Label: View {
|
|||||||
|
|
||||||
public var body: some View {
|
public var body: some View {
|
||||||
Button(action: shareItem, label: label)
|
Button(action: shareItem, label: label)
|
||||||
|
.buttonStyle(.plain)
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
.background(SharingServicePicker($sharePresented, $alert, itemURL))
|
.background(SharingServicePicker($sharePresented, $alert, itemURL))
|
||||||
#endif
|
#endif
|
||||||
@@ -66,7 +67,7 @@ public struct ShareButtonCompat<Label>: View where Label: View {
|
|||||||
private func shareItem() {
|
private func shareItem() {
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
Task {
|
Task {
|
||||||
await shareItem0()
|
await shareItemAsync()
|
||||||
}
|
}
|
||||||
#elseif os(macOS)
|
#elseif os(macOS)
|
||||||
sharePresented = true
|
sharePresented = true
|
||||||
@@ -74,11 +75,11 @@ public struct ShareButtonCompat<Label>: View where Label: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
private nonisolated func shareItem0() async {
|
private nonisolated func shareItemAsync() async {
|
||||||
do {
|
do {
|
||||||
let shareItem = try await itemURL()
|
let shareItem = try await itemURL()
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
shareItem1(shareItem)
|
presentShareController(shareItem)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
@@ -87,10 +88,20 @@ public struct ShareButtonCompat<Label>: View where Label: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func shareItem1(_ item: URL) {
|
private func presentShareController(_ item: URL) {
|
||||||
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene {
|
guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
||||||
windowScene.keyWindow?.rootViewController?.present(UIActivityViewController(activityItems: [item], applicationActivities: nil), animated: true, completion: nil)
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
public extension View {
|
||||||
|
@ViewBuilder
|
||||||
|
func presentationDetentsIfAvailable() -> some View {
|
||||||
|
#if os(iOS) || os(tvOS)
|
||||||
|
if #available(iOS 16.0, tvOS 17.0, *) {
|
||||||
|
self.presentationDetents([.large])
|
||||||
|
.presentationDragIndicator(.visible)
|
||||||
|
} else {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
self
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
#if os(iOS) || os(tvOS)
|
||||||
|
@available(iOS 16.0, tvOS 17.0, *)
|
||||||
|
@ViewBuilder
|
||||||
|
func presentationDetentsIfAvailable(_ detents: PresentationDetent...) -> some View {
|
||||||
|
let detentSet: Set<PresentationDetent> = detents.isEmpty ? [.large] : Set(detents)
|
||||||
|
presentationDetents(detentSet)
|
||||||
|
.presentationDragIndicator(.visible)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
func actionButtonStyle() -> some View {
|
||||||
|
if #available(iOS 26.0, macOS 26.0, tvOS 26.0, *) {
|
||||||
|
self.frame(width: 36, height: 36)
|
||||||
|
.glassEffect(.regular.interactive(), in: .circle)
|
||||||
|
} else {
|
||||||
|
frame(width: 36, height: 36)
|
||||||
|
.background(Color.secondary.opacity(0.1))
|
||||||
|
.clipShape(Circle())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,9 @@ public struct ActiveDashboardView: View {
|
|||||||
@State private var cardConfigurationVersion = 0
|
@State private var cardConfigurationVersion = 0
|
||||||
#if os(iOS) || os(tvOS)
|
#if os(iOS) || os(tvOS)
|
||||||
@State private var showCardManagement = false
|
@State private var showCardManagement = false
|
||||||
|
@State private var showGroups = false
|
||||||
|
@State private var showConnections = false
|
||||||
|
@State private var buttonState = ButtonVisibilityState()
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
private let externalCardConfigurationVersion: Int?
|
private let externalCardConfigurationVersion: Int?
|
||||||
@@ -42,20 +45,15 @@ public struct ActiveDashboardView: View {
|
|||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var content: some View {
|
private var content: some View {
|
||||||
VStack {
|
overviewPage
|
||||||
#if os(iOS) || os(tvOS)
|
|
||||||
if ApplicationLibrary.inPreview || profile.status.isConnectedStrict {
|
|
||||||
pageSelector
|
|
||||||
pageContent
|
|
||||||
} else {
|
|
||||||
overviewPage
|
|
||||||
}
|
|
||||||
#else
|
|
||||||
overviewPage
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
#if os(iOS) || os(tvOS)
|
#if os(iOS) || os(tvOS)
|
||||||
.toolbar { toolbar }
|
.toolbar { toolbar }
|
||||||
|
.sheet(isPresented: $showGroups) {
|
||||||
|
groupsSheetContent
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showConnections) {
|
||||||
|
connectionsSheetContent
|
||||||
|
}
|
||||||
#endif
|
#endif
|
||||||
.onAppear {
|
.onAppear {
|
||||||
if ApplicationLibrary.inPreview {
|
if ApplicationLibrary.inPreview {
|
||||||
@@ -83,45 +81,23 @@ public struct ActiveDashboardView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#if os(iOS) || os(tvOS)
|
||||||
|
.onReceive(environments.commandClient.$groups) { _ in
|
||||||
|
updateButtonVisibility()
|
||||||
|
}
|
||||||
|
.onReceive(environments.commandClient.$connections) { _ in
|
||||||
|
updateButtonVisibility()
|
||||||
|
}
|
||||||
|
.onReceive(profile.$status) { _ in
|
||||||
|
updateButtonVisibility()
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
updateButtonVisibility()
|
||||||
|
}
|
||||||
|
#endif
|
||||||
.alertBinding($coordinator.alert)
|
.alertBinding($coordinator.alert)
|
||||||
}
|
}
|
||||||
|
|
||||||
#if os(iOS) || os(tvOS)
|
|
||||||
@ViewBuilder
|
|
||||||
private var pageSelector: some View {
|
|
||||||
Picker("Page", selection: $coordinator.selection) {
|
|
||||||
ForEach(DashboardPage.enabledCases()) { page in
|
|
||||||
page.label
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.pickerStyle(.segmented)
|
|
||||||
#if os(iOS)
|
|
||||||
.padding([.leading, .trailing])
|
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private var pageContent: some View {
|
|
||||||
TabView(selection: $coordinator.selection) {
|
|
||||||
ForEach(DashboardPage.enabledCases()) { page in
|
|
||||||
page.contentView(
|
|
||||||
$coordinator.profileList,
|
|
||||||
$coordinator.selectedProfileID,
|
|
||||||
$coordinator.systemProxyAvailable,
|
|
||||||
$coordinator.systemProxyEnabled,
|
|
||||||
externalCardConfigurationVersion ?? cardConfigurationVersion
|
|
||||||
)
|
|
||||||
.tag(page)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#if os(iOS)
|
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
|
||||||
#endif
|
|
||||||
.tabViewStyle(.page(indexDisplayMode: .never))
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var overviewPage: some View {
|
private var overviewPage: some View {
|
||||||
OverviewView(
|
OverviewView(
|
||||||
@@ -134,26 +110,68 @@ public struct ActiveDashboardView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#if os(iOS) || os(tvOS)
|
#if os(iOS) || os(tvOS)
|
||||||
|
private func updateButtonVisibility() {
|
||||||
|
buttonState.update(profile: profile, commandClient: environments.commandClient)
|
||||||
|
}
|
||||||
|
|
||||||
@ToolbarContentBuilder
|
@ToolbarContentBuilder
|
||||||
private var toolbar: some ToolbarContent {
|
private var toolbar: some ToolbarContent {
|
||||||
ToolbarItem(placement: .topBarTrailing) {
|
ToolbarItem(placement: .topBarLeading) {
|
||||||
if coordinator.selection == .overview {
|
#if os(iOS)
|
||||||
if #available(iOS 16.0, tvOS 17.0, *) {
|
if #available(iOS 26.0, *), !Variant.debugNoIOS26 {
|
||||||
cardManagementButton
|
EmptyView()
|
||||||
|
} else {
|
||||||
|
navigationButtons
|
||||||
}
|
}
|
||||||
|
#else
|
||||||
|
navigationButtons
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
if #available(iOS 16.0, tvOS 17.0, *) {
|
||||||
|
cardManagementButton
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ToolbarItem(placement: .topBarTrailing) {
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
if #available(iOS 26.0, *), !Variant.debugNoIOS26 {
|
#if os(iOS)
|
||||||
EmptyView()
|
if #available(iOS 26.0, *), !Variant.debugNoIOS26 {
|
||||||
} else {
|
EmptyView()
|
||||||
StartStopButton()
|
} else {
|
||||||
}
|
HStack(spacing: 12) {
|
||||||
|
Divider()
|
||||||
|
StartStopButton()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Divider()
|
||||||
|
StartStopButton()
|
||||||
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var navigationButtons: some View {
|
||||||
|
NavigationButtonsView(
|
||||||
|
showGroupsButton: buttonState.showGroupsButton,
|
||||||
|
showConnectionsButton: buttonState.showConnectionsButton,
|
||||||
|
groupsCount: buttonState.groupsCount,
|
||||||
|
connectionsCount: buttonState.connectionsCount,
|
||||||
|
onGroupsTap: { showGroups = true },
|
||||||
|
onConnectionsTap: { showConnections = true }
|
||||||
|
)
|
||||||
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
#if os(iOS) || os(tvOS)
|
#if os(iOS) || os(tvOS)
|
||||||
|
private var groupsSheetContent: some View {
|
||||||
|
GroupsSheetContent()
|
||||||
|
}
|
||||||
|
|
||||||
|
private var connectionsSheetContent: some View {
|
||||||
|
ConnectionsSheetContent()
|
||||||
|
}
|
||||||
|
|
||||||
@available(iOS 16.0, tvOS 17.0, *)
|
@available(iOS 16.0, tvOS 17.0, *)
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var cardManagementButton: some View {
|
private var cardManagementButton: some View {
|
||||||
@@ -168,7 +186,8 @@ public struct ActiveDashboardView: View {
|
|||||||
}
|
}
|
||||||
.sheet(isPresented: $showCardManagement) {
|
.sheet(isPresented: $showCardManagement) {
|
||||||
CardManagementSheet(configurationVersion: $cardConfigurationVersion)
|
CardManagementSheet(configurationVersion: $cardConfigurationVersion)
|
||||||
.presentationDetents([.medium, .large])
|
.presentationDetents([.large])
|
||||||
|
.presentationDragIndicator(.visible)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import Foundation
|
||||||
|
import Library
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
public struct ButtonVisibilityState {
|
||||||
|
public var showGroupsButton = false
|
||||||
|
public var showConnectionsButton = false
|
||||||
|
public var groupsCount = 0
|
||||||
|
public var connectionsCount = 0
|
||||||
|
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
public mutating func update(
|
||||||
|
profile: ExtensionProfile?,
|
||||||
|
commandClient: CommandClient,
|
||||||
|
requireAnyConnection: Bool = false
|
||||||
|
) {
|
||||||
|
guard let profile else {
|
||||||
|
reset()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
groupsCount = commandClient.groups?.count ?? 0
|
||||||
|
connectionsCount = commandClient.connections?.count ?? 0
|
||||||
|
|
||||||
|
let isConnected = ApplicationLibrary.inPreview || profile.status.isConnectedStrict
|
||||||
|
|
||||||
|
showConnectionsButton = isConnected && (!requireAnyConnection || commandClient.hasAnyConnection)
|
||||||
|
showGroupsButton = isConnected && (commandClient.groups?.isEmpty == false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private mutating func reset() {
|
||||||
|
showGroupsButton = false
|
||||||
|
showConnectionsButton = false
|
||||||
|
groupsCount = 0
|
||||||
|
connectionsCount = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@ public struct ClashModeCard: View {
|
|||||||
|
|
||||||
public var body: some View {
|
public var body: some View {
|
||||||
if shouldShowPicker {
|
if shouldShowPicker {
|
||||||
DashboardCardView(title: "Mode", isHalfWidth: false) {
|
DashboardCardView(title: String(localized: "Mode"), isHalfWidth: false) {
|
||||||
Picker("", selection: Binding(get: {
|
Picker("", selection: Binding(get: {
|
||||||
clashMode
|
clashMode
|
||||||
}, set: { newMode in
|
}, set: { newMode in
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ public struct ConnectionsCard: View {
|
|||||||
public init() {}
|
public init() {}
|
||||||
|
|
||||||
public var body: some View {
|
public var body: some View {
|
||||||
DashboardCardView(title: "Connections", isHalfWidth: true) {
|
DashboardCardView(title: String(localized: "Connections"), isHalfWidth: true) {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
if ApplicationLibrary.inPreview {
|
if ApplicationLibrary.inPreview {
|
||||||
DashboardCardLine(String(localized: "Inbound"), "34")
|
DashboardCardLine(String(localized: "Inbound"), "34")
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
|
import Foundation
|
||||||
|
import Libbox
|
||||||
import Library
|
import Library
|
||||||
|
import QRCode
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
@MainActor
|
||||||
public struct ProfileCard: View {
|
public struct ProfileCard: View {
|
||||||
|
@EnvironmentObject private var environments: ExtensionEnvironments
|
||||||
|
@StateObject private var viewModel = ViewModel()
|
||||||
|
|
||||||
@Binding private var profileList: [ProfilePreview]
|
@Binding private var profileList: [ProfilePreview]
|
||||||
@Binding private var selectedProfileID: Int64
|
@Binding private var selectedProfileID: Int64
|
||||||
|
|
||||||
@@ -13,39 +20,431 @@ public struct ProfileCard: View {
|
|||||||
_selectedProfileID = selectedProfileID
|
_selectedProfileID = selectedProfileID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var selectedProfile: ProfilePreview? {
|
||||||
|
profileList.first { $0.id == selectedProfileID }
|
||||||
|
}
|
||||||
|
|
||||||
public var body: some View {
|
public var body: some View {
|
||||||
DashboardCardView(title: "Profile", isHalfWidth: false) {
|
DashboardCardView(title: "") {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
#if os(iOS) || os(tvOS)
|
headerView
|
||||||
Picker("", selection: $selectedProfileID) {
|
profileSelectorView
|
||||||
ForEach(profileList, id: \.id) { profile in
|
}
|
||||||
Text(profile.name).tag(profile.id)
|
}
|
||||||
}
|
.disabled(viewModel.isUpdating)
|
||||||
|
.sheet(isPresented: $viewModel.showNewProfile, onDismiss: {
|
||||||
|
environments.profileUpdate.send()
|
||||||
|
}) {
|
||||||
|
NewProfileNavigationView()
|
||||||
|
.environmentObject(environments)
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $viewModel.showManageProfiles) {
|
||||||
|
manageProfilesSheet
|
||||||
|
}
|
||||||
|
.sheet(item: $viewModel.profileToEdit) { profile in
|
||||||
|
editProfileSheet(for: profile)
|
||||||
|
}
|
||||||
|
#if os(iOS) || os(tvOS)
|
||||||
|
.sheet(isPresented: $viewModel.showQRCode) {
|
||||||
|
if let profile = selectedProfile, let remoteURL = profile.remoteURL {
|
||||||
|
QRCodeSheet(profileName: profile.name, remoteURL: remoteURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
.alertBinding($viewModel.alert)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var headerView: some View {
|
||||||
|
HStack {
|
||||||
|
Text("Profile")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var profileSelectorView: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
if profileList.isEmpty {
|
||||||
|
Text("Empty profiles")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
} else {
|
||||||
|
ProfileSelectorButton(
|
||||||
|
items: profileList,
|
||||||
|
selectedItem: selectedProfile,
|
||||||
|
onSelect: { selectedProfileID = $0 }
|
||||||
|
)
|
||||||
|
|
||||||
|
if let profile = selectedProfile {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
profileInfo(for: profile)
|
||||||
|
actionButtonsRow(for: profile)
|
||||||
}
|
}
|
||||||
.pickerStyle(.menu)
|
}
|
||||||
.labelsHidden()
|
}
|
||||||
#elseif os(macOS)
|
}
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
}
|
||||||
ForEach(profileList, id: \.id) { profile in
|
|
||||||
HStack {
|
@ViewBuilder
|
||||||
Button {
|
private func actionButtonsRow(for profile: ProfilePreview) -> some View {
|
||||||
selectedProfileID = profile.id
|
HStack(spacing: 12) {
|
||||||
} label: {
|
editButton(for: profile)
|
||||||
HStack(spacing: 8) {
|
|
||||||
Image(systemName: selectedProfileID == profile.id ? "circle.fill" : "circle")
|
if profile.type == .remote {
|
||||||
.font(.system(size: 12))
|
updateButton(for: profile)
|
||||||
Text(profile.name)
|
qrCodeButton(for: profile)
|
||||||
.font(.subheadline)
|
}
|
||||||
Spacer()
|
|
||||||
}
|
ProfileShareButton($viewModel.alert, profile.origin) {
|
||||||
}
|
Image(systemName: "square.and.arrow.up")
|
||||||
.buttonStyle(.plain)
|
.font(.system(size: 16))
|
||||||
.foregroundColor(selectedProfileID == profile.id ? .accentColor : .primary)
|
}
|
||||||
}
|
.buttonStyle(.plain)
|
||||||
}
|
.actionButtonStyle()
|
||||||
}
|
}
|
||||||
#endif
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func editButton(for profile: ProfilePreview) -> some View {
|
||||||
|
Button {
|
||||||
|
viewModel.profileToEdit = profile.origin
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "pencil")
|
||||||
|
.font(.system(size: 16))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.actionButtonStyle()
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func updateButton(for profile: ProfilePreview) -> some View {
|
||||||
|
Button {
|
||||||
|
viewModel.isUpdating = true
|
||||||
|
Task {
|
||||||
|
await viewModel.updateProfile(profile.origin, environments: environments)
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "arrow.clockwise")
|
||||||
|
.font(.system(size: 16))
|
||||||
|
.rotationEffect(.degrees(viewModel.isUpdating ? 360 : 0))
|
||||||
|
.animation(
|
||||||
|
viewModel.isUpdating
|
||||||
|
? .linear(duration: 1).repeatForever(autoreverses: false)
|
||||||
|
: .default,
|
||||||
|
value: viewModel.isUpdating
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.actionButtonStyle()
|
||||||
|
.disabled(viewModel.isUpdating)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func qrCodeButton(for profile: ProfilePreview) -> some View {
|
||||||
|
#if os(iOS) || os(tvOS)
|
||||||
|
Button {
|
||||||
|
viewModel.showQRCode = true
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "qrcode")
|
||||||
|
.font(.system(size: 16))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.actionButtonStyle()
|
||||||
|
#elseif os(macOS)
|
||||||
|
Button {
|
||||||
|
viewModel.showQRCode = true
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "qrcode")
|
||||||
|
.font(.system(size: 16))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.actionButtonStyle()
|
||||||
|
.popover(isPresented: $viewModel.showQRCode, arrowEdge: .bottom) {
|
||||||
|
if let remoteURL = profile.remoteURL {
|
||||||
|
QRCodeContentView(profileName: profile.name, remoteURL: remoteURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func profileInfo(for profile: ProfilePreview) -> some View {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: profile.type == .remote ? "cloud.fill" : "doc.fill")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
Text(profile.type == .remote ? "Remote" : "Local")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
}
|
||||||
|
|
||||||
|
if profile.type == .remote, let lastUpdated = profile.lastUpdated {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: "clock.fill")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
Text(lastUpdated.myFormat)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var manageProfilesSheet: some View {
|
||||||
|
NavigationSheet(
|
||||||
|
title: "Profiles",
|
||||||
|
showDoneButton: true,
|
||||||
|
onDismiss: { viewModel.showManageProfiles = false }
|
||||||
|
) {
|
||||||
|
ManageProfilesView()
|
||||||
|
.environmentObject(environments)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func editProfileSheet(for profile: Profile) -> some View {
|
||||||
|
NavigationSheet(title: "Edit Profile") {
|
||||||
|
EditProfileView()
|
||||||
|
.environmentObject(profile)
|
||||||
|
.environmentObject(environments)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - ViewModel
|
||||||
|
|
||||||
|
extension ProfileCard {
|
||||||
|
@MainActor
|
||||||
|
class ViewModel: ObservableObject {
|
||||||
|
@Published var showNewProfile = false
|
||||||
|
@Published var showManageProfiles = false
|
||||||
|
@Published var showQRCode = false
|
||||||
|
@Published var isUpdating = false
|
||||||
|
@Published var alert: Alert?
|
||||||
|
@Published var profileToEdit: Profile?
|
||||||
|
|
||||||
|
func updateProfile(_ profile: Profile, environments: ExtensionEnvironments) async {
|
||||||
|
defer { isUpdating = false }
|
||||||
|
|
||||||
|
do {
|
||||||
|
try await profile.updateRemoteProfile()
|
||||||
|
environments.profileUpdate.send()
|
||||||
|
} catch {
|
||||||
|
alert = Alert(
|
||||||
|
title: Text("Update Failed"),
|
||||||
|
message: Text(error.localizedDescription)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - NewProfileNavigationView
|
||||||
|
|
||||||
|
extension ProfileCard {
|
||||||
|
@MainActor
|
||||||
|
struct NewProfileNavigationView: View {
|
||||||
|
@EnvironmentObject private var environments: ExtensionEnvironments
|
||||||
|
@State private var createdProfile: Profile?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStackCompat {
|
||||||
|
if let profile = createdProfile {
|
||||||
|
EditProfileView()
|
||||||
|
.environmentObject(profile)
|
||||||
|
.environmentObject(environments)
|
||||||
|
} else {
|
||||||
|
NewProfileView { profile in
|
||||||
|
createdProfile = profile
|
||||||
|
}
|
||||||
|
.environmentObject(environments)
|
||||||
|
#if os(iOS)
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#if os(iOS) || os(tvOS)
|
||||||
|
.presentationDetentsIfAvailable()
|
||||||
|
#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().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)
|
||||||
|
.alertBinding($viewModel.alert, $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
|
||||||
|
|
||||||
|
init(_ viewModel: ProfileViewModel, _ profile: ProfilePreview) {
|
||||||
|
self.viewModel = viewModel
|
||||||
|
_profile = State(initialValue: profile)
|
||||||
|
}
|
||||||
|
|
||||||
|
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: 8) {
|
||||||
|
if profile.type == .remote {
|
||||||
|
Button {
|
||||||
|
viewModel.isUpdating = true
|
||||||
|
Task {
|
||||||
|
await viewModel.updateProfile(profile.origin)
|
||||||
|
profile = ProfilePreview(profile.origin)
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "arrow.clockwise")
|
||||||
|
.font(.system(size: 16))
|
||||||
|
.rotationEffect(.degrees(viewModel.isUpdating ? 360 : 0))
|
||||||
|
.animation(
|
||||||
|
viewModel.isUpdating
|
||||||
|
? .linear(duration: 1).repeatForever(autoreverses: false)
|
||||||
|
: .default,
|
||||||
|
value: viewModel.isUpdating
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.actionButtonStyle()
|
||||||
|
.disabled(viewModel.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
|
||||||
|
}
|
||||||
|
|
||||||
|
ShareButtonCompat($viewModel.alert) {
|
||||||
|
Image(systemName: "square.and.arrow.up")
|
||||||
|
.font(.system(size: 16))
|
||||||
|
} itemURL: {
|
||||||
|
try profile.origin.toContent().generateShareFile()
|
||||||
|
}
|
||||||
|
.actionButtonStyle()
|
||||||
|
|
||||||
|
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,288 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
#if os(iOS) || os(tvOS)
|
||||||
|
ProfileSelectorUIButton(
|
||||||
|
items: items,
|
||||||
|
selectedItem: selectedItem,
|
||||||
|
onSelect: onSelect
|
||||||
|
)
|
||||||
|
.frame(height: 44)
|
||||||
|
.selectorBackground()
|
||||||
|
#elseif os(macOS)
|
||||||
|
ProfileSelectorNSButton(
|
||||||
|
items: items,
|
||||||
|
selectedItem: selectedItem,
|
||||||
|
onSelect: onSelect
|
||||||
|
)
|
||||||
|
.frame(height: 32)
|
||||||
|
.selectorBackground()
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - UIKit Implementation (iOS/tvOS)
|
||||||
|
|
||||||
|
#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 {
|
||||||
|
if 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#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 {
|
||||||
|
@ViewBuilder
|
||||||
|
func selectorBackground() -> some View {
|
||||||
|
if #available(iOS 26.0, macOS 26.0, tvOS 26.0, *) {
|
||||||
|
self.glassEffect(.regular.interactive(), in: .rect(cornerRadius: 12))
|
||||||
|
} else {
|
||||||
|
background(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.fill(Color.secondary.opacity(0.1))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ public struct StatusCard: View {
|
|||||||
public init() {}
|
public init() {}
|
||||||
|
|
||||||
public var body: some View {
|
public var body: some View {
|
||||||
DashboardCardView(title: "Status", isHalfWidth: true) {
|
DashboardCardView(title: String(localized: "Status"), isHalfWidth: true) {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
if ApplicationLibrary.inPreview {
|
if ApplicationLibrary.inPreview {
|
||||||
DashboardCardLine(String(localized: "Memory"), "6.4 MB")
|
DashboardCardLine(String(localized: "Memory"), "6.4 MB")
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ public struct TrafficCard: View {
|
|||||||
public init() {}
|
public init() {}
|
||||||
|
|
||||||
public var body: some View {
|
public var body: some View {
|
||||||
DashboardCardView(title: "Traffic", isHalfWidth: true) {
|
DashboardCardView(title: String(localized: "Traffic"), isHalfWidth: true) {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
if ApplicationLibrary.inPreview {
|
if ApplicationLibrary.inPreview {
|
||||||
DashboardCardLine(String(localized: "Uplink"), "38 B/s")
|
DashboardCardLine(String(localized: "Uplink"), "38 B/s")
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ public struct TrafficTotalCard: View {
|
|||||||
public init() {}
|
public init() {}
|
||||||
|
|
||||||
public var body: some View {
|
public var body: some View {
|
||||||
DashboardCardView(title: "Traffic Total", isHalfWidth: true) {
|
DashboardCardView(title: String(localized: "Traffic Total"), isHalfWidth: true) {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
if ApplicationLibrary.inPreview {
|
if ApplicationLibrary.inPreview {
|
||||||
DashboardCardLine(String(localized: "Uplink"), "52 MB")
|
DashboardCardLine(String(localized: "Uplink"), "52 MB")
|
||||||
|
|||||||
@@ -6,8 +6,11 @@ import SwiftUI
|
|||||||
public struct DashboardView: View {
|
public struct DashboardView: View {
|
||||||
@Environment(\.openURL) private var openURL
|
@Environment(\.openURL) private var openURL
|
||||||
@Environment(\.cardConfigurationVersion) private var cardConfigurationVersion
|
@Environment(\.cardConfigurationVersion) private var cardConfigurationVersion
|
||||||
|
@Environment(\.importProfile) private var importProfile
|
||||||
|
@Environment(\.importRemoteProfile) private var importRemoteProfile
|
||||||
@EnvironmentObject private var environments: ExtensionEnvironments
|
@EnvironmentObject private var environments: ExtensionEnvironments
|
||||||
@StateObject private var coordinator = DashboardCoordinator()
|
@StateObject private var coordinator = DashboardCoordinator()
|
||||||
|
@State private var importRemoteProfileRequest: NewProfileView.ImportRequest?
|
||||||
|
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
@Environment(\.controlActiveState) private var controlActiveState
|
@Environment(\.controlActiveState) private var controlActiveState
|
||||||
@@ -22,6 +25,17 @@ public struct DashboardView: View {
|
|||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
Task { await coordinator.reload() }
|
Task { await coordinator.reload() }
|
||||||
#endif
|
#endif
|
||||||
|
handleImportProfile()
|
||||||
|
handleImportRemoteProfile()
|
||||||
|
}
|
||||||
|
.onChangeCompat(of: importProfile.wrappedValue) { _ in
|
||||||
|
handleImportProfile()
|
||||||
|
}
|
||||||
|
.onChangeCompat(of: importRemoteProfile.wrappedValue) { _ in
|
||||||
|
handleImportRemoteProfile()
|
||||||
|
}
|
||||||
|
.sheet(item: $importRemoteProfileRequest) { request in
|
||||||
|
importRemoteProfileSheet(for: request)
|
||||||
}
|
}
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
.onChangeCompat(of: controlActiveState) { state in
|
.onChangeCompat(of: controlActiveState) { state in
|
||||||
@@ -31,6 +45,52 @@ public struct DashboardView: View {
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func handleImportProfile() {
|
||||||
|
if let profile = importProfile.wrappedValue {
|
||||||
|
importProfile.wrappedValue = nil
|
||||||
|
coordinator.alert = Alert(
|
||||||
|
title: Text("Import Profile"),
|
||||||
|
message: Text("Are you sure to import profile \(profile.name)?"),
|
||||||
|
primaryButton: .default(Text("Import")) {
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
try await profile.importProfile()
|
||||||
|
} catch {
|
||||||
|
coordinator.alert = Alert(error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
environments.profileUpdate.send()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
secondaryButton: .cancel()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleImportRemoteProfile() {
|
||||||
|
if let remoteProfile = importRemoteProfile.wrappedValue {
|
||||||
|
importRemoteProfile.wrappedValue = nil
|
||||||
|
coordinator.alert = Alert(
|
||||||
|
title: Text("Import Remote Profile"),
|
||||||
|
message: Text("Are you sure to import remote profile \(remoteProfile.name)? You will connect to \(remoteProfile.host) to download the configuration."),
|
||||||
|
primaryButton: .default(Text("Import")) {
|
||||||
|
importRemoteProfileRequest = .init(name: remoteProfile.name, url: remoteProfile.url)
|
||||||
|
},
|
||||||
|
secondaryButton: .cancel()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func importRemoteProfileSheet(for request: NewProfileView.ImportRequest) -> some View {
|
||||||
|
NavigationSheet(title: "Import Profile", onDismiss: {
|
||||||
|
environments.profileUpdate.send()
|
||||||
|
}) {
|
||||||
|
NewProfileView(request)
|
||||||
|
.environmentObject(environments)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var content: some View {
|
private var content: some View {
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
|
|||||||
@@ -34,13 +34,6 @@ public struct OverviewView: View {
|
|||||||
Group {
|
Group {
|
||||||
if configuration.isLoading {
|
if configuration.isLoading {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
} else if profileList.isEmpty {
|
|
||||||
VStack {
|
|
||||||
Spacer()
|
|
||||||
Text("Empty profiles")
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
cardGrid
|
cardGrid
|
||||||
|
|||||||
@@ -13,14 +13,13 @@ public enum NavigationPage: Int, CaseIterable, Identifiable {
|
|||||||
case connections
|
case connections
|
||||||
#endif
|
#endif
|
||||||
case logs
|
case logs
|
||||||
case profiles
|
|
||||||
case settings
|
case settings
|
||||||
}
|
}
|
||||||
|
|
||||||
public extension NavigationPage {
|
public extension NavigationPage {
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
static var macosDefaultPages: [NavigationPage] {
|
static var macosDefaultPages: [NavigationPage] {
|
||||||
[.logs, .profiles, .settings]
|
[.logs, .settings]
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
@@ -37,12 +36,10 @@ public extension NavigationPage {
|
|||||||
case .groups:
|
case .groups:
|
||||||
return String(localized: "Groups")
|
return String(localized: "Groups")
|
||||||
case .connections:
|
case .connections:
|
||||||
return NSLocalizedString("Connections", comment: "")
|
return String(localized: "Connections")
|
||||||
#endif
|
#endif
|
||||||
case .logs:
|
case .logs:
|
||||||
return String(localized: "Logs")
|
return String(localized: "Logs")
|
||||||
case .profiles:
|
|
||||||
return String(localized: "Profiles")
|
|
||||||
case .settings:
|
case .settings:
|
||||||
return String(localized: "Settings")
|
return String(localized: "Settings")
|
||||||
}
|
}
|
||||||
@@ -60,8 +57,6 @@ public extension NavigationPage {
|
|||||||
#endif
|
#endif
|
||||||
case .logs:
|
case .logs:
|
||||||
return "doc.text.fill"
|
return "doc.text.fill"
|
||||||
case .profiles:
|
|
||||||
return "list.bullet.rectangle.fill"
|
|
||||||
case .settings:
|
case .settings:
|
||||||
return "gear.circle.fill"
|
return "gear.circle.fill"
|
||||||
}
|
}
|
||||||
@@ -81,8 +76,6 @@ public extension NavigationPage {
|
|||||||
#endif
|
#endif
|
||||||
case .logs:
|
case .logs:
|
||||||
LogView()
|
LogView()
|
||||||
case .profiles:
|
|
||||||
ProfileView()
|
|
||||||
case .settings:
|
case .settings:
|
||||||
SettingView()
|
SettingView()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,46 +55,16 @@ public struct EditProfileView: View {
|
|||||||
FormTextItem("Last Updated", profile.lastUpdated!.myFormat)
|
FormTextItem("Last Updated", profile.lastUpdated!.myFormat)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Section("Action") {
|
|
||||||
if profile.type != .remote {
|
#if os(iOS) || os(tvOS)
|
||||||
#if os(iOS) || os(macOS)
|
ProfileActionToolbar(profile: profile, viewModel: viewModel)
|
||||||
FormNavigationLink {
|
#endif
|
||||||
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(macOS)
|
||||||
|
.safeAreaInset(edge: .bottom) {
|
||||||
|
ProfileActionToolbar(profile: profile, viewModel: viewModel)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
.onChangeCompat(of: profile.name) {
|
.onChangeCompat(of: profile.name) {
|
||||||
viewModel.markAsChanged()
|
viewModel.markAsChanged()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,14 +8,20 @@ public struct NewProfileView: View {
|
|||||||
@EnvironmentObject private var environments: ExtensionEnvironments
|
@EnvironmentObject private var environments: ExtensionEnvironments
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@StateObject private var viewModel: NewProfileViewModel
|
@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 name: String
|
||||||
public let url: 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))
|
_viewModel = StateObject(wrappedValue: NewProfileViewModel(importRequest: importRequest))
|
||||||
|
self.onSuccess = onSuccess
|
||||||
}
|
}
|
||||||
|
|
||||||
public var body: some View {
|
public var body: some View {
|
||||||
@@ -86,23 +92,55 @@ public struct NewProfileView: View {
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Section {
|
#if os(iOS) || os(tvOS)
|
||||||
if !viewModel.isSaving {
|
Section {
|
||||||
FormButton {
|
if !viewModel.isSaving {
|
||||||
viewModel.isSaving = true
|
FormButton {
|
||||||
Task {
|
viewModel.isSaving = true
|
||||||
await viewModel.createProfile(environments: environments, dismiss: dismiss)
|
Task {
|
||||||
|
await viewModel.createProfile(
|
||||||
|
environments: environments,
|
||||||
|
dismiss: onSuccess == nil ? dismiss : nil,
|
||||||
|
onSuccess: onSuccess
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Label("Create", systemImage: "doc.fill.badge.plus")
|
||||||
}
|
}
|
||||||
} label: {
|
} else {
|
||||||
Label("Create", systemImage: "doc.fill.badge.plus")
|
ProgressView()
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
ProgressView()
|
|
||||||
}
|
}
|
||||||
}
|
#endif
|
||||||
}
|
}
|
||||||
.navigationTitle("New Profile")
|
.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)
|
#if os(iOS) || os(macOS)
|
||||||
.fileImporter(
|
.fileImporter(
|
||||||
isPresented: $viewModel.pickerPresented,
|
isPresented: $viewModel.pickerPresented,
|
||||||
|
|||||||
@@ -36,37 +36,52 @@ public final class NewProfileViewModel: ObservableObject {
|
|||||||
remotePath = ""
|
remotePath = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
public func createProfile(environments: ExtensionEnvironments, dismiss: DismissAction) async {
|
public func createProfile(
|
||||||
defer {
|
environments: ExtensionEnvironments,
|
||||||
isSaving = false
|
dismiss: DismissAction? = nil,
|
||||||
}
|
onSuccess: ((Profile) async -> Void)? = nil,
|
||||||
if profileName.isEmpty {
|
sendUpdateNotification: Bool = true
|
||||||
|
) async {
|
||||||
|
defer { isSaving = false }
|
||||||
|
|
||||||
|
guard !profileName.isEmpty else {
|
||||||
alert = Alert(errorMessage: String(localized: "Missing profile name"))
|
alert = Alert(errorMessage: String(localized: "Missing profile name"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if remotePath.isEmpty {
|
|
||||||
if profileType == .icloud {
|
if profileType == .icloud, remotePath.isEmpty {
|
||||||
alert = Alert(errorMessage: String(localized: "Missing path"))
|
alert = Alert(errorMessage: String(localized: "Missing path"))
|
||||||
return
|
return
|
||||||
} else if profileType == .remote {
|
|
||||||
alert = Alert(errorMessage: String(localized: "Missing URL"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if profileType == .remote, remotePath.isEmpty {
|
||||||
|
alert = Alert(errorMessage: String(localized: "Missing URL"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let createdProfile: Profile
|
||||||
do {
|
do {
|
||||||
try await createProfileBackground()
|
createdProfile = try await createProfileBackground()
|
||||||
} catch {
|
} catch {
|
||||||
alert = Alert(error)
|
alert = Alert(error)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
environments.profileUpdate.send()
|
|
||||||
dismiss()
|
if let onSuccess {
|
||||||
|
await onSuccess(createdProfile)
|
||||||
|
} else {
|
||||||
|
if sendUpdateNotification {
|
||||||
|
environments.profileUpdate.send()
|
||||||
|
}
|
||||||
|
dismiss?()
|
||||||
|
}
|
||||||
|
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
resetFields()
|
resetFields()
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
private nonisolated func createProfileBackground() async throws {
|
private nonisolated func createProfileBackground() async throws -> Profile {
|
||||||
let nextProfileID = try await ProfileManager.nextID()
|
let nextProfileID = try await ProfileManager.nextID()
|
||||||
|
|
||||||
var savePath = ""
|
var savePath = ""
|
||||||
@@ -130,7 +145,9 @@ public final class NewProfileViewModel: ObservableObject {
|
|||||||
remoteURL = remotePath
|
remoteURL = remotePath
|
||||||
lastUpdated = .now
|
lastUpdated = .now
|
||||||
}
|
}
|
||||||
try await ProfileManager.create(Profile(
|
|
||||||
|
// Create Profile object - GRDB will set its ID after insertion
|
||||||
|
let profile = Profile(
|
||||||
name: profileName,
|
name: profileName,
|
||||||
type: profileType,
|
type: profileType,
|
||||||
path: savePath,
|
path: savePath,
|
||||||
@@ -138,7 +155,9 @@ public final class NewProfileViewModel: ObservableObject {
|
|||||||
autoUpdate: autoUpdate,
|
autoUpdate: autoUpdate,
|
||||||
autoUpdateInterval: autoUpdateInterval,
|
autoUpdateInterval: autoUpdateInterval,
|
||||||
lastUpdated: lastUpdated
|
lastUpdated: lastUpdated
|
||||||
))
|
)
|
||||||
|
try await ProfileManager.create(profile)
|
||||||
|
|
||||||
if profileType == .remote {
|
if profileType == .remote {
|
||||||
#if os(iOS) || os(tvOS)
|
#if os(iOS) || os(tvOS)
|
||||||
try UIProfileUpdateTask.configure()
|
try UIProfileUpdateTask.configure()
|
||||||
@@ -146,5 +165,8 @@ public final class NewProfileViewModel: ObservableObject {
|
|||||||
try await ProfileUpdateTask.configure()
|
try await ProfileUpdateTask.configure()
|
||||||
#endif
|
#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 Libbox
|
||||||
import Library
|
import Library
|
||||||
import Network
|
import Network
|
||||||
import QRCode
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -172,17 +171,17 @@ public struct ProfileView: View {
|
|||||||
|
|
||||||
public var body: some View {
|
public var body: some View {
|
||||||
#if os(iOS) || os(macOS)
|
#if os(iOS) || os(macOS)
|
||||||
if #available(iOS 16.0, macOS 13.0,*) {
|
if #available(iOS 16.0, macOS 13.0, *) {
|
||||||
body0.draggable(profile.origin)
|
draggableBody.draggable(profile.origin)
|
||||||
} else {
|
} else {
|
||||||
body0
|
draggableBody
|
||||||
}
|
}
|
||||||
#else
|
#else
|
||||||
body0
|
draggableBody
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
private var body0: some View {
|
private var draggableBody: some View {
|
||||||
viewBuilder {
|
viewBuilder {
|
||||||
#if !os(macOS)
|
#if !os(macOS)
|
||||||
FormNavigationLink {
|
FormNavigationLink {
|
||||||
@@ -191,7 +190,7 @@ public struct ProfileView: View {
|
|||||||
Text(profile.name)
|
Text(profile.name)
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $shareLinkPresented) {
|
.sheet(isPresented: $shareLinkPresented) {
|
||||||
shareLinkView.padding()
|
QRCodeSheet(profileName: profile.name, remoteURL: profile.remoteURL!)
|
||||||
}
|
}
|
||||||
.contextMenu {
|
.contextMenu {
|
||||||
ProfileShareButton($viewModel.alert, profile.origin) {
|
ProfileShareButton($viewModel.alert, profile.origin) {
|
||||||
@@ -254,7 +253,7 @@ public struct ProfileView: View {
|
|||||||
}
|
}
|
||||||
.padding(.leading, 4)
|
.padding(.leading, 4)
|
||||||
.popover(isPresented: $shareLinkPresented, arrowEdge: .bottom) {
|
.popover(isPresented: $shareLinkPresented, arrowEdge: .bottom) {
|
||||||
shareLinkView
|
QRCodeContentView(profileName: profile.name, remoteURL: profile.remoteURL!)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ProfileShareButton($viewModel.alert, profile.origin) {
|
ProfileShareButton($viewModel.alert, profile.origin) {
|
||||||
@@ -279,41 +278,5 @@ public struct ProfileView: View {
|
|||||||
#endif
|
#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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -64,6 +64,7 @@ public class CommandClient: ObservableObject {
|
|||||||
@Published public var connectionStateFilter = ConnectionStateFilter.active
|
@Published public var connectionStateFilter = ConnectionStateFilter.active
|
||||||
@Published public var connectionSort = ConnectionSort.byDate
|
@Published public var connectionSort = ConnectionSort.byDate
|
||||||
@Published public var connections: [LibboxConnection]?
|
@Published public var connections: [LibboxConnection]?
|
||||||
|
@Published public var hasAnyConnection: Bool = false
|
||||||
public var rawConnections: LibboxConnections?
|
public var rawConnections: LibboxConnections?
|
||||||
|
|
||||||
// Batch processing for logs
|
// Batch processing for logs
|
||||||
@@ -92,7 +93,7 @@ public class CommandClient: ObservableObject {
|
|||||||
connectTask.cancel()
|
connectTask.cancel()
|
||||||
}
|
}
|
||||||
connectTask = Task {
|
connectTask = Task {
|
||||||
await connect0()
|
await performConnection()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,10 +126,13 @@ public class CommandClient: ObservableObject {
|
|||||||
guard let message = rawConnections else {
|
guard let message = rawConnections else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
connections = filterConnections(message)
|
let result = filterConnections(message)
|
||||||
|
connections = result.connections
|
||||||
|
hasAnyConnection = result.hasAny
|
||||||
}
|
}
|
||||||
|
|
||||||
private func filterConnections(_ message: LibboxConnections) -> [LibboxConnection] {
|
private func filterConnections(_ message: LibboxConnections) -> (connections: [LibboxConnection], hasAny: Bool) {
|
||||||
|
let hasAny = message.iterator()?.hasNext() ?? false
|
||||||
message.filterState(Int32(connectionStateFilter.rawValue))
|
message.filterState(Int32(connectionStateFilter.rawValue))
|
||||||
switch connectionSort {
|
switch connectionSort {
|
||||||
case .byDate:
|
case .byDate:
|
||||||
@@ -143,7 +147,7 @@ public class CommandClient: ObservableObject {
|
|||||||
while connectionIterator.hasNext() {
|
while connectionIterator.hasNext() {
|
||||||
connections.append(connectionIterator.next()!)
|
connections.append(connectionIterator.next()!)
|
||||||
}
|
}
|
||||||
return connections
|
return (connections: connections, hasAny: hasAny)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func initializeConnectionFilterState() async {
|
private func initializeConnectionFilterState() async {
|
||||||
@@ -155,7 +159,7 @@ public class CommandClient: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private nonisolated func connect0() async {
|
private nonisolated func performConnection() async {
|
||||||
if connectionTypes.contains(.connections) {
|
if connectionTypes.contains(.connections) {
|
||||||
await initializeConnectionFilterState()
|
await initializeConnectionFilterState()
|
||||||
}
|
}
|
||||||
@@ -299,10 +303,11 @@ public class CommandClient: ObservableObject {
|
|||||||
guard let message else {
|
guard let message else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let connections = commandClient.filterConnections(message)
|
let result = commandClient.filterConnections(message)
|
||||||
DispatchQueue.main.async { [self] in
|
DispatchQueue.main.async { [self] in
|
||||||
commandClient.rawConnections = message
|
commandClient.rawConnections = message
|
||||||
commandClient.connections = connections
|
commandClient.connections = result.connections
|
||||||
|
commandClient.hasAnyConnection = result.hasAny
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-12
@@ -529,7 +529,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Done" : {
|
"Done" : {
|
||||||
|
"localizations" : {
|
||||||
|
"zh-Hans" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "完成"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"Downlink" : {
|
"Downlink" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -611,17 +618,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Enabled" : {
|
|
||||||
"extractionState" : "stale",
|
|
||||||
"localizations" : {
|
|
||||||
"zh-Hans" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "启用"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"enforceRoutes" : {
|
"enforceRoutes" : {
|
||||||
"shouldTranslate" : false
|
"shouldTranslate" : false
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -45,8 +45,8 @@ public class MainViewModel: ObservableObject {
|
|||||||
if error != nil {
|
if error != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if selection != .profiles {
|
if selection != .dashboard {
|
||||||
selection = .profiles
|
selection = .dashboard
|
||||||
}
|
}
|
||||||
} else if url.pathExtension == "bpf" {
|
} else if url.pathExtension == "bpf" {
|
||||||
Task {
|
Task {
|
||||||
@@ -66,8 +66,8 @@ public class MainViewModel: ObservableObject {
|
|||||||
alert = Alert(error)
|
alert = Alert(error)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if selection != .profiles {
|
if selection != .dashboard {
|
||||||
selection = .profiles
|
selection = .dashboard
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+129
-52
@@ -12,57 +12,126 @@ struct MainView: View {
|
|||||||
@State private var importProfile: LibboxProfileContent?
|
@State private var importProfile: LibboxProfileContent?
|
||||||
@State private var importRemoteProfile: LibboxImportRemoteProfile?
|
@State private var importRemoteProfile: LibboxImportRemoteProfile?
|
||||||
@State private var alert: Alert?
|
@State private var alert: Alert?
|
||||||
|
@State private var showGroups = false
|
||||||
|
@State private var showConnections = false
|
||||||
|
@State private var buttonState = ButtonVisibilityState()
|
||||||
|
|
||||||
var body: some View {
|
private var shouldShowBottomAccessory: Bool {
|
||||||
if ApplicationLibrary.inPreview {
|
guard !environments.extensionProfileLoading else {
|
||||||
body1.preferredColorScheme(.dark)
|
return false
|
||||||
|
}
|
||||||
|
guard !environments.emptyProfiles else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
guard environments.extensionProfile != nil else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(iOS 26.0, *)
|
||||||
|
@ViewBuilder
|
||||||
|
private var tabViewContent: some View {
|
||||||
|
if shouldShowBottomAccessory {
|
||||||
|
TabView(selection: $selection) {
|
||||||
|
ForEach(NavigationPage.allCases, id: \.self) { page in
|
||||||
|
NavigationStackCompat {
|
||||||
|
page.contentView
|
||||||
|
.navigationTitle(page.title)
|
||||||
|
}
|
||||||
|
.tag(page)
|
||||||
|
.tabItem { page.label }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.tabViewBottomAccessory {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
if let profile = environments.extensionProfile {
|
||||||
|
StatusText(profile: profile)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
NavigationButtonsView(
|
||||||
|
showGroupsButton: buttonState.showGroupsButton,
|
||||||
|
showConnectionsButton: buttonState.showConnectionsButton,
|
||||||
|
groupsCount: buttonState.groupsCount,
|
||||||
|
connectionsCount: buttonState.connectionsCount,
|
||||||
|
onGroupsTap: { showGroups = true },
|
||||||
|
onConnectionsTap: { showConnections = true }
|
||||||
|
)
|
||||||
|
Divider()
|
||||||
|
StartStopButton()
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
body1
|
TabView(selection: $selection) {
|
||||||
|
ForEach(NavigationPage.allCases, id: \.self) { page in
|
||||||
|
NavigationStackCompat {
|
||||||
|
page.contentView
|
||||||
|
.navigationTitle(page.title)
|
||||||
|
}
|
||||||
|
.tag(page)
|
||||||
|
.tabItem { page.label }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var body1: some View {
|
var body: some View {
|
||||||
|
if ApplicationLibrary.inPreview {
|
||||||
|
mainBody.preferredColorScheme(.dark)
|
||||||
|
} else {
|
||||||
|
mainBody
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var mainBody: some View {
|
||||||
viewBuilder {
|
viewBuilder {
|
||||||
if #available(iOS 26.0, *), !Variant.debugNoIOS26 {
|
if #available(iOS 26.0, *), !Variant.debugNoIOS26 {
|
||||||
TabView(selection: $selection) {
|
tabViewContent
|
||||||
ForEach(NavigationPage.allCases, id: \.self) { page in
|
.onAppear {
|
||||||
NavigationStackCompat {
|
|
||||||
page.contentView
|
|
||||||
.navigationTitle(page.title)
|
|
||||||
}
|
|
||||||
.tag(page)
|
|
||||||
.tabItem { page.label }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.tabViewBottomAccessory {
|
|
||||||
HStack(spacing: 12) {
|
|
||||||
if let profile = environments.extensionProfile {
|
|
||||||
StatusText(profile: profile)
|
|
||||||
}
|
|
||||||
Spacer()
|
|
||||||
StartStopButton()
|
|
||||||
}
|
|
||||||
.padding(.horizontal)
|
|
||||||
}
|
|
||||||
.onAppear {
|
|
||||||
environments.postReload()
|
|
||||||
}
|
|
||||||
.alertBinding($alert)
|
|
||||||
.onChangeCompat(of: scenePhase) { newValue in
|
|
||||||
if newValue == .active {
|
|
||||||
environments.postReload()
|
environments.postReload()
|
||||||
|
updateButtonVisibility()
|
||||||
}
|
}
|
||||||
}
|
.alertBinding($alert)
|
||||||
.onChangeCompat(of: selection) { newValue in
|
.onChangeCompat(of: scenePhase) { newValue in
|
||||||
if newValue == .logs {
|
if newValue == .active {
|
||||||
environments.connect()
|
environments.postReload()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChangeCompat(of: selection) { newValue in
|
||||||
|
if newValue == .logs {
|
||||||
|
environments.connect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onReceive(environments.commandClient.$groups) { _ in
|
||||||
|
updateButtonVisibility()
|
||||||
|
}
|
||||||
|
.onReceive(environments.commandClient.$connections) { _ in
|
||||||
|
updateButtonVisibility()
|
||||||
|
}
|
||||||
|
.onReceive(environments.commandClient.$hasAnyConnection) { _ in
|
||||||
|
updateButtonVisibility()
|
||||||
|
}
|
||||||
|
.onReceive(NotificationCenter.default.publisher(for: .NEVPNStatusDidChange)) { _ in
|
||||||
|
updateButtonVisibility()
|
||||||
|
}
|
||||||
|
.onReceive(environments.$extensionProfile) { _ in
|
||||||
|
updateButtonVisibility()
|
||||||
|
}
|
||||||
|
.onReceive(environments.$emptyProfiles) { _ in
|
||||||
|
updateButtonVisibility()
|
||||||
|
}
|
||||||
|
.environment(\.selection, $selection)
|
||||||
|
.environment(\.importProfile, $importProfile)
|
||||||
|
.environment(\.importRemoteProfile, $importRemoteProfile)
|
||||||
|
.handlesExternalEvents(preferring: [], allowing: ["*"])
|
||||||
|
.onOpenURL(perform: openURL)
|
||||||
|
.sheet(isPresented: $showGroups) {
|
||||||
|
GroupsSheetContent()
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showConnections) {
|
||||||
|
ConnectionsSheetContent()
|
||||||
}
|
}
|
||||||
}
|
|
||||||
.environment(\.selection, $selection)
|
|
||||||
.environment(\.importProfile, $importProfile)
|
|
||||||
.environment(\.importRemoteProfile, $importRemoteProfile)
|
|
||||||
.handlesExternalEvents(preferring: [], allowing: ["*"])
|
|
||||||
.onOpenURL(perform: openURL)
|
|
||||||
} else {
|
} else {
|
||||||
TabView(selection: $selection) {
|
TabView(selection: $selection) {
|
||||||
ForEach(NavigationPage.allCases, id: \.self) { page in
|
ForEach(NavigationPage.allCases, id: \.self) { page in
|
||||||
@@ -97,6 +166,14 @@ struct MainView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func updateButtonVisibility() {
|
||||||
|
buttonState.update(
|
||||||
|
profile: environments.extensionProfile,
|
||||||
|
commandClient: environments.commandClient,
|
||||||
|
requireAnyConnection: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private struct StatusText: View {
|
private struct StatusText: View {
|
||||||
@ObservedObject var profile: ExtensionProfile
|
@ObservedObject var profile: ExtensionProfile
|
||||||
|
|
||||||
@@ -109,19 +186,19 @@ struct MainView: View {
|
|||||||
private var statusText: String {
|
private var statusText: String {
|
||||||
switch profile.status {
|
switch profile.status {
|
||||||
case .invalid:
|
case .invalid:
|
||||||
return "Invalid"
|
return String(localized: "Invalid")
|
||||||
case .disconnected:
|
case .disconnected:
|
||||||
return "Stopped"
|
return String(localized: "Stopped")
|
||||||
case .connecting:
|
case .connecting:
|
||||||
return "Starting"
|
return String(localized: "Starting")
|
||||||
case .connected:
|
case .connected:
|
||||||
return "Started"
|
return String(localized: "Started")
|
||||||
case .reasserting:
|
case .reasserting:
|
||||||
return "Reasserting"
|
return String(localized: "Reasserting")
|
||||||
case .disconnecting:
|
case .disconnecting:
|
||||||
return "Stopping"
|
return String(localized: "Stopping")
|
||||||
@unknown default:
|
@unknown default:
|
||||||
return "Unknown"
|
return String(localized: "Unknown")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -134,8 +211,8 @@ struct MainView: View {
|
|||||||
alert = Alert(error)
|
alert = Alert(error)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if selection != .profiles {
|
if selection != .dashboard {
|
||||||
selection = .profiles
|
selection = .dashboard
|
||||||
}
|
}
|
||||||
} else if url.pathExtension == "bpf" {
|
} else if url.pathExtension == "bpf" {
|
||||||
do {
|
do {
|
||||||
@@ -146,8 +223,8 @@ struct MainView: View {
|
|||||||
alert = Alert(error)
|
alert = Alert(error)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if selection != .profiles {
|
if selection != .dashboard {
|
||||||
selection = .profiles
|
selection = .dashboard
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
alert = Alert(errorMessage: String(localized: "Handled unknown URL \(url.absoluteString)"))
|
alert = Alert(errorMessage: String(localized: "Handled unknown URL \(url.absoluteString)"))
|
||||||
|
|||||||
Reference in New Issue
Block a user