Improve Start/Stop button
This commit is contained in:
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -527,6 +527,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Done" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Downlink" : {
|
"Downlink" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
|
|||||||
@@ -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
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user