Redesign ConnectView for UGreen NAS, default to SFTP
- Standalone IP field with hint labels below (matches UGreen connect pattern) - Combined credentials card: username + hairline + password with show/hide eye - Path field shows full absolute path (UGreen uses POSIX paths via SFTP) - Default protocol changed from SMB → SFTP (UGreen absolute paths are SFTP-native) - surfaceSunken background, protocol picker stays as secondary control - Help button stub at bottom Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -3,23 +3,37 @@ import SwiftUI
|
|||||||
struct ConnectView: View {
|
struct ConnectView: View {
|
||||||
@StateObject private var vm = ConnectViewModel()
|
@StateObject private var vm = ConnectViewModel()
|
||||||
@State private var navigateToDashboard = false
|
@State private var navigateToDashboard = false
|
||||||
|
@State private var showPassword = false
|
||||||
@FocusState private var focused: ConnectField?
|
@FocusState private var focused: ConnectField?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
ZStack {
|
ZStack {
|
||||||
Color.white.ignoresSafeArea()
|
AppTheme.surfaceSunken.ignoresSafeArea()
|
||||||
|
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
// Logo
|
|
||||||
|
// Logo + title
|
||||||
|
VStack(spacing: 10) {
|
||||||
Image("nas_logo_clean")
|
Image("nas_logo_clean")
|
||||||
.resizable()
|
.resizable()
|
||||||
.scaledToFit()
|
.scaledToFit()
|
||||||
.frame(width: 44, height: 44)
|
.frame(width: 44, height: 44)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 11, style: .continuous))
|
.clipShape(RoundedRectangle(cornerRadius: 11, style: .continuous))
|
||||||
.padding(.top, 44)
|
.padding(.top, 44)
|
||||||
.padding(.bottom, 24)
|
|
||||||
|
Text("NAS Connect")
|
||||||
|
.font(.system(size: 26, weight: .semibold))
|
||||||
|
.foregroundStyle(AppTheme.ink)
|
||||||
|
|
||||||
|
Text("Connect to your UGreen or any SMB / SFTP NAS")
|
||||||
|
.font(AppTheme.caption())
|
||||||
|
.foregroundStyle(AppTheme.inkTertiary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
}
|
||||||
|
.padding(.bottom, 32)
|
||||||
|
.padding(.horizontal, AppTheme.hPad)
|
||||||
|
|
||||||
// Protocol picker
|
// Protocol picker
|
||||||
HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
@@ -30,54 +44,124 @@ struct ConnectView: View {
|
|||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
Text(proto.rawValue)
|
Text(proto.rawValue)
|
||||||
.font(.system(size: 13, weight: .medium))
|
.font(.system(size: 12, weight: .medium))
|
||||||
.foregroundStyle(vm.selectedProtocol == proto ? AppTheme.ink : AppTheme.inkTertiary)
|
.foregroundStyle(vm.selectedProtocol == proto ? AppTheme.ink : AppTheme.inkTertiary)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 7)
|
||||||
.background(
|
.background(
|
||||||
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
RoundedRectangle(cornerRadius: 7, style: .continuous)
|
||||||
.fill(vm.selectedProtocol == proto ? Color.white : Color.clear)
|
.fill(vm.selectedProtocol == proto ? Color.white : Color.clear)
|
||||||
.shadow(
|
.shadow(
|
||||||
color: vm.selectedProtocol == proto ? .black.opacity(0.07) : .clear,
|
color: vm.selectedProtocol == proto ? .black.opacity(0.06) : .clear,
|
||||||
radius: 4, x: 0, y: 1
|
radius: 3, x: 0, y: 1
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.buttonStyle(PlainButtonStyle())
|
.buttonStyle(PlainButtonStyle())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(4)
|
.padding(3)
|
||||||
.background(AppTheme.surfaceSunken)
|
.background(Color.black.opacity(0.06))
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 11, style: .continuous))
|
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||||
.padding(.horizontal, AppTheme.hPad)
|
.padding(.horizontal, AppTheme.hPad)
|
||||||
.padding(.bottom, 16)
|
.padding(.bottom, 20)
|
||||||
|
|
||||||
// Form card
|
// 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(Color.white)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
|
||||||
|
.shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2)
|
||||||
|
|
||||||
|
HStack(spacing: 20) {
|
||||||
|
Text("IP Address, ex: 192.168.1.10")
|
||||||
|
.font(AppTheme.micro(11))
|
||||||
|
.foregroundStyle(AppTheme.inkTertiary)
|
||||||
|
Text("NAS Name, ex: 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) {
|
VStack(spacing: 0) {
|
||||||
formField(
|
HStack(spacing: 12) {
|
||||||
icon: "network",
|
Image(systemName: "person")
|
||||||
placeholder: "IP address or hostname",
|
.font(.system(size: 14, weight: .regular))
|
||||||
text: $vm.host,
|
.foregroundStyle(AppTheme.inkSecondary)
|
||||||
field: .host,
|
.frame(width: 20)
|
||||||
keyboard: .URL
|
TextField("Username", text: $vm.username)
|
||||||
)
|
.font(AppTheme.body())
|
||||||
hairline
|
.foregroundStyle(AppTheme.ink)
|
||||||
formField(
|
.keyboardType(.emailAddress)
|
||||||
icon: "person",
|
.autocorrectionDisabled()
|
||||||
placeholder: "Username",
|
.textInputAutocapitalization(.never)
|
||||||
text: $vm.username,
|
.focused($focused, equals: .username)
|
||||||
field: .username,
|
if !vm.username.isEmpty {
|
||||||
keyboard: .emailAddress
|
Button { vm.username = "" } label: {
|
||||||
)
|
Image(systemName: "xmark.circle.fill")
|
||||||
hairline
|
.foregroundStyle(AppTheme.inkQuaternary)
|
||||||
secureField(
|
.font(.system(size: 16))
|
||||||
icon: "lock",
|
}
|
||||||
placeholder: "Password",
|
}
|
||||||
text: $vm.password,
|
}
|
||||||
field: .password
|
.padding(.horizontal, 16)
|
||||||
)
|
.padding(.vertical, 14)
|
||||||
hairline
|
|
||||||
folderRow
|
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(Color.white)
|
.background(Color.white)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
|
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
|
||||||
@@ -86,15 +170,43 @@ struct ConnectView: View {
|
|||||||
.stroke(
|
.stroke(
|
||||||
vm.isConnected ? AppTheme.positive.opacity(0.4) :
|
vm.isConnected ? AppTheme.positive.opacity(0.4) :
|
||||||
vm.verifyError != nil ? AppTheme.destructive.opacity(0.4) :
|
vm.verifyError != nil ? AppTheme.destructive.opacity(0.4) :
|
||||||
AppTheme.cardBorderColor,
|
Color.clear,
|
||||||
lineWidth: vm.isConnected || vm.verifyError != nil ? 1 : 0.5
|
lineWidth: 1
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2)
|
.shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2)
|
||||||
.padding(.horizontal, AppTheme.hPad)
|
.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.isConnected)
|
||||||
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: vm.verifyError != nil)
|
.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(Color.white)
|
||||||
|
.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
|
// Feedback
|
||||||
Group {
|
Group {
|
||||||
if let error = vm.verifyError {
|
if let error = vm.verifyError {
|
||||||
@@ -105,7 +217,6 @@ struct ConnectView: View {
|
|||||||
.font(AppTheme.caption())
|
.font(AppTheme.caption())
|
||||||
}
|
}
|
||||||
.foregroundStyle(AppTheme.destructive)
|
.foregroundStyle(AppTheme.destructive)
|
||||||
.padding(.top, 10)
|
|
||||||
.padding(.horizontal, AppTheme.hPad)
|
.padding(.horizontal, AppTheme.hPad)
|
||||||
.transition(.opacity.combined(with: .move(edge: .top)))
|
.transition(.opacity.combined(with: .move(edge: .top)))
|
||||||
} else if vm.isConnected {
|
} else if vm.isConnected {
|
||||||
@@ -116,17 +227,15 @@ struct ConnectView: View {
|
|||||||
.font(AppTheme.caption())
|
.font(AppTheme.caption())
|
||||||
}
|
}
|
||||||
.foregroundStyle(AppTheme.positive)
|
.foregroundStyle(AppTheme.positive)
|
||||||
.padding(.top, 10)
|
|
||||||
.padding(.horizontal, AppTheme.hPad)
|
.padding(.horizontal, AppTheme.hPad)
|
||||||
.transition(.opacity.combined(with: .move(edge: .top)))
|
.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.verifyError)
|
||||||
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: vm.isConnected)
|
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: vm.isConnected)
|
||||||
|
.padding(.bottom, 16)
|
||||||
|
|
||||||
// Button — below the card with deliberate gap
|
// Connect / Continue button
|
||||||
Spacer().frame(height: 20)
|
|
||||||
|
|
||||||
Button(action: {
|
Button(action: {
|
||||||
if vm.isConnected { navigateToDashboard = true }
|
if vm.isConnected { navigateToDashboard = true }
|
||||||
else { Task { await vm.verify() } }
|
else { Task { await vm.verify() } }
|
||||||
@@ -146,11 +255,24 @@ struct ConnectView: View {
|
|||||||
.disabled(!vm.canConnect)
|
.disabled(!vm.canConnect)
|
||||||
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: vm.isConnected)
|
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: vm.isConnected)
|
||||||
|
|
||||||
Spacer().frame(height: 40)
|
// Help
|
||||||
|
Button {
|
||||||
|
// future: show help sheet
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: "questionmark.circle")
|
||||||
|
.font(.system(size: 13))
|
||||||
|
Text("Help")
|
||||||
|
.font(AppTheme.caption())
|
||||||
|
}
|
||||||
|
.foregroundStyle(AppTheme.inkTertiary)
|
||||||
|
}
|
||||||
|
.padding(.top, 20)
|
||||||
|
.padding(.bottom, 48)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle("Connect to NAS")
|
.navigationTitle("NAS Connect")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.navigationDestination(isPresented: $navigateToDashboard) { MainTabView() }
|
.navigationDestination(isPresented: $navigateToDashboard) { MainTabView() }
|
||||||
.sheet(isPresented: $vm.showFolderBrowser) {
|
.sheet(isPresented: $vm.showFolderBrowser) {
|
||||||
@@ -160,79 +282,4 @@ struct ConnectView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: — Sub-views
|
|
||||||
|
|
||||||
private var hairline: some View {
|
|
||||||
Rectangle()
|
|
||||||
.fill(AppTheme.separator)
|
|
||||||
.frame(height: 0.5)
|
|
||||||
.padding(.leading, 44)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var folderRow: some View {
|
|
||||||
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(1)
|
|
||||||
Spacer()
|
|
||||||
Image(systemName: "chevron.right")
|
|
||||||
.font(.system(size: 12, weight: .medium))
|
|
||||||
.foregroundStyle(AppTheme.inkQuaternary)
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 16)
|
|
||||||
.padding(.vertical, 14)
|
|
||||||
}
|
|
||||||
.buttonStyle(ScaleButtonStyle())
|
|
||||||
}
|
|
||||||
|
|
||||||
private func formField(
|
|
||||||
icon: String,
|
|
||||||
placeholder: String,
|
|
||||||
text: Binding<String>,
|
|
||||||
field: ConnectField,
|
|
||||||
keyboard: UIKeyboardType
|
|
||||||
) -> some View {
|
|
||||||
HStack(spacing: 12) {
|
|
||||||
Image(systemName: icon)
|
|
||||||
.font(.system(size: 14, weight: .regular))
|
|
||||||
.foregroundStyle(AppTheme.inkSecondary)
|
|
||||||
.frame(width: 20)
|
|
||||||
TextField(placeholder, text: text)
|
|
||||||
.font(AppTheme.body())
|
|
||||||
.foregroundStyle(AppTheme.ink)
|
|
||||||
.keyboardType(keyboard)
|
|
||||||
.autocorrectionDisabled()
|
|
||||||
.textInputAutocapitalization(.never)
|
|
||||||
.focused($focused, equals: field)
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 16)
|
|
||||||
.padding(.vertical, 14)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func secureField(
|
|
||||||
icon: String,
|
|
||||||
placeholder: String,
|
|
||||||
text: Binding<String>,
|
|
||||||
field: ConnectField
|
|
||||||
) -> some View {
|
|
||||||
HStack(spacing: 12) {
|
|
||||||
Image(systemName: icon)
|
|
||||||
.font(.system(size: 14, weight: .regular))
|
|
||||||
.foregroundStyle(AppTheme.inkSecondary)
|
|
||||||
.frame(width: 20)
|
|
||||||
SecureField(placeholder, text: text)
|
|
||||||
.font(AppTheme.body())
|
|
||||||
.foregroundStyle(AppTheme.ink)
|
|
||||||
.focused($focused, equals: field)
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 16)
|
|
||||||
.padding(.vertical, 14)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ final class ConnectViewModel: ObservableObject {
|
|||||||
@Published var username = ""
|
@Published var username = ""
|
||||||
@Published var password = ""
|
@Published var password = ""
|
||||||
@Published var remotePath = "/"
|
@Published var remotePath = "/"
|
||||||
@Published var selectedProtocol: NASProtocol = .smb
|
@Published var selectedProtocol: NASProtocol = .sftp
|
||||||
@Published var isVerifying = false
|
@Published var isVerifying = false
|
||||||
@Published var isConnected = false
|
@Published var isConnected = false
|
||||||
@Published var verifyError: String? = nil
|
@Published var verifyError: String? = nil
|
||||||
|
|||||||
Reference in New Issue
Block a user