Apply Emil Kowalski design across all screens

Near-monochrome palette (ink tokens), black primary buttons, spring
animations, 0.5pt hairline dividers, shadow cards, squircle corners.
Replaces all blue/coloured accents. Adds ButtonStyles component.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Robin Kutesa
2026-05-16 00:43:31 +03:00
parent 848c1da820
commit 04e7a77c1a
14 changed files with 889 additions and 625 deletions

View File

@@ -3,6 +3,7 @@ import SwiftUI
struct ConnectView: View {
@StateObject private var vm = ConnectViewModel()
@State private var navigateToDashboard = false
@FocusState private var focused: ConnectField?
var body: some View {
NavigationStack {
@@ -15,109 +16,143 @@ struct ConnectView: View {
Image("nas_logo_clean")
.resizable()
.scaledToFit()
.frame(width: 56, height: 56)
.clipShape(RoundedRectangle(cornerRadius: 12))
.padding(.top, 48)
.padding(.bottom, 28)
.frame(width: 44, height: 44)
.clipShape(RoundedRectangle(cornerRadius: 11, style: .continuous))
.padding(.top, 44)
.padding(.bottom, 24)
// Protocol picker
Picker("Protocol", selection: $vm.selectedProtocol) {
ForEach(NASProtocol.allCases, id: \.self) { Text($0.rawValue).tag($0) }
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: 13, weight: .medium))
.foregroundStyle(vm.selectedProtocol == proto ? AppTheme.ink : AppTheme.inkTertiary)
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
.background(
RoundedRectangle(cornerRadius: 8, 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
)
)
}
.buttonStyle(PlainButtonStyle())
}
}
.pickerStyle(.segmented)
.padding(.horizontal, 20)
.padding(.bottom, 20)
.padding(4)
.background(AppTheme.surfaceSunken)
.clipShape(RoundedRectangle(cornerRadius: 11, style: .continuous))
.padding(.horizontal, AppTheme.hPad)
.padding(.bottom, 16)
// Form card
VStack(spacing: 0) {
fieldRow(icon: "network", placeholder: "IP Address or hostname",
text: $vm.host, keyboardType: .URL)
Divider().padding(.leading, 52)
fieldRow(icon: "person", placeholder: "Username",
text: $vm.username, keyboardType: .emailAddress)
Divider().padding(.leading, 52)
secureFieldRow(icon: "lock", placeholder: "Password", text: $vm.password)
Divider().padding(.leading, 52)
// Folder picker row
Button(action: { if vm.isConnected { vm.showFolderBrowser = true } }) {
HStack(spacing: 12) {
Image(systemName: "folder")
.font(.system(size: 16))
.foregroundColor(vm.isConnected ? AppTheme.blue : AppTheme.textTertiary)
.frame(width: 28)
Text(vm.remotePath == "/" ? "Destination folder" : vm.remotePath)
.font(AppTheme.bodyFont)
.foregroundColor(vm.remotePath == "/" ? AppTheme.textTertiary : AppTheme.textPrimary)
Spacer()
Image(systemName: "chevron.right")
.font(.system(size: 13))
.foregroundColor(AppTheme.textTertiary)
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
}
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
}
.background(Color.white)
.clipShape(RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius))
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius)
.stroke(vm.isConnected ? AppTheme.connectedBorder :
(vm.verifyError != nil ? AppTheme.red : AppTheme.cardBorder),
lineWidth: vm.isConnected ? 1.5 : 1)
RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous)
.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
)
)
.padding(.horizontal, 20)
.shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2)
.padding(.horizontal, AppTheme.hPad)
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: vm.isConnected)
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: vm.verifyError != nil)
if let error = vm.verifyError {
Text(error)
.font(AppTheme.captionFont)
.foregroundColor(AppTheme.red)
// 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(.top, 10)
.padding(.horizontal, 24)
}
if vm.isConnected {
Text("Connected successfully")
.font(AppTheme.captionFont)
.foregroundColor(AppTheme.green)
.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(.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)
// Connect button sits BELOW the card with explicit gap
Spacer().frame(height: 24)
// Button below the card with deliberate gap
Spacer().frame(height: 20)
Button(action: {
if vm.isConnected {
navigateToDashboard = true
} else {
Task { await vm.verify() }
}
if vm.isConnected { navigateToDashboard = true }
else { Task { await vm.verify() } }
}) {
Group {
if vm.isVerifying {
ProgressView().tint(.white)
} else {
Text(vm.isConnected ? "Connected — continue" : "Connect")
.font(.system(size: 17, weight: .semibold))
Text(vm.isConnected ? "Continue" : "Connect")
}
}
.foregroundColor(.white)
.frame(maxWidth: .infinity)
.frame(height: 52)
.background(vm.isConnected ? AppTheme.green : AppTheme.blue)
.clipShape(RoundedRectangle(cornerRadius: 14))
}
.buttonStyle(PrimaryButtonStyle(
color: vm.isConnected ? AppTheme.positive : AppTheme.ink
))
.padding(.horizontal, AppTheme.hPad)
.disabled(!vm.canConnect)
.padding(.horizontal, 20)
.padding(.bottom, 40)
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: vm.isConnected)
Spacer().frame(height: 40)
}
}
}
.navigationTitle("Connect to NAS")
.navigationBarTitleDisplayMode(.inline)
.navigationDestination(isPresented: $navigateToDashboard) {
MainTabView()
}
.navigationDestination(isPresented: $navigateToDashboard) { MainTabView() }
.sheet(isPresented: $vm.showFolderBrowser) {
if let conn = ConnectionStore.shared.savedConnection {
BrowseView(connection: conn, selectedPath: $vm.remotePath)
@@ -126,30 +161,76 @@ struct ConnectView: View {
}
}
private func fieldRow(icon: String, placeholder: String, text: Binding<String>, keyboardType: UIKeyboardType = .default) -> some 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: 16))
.foregroundColor(AppTheme.textSecondary)
.frame(width: 28)
.font(.system(size: 14, weight: .regular))
.foregroundStyle(AppTheme.inkSecondary)
.frame(width: 20)
TextField(placeholder, text: text)
.font(AppTheme.bodyFont)
.keyboardType(keyboardType)
.font(AppTheme.body())
.foregroundStyle(AppTheme.ink)
.keyboardType(keyboard)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
.focused($focused, equals: field)
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
}
private func secureFieldRow(icon: String, placeholder: String, text: Binding<String>) -> some View {
private func secureField(
icon: String,
placeholder: String,
text: Binding<String>,
field: ConnectField
) -> some View {
HStack(spacing: 12) {
Image(systemName: icon)
.font(.system(size: 16))
.foregroundColor(AppTheme.textSecondary)
.frame(width: 28)
.font(.system(size: 14, weight: .regular))
.foregroundStyle(AppTheme.inkSecondary)
.frame(width: 20)
SecureField(placeholder, text: text)
.font(AppTheme.bodyFont)
.font(AppTheme.body())
.foregroundStyle(AppTheme.ink)
.focused($focused, equals: field)
}
.padding(.horizontal, 16)
.padding(.vertical, 14)