Files
evgeniyChepelevandClaude Opus 4.5 f5b1de0cc7 Redesign app (#60)
* fix ui state for airplaine mode

* fix slide bar

* Keep VPN tunnel alive during network unavailability

- Add isNetworkUnavailable flag to NetBirdAdapter to track network state
- Modify ConnectionListener to stay in 'connecting' state when network
  is unavailable instead of transitioning to 'disconnected'
- Update PacketTunnelProvider to set network unavailable flag and
  trigger automatic reconnect when network returns
- Fix CustomLottieView to show grey 'Disconnected' state immediately
  when network is lost, without closing the VPN tunnel
- Ensure UI shows correct state after app foreground/background cycle

This allows the VPN tunnel to survive temporary network outages
(e.g. airplane mode) and automatically reconnect when network returns.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Fix UI state after app foreground/background cycle

Show correct connected/disconnected state immediately when app returns
from background, without replaying animations. Use extensionStatus
(iOS VPN state) as the source of truth for UI state.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* netbird credential

* Update MainView.swift

* Separate button by state

* Add gogoleServiceInfo plist reference

* Remove dead code in animation state machine and fix copy-to-clipboard UX  - Remove unreachable shouldForceReset block in CustomLottieView (already handled by earlier return) - Guard against copying empty fqdn/ip strings when disconnected - Use consistent .smooth animation for both fqdn and ip copy feedback

- Remove unreachable shouldForceReset block in CustomLottieView (already handled by earlier return)
- Guard against copying empty fqdn/ip strings when disconnected
- Use consistent .smooth animation for both fqdn and ip copy feedback

* Tab bar

* Update peer view

* Add offline state handling and network warning banner

- Show "Offline" instead of "Connected" when VPN tunnel is active but device has no internet
- Add NetworkWarningBanner with "Network Issues" warning when connected without internet
- Remove InternetStatusView (online/offline indicator) from connection screen
- Add FirstLaunchView onboarding screen
- Remove unused components (CustomBackButton, Extensions, JustifiedText, SolidButton, TransparentGradientButton)
- Update AboutView, AdvancedView, ServerView, PeerTabView, RouteTabView, iOSNetworksView styling
- Add EmptyTabPlaceholder component

* Add AppButton component with liquid glass support for iOS 26+

* Update FirstLaunchView.swift

* old version

* Update project.pbxproj

* Code refactoring

* Update project.pbxproj

* Update MainViewModel.swift

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-02 15:23:51 +01:00

183 lines
8.1 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: .fit)
.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 + internet status
VStack {
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()
}
// VPN button + status text
VStack {
Spacer()
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: UIScreen.main.bounds.width * (isLandscape ? 0.40 : 0.79), height: UIScreen.main.bounds.width * (isLandscape ? 0.40 : 0.79))
.onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in
self.animationKey = UUID()
}
}
.padding(.top, -UIScreen.main.bounds.height / 27)
.padding(.bottom)
Text(viewModel.extensionStateText)
.foregroundColor(Color("TextSecondary"))
.font(.system(size: 24, weight: .regular))
Spacer()
}
.padding()
// 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 ServerView
NavigationLink("", destination: ServerView(), isActive: $viewModel.navigateToServerView)
.hidden()
.onChange(of: viewModel.navigateToServerView) { newValue in
if !newValue {
viewModel.startPollingDetails()
}
}
// Safari login view
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()
})
}
} else {
// Loading placeholder
ZStack {
Color("BgPrimary")
.ignoresSafeArea()
Image("netbird-logo-menu")
.resizable()
.scaledToFit()
.frame(width: 200)
}
}
}
}
.navigationBarTitleDisplayMode(.inline)
.navigationBarHidden(true)
}
}
#endif