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:
Stephen B
2025-12-09 10:45:34 -05:00
committed by se2crid
parent b1f140a0cb
commit bf4af3bb0b

View File

@@ -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")