diff --git a/NetBird.xcodeproj/project.pbxproj b/NetBird.xcodeproj/project.pbxproj index 70ee3fa..a4b04a6 100644 --- a/NetBird.xcodeproj/project.pbxproj +++ b/NetBird.xcodeproj/project.pbxproj @@ -134,6 +134,12 @@ 50E608132A7958B100BAF09B /* MainViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50E608122A7958B100BAF09B /* MainViewModel.swift */; }; 50E608242A79966600BAF09B /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50E608232A79966600BAF09B /* AboutView.swift */; }; 50E608262A79968500BAF09B /* AdvancedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50E608252A79968500BAF09B /* AdvancedView.swift */; }; + 558553FB2FE34921004FB58D /* jetbrains-mono-variable.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 558553FA2FE34921004FB58D /* jetbrains-mono-variable.ttf */; }; + 558553FC2FE34921004FB58D /* inter-variable.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 558553F92FE34921004FB58D /* inter-variable.ttf */; }; + 558553FD2FE34921004FB58D /* jetbrains-mono-variable.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 558553FA2FE34921004FB58D /* jetbrains-mono-variable.ttf */; }; + 558553FE2FE34921004FB58D /* inter-variable.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 558553F92FE34921004FB58D /* inter-variable.ttf */; }; + 558553FF2FE34921004FB58D /* jetbrains-mono-variable.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 558553FA2FE34921004FB58D /* jetbrains-mono-variable.ttf */; }; + 558554002FE34921004FB58D /* inter-variable.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 558553F92FE34921004FB58D /* inter-variable.ttf */; }; 5573F6EE2F9F523D00E63A73 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 5573F6ED2F9F523D00E63A73 /* GoogleService-Info.plist */; }; 5573F6EF2F9F523D00E63A73 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 5573F6ED2F9F523D00E63A73 /* GoogleService-Info.plist */; }; 5573F6F02F9F523D00E63A73 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 5573F6ED2F9F523D00E63A73 /* GoogleService-Info.plist */; }; @@ -152,6 +158,7 @@ 978FC4742EEDF168002D0EB8 /* Platform.swift in Sources */ = {isa = PBXBuildFile; fileRef = 443782BD2EDF284A00F9FA94 /* Platform.swift */; }; 9B6C03E5ED4245A597C0FBE7 /* iOSConnectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8DE6A20955914E68BFADDEED /* iOSConnectionView.swift */; }; 9CC0E000AE3F165CA72FD465 /* AppLoggerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AA7193B3AE82DF185EDEB1B /* AppLoggerTests.swift */; }; + A1B2C3D42F4A000100000001 /* VPNToggleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D32F4A000100000001 /* VPNToggleView.swift */; }; A1B2C3D52EEDF501001A2B3C /* ConfigurationProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D42EEDF500001A2B3C /* ConfigurationProvider.swift */; }; A1B2C3D62EEDF502001A2B3C /* ConfigurationProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D42EEDF500001A2B3C /* ConfigurationProvider.swift */; }; A1B2C3D72EEDF503001A2B3C /* ConfigurationProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D42EEDF500001A2B3C /* ConfigurationProvider.swift */; }; @@ -338,6 +345,8 @@ 50E608232A79966600BAF09B /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = ""; }; 50E608252A79968500BAF09B /* AdvancedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdvancedView.swift; sourceTree = ""; }; 53CB9305A9DC6CAD1895495A /* SharedUserDefaultsTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SharedUserDefaultsTests.swift; sourceTree = ""; }; + 558553F92FE34921004FB58D /* inter-variable.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "inter-variable.ttf"; sourceTree = ""; }; + 558553FA2FE34921004FB58D /* jetbrains-mono-variable.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "jetbrains-mono-variable.ttf"; sourceTree = ""; }; 5573F6ED2F9F523D00E63A73 /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; }; 55B5E81A2F39158200852AA7 /* InternetStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InternetStatusView.swift; sourceTree = ""; }; 55D865832F70982000A2EFF8 /* NetBirdWidgetExtensionExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = NetBirdWidgetExtensionExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -349,6 +358,7 @@ 91FA1F06D3375864C74EAB3B /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; }; 978FC46F2EEDF167002D0EB8 /* AppLogger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLogger.swift; sourceTree = ""; }; 9CD257EF78F038560FF3112D /* VPNOnDemandView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VPNOnDemandView.swift; sourceTree = ""; }; + A1B2C3D32F4A000100000001 /* VPNToggleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VPNToggleView.swift; sourceTree = ""; }; BB001A002F99000000000001 /* TroubleshootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TroubleshootView.swift; sourceTree = ""; }; A1B2C3D42EEDF500001A2B3C /* ConfigurationProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigurationProvider.swift; sourceTree = ""; }; A1C3D5E72F000001001A2B3C /* WiFiOnDemandPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WiFiOnDemandPolicy.swift; sourceTree = ""; }; @@ -583,6 +593,7 @@ 50A891192A792A15007C48FC /* NetBird */ = { isa = PBXGroup; children = ( + 558553F82FE34913004FB58D /* Fonts */, 50245A3B2A7FD1CD0034792B /* Info.plist */, 50245A1B2A7BCF120034792B /* NetBird.entitlements */, 50E608082A79542D00BAF09B /* Source */, @@ -663,6 +674,7 @@ E1A0B0002F5E000100000001 /* EmptyTabPlaceholder.swift */, E1C0D0002F72000100000001 /* AppButton.swift */, E1B0C0002F60000100000001 /* NetworkWarningBanner.swift */, + A1B2C3D32F4A000100000001 /* VPNToggleView.swift */, ); path = Components; sourceTree = ""; @@ -680,6 +692,15 @@ path = App; sourceTree = ""; }; + 558553F82FE34913004FB58D /* Fonts */ = { + isa = PBXGroup; + children = ( + 558553F92FE34921004FB58D /* inter-variable.ttf */, + 558553FA2FE34921004FB58D /* jetbrains-mono-variable.ttf */, + ); + path = Fonts; + sourceTree = ""; + }; 651C942641826A7AA94ED369 /* NetBirdTests */ = { isa = PBXGroup; children = ( @@ -916,6 +937,8 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + 558553FB2FE34921004FB58D /* jetbrains-mono-variable.ttf in Resources */, + 558553FC2FE34921004FB58D /* inter-variable.ttf in Resources */, 5573F6EE2F9F523D00E63A73 /* GoogleService-Info.plist in Resources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -956,6 +979,8 @@ 501B0DCD2AE04DDE004BE7A7 /* button-disconnecting.json in Resources */, 501B0DD12AE04DDE004BE7A7 /* logo_NetBird.json in Resources */, 501B0DDB2AE04DDE004BE7A7 /* button-connected.json in Resources */, + 558553FD2FE34921004FB58D /* jetbrains-mono-variable.ttf in Resources */, + 558553FE2FE34921004FB58D /* inter-variable.ttf in Resources */, 5573F6F02F9F523D00E63A73 /* GoogleService-Info.plist in Resources */, 501B0DCF2AE04DDE004BE7A7 /* button-connecting-loop.json in Resources */, 501B0DD72AE04DDE004BE7A7 /* button-full2.json in Resources */, @@ -966,6 +991,8 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + 558553FF2FE34921004FB58D /* jetbrains-mono-variable.ttf in Resources */, + 558554002FE34921004FB58D /* inter-variable.ttf in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1132,6 +1159,7 @@ E1A0B0012F5E000100000001 /* EmptyTabPlaceholder.swift in Sources */, E1C0D0012F72000100000001 /* AppButton.swift in Sources */, E1B0C0012F60000100000001 /* NetworkWarningBanner.swift in Sources */, + A1B2C3D42F4A000100000001 /* VPNToggleView.swift in Sources */, 50BB17412C30239400518BCA /* RouteCard.swift in Sources */, 50216D932ACB2488009574C9 /* NetworkExtensionAdapter.swift in Sources */, 55B5E81B2F39158200852AA7 /* InternetStatusView.swift in Sources */, diff --git a/NetBird/Fonts/inter-variable.ttf b/NetBird/Fonts/inter-variable.ttf new file mode 100644 index 0000000..4ab79e0 Binary files /dev/null and b/NetBird/Fonts/inter-variable.ttf differ diff --git a/NetBird/Fonts/jetbrains-mono-variable.ttf b/NetBird/Fonts/jetbrains-mono-variable.ttf new file mode 100644 index 0000000..b60e77f Binary files /dev/null and b/NetBird/Fonts/jetbrains-mono-variable.ttf differ diff --git a/NetBird/Info.plist b/NetBird/Info.plist index 25cec1b..9db8a08 100644 --- a/NetBird/Info.plist +++ b/NetBird/Info.plist @@ -2,6 +2,11 @@ + UIAppFonts + + inter-variable.ttf + jetbrains-mono-variable.ttf + CFBundleURLTypes diff --git a/NetBird/Source/App/ViewModels/MainViewModel.swift b/NetBird/Source/App/ViewModels/MainViewModel.swift index 8a85a95..97a6e60 100644 --- a/NetBird/Source/App/ViewModels/MainViewModel.swift +++ b/NetBird/Source/App/ViewModels/MainViewModel.swift @@ -102,6 +102,7 @@ class ViewModel: ObservableObject { @Published var fqdn = "" @Published var ip = "" + @Published var ipv6 = "" // Debug @Published var traceLogsEnabled: Bool { @@ -169,8 +170,9 @@ class ViewModel: ObservableObject { func loadConnectionInfoForProfile(_ profileName: String) { #if os(iOS) let entry = profileConnectionCache.entry(for: profileName) - ip = entry?.ip ?? "" - fqdn = entry?.fqdn ?? "" + ip = entry?.ip ?? "" + fqdn = entry?.fqdn ?? "" + ipv6 = entry?.ipv6 ?? "" #endif } @@ -198,8 +200,9 @@ class ViewModel: ObservableObject { let activeProfile = ProfileManager.shared.getActiveProfileName() let cache = ProfileConnectionCache() let cached = cache.entry(for: activeProfile) - self.ip = cached?.ip ?? "" - self.fqdn = cached?.fqdn ?? "" + self.ip = cached?.ip ?? "" + self.fqdn = cached?.fqdn ?? "" + self.ipv6 = cached?.ipv6 ?? "" #endif // Don't load rosenpass settings during init - they trigger expensive SDK initialization. @@ -548,13 +551,15 @@ class ViewModel: ObservableObject { if !self.profileSwitchPending && currentState == .connected { let newFqdn = details.fqdn.isEmpty ? self.fqdn : details.fqdn let newIp = details.ip.isEmpty ? self.ip : details.ip - let changed = newFqdn != self.fqdn || newIp != self.ip + let newIpv6 = details.ipv6 ?? self.ipv6 + let changed = newFqdn != self.fqdn || newIp != self.ip || newIpv6 != self.ipv6 if changed { self.fqdn = newFqdn self.ip = newIp + self.ipv6 = newIpv6 #if os(iOS) let profile = ProfileManager.shared.getActiveProfileName() - self.profileConnectionCache.save(ip: newIp, fqdn: newFqdn, for: profile) + self.profileConnectionCache.save(ip: newIp, fqdn: newFqdn, ipv6: newIpv6, for: profile) #endif } @@ -616,6 +621,7 @@ class ViewModel: ObservableObject { func clearDetails() { self.ip = "" self.fqdn = "" + self.ipv6 = "" defaults.removeObject(forKey: "ip") defaults.removeObject(forKey: "fqdn") diff --git a/NetBird/Source/App/Views/Components/VPNToggleView.swift b/NetBird/Source/App/Views/Components/VPNToggleView.swift new file mode 100644 index 0000000..9bfab35 --- /dev/null +++ b/NetBird/Source/App/Views/Components/VPNToggleView.swift @@ -0,0 +1,80 @@ +import SwiftUI + +#if os(iOS) + +struct VPNToggleView: View { + let vpnState: VPNDisplayState + let isLocked: Bool + let onConnect: () -> Void + let onDisconnect: () -> Void + + @State private var pulseOpacity: Double = 1.0 + // Optimistic override: set immediately on tap so thumb moves without waiting for OS + @State private var optimisticIsOn: Bool? = nil + + // Use optimistic value while OS hasn't confirmed yet; fall back to real state + private var isOn: Bool { + optimisticIsOn ?? (vpnState == .connected || vpnState == .connecting) + } + + // Pulse whenever the real state is transitioning OR we're waiting for OS confirmation + private var isTransitioning: Bool { + optimisticIsOn != nil || vpnState == .connecting || vpnState == .disconnecting + } + + private let trackWidth: CGFloat = 104 + private let trackHeight: CGFloat = 56 + private var thumbDiameter: CGFloat { trackHeight - 8 } + private var thumbTravel: CGFloat { (trackWidth - thumbDiameter) / 2 - 4 } + + var body: some View { + ZStack { + Capsule() + .fill(isOn ? Color.orange : Color(uiColor: .systemGray4)) + .opacity(pulseOpacity) + .frame(width: trackWidth, height: trackHeight) + .animation(.easeInOut(duration: 0.3), value: isOn) + + Circle() + .fill(Color.white) + .frame(width: thumbDiameter, height: thumbDiameter) + .offset(x: isOn ? thumbTravel : -thumbTravel) + .shadow(color: .black.opacity(0.25), radius: 4, x: 0, y: 2) + .animation(.spring(response: 0.38, dampingFraction: 0.75), value: isOn) + } + .contentShape(Rectangle()) + .onTapGesture { + guard !isLocked else { return } + switch vpnState { + case .disconnected: + optimisticIsOn = true + onConnect() + case .connected, .connecting: + optimisticIsOn = false + onDisconnect() + case .disconnecting: + break + } + } + // Clear optimistic as soon as the OS confirms any state change + .onChange(of: vpnState) { _ in + optimisticIsOn = nil + } + // Drive the pulse loop with a cancellable async task keyed to transitioning state + .task(id: isTransitioning) { + guard isTransitioning else { + withAnimation(.easeInOut(duration: 0.25)) { pulseOpacity = 1.0 } + return + } + while !Task.isCancelled { + withAnimation(.easeInOut(duration: 0.85)) { pulseOpacity = 0.45 } + try? await Task.sleep(nanoseconds: 850_000_000) + guard !Task.isCancelled else { break } + withAnimation(.easeInOut(duration: 0.85)) { pulseOpacity = 1.0 } + try? await Task.sleep(nanoseconds: 850_000_000) + } + } + } +} + +#endif diff --git a/NetBird/Source/App/Views/iOS/iOSConnectionView.swift b/NetBird/Source/App/Views/iOS/iOSConnectionView.swift index 1a22d45..644b746 100644 --- a/NetBird/Source/App/Views/iOS/iOSConnectionView.swift +++ b/NetBird/Source/App/Views/iOS/iOSConnectionView.swift @@ -2,201 +2,203 @@ // iOSConnectionView.swift // NetBird // -// Connection tab: VPN button, FQDN/IP display, status indicator. +// Connection tab: VPN toggle, FQDN/IP display, status indicator. // import SwiftUI -import Lottie import NetworkExtension #if os(iOS) struct iOSConnectionView: View { @EnvironmentObject var viewModel: ViewModel - @State private var animationKey: UUID = UUID() @State private var fqdnCopied = false - @State private var ipCopied = false + @State private var ipv4Copied = false + @State private var ipv6Copied = false + @State private var showAddressDetails = false var body: some View { - GeometryReader { geometry in - let isLandscape = geometry.size.width > geometry.size.height - let imageName = isLandscape ? "bg-bottom-landscape" : "bg-bottom" - - ZStack { - - if viewModel.statusDetailsValid { - // Background layers - VStack { - Color("BgSecondary") - .frame(height: UIScreen.main.bounds.height * 4/5) - .ignoresSafeArea(.all) - Color("BgPrimary") - .frame(height: UIScreen.main.bounds.height * 1/5) - .ignoresSafeArea(.all) + ZStack { + if viewModel.statusDetailsValid { + Color("BgMenu") + .ignoresSafeArea() + + VStack(spacing: 0) { + // Profile selector + ProfileBadge(profileName: viewModel.activeProfileName) { + viewModel.navigateToProfilesView = true } + .padding(.top, 8) + .frame(maxWidth: .infinity, alignment: .center) - VStack { - Image(imageName) - .resizable(resizingMode: .stretch) - .aspectRatio(contentMode: DeviceType.isPad ? .fill : .fit) - // Button overlaid directly on the image so both share the same - // coordinate space — mirrors Android where btn_connect is a sibling - // of bg_mask inside bg_mask_container. - // vertical_bias=0.07: button top = 7% of (imageHeight - buttonHeight) - // Portrait image ratio h/w = 488/360 = 1.357 - // offset = (Screen.width*1.357 - Screen.width*0.79) * 0.07 ≈ Screen.width * 0.04 - .overlay(alignment: .top) { - let btnSize = Screen.width * (isLandscape ? 0.40 : 0.79) - let imgH = isLandscape - ? Screen.height * 0.81 // landscape: height-constrained - : Screen.width * 1.357 // portrait: width-constrained - let biasOffset = max(0, (imgH - btnSize) * 0.07) - - VStack(spacing: 0) { - Color.clear.frame(height: biasOffset) - - Button(action: { - if !viewModel.buttonLock { - switch viewModel.vpnDisplayState { - case .disconnected: - viewModel.connect() - case .connecting, .connected: - viewModel.close() - case .disconnecting: - break - } - } - }) { - CustomLottieView(vpnState: $viewModel.vpnDisplayState) - .id(animationKey) - .frame(width: btnSize, height: btnSize) - .onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in - self.animationKey = UUID() - } - } - - Text(viewModel.extensionStateText) - .foregroundColor(Color("TextSecondary")) - .font(.system(size: 24, weight: .regular)) - .padding(.top, 24) + Spacer() - Spacer() - } - } - .padding(.top, Screen.height * (DeviceType.isPad ? (isLandscape ? -0.15 : 0.36) : 0.19)) - .padding(.leading, UIScreen.main.bounds.height * (isLandscape ? 0.04 : 0)) - .frame(width: UIScreen.main.bounds.width, height: UIScreen.main.bounds.height) - .edgesIgnoringSafeArea(.bottom) - } - - // FQDN + IP - VStack { - ProfileBadge(profileName: viewModel.activeProfileName) { - viewModel.navigateToProfilesView = true - } - .padding(.top, 8) - - Text(fqdnCopied ? "Copied" : viewModel.fqdn) + // Logo + toggle + status text + device info — all in one centered block + VStack(spacing: 24) { + Image("netbird-logo-menu") + .resizable() + .scaledToFit() + .frame(height: 44) + + VPNToggleView( + vpnState: viewModel.vpnDisplayState, + isLocked: viewModel.buttonLock, + onConnect: { viewModel.connect() }, + onDisconnect: { viewModel.close() } + ) + .padding(.vertical, 12) + + Text(viewModel.extensionStateText) + .font(.custom("InterVariable", size: 18)) + .fontWeight(.bold) .foregroundColor(Color("TextPrimary")) - .font(.system(size: 20, weight: .regular)) - .lineLimit(1) - .minimumScaleFactor(0.5) - .opacity(fqdnCopied ? 0.7 : 1.0) - .animation(.easeInOut(duration: 0.2), value: fqdnCopied) - .padding(.horizontal, 16) - .padding(.top, Screen.height * (DeviceType.isPad ? 0.09 : 0.13)) - .padding(.bottom, 5) - .onTapGesture { - guard !viewModel.fqdn.isEmpty else { return } - UIPasteboard.general.string = viewModel.fqdn - UIImpactFeedbackGenerator(style: .light).impactOccurred() - withAnimation(.smooth) { - fqdnCopied = true + + VStack(spacing: 15) { + Text(fqdnCopied ? "Copied" : viewModel.fqdn) + .font(.custom("JetBrainsMono-Regular", size: 15)) + .foregroundColor(Color("TextPrimary")) + .lineLimit(1) + .minimumScaleFactor(0.5) + .opacity(fqdnCopied ? 0.7 : 1.0) + .animation(.easeInOut(duration: 0.2), value: fqdnCopied) + .padding(.horizontal, 16) + .contentShape(Rectangle().inset(by: -12)) + .onTapGesture { copy(viewModel.fqdn, into: $fqdnCopied) } + + // Expandable IP details: tap to reveal IPv4 + IPv6 with copy actions. + // The dropdown is an overlay (not part of the VStack flow) so it doesn't + // change this block's height and shift the centered content above it. + Button { + withAnimation(.easeInOut(duration: 0.2)) { + showAddressDetails.toggle() } - DispatchQueue.main.asyncAfter(deadline: .now() + 1) { - withAnimation(.smooth) { - fqdnCopied = false - } + } label: { + HStack(spacing: 6) { + Text(viewModel.ip) + .font(.custom("JetBrainsMono-Regular", size: 15)) + .foregroundColor(Color("TextSecondary")) + .lineLimit(1) + .minimumScaleFactor(0.5) + Image(systemName: "chevron.down") + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(Color("TextSecondary")) + .rotationEffect(.degrees(showAddressDetails ? 0 : 180)) } + .contentShape(Rectangle().inset(by: -12)) } - - Text(ipCopied ? "Copied" : viewModel.ip) - .foregroundColor(Color("TextPrimary")) - .font(.system(size: 20, weight: .regular)) - .lineLimit(1) - .minimumScaleFactor(0.5) - .opacity(ipCopied ? 0.7 : 1.0) - .animation(.easeInOut(duration: 0.2), value: ipCopied) - .onTapGesture { - guard !viewModel.ip.isEmpty else { return } - UIPasteboard.general.string = viewModel.ip - UIImpactFeedbackGenerator(style: .light).impactOccurred() - withAnimation(.smooth) { - ipCopied = true - } - DispatchQueue.main.asyncAfter(deadline: .now() + 1) { - withAnimation(.smooth) { - ipCopied = false + .padding(.top, 4) + .overlay(alignment: .top) { + if showAddressDetails { + VStack(spacing: 0) { + addressRow(value: viewModel.ip, copied: $ipv4Copied) + Divider().background(Color("TextSecondary").opacity(0.2)) + addressRow(value: viewModel.ipv6, copied: $ipv6Copied) } + .frame(width: UIScreen.main.bounds.width - 92) + .background(Color("BgMenu")) + .clipShape(RoundedRectangle(cornerRadius: 10)) + .overlay(RoundedRectangle(cornerRadius: 10).stroke(Color("TextSecondary").opacity(0.2))) + .offset(y: 36) + .padding(.top, 10) + .transition(.opacity) } } - - Spacer() + } } - // Network warning banner – above tab bar - if viewModel.vpnDisplayState == .connected && !viewModel.isInternetConnected { + Spacer() + } + + // Network warning shown above tab bar when connected but offline + if viewModel.vpnDisplayState == .connected && !viewModel.isInternetConnected { + GeometryReader { geo in VStack { Spacer() NetworkWarningBanner() - .padding(.bottom, geometry.safeAreaInsets.bottom + 80) + .padding(.bottom, geo.safeAreaInsets.bottom + 80) } - .transition(.opacity.combined(with: .move(edge: .bottom))) - .animation(.easeInOut(duration: 0.3), value: viewModel.isInternetConnected) } + .transition(.opacity.combined(with: .move(edge: .bottom))) + .animation(.easeInOut(duration: 0.3), value: viewModel.isInternetConnected) + } - // Hidden NavigationLink for ProfilesView - NavigationLink("", destination: ProfilesListView(), isActive: $viewModel.navigateToProfilesView) - .hidden() + NavigationLink("", destination: ProfilesListView(), isActive: $viewModel.navigateToProfilesView) + .hidden() - // Hidden NavigationLink for ServerView - NavigationLink("", destination: ServerView(), isActive: $viewModel.navigateToServerView) - .hidden() - .onChange(of: viewModel.navigateToServerView) { newValue in - if !newValue { - viewModel.startPollingDetails() - } + NavigationLink("", destination: ServerView(), isActive: $viewModel.navigateToServerView) + .hidden() + .onChange(of: viewModel.navigateToServerView) { newValue in + if !newValue { + viewModel.startPollingDetails() } - - } else { - // Loading placeholder - ZStack { - Color("BgPrimary") - .ignoresSafeArea() - Image("netbird-logo-menu") - .resizable() - .scaledToFit() - .frame(width: 200) } + + } else { + // Loading placeholder while extension state is unknown + ZStack { + Color("BgMenu").ignoresSafeArea() + Image("netbird-logo-menu") + .resizable() + .scaledToFit() + .frame(width: 200) } + } - // Safari login view — shown regardless of statusDetailsValid - if viewModel.networkExtensionAdapter.showBrowser, - let loginURLString = viewModel.networkExtensionAdapter.loginURL, - let loginURL = URL(string: loginURLString) - { - SafariView(isPresented: $viewModel.networkExtensionAdapter.showBrowser, - url: loginURL, - didFinish: { + // Safari-based login flow + if viewModel.networkExtensionAdapter.showBrowser, + let loginURLString = viewModel.networkExtensionAdapter.loginURL, + let loginURL = URL(string: loginURLString) + { + SafariView( + isPresented: $viewModel.networkExtensionAdapter.showBrowser, + url: loginURL, + didFinish: { print("Finish login") viewModel.networkExtensionAdapter.startVPNConnection() - }) - } + } + ) } } .navigationBarTitleDisplayMode(.inline) .navigationBarHidden(true) } + + @ViewBuilder + private func addressRow(value: String, copied: Binding) -> some View { + HStack { + Text(copied.wrappedValue ? "Copied" : (value.isEmpty ? "—" : value)) + .font(.custom("JetBrainsMono-Regular", size: 14)) + .foregroundColor(Color("TextPrimary")) + .lineLimit(1) + .truncationMode(.middle) + .opacity(copied.wrappedValue ? 0.7 : 1.0) + .animation(.easeInOut(duration: 0.2), value: copied.wrappedValue) + + Spacer() + + Button { + copy(value, into: copied) + } label: { + Image(systemName: "doc.on.doc") + .font(.system(size: 14)) + .foregroundColor(Color("TextSecondary")) + .contentShape(Rectangle().inset(by: -10)) + } + .disabled(value.isEmpty) + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + } + + private func copy(_ value: String, into flag: Binding) { + guard !value.isEmpty else { return } + UIPasteboard.general.string = value + UIImpactFeedbackGenerator(style: .light).impactOccurred() + withAnimation(.easeInOut(duration: 0.2)) { flag.wrappedValue = true } + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + withAnimation(.easeInOut(duration: 0.2)) { flag.wrappedValue = false } + } + } } #endif diff --git a/NetbirdKit/ProfileConnectionCache.swift b/NetbirdKit/ProfileConnectionCache.swift index 117b573..2db0f5f 100644 --- a/NetbirdKit/ProfileConnectionCache.swift +++ b/NetbirdKit/ProfileConnectionCache.swift @@ -11,6 +11,7 @@ struct ProfileConnectionEntry: Codable, Equatable { var ip: String var fqdn: String var managementURL: String? + var ipv6: String? } // MARK: - Cache @@ -38,18 +39,19 @@ struct ProfileConnectionCache { // MARK: - Write - func save(ip: String, fqdn: String, for profile: String) { + func save(ip: String, fqdn: String, ipv6: String? = nil, for profile: String) { var all = load() - var entry = all[profile] ?? ProfileConnectionEntry(ip: "", fqdn: "", managementURL: nil) + var entry = all[profile] ?? ProfileConnectionEntry(ip: "", fqdn: "", managementURL: nil, ipv6: nil) entry.ip = ip entry.fqdn = fqdn + entry.ipv6 = ipv6 all[profile] = entry persist(all) } func saveManagementURL(_ url: String, for profile: String) { var all = load() - var entry = all[profile] ?? ProfileConnectionEntry(ip: "", fqdn: "", managementURL: nil) + var entry = all[profile] ?? ProfileConnectionEntry(ip: "", fqdn: "", managementURL: nil, ipv6: nil) entry.managementURL = url all[profile] = entry persist(all) @@ -61,6 +63,7 @@ struct ProfileConnectionCache { guard var entry = all[profile] else { return } entry.ip = "" entry.fqdn = "" + entry.ipv6 = nil all[profile] = entry persist(all) }