diff --git a/ApplicationLibrary/Views/Abstract/NavigationStackCompat.swift b/ApplicationLibrary/Views/Abstract/NavigationStackCompat.swift index 3822d5b..ead485e 100644 --- a/ApplicationLibrary/Views/Abstract/NavigationStackCompat.swift +++ b/ApplicationLibrary/Views/Abstract/NavigationStackCompat.swift @@ -2,7 +2,7 @@ import SwiftUI public func NavigationStackCompat(@ViewBuilder content: () -> some View) -> some View { 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? NavigationStack { content() diff --git a/ApplicationLibrary/Views/Dashboard/ActiveDashboardView.swift b/ApplicationLibrary/Views/Dashboard/ActiveDashboardView.swift index 5108061..0cc575c 100644 --- a/ApplicationLibrary/Views/Dashboard/ActiveDashboardView.swift +++ b/ApplicationLibrary/Views/Dashboard/ActiveDashboardView.swift @@ -144,7 +144,11 @@ public struct ActiveDashboardView: View { } } ToolbarItem(placement: .topBarTrailing) { - StartStopButton() + if #available(iOS 26.0, *), !Variant.debugNoIOS26 { + EmptyView() + } else { + StartStopButton() + } } } #endif diff --git a/ApplicationLibrary/Views/Dashboard/Cards/CardManagementSheet.swift b/ApplicationLibrary/Views/Dashboard/Cards/CardManagementSheet.swift index 5af4586..9531ce8 100644 --- a/ApplicationLibrary/Views/Dashboard/Cards/CardManagementSheet.swift +++ b/ApplicationLibrary/Views/Dashboard/Cards/CardManagementSheet.swift @@ -2,6 +2,7 @@ import Library import SwiftUI @MainActor public struct CardManagementSheet: View { + @Environment(\.dismiss) private var dismiss @StateObject private var configuration = DashboardCardConfiguration() @Binding private var configurationVersion: Int @@ -33,7 +34,7 @@ import SwiftUI } } #else - ToolbarItem(placement: .automatic) { + ToolbarItem(placement: .cancellationAction) { Button("Reset", role: .destructive) { Task { await configuration.resetToDefault() @@ -41,6 +42,11 @@ import SwiftUI } } } + ToolbarItem(placement: .confirmationAction) { + Button("Done") { + dismiss() + } + } #endif } } @@ -78,18 +84,11 @@ private struct CardRow: View { .foregroundColor(.secondary) .imageScale(.small) - if isProfileCard { + Toggle(isOn: isToggleEnabled) { 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) } } diff --git a/ApplicationLibrary/Views/Dashboard/Components/StartStopButton.swift b/ApplicationLibrary/Views/Dashboard/Components/StartStopButton.swift index 428804f..f5ea34a 100644 --- a/ApplicationLibrary/Views/Dashboard/Components/StartStopButton.swift +++ b/ApplicationLibrary/Views/Dashboard/Components/StartStopButton.swift @@ -32,6 +32,9 @@ public struct StartStopButton: View { @EnvironmentObject private var environments: ExtensionEnvironments @EnvironmentObject private var profile: ExtensionProfile @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 { Button { @@ -39,15 +42,49 @@ public struct StartStopButton: View { await switchProfile(!profile.status.isConnected) } } label: { - if !profile.status.isConnected { - Label("Start", systemImage: "play.fill") - } else { - Label("Stop", systemImage: "stop.fill") + HStack(spacing: 8) { + if profile.status.isConnectedStrict, let duration = runtimeDuration { + Text(duration) + .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) + .tint(.primary) .disabled(!profile.status.isEnabled) .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 { diff --git a/ApplicationLibrary/Views/Dashboard/Overview/OverviewView.swift b/ApplicationLibrary/Views/Dashboard/Overview/OverviewView.swift index c0d0022..2f15914 100644 --- a/ApplicationLibrary/Views/Dashboard/Overview/OverviewView.swift +++ b/ApplicationLibrary/Views/Dashboard/Overview/OverviewView.swift @@ -32,7 +32,9 @@ public struct OverviewView: View { public var body: some View { Group { - if profileList.isEmpty { + if configuration.isLoading { + ProgressView() + } else if profileList.isEmpty { VStack { Spacer() Text("Empty profiles") diff --git a/Library/Network/ExtensionProfile.swift b/Library/Network/ExtensionProfile.swift index 6550e61..8cc6dcc 100644 --- a/Library/Network/ExtensionProfile.swift +++ b/Library/Network/ExtensionProfile.swift @@ -10,11 +10,13 @@ public class ExtensionProfile: ObservableObject { private var observer: Any? @Published public var status: NEVPNStatus + @Published public var connectedDate: Date? public init(_ manager: NEVPNManager) { self.manager = manager connection = manager.connection status = manager.connection.status + connectedDate = manager.connection.connectedDate } public func register() { @@ -28,6 +30,7 @@ public class ExtensionProfile: ObservableObject { } self.connection = notification.object as! NEVPNConnection self.status = self.connection.status + self.connectedDate = self.connection.connectedDate } } diff --git a/Library/Shared/Variant.swift b/Library/Shared/Variant.swift index 669e4bb..0a84001 100644 --- a/Library/Shared/Variant.swift +++ b/Library/Shared/Variant.swift @@ -17,4 +17,9 @@ public enum Variant { #endif public static var isBeta = LibboxVersion().contains("-") + + #if os(iOS) + public static var debugNoIOS26 = false + public static var debugNoIOS18 = false + #endif } diff --git a/Localizable.xcstrings b/Localizable.xcstrings index 9f39564..219f02b 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -527,6 +527,9 @@ } } } + }, + "Done" : { + }, "Downlink" : { "localizations" : { diff --git a/MacLibrary/MainView.swift b/MacLibrary/MainView.swift index bdece27..67c827f 100644 --- a/MacLibrary/MainView.swift +++ b/MacLibrary/MainView.swift @@ -35,10 +35,14 @@ public struct MainView: View { } if viewModel.selection == .dashboard { ToolbarItem(placement: .automatic) { - Button { - showCardManagement = true + Menu { + Button { + showCardManagement = true + } label: { + Label("Dashboard Items", systemImage: "square.grid.2x2") + } } 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) .sheet(isPresented: $showCardManagement) { CardManagementSheet(configurationVersion: $cardConfigurationVersion) - .presentationDetents([.medium, .large]) + .frame(minWidth: 400, minHeight: 400) } } } diff --git a/SFI/MainView.swift b/SFI/MainView.swift index b3718d2..26544d7 100644 --- a/SFI/MainView.swift +++ b/SFI/MainView.swift @@ -1,6 +1,7 @@ import ApplicationLibrary import Libbox import Library +import NetworkExtension import SwiftUI struct MainView: View { @@ -21,35 +22,108 @@ struct MainView: View { } var body1: some View { - TabView(selection: $selection) { - ForEach(NavigationPage.allCases, id: \.self) { page in - NavigationStackCompat { - page.contentView - .navigationTitle(page.title) + viewBuilder { + if #available(iOS 26.0, *), !Variant.debugNoIOS26 { + TabView(selection: $selection) { + ForEach(NavigationPage.allCases, id: \.self) { page in + NavigationStackCompat { + page.contentView + .navigationTitle(page.title) + } + .tag(page) + .tabItem { page.label } + } } - .tag(page) - .tabItem { page.label } + .tabViewBottomAccessory { + 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 - if newValue == .active { - environments.postReload() + + private var statusText: String { + switch profile.status { + 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) {