Refactor Dashboard traffic cards with line charts
This commit is contained in:
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
"" : {
|
||||
"shouldTranslate" : false
|
||||
},
|
||||
"..." : {
|
||||
"shouldTranslate" : false
|
||||
},
|
||||
"**If I’ve 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
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user