mirror of
https://github.com/jkcoxson/LocalDevVPN.git
synced 2026-03-02 14:36:16 +01:00
Redesign main UI with dashboard cards (#3)
Refactored ContentView to use a ScrollView and modular dashboard card components for status, connectivity controls, and connection stats. Introduced new views for improved layout and clarity, including StatusOverviewCard, ConnectivityControlsCard, and ConnectionStatsView. Enhanced visual styling and organization for better user experience. .
This commit is contained in:
@@ -627,37 +627,41 @@ struct ContentView: View {
|
||||
@State var tunnel = false
|
||||
@AppStorage("autoConnect") private var autoConnect = false
|
||||
@AppStorage("hasNotCompletedSetup") private var hasNotCompletedSetup = true
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
var body: some View {
|
||||
NBNavigationStack {
|
||||
VStack(spacing: 30) {
|
||||
Spacer()
|
||||
ScrollView(showsIndicators: false) {
|
||||
VStack(spacing: 20) {
|
||||
StatusOverviewCard()
|
||||
|
||||
StatusIndicatorView()
|
||||
ConnectivityControlsCard(
|
||||
autoConnect: $autoConnect,
|
||||
action: {
|
||||
tunnelManager.tunnelStatus == .connected ? tunnelManager.stopVPN() : tunnelManager.startVPN()
|
||||
}
|
||||
)
|
||||
|
||||
ConnectionButton(
|
||||
action: {
|
||||
tunnelManager.tunnelStatus == .connected ? tunnelManager.stopVPN() : tunnelManager.startVPN()
|
||||
if tunnelManager.tunnelStatus == .connected {
|
||||
ConnectionStatsView()
|
||||
}
|
||||
)
|
||||
|
||||
Spacer()
|
||||
|
||||
if tunnelManager.tunnelStatus == .connected {
|
||||
ConnectionStatsView()
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 24)
|
||||
}
|
||||
.padding()
|
||||
.background(backgroundColor.ignoresSafeArea())
|
||||
.navigationTitle("LocalDevVPN")
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
#endif
|
||||
.tvOSNavigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
showSettings = true
|
||||
} label: {
|
||||
Image(systemName: "gearshape.fill")
|
||||
.font(.system(size: 20, weight: .medium))
|
||||
.foregroundColor(.secondary)
|
||||
Image(systemName: "gear")
|
||||
.foregroundColor(.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -674,6 +678,14 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var backgroundColor: Color {
|
||||
if colorScheme == .dark {
|
||||
return Color(.systemBackground)
|
||||
} else {
|
||||
return Color(.systemGroupedBackground)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
@@ -687,120 +699,175 @@ extension View {
|
||||
}
|
||||
}
|
||||
|
||||
struct StatusIndicatorView: View {
|
||||
struct StatusOverviewCard: View {
|
||||
@StateObject private var tunnelManager = TunnelManager.shared
|
||||
@State private var animationAmount = 1.0
|
||||
@State private var isAnimating = false
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 20) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.stroke(tunnelManager.tunnelStatus.color.opacity(0.2), lineWidth: 20)
|
||||
.frame(width: 200, height: 200)
|
||||
DashboardCard {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
Text("Current status")
|
||||
.font(.headline)
|
||||
|
||||
Circle()
|
||||
.stroke(tunnelManager.tunnelStatus.color, lineWidth: 10)
|
||||
.frame(width: 200, height: 200)
|
||||
.scaleEffect(animationAmount)
|
||||
.opacity(2 - animationAmount)
|
||||
.animation(isAnimating ? Animation.easeOut(duration: 1.5).repeatForever(autoreverses: false) : .default, value: animationAmount)
|
||||
|
||||
VStack(spacing: 10) {
|
||||
Image(systemName: tunnelManager.tunnelStatus.systemImage)
|
||||
.font(.system(size: 50))
|
||||
.foregroundColor(tunnelManager.tunnelStatus.color)
|
||||
HStack(spacing: 18) {
|
||||
StatusGlyphView()
|
||||
|
||||
Text(tunnelManager.tunnelStatus.localizedTitle)
|
||||
.font(.headline)
|
||||
.foregroundColor(.primary)
|
||||
.font(.title3)
|
||||
.fontWeight(.semibold)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
HStack {
|
||||
Label {
|
||||
Text(statusTip)
|
||||
} icon: {
|
||||
Image(systemName: "info.circle")
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(Date(), style: .time)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
updateAnimation()
|
||||
}
|
||||
.onChange(of: tunnelManager.tunnelStatus) { _ in
|
||||
updateAnimation()
|
||||
}
|
||||
Text(tunnelManager.tunnelStatus == .connected ?
|
||||
NSLocalizedString("local_tunnel_active", comment: "") :
|
||||
NSLocalizedString("local_tunnel_inactive", comment: ""))
|
||||
.font(.subheadline)
|
||||
.foregroundColor(tunnelManager.tunnelStatus == .connected ? .green : .secondary)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
|
||||
private func updateAnimation() {
|
||||
private var statusTip: String {
|
||||
switch tunnelManager.tunnelStatus {
|
||||
case .connected:
|
||||
return NSLocalizedString("Connected to 10.7.0.1", comment: "")
|
||||
case .connecting:
|
||||
return NSLocalizedString("macOS might ask you to approve the VPN", comment: "")
|
||||
case .disconnecting:
|
||||
isAnimating = false
|
||||
withAnimation {
|
||||
animationAmount = 1.0
|
||||
}
|
||||
case .disconnected:
|
||||
isAnimating = false
|
||||
animationAmount = 1.0
|
||||
return NSLocalizedString("Disconnecting safely", comment: "")
|
||||
case .error:
|
||||
return NSLocalizedString("Open Settings to review details", comment: "")
|
||||
default:
|
||||
isAnimating = true
|
||||
animationAmount = 1.0
|
||||
return NSLocalizedString("Tap connect to create the tunnel", comment: "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct StatusGlyphView: View {
|
||||
@StateObject private var tunnelManager = TunnelManager.shared
|
||||
@State private var animate = false
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Circle()
|
||||
.stroke(tunnelManager.tunnelStatus.color.opacity(0.25), lineWidth: 6)
|
||||
.scaleEffect(animate ? 1.05 : 0.95)
|
||||
.animation(.easeInOut(duration: 1.2).repeatForever(autoreverses: true), value: animate)
|
||||
|
||||
Circle()
|
||||
.fill(tunnelManager.tunnelStatus.color.opacity(0.15))
|
||||
|
||||
Image(systemName: tunnelManager.tunnelStatus.systemImage)
|
||||
.font(.title)
|
||||
.foregroundColor(tunnelManager.tunnelStatus.color)
|
||||
}
|
||||
.frame(width: 92, height: 92)
|
||||
.onAppear {
|
||||
animate = true
|
||||
}
|
||||
.onChange(of: tunnelManager.tunnelStatus) { _ in
|
||||
animate.toggle()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||
withAnimation {
|
||||
animationAmount = 2.0
|
||||
}
|
||||
animate = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ConnectivityControlsCard: View {
|
||||
@Binding var autoConnect: Bool
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
DashboardCard {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Connection")
|
||||
.font(.headline)
|
||||
Text("Start or stop the secure local tunnel.")
|
||||
.font(.footnote)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
ConnectionButton(action: action)
|
||||
|
||||
Toggle(isOn: $autoConnect) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Auto-connect on launch")
|
||||
.fontWeight(.semibold)
|
||||
Text("Resume your last state automatically.")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ConnectionInfoRow: View {
|
||||
let title: String
|
||||
let value: String
|
||||
let icon: String
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: icon)
|
||||
.font(.body)
|
||||
.foregroundColor(.accentColor)
|
||||
.frame(width: 24)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
Text(value)
|
||||
.font(.body)
|
||||
.foregroundColor(.primary)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ConnectionButton: View {
|
||||
@StateObject private var tunnelManager = TunnelManager.shared
|
||||
let action: () -> Void
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
if #available(iOS 19.0, *) {
|
||||
HStack(spacing: 8) {
|
||||
if tunnelManager.tunnelStatus == .connecting || tunnelManager.tunnelStatus == .disconnecting {
|
||||
ProgressView()
|
||||
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||||
}
|
||||
|
||||
Text(buttonText)
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
HStack {
|
||||
Text(buttonText)
|
||||
.font(.headline)
|
||||
.fontWeight(.semibold)
|
||||
|
||||
if tunnelManager.tunnelStatus == .connecting || tunnelManager.tunnelStatus == .disconnecting {
|
||||
ProgressView()
|
||||
.progressViewStyle(CircularProgressViewStyle())
|
||||
.padding(.leading, 5)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 56)
|
||||
.glassEffect(.regular.interactive().tint(tunnelManager.tunnelStatus != .connected ? .red : .green), in: RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||
.foregroundColor(.white)
|
||||
.shadow(color: tunnelManager.tunnelStatus.color.opacity(0.3), radius: 12, x: 0, y: 6)
|
||||
} else {
|
||||
HStack(spacing: 8) {
|
||||
if tunnelManager.tunnelStatus == .connecting || tunnelManager.tunnelStatus == .disconnecting {
|
||||
ProgressView()
|
||||
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||||
}
|
||||
|
||||
Text(buttonText)
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 56)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.fill(buttonBackground)
|
||||
)
|
||||
.foregroundColor(.white)
|
||||
.shadow(color: tunnelManager.tunnelStatus.color.opacity(0.3), radius: 12, x: 0, y: 6)
|
||||
}
|
||||
.frame(width: 200, height: 50)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 56)
|
||||
.background(buttonBackground)
|
||||
.foregroundColor(.white)
|
||||
.clipShape(Capsule())
|
||||
.shadow(color: Color.black.opacity(0.15), radius: 10, x: 0, y: 5)
|
||||
.shadow(color: shadowColor, radius: 10, x: 0, y: 5)
|
||||
}
|
||||
.disabled(tunnelManager.tunnelStatus == .connecting || tunnelManager.tunnelStatus == .disconnecting)
|
||||
.padding(.horizontal, 20)
|
||||
}
|
||||
|
||||
private var buttonText: String {
|
||||
@@ -833,48 +900,69 @@ struct ConnectionButton: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var shadowColor: Color {
|
||||
colorScheme == .dark ? Color.black.opacity(0.5) : Color.black.opacity(0.15)
|
||||
}
|
||||
}
|
||||
|
||||
struct ConnectionStatsView: View {
|
||||
@StateObject private var tunnelManager = TunnelManager.shared
|
||||
@State private var time = 0
|
||||
let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
|
||||
@AppStorage("TunnelDeviceIP") private var deviceIP = "10.7.0.0"
|
||||
@AppStorage("TunnelFakeIP") private var fakeIP = "10.7.0.1"
|
||||
@AppStorage("TunnelSubnetMask") private var subnetMask = "255.255.255.0"
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 25) {
|
||||
Text("local_tunnel_details")
|
||||
.font(.headline)
|
||||
.foregroundColor(.primary)
|
||||
HStack(spacing: 30) {
|
||||
StatItemView(
|
||||
title: "time_connected",
|
||||
value: formattedTime,
|
||||
icon: "clock.fill"
|
||||
DashboardCard {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Session details")
|
||||
.font(.headline)
|
||||
Text("Live stats while the tunnel is connected.")
|
||||
.font(.footnote)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
HStack(spacing: 16) {
|
||||
StatItemView(
|
||||
title: "time_connected",
|
||||
value: formattedTime,
|
||||
icon: "clock.fill"
|
||||
)
|
||||
StatItemView(
|
||||
title: "status",
|
||||
value: statusValue,
|
||||
icon: tunnelManager.tunnelStatus.systemImage
|
||||
)
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
Text("Network configuration")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
ConnectionInfoRow(
|
||||
title: "Local device IP",
|
||||
value: deviceIP,
|
||||
icon: "desktopcomputer"
|
||||
)
|
||||
StatItemView(
|
||||
title: "status",
|
||||
value: NSLocalizedString("active", comment: ""),
|
||||
icon: "checkmark.circle.fill"
|
||||
|
||||
ConnectionInfoRow(
|
||||
title: "Tunnel IP",
|
||||
value: fakeIP,
|
||||
icon: "point.3.filled.connected.trianglepath.dotted"
|
||||
)
|
||||
}
|
||||
HStack(spacing: 30) {
|
||||
StatItemView(
|
||||
title: "network_interface",
|
||||
value: NSLocalizedString("local", comment: ""),
|
||||
|
||||
ConnectionInfoRow(
|
||||
title: "Subnet mask",
|
||||
value: subnetMask,
|
||||
icon: "network"
|
||||
)
|
||||
StatItemView(
|
||||
title: "assigned_ip",
|
||||
value: "10.7.0.1",
|
||||
icon: "number"
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 20)
|
||||
.fill(Color(UIColor.darkGray))
|
||||
.shadow(color: Color.black.opacity(0.05), radius: 10, x: 0, y: 5)
|
||||
)
|
||||
.onReceive(timer) { _ in
|
||||
time += 1
|
||||
}
|
||||
@@ -888,46 +976,78 @@ struct ConnectionStatsView: View {
|
||||
if hours > 0 {
|
||||
return String(format: "%02d:%02d:%02d", hours, minutes, seconds)
|
||||
} else {
|
||||
return LinearGradient(
|
||||
colors: [Color.blue, Color.blue.opacity(0.8)],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
return String(format: "%02d:%02d", minutes, seconds)
|
||||
}
|
||||
}
|
||||
private var statusValue: String {
|
||||
switch tunnelManager.tunnelStatus {
|
||||
case .connected:
|
||||
return NSLocalizedString("Active", comment: "")
|
||||
case .connecting:
|
||||
return NSLocalizedString("Connecting", comment: "")
|
||||
case .disconnecting:
|
||||
return NSLocalizedString("Disconnecting", comment: "")
|
||||
case .error:
|
||||
return NSLocalizedString("Error", comment: "")
|
||||
default:
|
||||
return NSLocalizedString("Idle", comment: "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct InfoCard: View {
|
||||
struct StatItemView: View {
|
||||
let title: LocalizedStringKey
|
||||
let value: String
|
||||
let icon: String
|
||||
let title: String
|
||||
let subtitle: String
|
||||
let accentColor: Color
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack {
|
||||
Image(systemName: icon)
|
||||
.foregroundColor(.blue)
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: icon)
|
||||
.foregroundColor(.accentColor)
|
||||
.font(.caption)
|
||||
Text(title)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundColor(.primary)
|
||||
|
||||
Text(subtitle)
|
||||
.font(.system(size: 13, weight: .regular))
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
|
||||
Text(value)
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.font(.headline)
|
||||
.foregroundColor(.primary)
|
||||
}
|
||||
.padding(16)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.fill(Color(UIColor.secondarySystemGroupedBackground))
|
||||
)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
struct DashboardCard<Content: View>: View {
|
||||
private let content: () -> Content
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
init(@ViewBuilder content: @escaping () -> Content) {
|
||||
self.content = content
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
content()
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 22, style: .continuous)
|
||||
.fill(Color(.secondarySystemBackground))
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 22, style: .continuous)
|
||||
.stroke(borderColor)
|
||||
)
|
||||
.shadow(color: shadowColor, radius: 12, x: 0, y: 6)
|
||||
}
|
||||
|
||||
private var borderColor: Color {
|
||||
colorScheme == .dark ? Color.white.opacity(0.08) : Color.black.opacity(0.06)
|
||||
}
|
||||
|
||||
private var shadowColor: Color {
|
||||
colorScheme == .dark ? Color.black.opacity(0.5) : Color.black.opacity(0.12)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -967,7 +1087,7 @@ struct SettingsView: View {
|
||||
|
||||
Section(header: Text("app_information")) {
|
||||
Button {
|
||||
UIApplication.shared.open(URL(string: "https://github.com/stossy11/PrivacyPolicy/blob/main/PrivacyPolicy.md")!, options: [:])
|
||||
UIApplication.shared.open(URL(string: "https://jkcoxson.com/cdn/LocalDevVPN/LocalDevVPNPrivacyPolicy.md")!, options: [:])
|
||||
} label: {
|
||||
Label("privacy_policy", systemImage: "lock.shield")
|
||||
}
|
||||
@@ -1192,18 +1312,10 @@ struct HelpView: View {
|
||||
}
|
||||
}
|
||||
Section(header: Text("app_info_header")) {
|
||||
if let minVersion = Bundle.main.infoDictionary?["MinimumOSVersion"] as? String {
|
||||
let message = String(
|
||||
format: NSLocalizedString("requires_ios", comment: ""),
|
||||
minVersion
|
||||
)
|
||||
HStack {
|
||||
Image(systemName: "exclamationmark.shield")
|
||||
Text(message)
|
||||
}
|
||||
HStack {
|
||||
Image(systemName: "exclamationmark.shield")
|
||||
Text("requires_ios")
|
||||
}
|
||||
|
||||
|
||||
HStack {
|
||||
Image(systemName: "lock.shield")
|
||||
Text("uses_network_extension")
|
||||
|
||||
Reference in New Issue
Block a user