diff --git a/ApplicationLibrary/Views/Dashboard/Cards/ClashModeCard.swift b/ApplicationLibrary/Views/Dashboard/Cards/ClashModeCard.swift index a105171..f1c1c69 100644 --- a/ApplicationLibrary/Views/Dashboard/Cards/ClashModeCard.swift +++ b/ApplicationLibrary/Views/Dashboard/Cards/ClashModeCard.swift @@ -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 diff --git a/ApplicationLibrary/Views/Dashboard/Cards/ConnectionsCard.swift b/ApplicationLibrary/Views/Dashboard/Cards/ConnectionsCard.swift index 245801d..9b41c2e 100644 --- a/ApplicationLibrary/Views/Dashboard/Cards/ConnectionsCard.swift +++ b/ApplicationLibrary/Views/Dashboard/Cards/ConnectionsCard.swift @@ -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") diff --git a/ApplicationLibrary/Views/Dashboard/Cards/DashboardCard.swift b/ApplicationLibrary/Views/Dashboard/Cards/DashboardCard.swift index ddfe48a..5d5e083 100644 --- a/ApplicationLibrary/Views/Dashboard/Cards/DashboardCard.swift +++ b/ApplicationLibrary/Views/Dashboard/Cards/DashboardCard.swift @@ -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] } } diff --git a/ApplicationLibrary/Views/Dashboard/Cards/DashboardCardConfiguration.swift b/ApplicationLibrary/Views/Dashboard/Cards/DashboardCardConfiguration.swift index 0977fd7..82d5a4d 100644 --- a/ApplicationLibrary/Views/Dashboard/Cards/DashboardCardConfiguration.swift +++ b/ApplicationLibrary/Views/Dashboard/Cards/DashboardCardConfiguration.swift @@ -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)) } diff --git a/ApplicationLibrary/Views/Dashboard/Cards/DashboardCardHeader.swift b/ApplicationLibrary/Views/Dashboard/Cards/DashboardCardHeader.swift new file mode 100644 index 0000000..5c8e7a7 --- /dev/null +++ b/ApplicationLibrary/Views/Dashboard/Cards/DashboardCardHeader.swift @@ -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) + } + } +} diff --git a/ApplicationLibrary/Views/Dashboard/Cards/DownloadTrafficCard.swift b/ApplicationLibrary/Views/Dashboard/Cards/DownloadTrafficCard.swift new file mode 100644 index 0000000..b1b9aec --- /dev/null +++ b/ApplicationLibrary/Views/Dashboard/Cards/DownloadTrafficCard.swift @@ -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 + ) + } + } + } +} diff --git a/ApplicationLibrary/Views/Dashboard/Cards/HTTPProxyCard.swift b/ApplicationLibrary/Views/Dashboard/Cards/HTTPProxyCard.swift index 68d5b46..46fc105 100644 --- a/ApplicationLibrary/Views/Dashboard/Cards/HTTPProxyCard.swift +++ b/ApplicationLibrary/Views/Dashboard/Cards/HTTPProxyCard.swift @@ -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() diff --git a/ApplicationLibrary/Views/Dashboard/Cards/ProfileCard.swift b/ApplicationLibrary/Views/Dashboard/Cards/ProfileCard.swift index 6099d7c..af988ab 100644 --- a/ApplicationLibrary/Views/Dashboard/Cards/ProfileCard.swift +++ b/ApplicationLibrary/Views/Dashboard/Cards/ProfileCard.swift @@ -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() diff --git a/ApplicationLibrary/Views/Dashboard/Cards/StatusCard.swift b/ApplicationLibrary/Views/Dashboard/Cards/StatusCard.swift index 6280cdb..40abf71 100644 --- a/ApplicationLibrary/Views/Dashboard/Cards/StatusCard.swift +++ b/ApplicationLibrary/Views/Dashboard/Cards/StatusCard.swift @@ -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") diff --git a/ApplicationLibrary/Views/Dashboard/Cards/TrafficCard.swift b/ApplicationLibrary/Views/Dashboard/Cards/TrafficCard.swift deleted file mode 100644 index 364ab45..0000000 --- a/ApplicationLibrary/Views/Dashboard/Cards/TrafficCard.swift +++ /dev/null @@ -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"), "...") - } - } - } - } -} diff --git a/ApplicationLibrary/Views/Dashboard/Cards/TrafficLineChart.swift b/ApplicationLibrary/Views/Dashboard/Cards/TrafficLineChart.swift new file mode 100644 index 0000000..2472331 --- /dev/null +++ b/ApplicationLibrary/Views/Dashboard/Cards/TrafficLineChart.swift @@ -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))) + } +} diff --git a/ApplicationLibrary/Views/Dashboard/Cards/TrafficTotalCard.swift b/ApplicationLibrary/Views/Dashboard/Cards/TrafficTotalCard.swift deleted file mode 100644 index 13ae22f..0000000 --- a/ApplicationLibrary/Views/Dashboard/Cards/TrafficTotalCard.swift +++ /dev/null @@ -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"), "...") - } - } - } - } -} diff --git a/ApplicationLibrary/Views/Dashboard/Cards/UploadTrafficCard.swift b/ApplicationLibrary/Views/Dashboard/Cards/UploadTrafficCard.swift new file mode 100644 index 0000000..75bfeee --- /dev/null +++ b/ApplicationLibrary/Views/Dashboard/Cards/UploadTrafficCard.swift @@ -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 + ) + } + } + } +} diff --git a/ApplicationLibrary/Views/Dashboard/Overview/OverviewView.swift b/ApplicationLibrary/Views/Dashboard/Overview/OverviewView.swift index 2bbb365..830df67 100644 --- a/ApplicationLibrary/Views/Dashboard/Overview/OverviewView.swift +++ b/ApplicationLibrary/Views/Dashboard/Overview/OverviewView.swift @@ -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( diff --git a/ApplicationLibrary/Views/NavigationPage.swift b/ApplicationLibrary/Views/NavigationPage.swift index 1de0ed3..697381e 100644 --- a/ApplicationLibrary/Views/NavigationPage.swift +++ b/ApplicationLibrary/Views/NavigationPage.swift @@ -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" } diff --git a/Library/Network/CommandClient.swift b/Library/Network/CommandClient.swift index adba3d7..4fe0427 100644 --- a/Library/Network/CommandClient.swift +++ b/Library/Network/CommandClient.swift @@ -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)) + } } } diff --git a/Localizable.xcstrings b/Localizable.xcstrings index 8ded7ba..95e87be 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -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 },