From c34dada2b58c591cb5ba7b58adc5ac79c631a867 Mon Sep 17 00:00:00 2001 From: Robin Kutesa Date: Sat, 16 May 2026 11:11:52 +0300 Subject: [PATCH] Redesign ConnectView for UGreen NAS, default to SFTP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- Features/Connect/ConnectView.swift | 301 ++++++++++++++---------- Features/Connect/ConnectViewModel.swift | 2 +- 2 files changed, 175 insertions(+), 128 deletions(-) diff --git a/Features/Connect/ConnectView.swift b/Features/Connect/ConnectView.swift index a538879..09df8e2 100644 --- a/Features/Connect/ConnectView.swift +++ b/Features/Connect/ConnectView.swift @@ -3,23 +3,37 @@ 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 { - Color.white.ignoresSafeArea() + AppTheme.surfaceSunken.ignoresSafeArea() ScrollView { VStack(spacing: 0) { - // Logo - Image("nas_logo_clean") - .resizable() - .scaledToFit() - .frame(width: 44, height: 44) - .clipShape(RoundedRectangle(cornerRadius: 11, style: .continuous)) - .padding(.top, 44) - .padding(.bottom, 24) + + // Logo + title + VStack(spacing: 10) { + Image("nas_logo_clean") + .resizable() + .scaledToFit() + .frame(width: 44, height: 44) + .clipShape(RoundedRectangle(cornerRadius: 11, style: .continuous)) + .padding(.top, 44) + + 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 HStack(spacing: 0) { @@ -30,54 +44,124 @@ struct ConnectView: View { } } label: { Text(proto.rawValue) - .font(.system(size: 13, weight: .medium)) + .font(.system(size: 12, weight: .medium)) .foregroundStyle(vm.selectedProtocol == proto ? AppTheme.ink : AppTheme.inkTertiary) .frame(maxWidth: .infinity) - .padding(.vertical, 8) + .padding(.vertical, 7) .background( - RoundedRectangle(cornerRadius: 8, style: .continuous) + RoundedRectangle(cornerRadius: 7, style: .continuous) .fill(vm.selectedProtocol == proto ? Color.white : Color.clear) .shadow( - color: vm.selectedProtocol == proto ? .black.opacity(0.07) : .clear, - radius: 4, x: 0, y: 1 + color: vm.selectedProtocol == proto ? .black.opacity(0.06) : .clear, + radius: 3, x: 0, y: 1 ) ) } .buttonStyle(PlainButtonStyle()) } } - .padding(4) - .background(AppTheme.surfaceSunken) - .clipShape(RoundedRectangle(cornerRadius: 11, style: .continuous)) + .padding(3) + .background(Color.black.opacity(0.06)) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) .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) { - formField( - icon: "network", - placeholder: "IP address or hostname", - text: $vm.host, - field: .host, - keyboard: .URL - ) - hairline - formField( - icon: "person", - placeholder: "Username", - text: $vm.username, - field: .username, - keyboard: .emailAddress - ) - hairline - secureField( - icon: "lock", - placeholder: "Password", - text: $vm.password, - field: .password - ) - hairline - folderRow + 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(Color.white) .clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous)) @@ -86,15 +170,43 @@ struct ConnectView: View { .stroke( vm.isConnected ? AppTheme.positive.opacity(0.4) : vm.verifyError != nil ? AppTheme.destructive.opacity(0.4) : - AppTheme.cardBorderColor, - lineWidth: vm.isConnected || vm.verifyError != nil ? 1 : 0.5 + 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(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 Group { if let error = vm.verifyError { @@ -105,7 +217,6 @@ struct ConnectView: View { .font(AppTheme.caption()) } .foregroundStyle(AppTheme.destructive) - .padding(.top, 10) .padding(.horizontal, AppTheme.hPad) .transition(.opacity.combined(with: .move(edge: .top))) } else if vm.isConnected { @@ -116,17 +227,15 @@ struct ConnectView: View { .font(AppTheme.caption()) } .foregroundStyle(AppTheme.positive) - .padding(.top, 10) .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) - // Button — below the card with deliberate gap - Spacer().frame(height: 20) - + // Connect / Continue button Button(action: { if vm.isConnected { navigateToDashboard = true } else { Task { await vm.verify() } } @@ -146,11 +255,24 @@ struct ConnectView: View { .disabled(!vm.canConnect) .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) .navigationDestination(isPresented: $navigateToDashboard) { MainTabView() } .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, - 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, - 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) - } } diff --git a/Features/Connect/ConnectViewModel.swift b/Features/Connect/ConnectViewModel.swift index 11b9412..69e0067 100644 --- a/Features/Connect/ConnectViewModel.swift +++ b/Features/Connect/ConnectViewModel.swift @@ -9,7 +9,7 @@ final class ConnectViewModel: ObservableObject { @Published var username = "" @Published var password = "" @Published var remotePath = "/" - @Published var selectedProtocol: NASProtocol = .smb + @Published var selectedProtocol: NASProtocol = .sftp @Published var isVerifying = false @Published var isConnected = false @Published var verifyError: String? = nil