Redesign Connect flow with step-based UX and matching logo

Replace the SF Symbol server.rack with the custom KisaniLogoMark
extracted into a shared component used by both ConnectView and BackupView.

Introduce ConnectStep enum (credentials → folder → ready) in
ConnectViewModel. The primary CTA changes label and colour at each
step: "Connect" → "Select Destination" → "Get Started". The folder
row animates in only after successful authentication; its border goes
green once a valid path is chosen. Status message updates live with
the current step. Submitting the password field triggers verify().
Rename project.yml name to Kisani so xcodegen produces Kisani.xcodeproj.

Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
This commit is contained in:
Robin Kutesa
2026-05-17 13:58:03 +03:00
parent 97dfb62dc7
commit 00f68eebcd
6 changed files with 655 additions and 578 deletions

View File

@@ -40,7 +40,7 @@ struct BackupView: View {
.font(.system(size: 11, weight: .medium, design: .monospaced)) .font(.system(size: 11, weight: .medium, design: .monospaced))
.foregroundStyle(AppTheme.ink) .foregroundStyle(AppTheme.ink)
.kerning(2) .kerning(2)
kisaniLogoMark KisaniLogoMark(size: 66)
} }
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.padding(.top, 16) .padding(.top, 16)
@@ -135,35 +135,6 @@ struct BackupView: View {
// MARK: Logo mark (drawn in code no asset dependency) // MARK: Logo mark (drawn in code no asset dependency)
private var kisaniLogoMark: some View {
VStack(spacing: 0) {
driveRow
AppTheme.ink.frame(height: 1.5)
driveRow
AppTheme.ink.frame(height: 1.5)
driveRow
}
.clipShape(RoundedRectangle(cornerRadius: 11, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 11, style: .continuous)
.strokeBorder(AppTheme.ink, lineWidth: 2.5)
)
.frame(width: 66, height: 66)
.shadow(color: .black.opacity(0.05), radius: 10, x: 0, y: 2)
}
private var driveRow: some View {
HStack(spacing: 0) {
Spacer()
Circle()
.fill(AppTheme.ink)
.frame(width: 7, height: 7)
.padding(.trailing, 7)
}
.frame(maxWidth: .infinity)
.frame(height: 18)
.background(AppTheme.surfaceRaised)
}
// MARK: Ring hero // MARK: Ring hero

View File

@@ -14,39 +14,30 @@ struct ConnectView: View {
ScrollView { ScrollView {
VStack(spacing: 0) { VStack(spacing: 0) {
// Header // Header identical treatment to BackupView
VStack(spacing: 12) { VStack(spacing: 10) {
// "kisani." wordmark
Text("kisani.") Text("kisani.")
.font(.system(size: 13, weight: .bold, design: .monospaced)) .font(.system(size: 11, weight: .medium, design: .monospaced))
.foregroundStyle(AppTheme.inkTertiary) .foregroundStyle(AppTheme.ink)
.kerning(1.2) .kerning(2)
.padding(.top, 44) .padding(.top, 52)
// NAS logo mark KisaniLogoMark(size: 66)
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) UGreenCompatibilityBadge(selectedProtocol: vm.selectedProtocol)
.padding(.top, 4)
} }
.padding(.bottom, 24) .frame(maxWidth: .infinity)
.padding(.horizontal, AppTheme.hPad) .padding(.bottom, 28)
// Protocol picker // Protocol picker
HStack(spacing: 0) { HStack(spacing: 0) {
ForEach(NASProtocol.allCases, id: \.self) { proto in ForEach(NASProtocol.allCases, id: \.self) { proto in
Button { Button {
withAnimation(.spring(response: 0.25, dampingFraction: 0.8)) { withAnimation(.spring(response: 0.25, dampingFraction: 0.8)) {
vm.selectedProtocol = proto vm.selectedProtocol = proto
vm.isConnected = false
vm.verifyError = nil
} }
} label: { } label: {
Text(proto.rawValue) Text(proto.rawValue)
@@ -72,7 +63,7 @@ struct ConnectView: View {
.padding(.horizontal, AppTheme.hPad) .padding(.horizontal, AppTheme.hPad)
.padding(.bottom, 20) .padding(.bottom, 20)
// IP field (standalone) // Step 1: Host
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 12) { HStack(spacing: 12) {
Image(systemName: "network") Image(systemName: "network")
@@ -86,11 +77,12 @@ struct ConnectView: View {
.autocorrectionDisabled() .autocorrectionDisabled()
.textInputAutocapitalization(.never) .textInputAutocapitalization(.never)
.focused($focused, equals: .host) .focused($focused, equals: .host)
.submitLabel(.next)
.onSubmit { focused = .username }
if !vm.host.isEmpty { if !vm.host.isEmpty {
Button { vm.host = "" } label: { Button { vm.host = "" } label: {
Image(systemName: "xmark.circle.fill") Image(systemName: "xmark.circle.fill")
.foregroundStyle(AppTheme.inkQuaternary) .foregroundStyle(AppTheme.inkQuaternary)
.font(.system(size: 16))
} }
} }
} }
@@ -108,7 +100,7 @@ struct ConnectView: View {
.padding(.horizontal, AppTheme.hPad) .padding(.horizontal, AppTheme.hPad)
.padding(.bottom, 12) .padding(.bottom, 12)
// Credentials card (username + password combined) // Step 1: Credentials card
VStack(spacing: 0) { VStack(spacing: 0) {
HStack(spacing: 12) { HStack(spacing: 12) {
Image(systemName: "person") Image(systemName: "person")
@@ -118,15 +110,15 @@ struct ConnectView: View {
TextField("Username", text: $vm.username) TextField("Username", text: $vm.username)
.font(AppTheme.body()) .font(AppTheme.body())
.foregroundStyle(AppTheme.ink) .foregroundStyle(AppTheme.ink)
.keyboardType(.emailAddress)
.autocorrectionDisabled() .autocorrectionDisabled()
.textInputAutocapitalization(.never) .textInputAutocapitalization(.never)
.focused($focused, equals: .username) .focused($focused, equals: .username)
.submitLabel(.next)
.onSubmit { focused = .password }
if !vm.username.isEmpty { if !vm.username.isEmpty {
Button { vm.username = "" } label: { Button { vm.username = "" } label: {
Image(systemName: "xmark.circle.fill") Image(systemName: "xmark.circle.fill")
.foregroundStyle(AppTheme.inkQuaternary) .foregroundStyle(AppTheme.inkQuaternary)
.font(.system(size: 16))
} }
} }
} }
@@ -153,6 +145,8 @@ struct ConnectView: View {
.font(AppTheme.body()) .font(AppTheme.body())
.foregroundStyle(AppTheme.ink) .foregroundStyle(AppTheme.ink)
.focused($focused, equals: .password) .focused($focused, equals: .password)
.submitLabel(.go)
.onSubmit { Task { await vm.verify() } }
Button { Button {
showPassword.toggle() showPassword.toggle()
} label: { } label: {
@@ -169,8 +163,8 @@ struct ConnectView: View {
.overlay( .overlay(
RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous) RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous)
.stroke( .stroke(
vm.isConnected ? AppTheme.positive.opacity(0.4) : vm.isConnected ? AppTheme.positive.opacity(0.45) :
vm.verifyError != nil ? AppTheme.destructive.opacity(0.4) : vm.verifyError != nil ? AppTheme.destructive.opacity(0.45) :
Color.clear, Color.clear,
lineWidth: 1 lineWidth: 1
) )
@@ -181,85 +175,88 @@ struct ConnectView: View {
.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 // Step 2: Destination folder slides in after auth
Button(action: { if vm.isConnected { vm.showFolderBrowser = true } }) { if vm.step != .credentials {
Button(action: { vm.showFolderBrowser = true }) {
HStack(spacing: 12) { HStack(spacing: 12) {
Image(systemName: "folder") Image(systemName: vm.remotePath == "/" ? "folder" : "folder.fill")
.font(.system(size: 14, weight: .regular)) .font(.system(size: 14, weight: .regular))
.foregroundStyle(vm.isConnected ? AppTheme.inkSecondary : AppTheme.inkQuaternary) .foregroundStyle(vm.remotePath == "/" ? AppTheme.inkSecondary : AppTheme.positive)
.frame(width: 20) .frame(width: 20)
Text(vm.remotePath == "/" ? "Destination folder" : vm.remotePath) Text(vm.remotePath == "/" ? "Select destination folder" : vm.remotePath)
.font(AppTheme.body()) .font(AppTheme.body())
.foregroundStyle(vm.remotePath == "/" ? AppTheme.inkTertiary : AppTheme.ink) .foregroundStyle(vm.remotePath == "/" ? AppTheme.inkTertiary : AppTheme.ink)
.lineLimit(2) .lineLimit(2)
.multilineTextAlignment(.leading) .multilineTextAlignment(.leading)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
Image(systemName: "folder.badge.plus") Image(systemName: "chevron.right")
.font(.system(size: 16)) .font(.system(size: 12, weight: .medium))
.foregroundStyle(vm.isConnected ? AppTheme.inkSecondary : AppTheme.inkQuaternary) .foregroundStyle(AppTheme.inkQuaternary)
} }
.padding(.horizontal, 16) .padding(.horizontal, 16)
.padding(.vertical, 14) .padding(.vertical, 14)
.background(AppTheme.surfaceRaised) .background(AppTheme.surfaceRaised)
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous)) .clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous)
.stroke(
vm.step == .ready ? AppTheme.positive.opacity(0.45) : Color.clear,
lineWidth: 1
)
)
.shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2) .shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2)
} }
.buttonStyle(ScaleButtonStyle()) .buttonStyle(ScaleButtonStyle())
.padding(.horizontal, AppTheme.hPad) .padding(.horizontal, AppTheme.hPad)
.padding(.bottom, 12)
.transition(.opacity.combined(with: .move(edge: .top)))
.animation(.spring(response: 0.35, dampingFraction: 0.8), value: vm.step)
}
// Status message
HStack(spacing: 6) {
Image(systemName: vm.statusIsError ? "exclamationmark.circle" :
vm.statusIsPositive ? "checkmark.circle" : "circle.dotted")
.font(.system(size: 13))
Text(vm.statusMessage)
.font(AppTheme.caption())
}
.foregroundStyle(
vm.statusIsError ? AppTheme.destructive :
vm.statusIsPositive ? AppTheme.positive :
AppTheme.inkTertiary
)
.padding(.horizontal, AppTheme.hPad)
.padding(.bottom, 20) .padding(.bottom, 20)
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: vm.step)
// 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.verifyError)
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: vm.isConnected)
.padding(.bottom, 16)
} }
} }
.safeAreaInset(edge: .bottom) { .safeAreaInset(edge: .bottom) {
VStack(spacing: 0) { VStack(spacing: 0) {
Button(action: { Button(action: {
if vm.isConnected { navigateToDashboard = true } focused = nil
else { Task { await vm.verify() } } if vm.step == .ready {
navigateToDashboard = true
} else {
Task { await vm.proceed() }
}
}) { }) {
Group { Group {
if vm.isVerifying { if vm.isVerifying {
ProgressView().tint(.white) ProgressView().tint(.white)
} else { } else {
Text(vm.isConnected ? "Continue" : "Connect") Text(ctaLabel)
} }
} }
.frame(maxWidth: .infinity)
} }
.buttonStyle(PrimaryButtonStyle( .buttonStyle(PrimaryButtonStyle(color: ctaColor))
color: vm.isConnected ? AppTheme.positive : AppTheme.ink
))
.padding(.horizontal, AppTheme.hPad) .padding(.horizontal, AppTheme.hPad)
.padding(.top, 12) .padding(.top, 12)
.padding(.bottom, 32) .padding(.bottom, 32)
.disabled(!vm.canConnect) .disabled(!vm.canProceed)
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: vm.isConnected) .animation(.spring(response: 0.3, dampingFraction: 0.8), value: vm.step)
} }
.background(AppTheme.background) .background(AppTheme.background)
} }
@@ -269,8 +266,30 @@ struct ConnectView: View {
.sheet(isPresented: $vm.showFolderBrowser) { .sheet(isPresented: $vm.showFolderBrowser) {
if let conn = ConnectionStore.shared.savedConnection { if let conn = ConnectionStore.shared.savedConnection {
BrowseView(connection: conn, selectedPath: $vm.remotePath, isPickerMode: true) BrowseView(connection: conn, selectedPath: $vm.remotePath, isPickerMode: true)
.onDisappear {
if vm.remotePath != "/", var c = ConnectionStore.shared.savedConnection {
c.remotePath = vm.remotePath
ConnectionStore.shared.savedConnection = c
} }
} }
} }
} }
} }
}
private var ctaLabel: String {
switch vm.step {
case .credentials: return "Connect"
case .folder: return "Select Destination"
case .ready: return "Get Started"
}
}
private var ctaColor: Color {
switch vm.step {
case .credentials: return AppTheme.ink
case .folder: return AppTheme.ink
case .ready: return AppTheme.positive
}
}
}

View File

@@ -1,7 +1,12 @@
import Foundation import Foundation
enum ConnectField { case host, username, password, folder } enum ConnectField { case host, username, password }
enum FieldState { case idle, valid, invalid }
enum ConnectStep: Equatable {
case credentials // not yet authenticated
case folder // authenticated, no destination chosen
case ready // authenticated + valid destination chosen
}
@MainActor @MainActor
final class ConnectViewModel: ObservableObject { final class ConnectViewModel: ObservableObject {
@@ -33,12 +38,46 @@ final class ConnectViewModel: ObservableObject {
} }
} }
var fieldState: FieldState { var step: ConnectStep {
isConnected ? .valid : (verifyError != nil ? .invalid : .idle) guard isConnected else { return .credentials }
return remotePath == "/" ? .folder : .ready
} }
var canConnect: Bool { var canProceed: Bool {
!host.isEmpty && !username.isEmpty && !password.isEmpty && !isVerifying switch step {
case .credentials:
return !host.trimmingCharacters(in: .whitespaces).isEmpty
&& !username.isEmpty
&& !password.isEmpty
&& !isVerifying
case .folder, .ready:
return true
}
}
var statusMessage: String {
switch step {
case .credentials:
return verifyError != nil ? verifyError! : "Enter your NAS credentials"
case .folder:
return "Authenticated · select a destination folder"
case .ready:
return "Ready to sync"
}
}
var statusIsError: Bool { verifyError != nil && step == .credentials }
var statusIsPositive: Bool { step == .folder || step == .ready }
func proceed() async {
switch step {
case .credentials:
await verify()
case .folder:
showFolderBrowser = true
case .ready:
break // navigation handled in the view
}
} }
func verify() async { func verify() async {
@@ -52,7 +91,7 @@ final class ConnectViewModel: ObservableObject {
verifyError = nil verifyError = nil
let connection = NASConnection( let connection = NASConnection(
host: host, host: host.trimmingCharacters(in: .whitespaces),
nasProtocol: selectedProtocol, nasProtocol: selectedProtocol,
username: username, username: username,
password: password, password: password,
@@ -60,8 +99,10 @@ final class ConnectViewModel: ObservableObject {
) )
do { do {
try await transfer.connect(to: connection.host, port: connection.port, try await transfer.connect(
username: connection.username, password: connection.password) to: connection.host, port: connection.port,
username: connection.username, password: connection.password
)
transfer.disconnect() transfer.disconnect()
isConnected = true isConnected = true
ConnectionStore.shared.savedConnection = connection ConnectionStore.shared.savedConnection = connection

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,35 @@
import SwiftUI
struct KisaniLogoMark: View {
var size: CGFloat = 66
var body: some View {
VStack(spacing: 0) {
driveRow
AppTheme.ink.frame(height: 1.5)
driveRow
AppTheme.ink.frame(height: 1.5)
driveRow
}
.clipShape(RoundedRectangle(cornerRadius: size * 0.167, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: size * 0.167, style: .continuous)
.strokeBorder(AppTheme.ink, lineWidth: 2.5)
)
.frame(width: size, height: size)
.shadow(color: .black.opacity(0.05), radius: 10, x: 0, y: 2)
}
private var driveRow: some View {
HStack(spacing: 0) {
Spacer()
Circle()
.fill(AppTheme.ink)
.frame(width: size * 0.106, height: size * 0.106)
.padding(.trailing, size * 0.106)
}
.frame(maxWidth: .infinity)
.frame(height: size * 0.273)
.background(AppTheme.surfaceRaised)
}
}

View File

@@ -1,4 +1,4 @@
name: NASBackup name: Kisani
options: options:
bundleIdPrefix: com.albert bundleIdPrefix: com.albert
deploymentTarget: deploymentTarget: