Refactor Dashboard traffic cards with line charts

This commit is contained in:
世界
2025-12-06 21:19:37 +08:00
parent 52efbd97d2
commit 0e206cc452
17 changed files with 299 additions and 93 deletions
@@ -11,20 +11,23 @@ public struct ClashModeCard: View {
public var body: some View { public var body: some View {
if shouldShowPicker { if shouldShowPicker {
DashboardCardView(title: String(localized: "Mode"), isHalfWidth: false) { DashboardCardView(title: "", isHalfWidth: false) {
Picker("", selection: Binding(get: { VStack(alignment: .leading, spacing: 12) {
clashMode DashboardCardHeader(icon: "circle.grid.2x2.fill", title: "Mode")
}, set: { newMode in Picker("", selection: Binding(get: {
clashMode = newMode clashMode
Task { }, set: { newMode in
await setClashMode(newMode) clashMode = newMode
} Task {
})) { await setClashMode(newMode)
ForEach(commandClient.clashModeList, id: \.self) { mode in }
Text(mode) })) {
ForEach(commandClient.clashModeList, id: \.self) { mode in
Text(mode)
}
} }
.pickerStyle(.segmented)
} }
.pickerStyle(.segmented)
} }
.onAppear { .onAppear {
clashMode = commandClient.clashMode clashMode = commandClient.clashMode
@@ -8,8 +8,9 @@ public struct ConnectionsCard: View {
public init() {} public init() {}
public var body: some View { public var body: some View {
DashboardCardView(title: String(localized: "Connections"), isHalfWidth: true) { DashboardCardView(title: "", isHalfWidth: true) {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
DashboardCardHeader(icon: "link.circle.fill", title: "Connections")
if ApplicationLibrary.inPreview { if ApplicationLibrary.inPreview {
DashboardCardLine(String(localized: "Inbound"), "34") DashboardCardLine(String(localized: "Inbound"), "34")
DashboardCardLine(String(localized: "Outbound"), "28") DashboardCardLine(String(localized: "Outbound"), "28")
@@ -4,8 +4,8 @@ import SwiftUI
public enum DashboardCard: String, CaseIterable, Identifiable, Codable, Hashable { public enum DashboardCard: String, CaseIterable, Identifiable, Codable, Hashable {
case status case status
case connections case connections
case traffic case uploadTraffic
case trafficTotal case downloadTraffic
case httpProxy case httpProxy
case clashMode case clashMode
case profile case profile
@@ -18,12 +18,12 @@ public enum DashboardCard: String, CaseIterable, Identifiable, Codable, Hashable
return "Status" return "Status"
case .connections: case .connections:
return "Connections" return "Connections"
case .traffic: case .uploadTraffic:
return "Traffic" return "Upload"
case .trafficTotal: case .downloadTraffic:
return "Traffic Total" return "Download"
case .httpProxy: case .httpProxy:
return "HTTP Proxy" return "System HTTP Proxy"
case .clashMode: case .clashMode:
return "Clash Mode" return "Clash Mode"
case .profile: case .profile:
@@ -37,22 +37,22 @@ public enum DashboardCard: String, CaseIterable, Identifiable, Codable, Hashable
return "info.circle.fill" return "info.circle.fill"
case .connections: case .connections:
return "link.circle.fill" return "link.circle.fill"
case .traffic: case .uploadTraffic:
return "arrow.up.arrow.down.circle.fill" return "arrow.up.circle.fill"
case .trafficTotal: case .downloadTraffic:
return "chart.bar.fill" return "arrow.down.circle.fill"
case .httpProxy: case .httpProxy:
return "network" return "network"
case .clashMode: case .clashMode:
return "circle.grid.2x2.fill" return "circle.grid.2x2.fill"
case .profile: case .profile:
return "person.crop.circle.fill" return "doc.text.fill"
} }
} }
public var isHalfWidth: Bool { public var isHalfWidth: Bool {
switch self { switch self {
case .status, .connections, .traffic, .trafficTotal: case .status, .connections, .uploadTraffic, .downloadTraffic:
return true return true
case .httpProxy, .clashMode, .profile: case .httpProxy, .clashMode, .profile:
return false return false
@@ -64,6 +64,6 @@ public enum DashboardCard: String, CaseIterable, Identifiable, Codable, Hashable
} }
public static var defaultOrder: [DashboardCard] { public static var defaultOrder: [DashboardCard] {
[.status, .connections, .traffic, .trafficTotal, .httpProxy, .clashMode, .profile] [.uploadTraffic, .downloadTraffic, .status, .connections, .httpProxy, .clashMode, .profile]
} }
} }
@@ -59,11 +59,11 @@ public final class DashboardCardConfiguration: ObservableObject {
let saved = await SharedPreferences.enabledDashboardCards.get() let saved = await SharedPreferences.enabledDashboardCards.get()
guard !saved.isEmpty else { return DashboardCard.defaultCards } guard !saved.isEmpty else { return DashboardCard.defaultCards }
var cards = saved.compactMap { DashboardCard(rawValue: $0) } var cards = saved.compactMap { migrateCardName($0) }.compactMap { DashboardCard(rawValue: $0) }
if !cards.contains(.profile) { if !cards.contains(.profile) {
cards.append(.profile) cards.append(.profile)
await SharedPreferences.enabledDashboardCards.set(cards.map(\.rawValue))
} }
await SharedPreferences.enabledDashboardCards.set(cards.map(\.rawValue))
return cards return cards
} }
@@ -71,13 +71,25 @@ public final class DashboardCardConfiguration: ObservableObject {
let saved = await SharedPreferences.dashboardCardOrder.get() let saved = await SharedPreferences.dashboardCardOrder.get()
guard !saved.isEmpty else { return DashboardCard.defaultOrder } guard !saved.isEmpty else { return DashboardCard.defaultOrder }
var order = saved.compactMap { DashboardCard(rawValue: $0) } var order = saved.compactMap { migrateCardName($0) }.compactMap { DashboardCard(rawValue: $0) }
let existingSet = Set(order) let existingSet = Set(order)
let newCards = DashboardCard.allCases.filter { !existingSet.contains($0) } let newCards = DashboardCard.allCases.filter { !existingSet.contains($0) }
order.append(contentsOf: newCards) order.append(contentsOf: newCards)
await SharedPreferences.dashboardCardOrder.set(order.map(\.rawValue))
return order return order
} }
private func migrateCardName(_ name: String) -> String {
switch name {
case "traffic":
return "uploadTraffic"
case "trafficTotal":
return "downloadTraffic"
default:
return name
}
}
private func saveEnabledCards() async { private func saveEnabledCards() async {
await SharedPreferences.enabledDashboardCards.set(enabledCards.map(\.rawValue)) await SharedPreferences.enabledDashboardCards.set(enabledCards.map(\.rawValue))
} }
@@ -0,0 +1,21 @@
import SwiftUI
public struct DashboardCardHeader: View {
private let icon: String
private let title: LocalizedStringKey
public init(icon: String, title: LocalizedStringKey) {
self.icon = icon
self.title = title
}
public var body: some View {
HStack(spacing: 8) {
Image(systemName: icon)
.foregroundStyle(.primary)
Text(title)
.font(.headline)
.fontWeight(.bold)
}
}
}
@@ -0,0 +1,45 @@
import Libbox
import Library
import SwiftUI
public struct DownloadTrafficCard: View {
@EnvironmentObject private var commandClient: CommandClient
public init() {}
public var body: some View {
DashboardCardView(title: "", isHalfWidth: true) {
VStack(alignment: .leading, spacing: 8) {
DashboardCardHeader(icon: "arrow.down.circle.fill", title: "Download")
if ApplicationLibrary.inPreview {
Text("249 MB/s")
.font(.title2)
.fontWeight(.medium)
Text("5.6 GB")
.font(.subheadline)
.foregroundStyle(.secondary)
} else if let message = commandClient.status, message.trafficAvailable {
Text("\(LibboxFormatBytes(message.downlink))/s")
.font(.title2)
.fontWeight(.medium)
Text(LibboxFormatBytes(message.downlinkTotal))
.font(.subheadline)
.foregroundStyle(.secondary)
} else {
Text("...")
.font(.title2)
.fontWeight(.medium)
Text("...")
.font(.subheadline)
.foregroundStyle(.secondary)
}
TrafficLineChart(
data: commandClient.downlinkHistory,
lineColor: .primary
)
}
}
}
}
@@ -20,7 +20,7 @@ public struct HTTPProxyCard: View {
public var body: some View { public var body: some View {
DashboardCardView(title: "", isHalfWidth: false) { DashboardCardView(title: "", isHalfWidth: false) {
HStack { HStack {
Text("System HTTP Proxy") DashboardCardHeader(icon: "network", title: "System HTTP Proxy")
Spacer() Spacer()
Toggle("", isOn: $systemProxyEnabled) Toggle("", isOn: $systemProxyEnabled)
.labelsHidden() .labelsHidden()
@@ -104,9 +104,7 @@ public struct ProfileCard: View {
private var headerView: some View { private var headerView: some View {
HStack { HStack {
Text("Profile") DashboardCardHeader(icon: "doc.text.fill", title: "Profile")
.font(.headline)
.foregroundColor(.primary)
Spacer() Spacer()
@@ -8,8 +8,9 @@ public struct StatusCard: View {
public init() {} public init() {}
public var body: some View { public var body: some View {
DashboardCardView(title: String(localized: "Status"), isHalfWidth: true) { DashboardCardView(title: "", isHalfWidth: true) {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
DashboardCardHeader(icon: "info.circle.fill", title: "Status")
if ApplicationLibrary.inPreview { if ApplicationLibrary.inPreview {
DashboardCardLine(String(localized: "Memory"), "6.4 MB") DashboardCardLine(String(localized: "Memory"), "6.4 MB")
DashboardCardLine(String(localized: "Goroutines"), "89") DashboardCardLine(String(localized: "Goroutines"), "89")
@@ -1,26 +0,0 @@
import Libbox
import Library
import SwiftUI
public struct TrafficCard: View {
@EnvironmentObject private var commandClient: CommandClient
public init() {}
public var body: some View {
DashboardCardView(title: String(localized: "Traffic"), isHalfWidth: true) {
VStack(alignment: .leading, spacing: 8) {
if ApplicationLibrary.inPreview {
DashboardCardLine(String(localized: "Uplink"), "38 B/s")
DashboardCardLine(String(localized: "Downlink"), "249 MB/s")
} else if let message = commandClient.status, message.trafficAvailable {
DashboardCardLine(String(localized: "Uplink"), "\(LibboxFormatBytes(message.uplink))/s")
DashboardCardLine(String(localized: "Downlink"), "\(LibboxFormatBytes(message.downlink))/s")
} else {
DashboardCardLine(String(localized: "Uplink"), "...")
DashboardCardLine(String(localized: "Downlink"), "...")
}
}
}
}
}
@@ -0,0 +1,84 @@
import SwiftUI
public struct TrafficLineChart: View {
private let data: [CGFloat]
private let lineColor: Color
private let gridColor: Color
private let chartHeight: CGFloat
public init(
data: [CGFloat],
lineColor: Color = .primary,
gridColor: Color = Color.secondary.opacity(0.3),
chartHeight: CGFloat = 60
) {
self.data = data
self.lineColor = lineColor
self.gridColor = gridColor
self.chartHeight = chartHeight
}
public var body: some View {
Canvas { context, size in
let width = size.width
let height = size.height
let maxValue = max((data.max() ?? 1) * 1.2, 1)
let pointCount = data.count
drawGrid(context: context, width: width, height: height)
guard pointCount > 1 else { return }
let spacing = width / CGFloat(pointCount - 1)
let points = data.enumerated().map { index, value in
let x = CGFloat(index) * spacing
let normalizedValue = min(max(value / maxValue, 0), 1)
let y = height * (1 - normalizedValue)
return CGPoint(x: x, y: y)
}
drawLine(context: context, points: points, height: height)
}
.frame(height: chartHeight)
}
private func drawGrid(context: GraphicsContext, width: CGFloat, height: CGFloat) {
let gridLineCount = 3
for i in 0 ... gridLineCount {
let y = height * CGFloat(i) / CGFloat(gridLineCount)
var path = Path()
path.move(to: CGPoint(x: 0, y: y))
path.addLine(to: CGPoint(x: width, y: y))
context.stroke(
path,
with: .color(gridColor),
style: StrokeStyle(lineWidth: 1, dash: [5, 5])
)
}
}
private func drawLine(context: GraphicsContext, points: [CGPoint], height: CGFloat) {
guard !points.isEmpty else { return }
var linePath = Path()
linePath.move(to: points[0])
for point in points.dropFirst() {
linePath.addLine(to: point)
}
context.stroke(
linePath,
with: .color(lineColor),
style: StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round)
)
var fillPath = linePath
if let lastPoint = points.last {
fillPath.addLine(to: CGPoint(x: lastPoint.x, y: height))
fillPath.addLine(to: CGPoint(x: 0, y: height))
fillPath.addLine(to: points[0])
}
context.fill(fillPath, with: .color(lineColor.opacity(0.1)))
}
}
@@ -1,26 +0,0 @@
import Libbox
import Library
import SwiftUI
public struct TrafficTotalCard: View {
@EnvironmentObject private var commandClient: CommandClient
public init() {}
public var body: some View {
DashboardCardView(title: String(localized: "Traffic Total"), isHalfWidth: true) {
VStack(alignment: .leading, spacing: 8) {
if ApplicationLibrary.inPreview {
DashboardCardLine(String(localized: "Uplink"), "52 MB")
DashboardCardLine(String(localized: "Downlink"), "5.6 GB")
} else if let message = commandClient.status, message.trafficAvailable {
DashboardCardLine(String(localized: "Uplink"), LibboxFormatBytes(message.uplinkTotal))
DashboardCardLine(String(localized: "Downlink"), LibboxFormatBytes(message.downlinkTotal))
} else {
DashboardCardLine(String(localized: "Uplink"), "...")
DashboardCardLine(String(localized: "Downlink"), "...")
}
}
}
}
}
@@ -0,0 +1,45 @@
import Libbox
import Library
import SwiftUI
public struct UploadTrafficCard: View {
@EnvironmentObject private var commandClient: CommandClient
public init() {}
public var body: some View {
DashboardCardView(title: "", isHalfWidth: true) {
VStack(alignment: .leading, spacing: 8) {
DashboardCardHeader(icon: "arrow.up.circle.fill", title: "Upload")
if ApplicationLibrary.inPreview {
Text("38 B/s")
.font(.title2)
.fontWeight(.medium)
Text("52 MB")
.font(.subheadline)
.foregroundStyle(.secondary)
} else if let message = commandClient.status, message.trafficAvailable {
Text("\(LibboxFormatBytes(message.uplink))/s")
.font(.title2)
.fontWeight(.medium)
Text(LibboxFormatBytes(message.uplinkTotal))
.font(.subheadline)
.foregroundStyle(.secondary)
} else {
Text("...")
.font(.title2)
.fontWeight(.medium)
Text("...")
.font(.subheadline)
.foregroundStyle(.secondary)
}
TrafficLineChart(
data: commandClient.uplinkHistory,
lineColor: .primary
)
}
}
}
}
@@ -84,7 +84,7 @@ public struct OverviewView: View {
private func shouldShowCard(_ card: DashboardCard) -> Bool { private func shouldShowCard(_ card: DashboardCard) -> Bool {
switch card { switch card {
case .status, .connections, .traffic, .trafficTotal, .clashMode: case .status, .connections, .uploadTraffic, .downloadTraffic, .clashMode:
return ApplicationLibrary.inPreview || profile.status.isConnected return ApplicationLibrary.inPreview || profile.status.isConnected
case .httpProxy: case .httpProxy:
return (ApplicationLibrary.inPreview || profile.status.isConnectedStrict) && systemProxyAvailable return (ApplicationLibrary.inPreview || profile.status.isConnectedStrict) && systemProxyAvailable
@@ -102,11 +102,11 @@ public struct OverviewView: View {
case .connections: case .connections:
ConnectionsCard() ConnectionsCard()
.environmentObject(environments.commandClient) .environmentObject(environments.commandClient)
case .traffic: case .uploadTraffic:
TrafficCard() UploadTrafficCard()
.environmentObject(environments.commandClient) .environmentObject(environments.commandClient)
case .trafficTotal: case .downloadTraffic:
TrafficTotalCard() DownloadTrafficCard()
.environmentObject(environments.commandClient) .environmentObject(environments.commandClient)
case .httpProxy: case .httpProxy:
HTTPProxyCard( HTTPProxyCard(
@@ -56,7 +56,7 @@ public extension NavigationPage {
return "list.bullet.rectangle.portrait.fill" return "list.bullet.rectangle.portrait.fill"
#endif #endif
case .logs: case .logs:
return "doc.text.fill" return "list.bullet.rectangle"
case .settings: case .settings:
return "gear.circle.fill" return "gear.circle.fill"
} }
+9
View File
@@ -67,6 +67,9 @@ public class CommandClient: ObservableObject {
@Published public var hasAnyConnection: Bool = false @Published public var hasAnyConnection: Bool = false
public var rawConnections: LibboxConnections? public var rawConnections: LibboxConnections?
@Published public var uplinkHistory: [CGFloat] = Array(repeating: 0, count: 30)
@Published public var downlinkHistory: [CGFloat] = Array(repeating: 0, count: 30)
// Batch processing for logs // Batch processing for logs
private var pendingLogs: [LogEntry] = [] private var pendingLogs: [LogEntry] = []
private var logBatchTimer: DispatchWorkItem? private var logBatchTimer: DispatchWorkItem?
@@ -270,6 +273,12 @@ public class CommandClient: ObservableObject {
func writeStatus(_ message: LibboxStatusMessage?) { func writeStatus(_ message: LibboxStatusMessage?) {
DispatchQueue.main.async { [self] in DispatchQueue.main.async { [self] in
commandClient.status = message commandClient.status = message
if let message = message, message.trafficAvailable {
commandClient.uplinkHistory.removeFirst()
commandClient.uplinkHistory.append(CGFloat(message.uplink))
commandClient.downlinkHistory.removeFirst()
commandClient.downlinkHistory.append(CGFloat(message.downlink))
}
} }
} }
+39
View File
@@ -4,6 +4,9 @@
"" : { "" : {
"shouldTranslate" : false "shouldTranslate" : false
}, },
"..." : {
"shouldTranslate" : false
},
"**If Ive defended your modern life, please consider sponsoring me.**" : { "**If Ive defended your modern life, please consider sponsoring me.**" : {
"localizations" : { "localizations" : {
"zh-Hans" : { "zh-Hans" : {
@@ -25,6 +28,9 @@
}, },
"shouldTranslate" : false "shouldTranslate" : false
}, },
"%@/s" : {
"shouldTranslate" : false
},
"%lld" : { "%lld" : {
"shouldTranslate" : false "shouldTranslate" : false
}, },
@@ -50,6 +56,18 @@
"↓ %@/s" : { "↓ %@/s" : {
"shouldTranslate" : false "shouldTranslate" : false
}, },
"5.6 GB" : {
"shouldTranslate" : false
},
"38 B/s" : {
"shouldTranslate" : false
},
"52 MB" : {
"shouldTranslate" : false
},
"249 MB/s" : {
"shouldTranslate" : false
},
"About" : { "About" : {
"localizations" : { "localizations" : {
"zh-Hans" : { "zh-Hans" : {
@@ -609,6 +627,16 @@
} }
} }
}, },
"Download" : {
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "下载"
}
}
}
},
"Edit" : { "Edit" : {
"localizations" : { "localizations" : {
"zh-Hans" : { "zh-Hans" : {
@@ -846,6 +874,7 @@
} }
}, },
"HTTP Proxy" : { "HTTP Proxy" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"zh-Hans" : { "zh-Hans" : {
"stringUnit" : { "stringUnit" : {
@@ -1976,6 +2005,16 @@
} }
} }
}, },
"Upload" : {
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "上传"
}
}
}
},
"URL" : { "URL" : {
"shouldTranslate" : false "shouldTranslate" : false
}, },