From 68b414234096a86e5bbe1e7a500acb68ee0cf73a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Thu, 16 Oct 2025 21:01:00 +0800 Subject: [PATCH] Redesign dashboard --- .../Views/Dashboard/ActiveDashboardView.swift | 28 ++++ .../Views/Dashboard/Cards/ClashModeCard.swift | 52 ++++++ .../Dashboard/Cards/ConnectionsCard.swift | 47 ++++++ .../Views/Dashboard/Cards/DashboardCard.swift | 69 ++++++++ .../Dashboard/Cards/DashboardCardView.swift | 65 ++++++++ .../Views/Dashboard/Cards/HTTPProxyCard.swift | 30 ++++ .../Views/Dashboard/Cards/ProfileCard.swift | 51 ++++++ .../Views/Dashboard/Cards/StatusCard.swift | 47 ++++++ .../Views/Dashboard/Cards/TrafficCard.swift | 47 ++++++ .../Dashboard/Cards/TrafficTotalCard.swift | 47 ++++++ .../Views/Dashboard/DashboardMenu.swift | 71 ++++++++ .../Views/Dashboard/OverviewView.swift | 152 ++++++++++++------ .../Dashboard/OverviewViewModel+Cards.swift | 52 ++++++ .../Views/Dashboard/StartStopButton.swift | 64 +++----- Library/Database/SharedPreferences.swift | 5 + Localizable.xcstrings | 16 ++ MacLibrary/MainView.swift | 5 + 17 files changed, 759 insertions(+), 89 deletions(-) create mode 100644 ApplicationLibrary/Views/Dashboard/Cards/ClashModeCard.swift create mode 100644 ApplicationLibrary/Views/Dashboard/Cards/ConnectionsCard.swift create mode 100644 ApplicationLibrary/Views/Dashboard/Cards/DashboardCard.swift create mode 100644 ApplicationLibrary/Views/Dashboard/Cards/DashboardCardView.swift create mode 100644 ApplicationLibrary/Views/Dashboard/Cards/HTTPProxyCard.swift create mode 100644 ApplicationLibrary/Views/Dashboard/Cards/ProfileCard.swift create mode 100644 ApplicationLibrary/Views/Dashboard/Cards/StatusCard.swift create mode 100644 ApplicationLibrary/Views/Dashboard/Cards/TrafficCard.swift create mode 100644 ApplicationLibrary/Views/Dashboard/Cards/TrafficTotalCard.swift create mode 100644 ApplicationLibrary/Views/Dashboard/DashboardMenu.swift create mode 100644 ApplicationLibrary/Views/Dashboard/OverviewViewModel+Cards.swift diff --git a/ApplicationLibrary/Views/Dashboard/ActiveDashboardView.swift b/ApplicationLibrary/Views/Dashboard/ActiveDashboardView.swift index 8221c7b..2a4c913 100644 --- a/ApplicationLibrary/Views/Dashboard/ActiveDashboardView.swift +++ b/ApplicationLibrary/Views/Dashboard/ActiveDashboardView.swift @@ -74,6 +74,14 @@ public struct ActiveDashboardView: View { OverviewView($viewModel.profileList, $viewModel.selectedProfileID, $viewModel.systemProxyAvailable, $viewModel.systemProxyEnabled) #endif } + #if os(iOS) || os(tvOS) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + StartStopButton() + } + } + .modifier(DashboardMenuToolbarModifier(selection: viewModel.selection)) + #endif .onAppear { if ApplicationLibrary.inPreview { environments.commandClient.connect() @@ -107,3 +115,23 @@ public struct ActiveDashboardView: View { .alertBinding($viewModel.alert) } } + +#if os(iOS) || os(tvOS) + private struct DashboardMenuToolbarModifier: ViewModifier { + let selection: DashboardPage + + func body(content: Content) -> some View { + if #available(iOS 16.0, tvOS 17.0, *) { + content.toolbar { + if selection == .overview { + ToolbarItem(placement: .topBarTrailing) { + DashboardMenu() + } + } + } + } else { + content + } + } + } +#endif diff --git a/ApplicationLibrary/Views/Dashboard/Cards/ClashModeCard.swift b/ApplicationLibrary/Views/Dashboard/Cards/ClashModeCard.swift new file mode 100644 index 0000000..3554a47 --- /dev/null +++ b/ApplicationLibrary/Views/Dashboard/Cards/ClashModeCard.swift @@ -0,0 +1,52 @@ +import Libbox +import Library +import SwiftUI + +public struct ClashModeCard: View { + @EnvironmentObject private var commandClient: CommandClient + @State private var clashMode: String = "" + @State private var alert: Alert? + + public init() {} + + public var body: some View { + if shouldShowPicker { + DashboardCardView(title: "Mode", isHalfWidth: false) { + Picker("", selection: Binding(get: { + clashMode + }, set: { newMode in + clashMode = newMode + Task { + await setClashMode(newMode) + } + })) { + ForEach(commandClient.clashModeList, id: \.self) { mode in + Text(mode) + } + } + .pickerStyle(.segmented) + } + .onAppear { + clashMode = commandClient.clashMode + } + .onChangeCompat(of: commandClient.clashMode) { newValue in + clashMode = newValue + } + .alertBinding($alert) + } + } + + private var shouldShowPicker: Bool { + commandClient.clashModeList.count > 1 + } + + private nonisolated func setClashMode(_ newMode: String) async { + do { + try LibboxNewStandaloneCommandClient()!.setClashMode(newMode) + } catch { + await MainActor.run { + alert = Alert(error) + } + } + } +} diff --git a/ApplicationLibrary/Views/Dashboard/Cards/ConnectionsCard.swift b/ApplicationLibrary/Views/Dashboard/Cards/ConnectionsCard.swift new file mode 100644 index 0000000..5798492 --- /dev/null +++ b/ApplicationLibrary/Views/Dashboard/Cards/ConnectionsCard.swift @@ -0,0 +1,47 @@ +import Libbox +import Library +import SwiftUI + +public struct ConnectionsCard: View { + @EnvironmentObject private var commandClient: CommandClient + + public init() {} + + public var body: some View { + DashboardCardView(title: "Connections", isHalfWidth: true) { + VStack(alignment: .leading, spacing: 8) { + if ApplicationLibrary.inPreview { + CardLine(String(localized: "Inbound"), "34") + CardLine(String(localized: "Outbound"), "28") + } else if let message = commandClient.status { + CardLine(String(localized: "Inbound"), "\(message.connectionsIn)") + CardLine(String(localized: "Outbound"), "\(message.connectionsOut)") + } else { + CardLine(String(localized: "Inbound"), "...") + CardLine(String(localized: "Outbound"), "...") + } + } + } + } +} + +private struct CardLine: View { + private let name: String + private let value: String + + init(_ name: String, _ value: String) { + self.name = name + self.value = value + } + + var body: some View { + HStack { + Text(name) + .font(.subheadline) + .foregroundColor(.secondary) + Spacer() + Text(value) + .font(.subheadline) + } + } +} diff --git a/ApplicationLibrary/Views/Dashboard/Cards/DashboardCard.swift b/ApplicationLibrary/Views/Dashboard/Cards/DashboardCard.swift new file mode 100644 index 0000000..ddfe48a --- /dev/null +++ b/ApplicationLibrary/Views/Dashboard/Cards/DashboardCard.swift @@ -0,0 +1,69 @@ +import Foundation +import SwiftUI + +public enum DashboardCard: String, CaseIterable, Identifiable, Codable, Hashable { + case status + case connections + case traffic + case trafficTotal + case httpProxy + case clashMode + case profile + + public var id: String { rawValue } + + public var title: LocalizedStringKey { + switch self { + case .status: + return "Status" + case .connections: + return "Connections" + case .traffic: + return "Traffic" + case .trafficTotal: + return "Traffic Total" + case .httpProxy: + return "HTTP Proxy" + case .clashMode: + return "Clash Mode" + case .profile: + return "Profile" + } + } + + public var systemImage: String { + switch self { + case .status: + return "info.circle.fill" + case .connections: + return "link.circle.fill" + case .traffic: + return "arrow.up.arrow.down.circle.fill" + case .trafficTotal: + return "chart.bar.fill" + case .httpProxy: + return "network" + case .clashMode: + return "circle.grid.2x2.fill" + case .profile: + return "person.crop.circle.fill" + } + } + + public var isHalfWidth: Bool { + switch self { + case .status, .connections, .traffic, .trafficTotal: + return true + case .httpProxy, .clashMode, .profile: + return false + } + } + + public static var defaultCards: [DashboardCard] { + allCases + } + + public static var defaultOrder: [DashboardCard] { + [.status, .connections, .traffic, .trafficTotal, .httpProxy, .clashMode, .profile] + } +} diff --git a/ApplicationLibrary/Views/Dashboard/Cards/DashboardCardView.swift b/ApplicationLibrary/Views/Dashboard/Cards/DashboardCardView.swift new file mode 100644 index 0000000..7da35e5 --- /dev/null +++ b/ApplicationLibrary/Views/Dashboard/Cards/DashboardCardView.swift @@ -0,0 +1,65 @@ +import SwiftUI + +public struct DashboardCardView: View { + @Environment(\.colorScheme) private var colorScheme + + private let title: String + private let isHalfWidth: Bool + @ViewBuilder private let content: () -> Content + + public init(title: String, isHalfWidth: Bool = false, @ViewBuilder content: @escaping () -> Content) { + self.title = title + self.isHalfWidth = isHalfWidth + self.content = content + } + + public var body: some View { + VStack(alignment: .leading, spacing: title.isEmpty ? 0 : 12) { + if !title.isEmpty { + Text(title) + .font(.headline) + .foregroundStyle(.primary) + } + content() + } + .frame(maxWidth: .infinity, alignment: .leading) + #if os(tvOS) + .padding(EdgeInsets(top: 20, leading: 26, bottom: 20, trailing: 26)) + #else + .padding(EdgeInsets(top: 16, leading: 16, bottom: 16, trailing: 16)) + #endif + .modifier(CardStyleModifier(colorScheme: colorScheme)) + } +} + +private struct CardStyleModifier: ViewModifier { + let colorScheme: ColorScheme + + func body(content: Content) -> some View { + if #available(iOS 26.0, macOS 26.0, tvOS 26.0, *) { + content + .glassEffect(.regular.interactive(), in: .rect(cornerRadius: 16)) + } else { + content + .background(backgroundColor) + .cornerRadius(16) + } + } + + private var backgroundColor: Color { + #if os(iOS) + return Color(uiColor: .secondarySystemGroupedBackground) + #elseif os(macOS) + return Color(nsColor: .textBackgroundColor) + #elseif os(tvOS) + switch colorScheme { + case .dark: + return Color(uiColor: .black) + default: + return Color(uiColor: .white) + } + #else + return Color.clear + #endif + } +} diff --git a/ApplicationLibrary/Views/Dashboard/Cards/HTTPProxyCard.swift b/ApplicationLibrary/Views/Dashboard/Cards/HTTPProxyCard.swift new file mode 100644 index 0000000..e5a0974 --- /dev/null +++ b/ApplicationLibrary/Views/Dashboard/Cards/HTTPProxyCard.swift @@ -0,0 +1,30 @@ +import Library +import SwiftUI + +public struct HTTPProxyCard: View { + @EnvironmentObject private var profile: ExtensionProfile + @Binding private var systemProxyAvailable: Bool + @Binding private var systemProxyEnabled: Bool + private let onToggle: (Bool) async -> Void + + public init( + systemProxyAvailable: Binding, + systemProxyEnabled: Binding, + onToggle: @escaping (Bool) async -> Void + ) { + _systemProxyAvailable = systemProxyAvailable + _systemProxyEnabled = systemProxyEnabled + self.onToggle = onToggle + } + + public var body: some View { + DashboardCardView(title: "", isHalfWidth: false) { + Toggle("System HTTP Proxy", isOn: $systemProxyEnabled) + .onChangeCompat(of: systemProxyEnabled) { newValue in + Task { + await onToggle(newValue) + } + } + } + } +} diff --git a/ApplicationLibrary/Views/Dashboard/Cards/ProfileCard.swift b/ApplicationLibrary/Views/Dashboard/Cards/ProfileCard.swift new file mode 100644 index 0000000..f68e263 --- /dev/null +++ b/ApplicationLibrary/Views/Dashboard/Cards/ProfileCard.swift @@ -0,0 +1,51 @@ +import Library +import SwiftUI + +public struct ProfileCard: View { + @Binding private var profileList: [ProfilePreview] + @Binding private var selectedProfileID: Int64 + + public init( + profileList: Binding<[ProfilePreview]>, + selectedProfileID: Binding + ) { + _profileList = profileList + _selectedProfileID = selectedProfileID + } + + public var body: some View { + DashboardCardView(title: "Profile", isHalfWidth: false) { + VStack(alignment: .leading, spacing: 12) { + #if os(iOS) || os(tvOS) + Picker("", selection: $selectedProfileID) { + ForEach(profileList, id: \.id) { profile in + Text(profile.name).tag(profile.id) + } + } + .pickerStyle(.menu) + .labelsHidden() + #elseif os(macOS) + VStack(alignment: .leading, spacing: 8) { + ForEach(profileList, id: \.id) { profile in + HStack { + Button { + selectedProfileID = profile.id + } label: { + HStack(spacing: 8) { + Image(systemName: selectedProfileID == profile.id ? "circle.fill" : "circle") + .font(.system(size: 12)) + Text(profile.name) + .font(.subheadline) + Spacer() + } + } + .buttonStyle(.plain) + .foregroundColor(selectedProfileID == profile.id ? .accentColor : .primary) + } + } + } + #endif + } + } + } +} diff --git a/ApplicationLibrary/Views/Dashboard/Cards/StatusCard.swift b/ApplicationLibrary/Views/Dashboard/Cards/StatusCard.swift new file mode 100644 index 0000000..4100ad4 --- /dev/null +++ b/ApplicationLibrary/Views/Dashboard/Cards/StatusCard.swift @@ -0,0 +1,47 @@ +import Libbox +import Library +import SwiftUI + +public struct StatusCard: View { + @EnvironmentObject private var commandClient: CommandClient + + public init() {} + + public var body: some View { + DashboardCardView(title: "Status", isHalfWidth: true) { + VStack(alignment: .leading, spacing: 8) { + if ApplicationLibrary.inPreview { + CardLine(String(localized: "Memory"), "6.4 MB") + CardLine(String(localized: "Goroutines"), "89") + } else if let message = commandClient.status { + CardLine(String(localized: "Memory"), LibboxFormatMemoryBytes(message.memory)) + CardLine(String(localized: "Goroutines"), "\(message.goroutines)") + } else { + CardLine(String(localized: "Memory"), "...") + CardLine(String(localized: "Goroutines"), "...") + } + } + } + } +} + +private struct CardLine: View { + private let name: String + private let value: String + + init(_ name: String, _ value: String) { + self.name = name + self.value = value + } + + var body: some View { + HStack { + Text(name) + .font(.subheadline) + .foregroundColor(.secondary) + Spacer() + Text(value) + .font(.subheadline) + } + } +} diff --git a/ApplicationLibrary/Views/Dashboard/Cards/TrafficCard.swift b/ApplicationLibrary/Views/Dashboard/Cards/TrafficCard.swift new file mode 100644 index 0000000..2d86902 --- /dev/null +++ b/ApplicationLibrary/Views/Dashboard/Cards/TrafficCard.swift @@ -0,0 +1,47 @@ +import Libbox +import Library +import SwiftUI + +public struct TrafficCard: View { + @EnvironmentObject private var commandClient: CommandClient + + public init() {} + + public var body: some View { + DashboardCardView(title: "Traffic", isHalfWidth: true) { + VStack(alignment: .leading, spacing: 8) { + if ApplicationLibrary.inPreview { + CardLine(String(localized: "Uplink"), "38 B/s") + CardLine(String(localized: "Downlink"), "249 MB/s") + } else if let message = commandClient.status, message.trafficAvailable { + CardLine(String(localized: "Uplink"), "\(LibboxFormatBytes(message.uplink))/s") + CardLine(String(localized: "Downlink"), "\(LibboxFormatBytes(message.downlink))/s") + } else { + CardLine(String(localized: "Uplink"), "...") + CardLine(String(localized: "Downlink"), "...") + } + } + } + } +} + +private struct CardLine: View { + private let name: String + private let value: String + + init(_ name: String, _ value: String) { + self.name = name + self.value = value + } + + var body: some View { + HStack { + Text(name) + .font(.subheadline) + .foregroundColor(.secondary) + Spacer() + Text(value) + .font(.subheadline) + } + } +} diff --git a/ApplicationLibrary/Views/Dashboard/Cards/TrafficTotalCard.swift b/ApplicationLibrary/Views/Dashboard/Cards/TrafficTotalCard.swift new file mode 100644 index 0000000..7561ae4 --- /dev/null +++ b/ApplicationLibrary/Views/Dashboard/Cards/TrafficTotalCard.swift @@ -0,0 +1,47 @@ +import Libbox +import Library +import SwiftUI + +public struct TrafficTotalCard: View { + @EnvironmentObject private var commandClient: CommandClient + + public init() {} + + public var body: some View { + DashboardCardView(title: "Traffic Total", isHalfWidth: true) { + VStack(alignment: .leading, spacing: 8) { + if ApplicationLibrary.inPreview { + CardLine(String(localized: "Uplink"), "52 MB") + CardLine(String(localized: "Downlink"), "5.6 GB") + } else if let message = commandClient.status, message.trafficAvailable { + CardLine(String(localized: "Uplink"), LibboxFormatBytes(message.uplinkTotal)) + CardLine(String(localized: "Downlink"), LibboxFormatBytes(message.downlinkTotal)) + } else { + CardLine(String(localized: "Uplink"), "...") + CardLine(String(localized: "Downlink"), "...") + } + } + } + } +} + +private struct CardLine: View { + private let name: String + private let value: String + + init(_ name: String, _ value: String) { + self.name = name + self.value = value + } + + var body: some View { + HStack { + Text(name) + .font(.subheadline) + .foregroundColor(.secondary) + Spacer() + Text(value) + .font(.subheadline) + } + } +} diff --git a/ApplicationLibrary/Views/Dashboard/DashboardMenu.swift b/ApplicationLibrary/Views/Dashboard/DashboardMenu.swift new file mode 100644 index 0000000..78aa0b1 --- /dev/null +++ b/ApplicationLibrary/Views/Dashboard/DashboardMenu.swift @@ -0,0 +1,71 @@ +import Foundation +import Library +import SwiftUI + +@MainActor +public struct DashboardMenu: View { + @StateObject private var viewModel = DashboardMenuViewModel() + + public init() {} + + public var body: some View { + Menu { + ForEach(DashboardCard.allCases) { card in + Toggle(isOn: Binding( + get: { viewModel.enabledCards.contains(card) }, + set: { _ in + Task { + await viewModel.toggleCard(card) + } + } + )) { + Label(card.title, systemImage: card.systemImage) + } + } + + Divider() + + Button("Reset to Default") { + Task { + await viewModel.resetToDefault() + } + } + } label: { + Label("Dashboard Items", systemImage: "square.grid.2x2") + } + .onAppear { + Task { + await viewModel.loadCards() + } + } + } +} + +@MainActor +private final class DashboardMenuViewModel: ObservableObject { + @Published var enabledCards: [DashboardCard] = [] + + func loadCards() async { + let savedCards = await SharedPreferences.enabledDashboardCards.get() + if savedCards.isEmpty { + enabledCards = DashboardCard.defaultCards + } else { + enabledCards = savedCards.compactMap { DashboardCard(rawValue: $0) } + } + } + + func toggleCard(_ card: DashboardCard) async { + if enabledCards.contains(card) { + enabledCards.removeAll { $0 == card } + } else { + enabledCards.append(card) + } + await SharedPreferences.enabledDashboardCards.set(enabledCards.map(\.rawValue)) + } + + func resetToDefault() async { + await SharedPreferences.enabledDashboardCards.set([]) + await SharedPreferences.dashboardCardOrder.set([]) + enabledCards = DashboardCard.defaultCards + } +} diff --git a/ApplicationLibrary/Views/Dashboard/OverviewView.swift b/ApplicationLibrary/Views/Dashboard/OverviewView.swift index e626f36..eed0783 100644 --- a/ApplicationLibrary/Views/Dashboard/OverviewView.swift +++ b/ApplicationLibrary/Views/Dashboard/OverviewView.swift @@ -14,6 +14,9 @@ public struct OverviewView: View { @Binding private var systemProxyEnabled: Bool @StateObject private var viewModel = OverviewViewModel() + @State private var enabledCards: [DashboardCard] = [] + @State private var cardOrder: [DashboardCard] = [] + private var selectedProfileIDLocal: Binding { $selectedProfileID.withSetter { newValue in viewModel.reasserting = true @@ -31,56 +34,115 @@ public struct OverviewView: View { } public var body: some View { - VStack { - if ApplicationLibrary.inPreview || profile.status.isConnected { - ExtensionStatusView() - .environmentObject(environments.commandClient) - ClashModeView() - } + Group { if profileList.isEmpty { - Text("Empty profiles") - } else { - FormView { - #if os(iOS) || os(tvOS) - StartStopButton() - if ApplicationLibrary.inPreview || profile.status.isConnectedStrict, systemProxyAvailable { - Toggle("HTTP Proxy", isOn: $systemProxyEnabled) - .onChangeCompat(of: systemProxyEnabled) { newValue in - Task { - await viewModel.setSystemProxyEnabled(newValue, profile: profile) - } - } - } - Section("Profile") { - Picker(selection: selectedProfileIDLocal) { - ForEach(profileList, id: \.id) { profile in - Text(profile.name).tag(profile.id) - } - } label: {} - .pickerStyle(.inline) - } - #elseif os(macOS) - if ApplicationLibrary.inPreview || profile.status.isConnectedStrict, systemProxyAvailable { - Toggle("HTTP Proxy", isOn: $systemProxyEnabled) - .onChangeCompat(of: systemProxyEnabled) { newValue in - Task { - await viewModel.setSystemProxyEnabled(newValue, profile: profile) - } - } - } - Section("Profile") { - ForEach(profileList, id: \.id) { profile in - Picker(profile.name, selection: selectedProfileIDLocal) { - Text("").tag(profile.id) - } - } - .pickerStyle(.radioGroup) - } - #endif + VStack { + Spacer() + Text("Empty profiles") + .foregroundStyle(.secondary) + Spacer() } + } else { + ScrollView { + cardGrid + .padding() + } + } + } + .onAppear { + Task { + enabledCards = await viewModel.loadEnabledCards() + cardOrder = await viewModel.loadCardOrder() } } .alertBinding($viewModel.alert) .disabled(!ApplicationLibrary.inPreview && (!profile.status.isSwitchable || viewModel.reasserting)) } + + @ViewBuilder + private var cardGrid: some View { + let orderedCards = viewModel.getOrderedEnabledCards(enabledCards: enabledCards, order: cardOrder) + let visibleCards = orderedCards.filter { shouldShowCard($0) } + let groupedCards = groupCards(visibleCards) + + VStack(spacing: 16) { + ForEach(Array(groupedCards.enumerated()), id: \.offset) { _, group in + if group.count == 2 { + HStack(spacing: 16) { + cardView(for: group[0]) + .frame(maxWidth: .infinity) + cardView(for: group[1]) + .frame(maxWidth: .infinity) + } + } else { + cardView(for: group[0]) + } + } + } + } + + private func groupCards(_ cards: [DashboardCard]) -> [[DashboardCard]] { + var result: [[DashboardCard]] = [] + var index = 0 + + while index < cards.count { + let card = cards[index] + + if card.isHalfWidth, index + 1 < cards.count, cards[index + 1].isHalfWidth { + result.append([card, cards[index + 1]]) + index += 2 + } else { + result.append([card]) + index += 1 + } + } + + return result + } + + private func shouldShowCard(_ card: DashboardCard) -> Bool { + switch card { + case .status, .connections, .traffic, .trafficTotal: + return ApplicationLibrary.inPreview || profile.status.isConnected + case .httpProxy: + return (ApplicationLibrary.inPreview || profile.status.isConnectedStrict) && systemProxyAvailable + case .clashMode: + return ApplicationLibrary.inPreview || profile.status.isConnected + case .profile: + return true + } + } + + @ViewBuilder + private func cardView(for card: DashboardCard) -> some View { + switch card { + case .status: + StatusCard() + .environmentObject(environments.commandClient) + case .connections: + ConnectionsCard() + .environmentObject(environments.commandClient) + case .traffic: + TrafficCard() + .environmentObject(environments.commandClient) + case .trafficTotal: + TrafficTotalCard() + .environmentObject(environments.commandClient) + case .httpProxy: + HTTPProxyCard( + systemProxyAvailable: $systemProxyAvailable, + systemProxyEnabled: $systemProxyEnabled + ) { newValue in + await viewModel.setSystemProxyEnabled(newValue, profile: profile) + } + case .clashMode: + ClashModeCard() + .environmentObject(environments.commandClient) + case .profile: + ProfileCard( + profileList: $profileList, + selectedProfileID: selectedProfileIDLocal + ) + } + } } diff --git a/ApplicationLibrary/Views/Dashboard/OverviewViewModel+Cards.swift b/ApplicationLibrary/Views/Dashboard/OverviewViewModel+Cards.swift new file mode 100644 index 0000000..305398d --- /dev/null +++ b/ApplicationLibrary/Views/Dashboard/OverviewViewModel+Cards.swift @@ -0,0 +1,52 @@ +import Foundation +import Library + +extension OverviewViewModel { + func loadEnabledCards() async -> [DashboardCard] { + let savedCards = await SharedPreferences.enabledDashboardCards.get() + if savedCards.isEmpty { + return DashboardCard.defaultCards + } + return savedCards.compactMap { DashboardCard(rawValue: $0) } + } + + func loadCardOrder() async -> [DashboardCard] { + let savedOrder = await SharedPreferences.dashboardCardOrder.get() + if savedOrder.isEmpty { + return DashboardCard.defaultOrder + } + return savedOrder.compactMap { DashboardCard(rawValue: $0) } + } + + func saveEnabledCards(_ cards: [DashboardCard]) async { + await SharedPreferences.enabledDashboardCards.set(cards.map(\.rawValue)) + } + + func saveCardOrder(_ cards: [DashboardCard]) async { + await SharedPreferences.dashboardCardOrder.set(cards.map(\.rawValue)) + } + + func isCardEnabled(_ card: DashboardCard, in enabledCards: [DashboardCard]) -> Bool { + enabledCards.contains(card) + } + + func toggleCard(_ card: DashboardCard, enabledCards: [DashboardCard]) async -> [DashboardCard] { + var newEnabledCards = enabledCards + if newEnabledCards.contains(card) { + newEnabledCards.removeAll { $0 == card } + } else { + newEnabledCards.append(card) + } + await saveEnabledCards(newEnabledCards) + return newEnabledCards + } + + func resetCardsToDefault() async { + await SharedPreferences.enabledDashboardCards.set([]) + await SharedPreferences.dashboardCardOrder.set([]) + } + + func getOrderedEnabledCards(enabledCards: [DashboardCard], order: [DashboardCard]) -> [DashboardCard] { + order.filter { enabledCards.contains($0) } + } +} diff --git a/ApplicationLibrary/Views/Dashboard/StartStopButton.swift b/ApplicationLibrary/Views/Dashboard/StartStopButton.swift index 69a0dc1..9a51b92 100644 --- a/ApplicationLibrary/Views/Dashboard/StartStopButton.swift +++ b/ApplicationLibrary/Views/Dashboard/StartStopButton.swift @@ -11,29 +11,18 @@ public struct StartStopButton: View { public var body: some View { viewBuilder { if ApplicationLibrary.inPreview { - #if os(iOS) || os(tvOS) - Toggle(isOn: .constant(true)) { - Text("Enabled") - } - #elseif os(macOS) - Button {} label: { - Label("Stop", systemImage: "stop.fill") - } - #endif - + Button {} label: { + Label("Stop", systemImage: "stop.fill") + } + .labelStyle(.iconOnly) } else if let profile = environments.extensionProfile { Button0().environmentObject(profile) } else { - #if os(iOS) || os(tvOS) - Toggle(isOn: .constant(false)) { - Text("Enabled") - } - #elseif os(macOS) - Button {} label: { - Label("Start", systemImage: "play.fill") - } - .disabled(true) - #endif + Button {} label: { + Label("Start", systemImage: "play.fill") + } + .labelStyle(.iconOnly) + .disabled(true) } } .disabled(environments.emptyProfiles) @@ -45,31 +34,18 @@ public struct StartStopButton: View { @State private var alert: Alert? var body: some View { - viewBuilder { - #if os(iOS) || os(tvOS) - Toggle(isOn: Binding(get: { - profile.status.isConnected - }, set: { newValue, _ in - Task { - await switchProfile(newValue) - } - })) { - Text("Enabled") - } - #elseif os(macOS) - Button { - Task { - await switchProfile(!profile.status.isConnected) - } - } label: { - if !profile.status.isConnected { - Label("Start", systemImage: "play.fill") - } else { - Label("Stop", systemImage: "stop.fill") - } - } - #endif + Button { + Task { + await switchProfile(!profile.status.isConnected) + } + } label: { + if !profile.status.isConnected { + Label("Start", systemImage: "play.fill") + } else { + Label("Stop", systemImage: "stop.fill") + } } + .labelStyle(.iconOnly) .disabled(!profile.status.isEnabled) .alertBinding($alert) } diff --git a/Library/Database/SharedPreferences.swift b/Library/Database/SharedPreferences.swift index feffbb4..ecf39f5 100644 --- a/Library/Database/SharedPreferences.swift +++ b/Library/Database/SharedPreferences.swift @@ -92,6 +92,11 @@ public enum SharedPreferences { public static let disableDeprecatedWarnings = Preference("disable_deprecated_warnings", defaultValue: false) + // Dashboard + + public static let enabledDashboardCards = Preference<[String]>("enabled_dashboard_cards", defaultValue: []) + public static let dashboardCardOrder = Preference<[String]>("dashboard_card_order", defaultValue: []) + #if DEBUG public static let inDebug = true #else diff --git a/Localizable.xcstrings b/Localizable.xcstrings index 9fd7fcd..a474f02 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -225,6 +225,9 @@ } } } + }, + "Clash Mode" : { + }, "Clear Logs" : { "comment" : "Clear all logs", @@ -376,6 +379,9 @@ } } } + }, + "Dashboard Items" : { + }, "Data Size" : { "localizations" : { @@ -589,6 +595,7 @@ } }, "Enabled" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -1050,6 +1057,9 @@ } } } + }, + "Mode" : { + }, "Name" : { "localizations" : { @@ -1337,6 +1347,9 @@ } } } + }, + "Reset to Default" : { + }, "Resume" : { "comment" : "Resume log auto-scroll" @@ -1566,6 +1579,9 @@ } } } + }, + "System HTTP Proxy" : { + }, "System: " : { "shouldTranslate" : false diff --git a/MacLibrary/MainView.swift b/MacLibrary/MainView.swift index 885ac7b..4dd2cb3 100644 --- a/MacLibrary/MainView.swift +++ b/MacLibrary/MainView.swift @@ -30,6 +30,11 @@ public struct MainView: View { ToolbarItem(placement: .navigation) { StartStopButton() } + if viewModel.selection == .dashboard { + ToolbarItem(placement: .automatic) { + DashboardMenu() + } + } } .onChangeCompat(of: controlActiveState) { newValue in viewModel.onControlActiveStateChange(newValue, environments: environments)