Redesign dashboard

This commit is contained in:
世界
2025-11-26 22:25:51 +08:00
parent b7a810b168
commit 68b4142340
17 changed files with 759 additions and 89 deletions
@@ -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
@@ -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)
}
}
}
}
@@ -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)
}
}
}
@@ -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]
}
}
@@ -0,0 +1,65 @@
import SwiftUI
public struct DashboardCardView<Content: View>: 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
}
}
@@ -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<Bool>,
systemProxyEnabled: Binding<Bool>,
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)
}
}
}
}
}
@@ -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<Int64>
) {
_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
}
}
}
}
@@ -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)
}
}
}
@@ -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)
}
}
}
@@ -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)
}
}
}
@@ -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
}
}
@@ -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<Int64> {
$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
)
}
}
}
@@ -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) }
}
}
@@ -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)
}
+5
View File
@@ -92,6 +92,11 @@ public enum SharedPreferences {
public static let disableDeprecatedWarnings = Preference<Bool>("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
+16
View File
@@ -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
+5
View File
@@ -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)