Redesign dashboard
This commit is contained in:
@@ -74,6 +74,14 @@ public struct ActiveDashboardView: View {
|
|||||||
OverviewView($viewModel.profileList, $viewModel.selectedProfileID, $viewModel.systemProxyAvailable, $viewModel.systemProxyEnabled)
|
OverviewView($viewModel.profileList, $viewModel.selectedProfileID, $viewModel.systemProxyAvailable, $viewModel.systemProxyEnabled)
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
#if os(iOS) || os(tvOS)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
StartStopButton()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.modifier(DashboardMenuToolbarModifier(selection: viewModel.selection))
|
||||||
|
#endif
|
||||||
.onAppear {
|
.onAppear {
|
||||||
if ApplicationLibrary.inPreview {
|
if ApplicationLibrary.inPreview {
|
||||||
environments.commandClient.connect()
|
environments.commandClient.connect()
|
||||||
@@ -107,3 +115,23 @@ public struct ActiveDashboardView: View {
|
|||||||
.alertBinding($viewModel.alert)
|
.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
|
@Binding private var systemProxyEnabled: Bool
|
||||||
@StateObject private var viewModel = OverviewViewModel()
|
@StateObject private var viewModel = OverviewViewModel()
|
||||||
|
|
||||||
|
@State private var enabledCards: [DashboardCard] = []
|
||||||
|
@State private var cardOrder: [DashboardCard] = []
|
||||||
|
|
||||||
private var selectedProfileIDLocal: Binding<Int64> {
|
private var selectedProfileIDLocal: Binding<Int64> {
|
||||||
$selectedProfileID.withSetter { newValue in
|
$selectedProfileID.withSetter { newValue in
|
||||||
viewModel.reasserting = true
|
viewModel.reasserting = true
|
||||||
@@ -31,56 +34,115 @@ public struct OverviewView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public var body: some View {
|
public var body: some View {
|
||||||
VStack {
|
Group {
|
||||||
if ApplicationLibrary.inPreview || profile.status.isConnected {
|
|
||||||
ExtensionStatusView()
|
|
||||||
.environmentObject(environments.commandClient)
|
|
||||||
ClashModeView()
|
|
||||||
}
|
|
||||||
if profileList.isEmpty {
|
if profileList.isEmpty {
|
||||||
Text("Empty profiles")
|
VStack {
|
||||||
} else {
|
Spacer()
|
||||||
FormView {
|
Text("Empty profiles")
|
||||||
#if os(iOS) || os(tvOS)
|
.foregroundStyle(.secondary)
|
||||||
StartStopButton()
|
Spacer()
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
ScrollView {
|
||||||
|
cardGrid
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
Task {
|
||||||
|
enabledCards = await viewModel.loadEnabledCards()
|
||||||
|
cardOrder = await viewModel.loadCardOrder()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.alertBinding($viewModel.alert)
|
.alertBinding($viewModel.alert)
|
||||||
.disabled(!ApplicationLibrary.inPreview && (!profile.status.isSwitchable || viewModel.reasserting))
|
.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 {
|
public var body: some View {
|
||||||
viewBuilder {
|
viewBuilder {
|
||||||
if ApplicationLibrary.inPreview {
|
if ApplicationLibrary.inPreview {
|
||||||
#if os(iOS) || os(tvOS)
|
Button {} label: {
|
||||||
Toggle(isOn: .constant(true)) {
|
Label("Stop", systemImage: "stop.fill")
|
||||||
Text("Enabled")
|
}
|
||||||
}
|
.labelStyle(.iconOnly)
|
||||||
#elseif os(macOS)
|
|
||||||
Button {} label: {
|
|
||||||
Label("Stop", systemImage: "stop.fill")
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
} else if let profile = environments.extensionProfile {
|
} else if let profile = environments.extensionProfile {
|
||||||
Button0().environmentObject(profile)
|
Button0().environmentObject(profile)
|
||||||
} else {
|
} else {
|
||||||
#if os(iOS) || os(tvOS)
|
Button {} label: {
|
||||||
Toggle(isOn: .constant(false)) {
|
Label("Start", systemImage: "play.fill")
|
||||||
Text("Enabled")
|
}
|
||||||
}
|
.labelStyle(.iconOnly)
|
||||||
#elseif os(macOS)
|
.disabled(true)
|
||||||
Button {} label: {
|
|
||||||
Label("Start", systemImage: "play.fill")
|
|
||||||
}
|
|
||||||
.disabled(true)
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.disabled(environments.emptyProfiles)
|
.disabled(environments.emptyProfiles)
|
||||||
@@ -45,31 +34,18 @@ public struct StartStopButton: View {
|
|||||||
@State private var alert: Alert?
|
@State private var alert: Alert?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
viewBuilder {
|
Button {
|
||||||
#if os(iOS) || os(tvOS)
|
Task {
|
||||||
Toggle(isOn: Binding(get: {
|
await switchProfile(!profile.status.isConnected)
|
||||||
profile.status.isConnected
|
}
|
||||||
}, set: { newValue, _ in
|
} label: {
|
||||||
Task {
|
if !profile.status.isConnected {
|
||||||
await switchProfile(newValue)
|
Label("Start", systemImage: "play.fill")
|
||||||
}
|
} else {
|
||||||
})) {
|
Label("Stop", systemImage: "stop.fill")
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
.labelStyle(.iconOnly)
|
||||||
.disabled(!profile.status.isEnabled)
|
.disabled(!profile.status.isEnabled)
|
||||||
.alertBinding($alert)
|
.alertBinding($alert)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,6 +92,11 @@ public enum SharedPreferences {
|
|||||||
|
|
||||||
public static let disableDeprecatedWarnings = Preference<Bool>("disable_deprecated_warnings", defaultValue: false)
|
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
|
#if DEBUG
|
||||||
public static let inDebug = true
|
public static let inDebug = true
|
||||||
#else
|
#else
|
||||||
|
|||||||
@@ -225,6 +225,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Clash Mode" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Clear Logs" : {
|
"Clear Logs" : {
|
||||||
"comment" : "Clear all logs",
|
"comment" : "Clear all logs",
|
||||||
@@ -376,6 +379,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Dashboard Items" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Data Size" : {
|
"Data Size" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -589,6 +595,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Enabled" : {
|
"Enabled" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"zh-Hans" : {
|
"zh-Hans" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -1050,6 +1057,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Mode" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Name" : {
|
"Name" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -1337,6 +1347,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Reset to Default" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Resume" : {
|
"Resume" : {
|
||||||
"comment" : "Resume log auto-scroll"
|
"comment" : "Resume log auto-scroll"
|
||||||
@@ -1566,6 +1579,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"System HTTP Proxy" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"System: " : {
|
"System: " : {
|
||||||
"shouldTranslate" : false
|
"shouldTranslate" : false
|
||||||
|
|||||||
@@ -30,6 +30,11 @@ public struct MainView: View {
|
|||||||
ToolbarItem(placement: .navigation) {
|
ToolbarItem(placement: .navigation) {
|
||||||
StartStopButton()
|
StartStopButton()
|
||||||
}
|
}
|
||||||
|
if viewModel.selection == .dashboard {
|
||||||
|
ToolbarItem(placement: .automatic) {
|
||||||
|
DashboardMenu()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.onChangeCompat(of: controlActiveState) { newValue in
|
.onChangeCompat(of: controlActiveState) { newValue in
|
||||||
viewModel.onControlActiveStateChange(newValue, environments: environments)
|
viewModel.onControlActiveStateChange(newValue, environments: environments)
|
||||||
|
|||||||
Reference in New Issue
Block a user