Files
evgeniyChepelev 8bf2de390e fix(iOS): align VPN button to arch background via overlay (#107)
Move the Lottie button into an overlay on the background image so
both share the same coordinate space, matching Android's approach
of placing btn_connect as a sibling of bg_mask inside bg_mask_container.
2026-04-28 15:28:19 +02:00

203 lines
9.7 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// iOSConnectionView.swift
// NetBird
//
// Connection tab: VPN button, 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
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)
}
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()
}
}
.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)
.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
}
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
withAnimation(.smooth) {
fqdnCopied = false
}
}
}
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
}
}
}
Spacer()
}
// Network warning banner above tab bar
if viewModel.vpnDisplayState == .connected && !viewModel.isInternetConnected {
VStack {
Spacer()
NetworkWarningBanner()
.padding(.bottom, geometry.safeAreaInsets.bottom + 80)
}
.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()
// Hidden NavigationLink for ServerView
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)
}
}
// 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: {
print("Finish login")
viewModel.networkExtensionAdapter.startVPNConnection()
})
}
}
}
.navigationBarTitleDisplayMode(.inline)
.navigationBarHidden(true)
}
}
#endif