Files
Kisani/Features/Connect/ConnectView.swift
Robin Kutesa 2bdf41502e fix: pin Connect button to bottom, dynamic protocol badge
- safeAreaInset pins Connect/Continue button to screen bottom,
  eliminating dead space below the form
- UGreenCompatibilityBadge now takes selectedProtocol param:
  green dot + green SMB label when SMB selected,
  grey dot + grey SFTP label when SFTP selected

Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-16 19:19:37 +03:00

277 lines
15 KiB
Swift

import SwiftUI
struct ConnectView: View {
@StateObject private var vm = ConnectViewModel()
@State private var navigateToDashboard = false
@State private var showPassword = false
@FocusState private var focused: ConnectField?
var body: some View {
NavigationStack {
ZStack {
AppTheme.background.ignoresSafeArea()
ScrollView {
VStack(spacing: 0) {
// Header
VStack(spacing: 12) {
// "kisani." wordmark
Text("kisani.")
.font(.system(size: 13, weight: .bold, design: .monospaced))
.foregroundStyle(AppTheme.inkTertiary)
.kerning(1.2)
.padding(.top, 44)
// NAS logo mark
ZStack {
RoundedRectangle(cornerRadius: 18, style: .continuous)
.fill(AppTheme.ink)
.frame(width: 72, height: 72)
Image(systemName: "server.rack")
.font(.system(size: 28, weight: .medium))
.foregroundStyle(AppTheme.inkInverse)
}
.shadow(color: AppTheme.ink.opacity(0.18), radius: 12, x: 0, y: 4)
// UGreen NAS compatibility badge reacts to selected protocol
UGreenCompatibilityBadge(selectedProtocol: vm.selectedProtocol)
}
.padding(.bottom, 24)
.padding(.horizontal, AppTheme.hPad)
// Protocol picker
HStack(spacing: 0) {
ForEach(NASProtocol.allCases, id: \.self) { proto in
Button {
withAnimation(.spring(response: 0.25, dampingFraction: 0.8)) {
vm.selectedProtocol = proto
}
} label: {
Text(proto.rawValue)
.font(.system(size: 12, weight: .medium))
.foregroundStyle(vm.selectedProtocol == proto ? AppTheme.ink : AppTheme.inkTertiary)
.frame(maxWidth: .infinity)
.padding(.vertical, 7)
.background(
RoundedRectangle(cornerRadius: 7, style: .continuous)
.fill(vm.selectedProtocol == proto ? AppTheme.surfaceRaised : Color.clear)
.shadow(
color: vm.selectedProtocol == proto ? .black.opacity(0.06) : .clear,
radius: 3, x: 0, y: 1
)
)
}
.buttonStyle(PlainButtonStyle())
}
}
.padding(3)
.background(AppTheme.surfaceSunken)
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
.padding(.horizontal, AppTheme.hPad)
.padding(.bottom, 20)
// IP field (standalone)
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 12) {
Image(systemName: "network")
.font(.system(size: 14, weight: .regular))
.foregroundStyle(AppTheme.inkSecondary)
.frame(width: 20)
TextField("IP address or hostname", text: $vm.host)
.font(AppTheme.body())
.foregroundStyle(AppTheme.ink)
.keyboardType(.URL)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
.focused($focused, equals: .host)
if !vm.host.isEmpty {
Button { vm.host = "" } label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(AppTheme.inkQuaternary)
.font(.system(size: 16))
}
}
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
.background(AppTheme.surfaceRaised)
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
.shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2)
Text("e.g. 192.168.1.10 or MY_NAS")
.font(AppTheme.micro(11))
.foregroundStyle(AppTheme.inkTertiary)
.padding(.leading, 4)
}
.padding(.horizontal, AppTheme.hPad)
.padding(.bottom, 12)
// Credentials card (username + password combined)
VStack(spacing: 0) {
HStack(spacing: 12) {
Image(systemName: "person")
.font(.system(size: 14, weight: .regular))
.foregroundStyle(AppTheme.inkSecondary)
.frame(width: 20)
TextField("Username", text: $vm.username)
.font(AppTheme.body())
.foregroundStyle(AppTheme.ink)
.keyboardType(.emailAddress)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
.focused($focused, equals: .username)
if !vm.username.isEmpty {
Button { vm.username = "" } label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(AppTheme.inkQuaternary)
.font(.system(size: 16))
}
}
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
Rectangle()
.fill(AppTheme.separator)
.frame(height: 0.5)
.padding(.leading, 44)
HStack(spacing: 12) {
Image(systemName: "lock")
.font(.system(size: 14, weight: .regular))
.foregroundStyle(AppTheme.inkSecondary)
.frame(width: 20)
Group {
if showPassword {
TextField("Password", text: $vm.password)
} else {
SecureField("Password", text: $vm.password)
}
}
.font(AppTheme.body())
.foregroundStyle(AppTheme.ink)
.focused($focused, equals: .password)
Button {
showPassword.toggle()
} label: {
Image(systemName: showPassword ? "eye.slash" : "eye")
.font(.system(size: 14))
.foregroundStyle(AppTheme.inkTertiary)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
}
.background(AppTheme.surfaceRaised)
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous)
.stroke(
vm.isConnected ? AppTheme.positive.opacity(0.4) :
vm.verifyError != nil ? AppTheme.destructive.opacity(0.4) :
Color.clear,
lineWidth: 1
)
)
.shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2)
.padding(.horizontal, AppTheme.hPad)
.padding(.bottom, 12)
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: vm.isConnected)
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: vm.verifyError != nil)
// Path field
Button(action: { if vm.isConnected { vm.showFolderBrowser = true } }) {
HStack(spacing: 12) {
Image(systemName: "folder")
.font(.system(size: 14, weight: .regular))
.foregroundStyle(vm.isConnected ? AppTheme.inkSecondary : AppTheme.inkQuaternary)
.frame(width: 20)
Text(vm.remotePath == "/" ? "Destination folder" : vm.remotePath)
.font(AppTheme.body())
.foregroundStyle(vm.remotePath == "/" ? AppTheme.inkTertiary : AppTheme.ink)
.lineLimit(2)
.multilineTextAlignment(.leading)
.frame(maxWidth: .infinity, alignment: .leading)
Image(systemName: "folder.badge.plus")
.font(.system(size: 16))
.foregroundStyle(vm.isConnected ? AppTheme.inkSecondary : AppTheme.inkQuaternary)
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
.background(AppTheme.surfaceRaised)
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
.shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2)
}
.buttonStyle(ScaleButtonStyle())
.padding(.horizontal, AppTheme.hPad)
.padding(.bottom, 20)
// Feedback
Group {
if let error = vm.verifyError {
HStack(spacing: 6) {
Image(systemName: "exclamationmark.circle")
.font(.system(size: 13))
Text(error)
.font(AppTheme.caption())
}
.foregroundStyle(AppTheme.destructive)
.padding(.horizontal, AppTheme.hPad)
.transition(.opacity.combined(with: .move(edge: .top)))
} else if vm.isConnected {
HStack(spacing: 6) {
Image(systemName: "checkmark.circle")
.font(.system(size: 13))
Text("Connected successfully")
.font(AppTheme.caption())
}
.foregroundStyle(AppTheme.positive)
.padding(.horizontal, AppTheme.hPad)
.transition(.opacity.combined(with: .move(edge: .top)))
}
}
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: vm.verifyError)
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: vm.isConnected)
.padding(.bottom, 16)
}
}
.safeAreaInset(edge: .bottom) {
VStack(spacing: 0) {
Button(action: {
if vm.isConnected { navigateToDashboard = true }
else { Task { await vm.verify() } }
}) {
Group {
if vm.isVerifying {
ProgressView().tint(.white)
} else {
Text(vm.isConnected ? "Continue" : "Connect")
}
}
}
.buttonStyle(PrimaryButtonStyle(
color: vm.isConnected ? AppTheme.positive : AppTheme.ink
))
.padding(.horizontal, AppTheme.hPad)
.padding(.top, 12)
.padding(.bottom, 32)
.disabled(!vm.canConnect)
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: vm.isConnected)
}
.background(AppTheme.background)
}
}
.navigationBarHidden(true)
.navigationDestination(isPresented: $navigateToDashboard) { MainTabView() }
.sheet(isPresented: $vm.showFolderBrowser) {
if let conn = ConnectionStore.shared.savedConnection {
BrowseView(connection: conn, selectedPath: $vm.remotePath)
}
}
}
}
}