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 {
if shouldShowPicker {
DashboardCardView(title: String(localized: "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)
DashboardCardView(title: "", isHalfWidth: false) {
VStack(alignment: .leading, spacing: 12) {
DashboardCardHeader(icon: "circle.grid.2x2.fill", title: "Mode")
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)
}
.pickerStyle(.segmented)
}
.onAppear {
clashMode = commandClient.clashMode
@@ -8,8 +8,9 @@ public struct ConnectionsCard: View {
public init() {}
public var body: some View {
DashboardCardView(title: String(localized: "Connections"), isHalfWidth: true) {
DashboardCardView(title: "", isHalfWidth: true) {
VStack(alignment: .leading, spacing: 8) {
DashboardCardHeader(icon: "link.circle.fill", title: "Connections")
if ApplicationLibrary.inPreview {
DashboardCardLine(String(localized: "Inbound"), "34")
DashboardCardLine(String(localized: "Outbound"), "28")
@@ -4,8 +4,8 @@ import SwiftUI
public enum DashboardCard: String, CaseIterable, Identifiable, Codable, Hashable {
case status
case connections
case traffic
case trafficTotal
case uploadTraffic
case downloadTraffic
case httpProxy
case clashMode
case profile
@@ -18,12 +18,12 @@ public enum DashboardCard: String, CaseIterable, Identifiable, Codable, Hashable
return "Status"
case .connections:
return "Connections"
case .traffic:
return "Traffic"
case .trafficTotal:
return "Traffic Total"
case .uploadTraffic:
return "Upload"
case .downloadTraffic:
return "Download"
case .httpProxy:
return "HTTP Proxy"
return "System HTTP Proxy"
case .clashMode:
return "Clash Mode"
case .profile:
@@ -37,22 +37,22 @@ public enum DashboardCard: String, CaseIterable, Identifiable, Codable, Hashable
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 .uploadTraffic:
return "arrow.up.circle.fill"
case .downloadTraffic:
return "arrow.down.circle.fill"
case .httpProxy:
return "network"
case .clashMode:
return "circle.grid.2x2.fill"
case .profile:
return "person.crop.circle.fill"
return "doc.text.fill"
}
}
public var isHalfWidth: Bool {
switch self {
case .status, .connections, .traffic, .trafficTotal:
case .status, .connections, .uploadTraffic, .downloadTraffic:
return true
case .httpProxy, .clashMode, .profile:
return false
@@ -64,6 +64,6 @@ public enum DashboardCard: String, CaseIterable, Identifiable, Codable, Hashable
}
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()
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) {
cards.append(.profile)
await SharedPreferences.enabledDashboardCards.set(cards.map(\.rawValue))
}
await SharedPreferences.enabledDashboardCards.set(cards.map(\.rawValue))
return cards
}
@@ -71,13 +71,25 @@ public final class DashboardCardConfiguration: ObservableObject {
let saved = await SharedPreferences.dashboardCardOrder.get()
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 newCards = DashboardCard.allCases.filter { !existingSet.contains($0) }
order.append(contentsOf: newCards)
await SharedPreferences.dashboardCardOrder.set(order.map(\.rawValue))
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 {
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 {
DashboardCardView(title: "", isHalfWidth: false) {
HStack {
Text("System HTTP Proxy")
DashboardCardHeader(icon: "network", title: "System HTTP Proxy")
Spacer()
Toggle("", isOn: $systemProxyEnabled)
.labelsHidden()
@@ -104,9 +104,7 @@ public struct ProfileCard: View {
private var headerView: some View {
HStack {
Text("Profile")
.font(.headline)
.foregroundColor(.primary)
DashboardCardHeader(icon: "doc.text.fill", title: "Profile")
Spacer()
@@ -8,8 +8,9 @@ public struct StatusCard: View {
public init() {}
public var body: some View {
DashboardCardView(title: String(localized: "Status"), isHalfWidth: true) {
DashboardCardView(title: "", isHalfWidth: true) {
VStack(alignment: .leading, spacing: 8) {
DashboardCardHeader(icon: "info.circle.fill", title: "Status")
if ApplicationLibrary.inPreview {
DashboardCardLine(String(localized: "Memory"), "6.4 MB")
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 {
switch card {
case .status, .connections, .traffic, .trafficTotal, .clashMode:
case .status, .connections, .uploadTraffic, .downloadTraffic, .clashMode:
return ApplicationLibrary.inPreview || profile.status.isConnected
case .httpProxy:
return (ApplicationLibrary.inPreview || profile.status.isConnectedStrict) && systemProxyAvailable
@@ -102,11 +102,11 @@ public struct OverviewView: View {
case .connections:
ConnectionsCard()
.environmentObject(environments.commandClient)
case .traffic:
TrafficCard()
case .uploadTraffic:
UploadTrafficCard()
.environmentObject(environments.commandClient)
case .trafficTotal:
TrafficTotalCard()
case .downloadTraffic:
DownloadTrafficCard()
.environmentObject(environments.commandClient)
case .httpProxy:
HTTPProxyCard(
@@ -56,7 +56,7 @@ public extension NavigationPage {
return "list.bullet.rectangle.portrait.fill"
#endif
case .logs:
return "doc.text.fill"
return "list.bullet.rectangle"
case .settings:
return "gear.circle.fill"
}
+9
View File
@@ -67,6 +67,9 @@ public class CommandClient: ObservableObject {
@Published public var hasAnyConnection: Bool = false
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
private var pendingLogs: [LogEntry] = []
private var logBatchTimer: DispatchWorkItem?
@@ -270,6 +273,12 @@ public class CommandClient: ObservableObject {
func writeStatus(_ message: LibboxStatusMessage?) {
DispatchQueue.main.async { [self] in
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
},
"**If Ive defended your modern life, please consider sponsoring me.**" : {
"localizations" : {
"zh-Hans" : {
@@ -25,6 +28,9 @@
},
"shouldTranslate" : false
},
"%@/s" : {
"shouldTranslate" : false
},
"%lld" : {
"shouldTranslate" : false
},
@@ -50,6 +56,18 @@
"↓ %@/s" : {
"shouldTranslate" : false
},
"5.6 GB" : {
"shouldTranslate" : false
},
"38 B/s" : {
"shouldTranslate" : false
},
"52 MB" : {
"shouldTranslate" : false
},
"249 MB/s" : {
"shouldTranslate" : false
},
"About" : {
"localizations" : {
"zh-Hans" : {
@@ -609,6 +627,16 @@
}
}
},
"Download" : {
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "下载"
}
}
}
},
"Edit" : {
"localizations" : {
"zh-Hans" : {
@@ -846,6 +874,7 @@
}
},
"HTTP Proxy" : {
"extractionState" : "stale",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
@@ -1976,6 +2005,16 @@
}
}
},
"Upload" : {
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "上传"
}
}
}
},
"URL" : {
"shouldTranslate" : false
},