Improve Start/Stop button

This commit is contained in:
世界
2025-11-26 22:25:52 +08:00
parent 32f5bf1f3b
commit 4f0d1098e6
10 changed files with 176 additions and 45 deletions
@@ -2,7 +2,7 @@ import SwiftUI
public func NavigationStackCompat(@ViewBuilder content: () -> some View) -> some View { public func NavigationStackCompat(@ViewBuilder content: () -> some View) -> some View {
viewBuilder { viewBuilder {
if #available(iOS 17.0, *) { if #available(iOS 17.0, macOS 13.0, tvOS 17.0, *) {
// view not updating in iOS 16, but why? // view not updating in iOS 16, but why?
NavigationStack { NavigationStack {
content() content()
@@ -144,7 +144,11 @@ public struct ActiveDashboardView: View {
} }
} }
ToolbarItem(placement: .topBarTrailing) { ToolbarItem(placement: .topBarTrailing) {
StartStopButton() if #available(iOS 26.0, *), !Variant.debugNoIOS26 {
EmptyView()
} else {
StartStopButton()
}
} }
} }
#endif #endif
@@ -2,6 +2,7 @@ import Library
import SwiftUI import SwiftUI
@MainActor public struct CardManagementSheet: View { @MainActor public struct CardManagementSheet: View {
@Environment(\.dismiss) private var dismiss
@StateObject private var configuration = DashboardCardConfiguration() @StateObject private var configuration = DashboardCardConfiguration()
@Binding private var configurationVersion: Int @Binding private var configurationVersion: Int
@@ -33,7 +34,7 @@ import SwiftUI
} }
} }
#else #else
ToolbarItem(placement: .automatic) { ToolbarItem(placement: .cancellationAction) {
Button("Reset", role: .destructive) { Button("Reset", role: .destructive) {
Task { Task {
await configuration.resetToDefault() await configuration.resetToDefault()
@@ -41,6 +42,11 @@ import SwiftUI
} }
} }
} }
ToolbarItem(placement: .confirmationAction) {
Button("Done") {
dismiss()
}
}
#endif #endif
} }
} }
@@ -78,18 +84,11 @@ private struct CardRow: View {
.foregroundColor(.secondary) .foregroundColor(.secondary)
.imageScale(.small) .imageScale(.small)
if isProfileCard { Toggle(isOn: isToggleEnabled) {
Label(card.title, systemImage: card.systemImage) Label(card.title, systemImage: card.systemImage)
Spacer()
Text("Required")
.font(.footnote)
.foregroundColor(.secondary)
} else {
Toggle(isOn: isToggleEnabled) {
Label(card.title, systemImage: card.systemImage)
}
.opacity(isEnabled ? 1.0 : 0.5)
} }
.disabled(isProfileCard)
.opacity(isEnabled ? 1.0 : 0.5)
} }
} }
@@ -32,6 +32,9 @@ public struct StartStopButton: View {
@EnvironmentObject private var environments: ExtensionEnvironments @EnvironmentObject private var environments: ExtensionEnvironments
@EnvironmentObject private var profile: ExtensionProfile @EnvironmentObject private var profile: ExtensionProfile
@State private var alert: Alert? @State private var alert: Alert?
@State private var currentTime = Date()
private let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
var body: some View { var body: some View {
Button { Button {
@@ -39,15 +42,49 @@ public struct StartStopButton: View {
await switchProfile(!profile.status.isConnected) await switchProfile(!profile.status.isConnected)
} }
} label: { } label: {
if !profile.status.isConnected { HStack(spacing: 8) {
Label("Start", systemImage: "play.fill") if profile.status.isConnectedStrict, let duration = runtimeDuration {
} else { Text(duration)
Label("Stop", systemImage: "stop.fill") .font(.caption)
.foregroundStyle(.secondary)
.monospacedDigit()
.transition(.asymmetric(
insertion: .move(edge: .trailing).combined(with: .opacity),
removal: .move(edge: .trailing).combined(with: .opacity)
))
}
if !profile.status.isConnected {
Label("Start", systemImage: "play.fill")
} else {
Label("Stop", systemImage: "stop.fill")
}
} }
.animation(.spring(response: 0.35, dampingFraction: 0.75), value: profile.status.isConnectedStrict)
} }
.labelStyle(.iconOnly) .labelStyle(.iconOnly)
.tint(.primary)
.disabled(!profile.status.isEnabled) .disabled(!profile.status.isEnabled)
.alertBinding($alert) .alertBinding($alert)
.onReceive(timer) { _ in
currentTime = Date()
}
}
private var runtimeDuration: String? {
guard let connectedDate = profile.connectedDate else { return nil }
let interval = currentTime.timeIntervalSince(connectedDate)
guard interval >= 0 else { return nil }
let hours = Int(interval) / 3600
let minutes = Int(interval) / 60 % 60
let seconds = Int(interval) % 60
if hours > 0 {
return String(format: "%d:%02d:%02d", hours, minutes, seconds)
} else {
return String(format: "%d:%02d", minutes, seconds)
}
} }
private nonisolated func switchProfile(_ isEnabled: Bool) async { private nonisolated func switchProfile(_ isEnabled: Bool) async {
@@ -32,7 +32,9 @@ public struct OverviewView: View {
public var body: some View { public var body: some View {
Group { Group {
if profileList.isEmpty { if configuration.isLoading {
ProgressView()
} else if profileList.isEmpty {
VStack { VStack {
Spacer() Spacer()
Text("Empty profiles") Text("Empty profiles")
+3
View File
@@ -10,11 +10,13 @@ public class ExtensionProfile: ObservableObject {
private var observer: Any? private var observer: Any?
@Published public var status: NEVPNStatus @Published public var status: NEVPNStatus
@Published public var connectedDate: Date?
public init(_ manager: NEVPNManager) { public init(_ manager: NEVPNManager) {
self.manager = manager self.manager = manager
connection = manager.connection connection = manager.connection
status = manager.connection.status status = manager.connection.status
connectedDate = manager.connection.connectedDate
} }
public func register() { public func register() {
@@ -28,6 +30,7 @@ public class ExtensionProfile: ObservableObject {
} }
self.connection = notification.object as! NEVPNConnection self.connection = notification.object as! NEVPNConnection
self.status = self.connection.status self.status = self.connection.status
self.connectedDate = self.connection.connectedDate
} }
} }
+5
View File
@@ -17,4 +17,9 @@ public enum Variant {
#endif #endif
public static var isBeta = LibboxVersion().contains("-") public static var isBeta = LibboxVersion().contains("-")
#if os(iOS)
public static var debugNoIOS26 = false
public static var debugNoIOS18 = false
#endif
} }
+3
View File
@@ -527,6 +527,9 @@
} }
} }
} }
},
"Done" : {
}, },
"Downlink" : { "Downlink" : {
"localizations" : { "localizations" : {
+8 -4
View File
@@ -35,10 +35,14 @@ public struct MainView: View {
} }
if viewModel.selection == .dashboard { if viewModel.selection == .dashboard {
ToolbarItem(placement: .automatic) { ToolbarItem(placement: .automatic) {
Button { Menu {
showCardManagement = true Button {
showCardManagement = true
} label: {
Label("Dashboard Items", systemImage: "square.grid.2x2")
}
} label: { } label: {
Label("Dashboard Items", systemImage: "square.grid.2x2") Label("Others", systemImage: "ellipsis.circle")
} }
} }
} }
@@ -59,7 +63,7 @@ public struct MainView: View {
.onOpenURL(perform: viewModel.openURL) .onOpenURL(perform: viewModel.openURL)
.sheet(isPresented: $showCardManagement) { .sheet(isPresented: $showCardManagement) {
CardManagementSheet(configurationVersion: $cardConfigurationVersion) CardManagementSheet(configurationVersion: $cardConfigurationVersion)
.presentationDetents([.medium, .large]) .frame(minWidth: 400, minHeight: 400)
} }
} }
} }
+97 -23
View File
@@ -1,6 +1,7 @@
import ApplicationLibrary import ApplicationLibrary
import Libbox import Libbox
import Library import Library
import NetworkExtension
import SwiftUI import SwiftUI
struct MainView: View { struct MainView: View {
@@ -21,35 +22,108 @@ struct MainView: View {
} }
var body1: some View { var body1: some View {
TabView(selection: $selection) { viewBuilder {
ForEach(NavigationPage.allCases, id: \.self) { page in if #available(iOS 26.0, *), !Variant.debugNoIOS26 {
NavigationStackCompat { TabView(selection: $selection) {
page.contentView ForEach(NavigationPage.allCases, id: \.self) { page in
.navigationTitle(page.title) NavigationStackCompat {
page.contentView
.navigationTitle(page.title)
}
.tag(page)
.tabItem { page.label }
}
} }
.tag(page) .tabViewBottomAccessory {
.tabItem { page.label } HStack(spacing: 12) {
if let profile = environments.extensionProfile {
StatusText(profile: profile)
}
Spacer()
StartStopButton()
}
.padding(.horizontal)
}
.onAppear {
environments.postReload()
}
.alertBinding($alert)
.onChangeCompat(of: scenePhase) { newValue in
if newValue == .active {
environments.postReload()
}
}
.onChangeCompat(of: selection) { newValue in
if newValue == .logs {
environments.connect()
}
}
.environment(\.selection, $selection)
.environment(\.importProfile, $importProfile)
.environment(\.importRemoteProfile, $importRemoteProfile)
.handlesExternalEvents(preferring: [], allowing: ["*"])
.onOpenURL(perform: openURL)
} else {
TabView(selection: $selection) {
ForEach(NavigationPage.allCases, id: \.self) { page in
NavigationStackCompat {
page.contentView
.navigationTitle(page.title)
}
.tag(page)
.tabItem { page.label }
}
}
.onAppear {
environments.postReload()
}
.alertBinding($alert)
.onChangeCompat(of: scenePhase) { newValue in
if newValue == .active {
environments.postReload()
}
}
.onChangeCompat(of: selection) { newValue in
if newValue == .logs {
environments.connect()
}
}
.environment(\.selection, $selection)
.environment(\.importProfile, $importProfile)
.environment(\.importRemoteProfile, $importRemoteProfile)
.handlesExternalEvents(preferring: [], allowing: ["*"])
.onOpenURL(perform: openURL)
} }
} }
.onAppear { }
environments.postReload()
private struct StatusText: View {
@ObservedObject var profile: ExtensionProfile
var body: some View {
Text(statusText)
.font(.subheadline)
.foregroundStyle(.secondary)
} }
.alertBinding($alert)
.onChangeCompat(of: scenePhase) { newValue in private var statusText: String {
if newValue == .active { switch profile.status {
environments.postReload() case .invalid:
return "Invalid"
case .disconnected:
return "Stopped"
case .connecting:
return "Starting"
case .connected:
return "Started"
case .reasserting:
return "Reasserting"
case .disconnecting:
return "Stopping"
@unknown default:
return "Unknown"
} }
} }
.onChangeCompat(of: selection) { newValue in
if newValue == .logs {
environments.connect()
}
}
.environment(\.selection, $selection)
.environment(\.importProfile, $importProfile)
.environment(\.importRemoteProfile, $importRemoteProfile)
.handlesExternalEvents(preferring: [], allowing: ["*"])
.onOpenURL(perform: openURL)
} }
private func openURL(url: URL) { private func openURL(url: URL) {