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:
@@ -40,7 +40,7 @@ struct BackupView: View {
|
||||
.font(.system(size: 11, weight: .medium, design: .monospaced))
|
||||
.foregroundStyle(AppTheme.ink)
|
||||
.kerning(2)
|
||||
kisaniLogoMark
|
||||
KisaniLogoMark(size: 66)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.top, 16)
|
||||
@@ -135,35 +135,6 @@ struct BackupView: View {
|
||||
|
||||
// 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
|
||||
|
||||
|
||||
@@ -14,39 +14,30 @@ struct ConnectView: View {
|
||||
ScrollView {
|
||||
VStack(spacing: 0) {
|
||||
|
||||
// Header
|
||||
VStack(spacing: 12) {
|
||||
// "kisani." wordmark
|
||||
// ── Header — identical treatment to BackupView ──
|
||||
VStack(spacing: 10) {
|
||||
Text("kisani.")
|
||||
.font(.system(size: 13, weight: .bold, design: .monospaced))
|
||||
.foregroundStyle(AppTheme.inkTertiary)
|
||||
.kerning(1.2)
|
||||
.padding(.top, 44)
|
||||
.font(.system(size: 11, weight: .medium, design: .monospaced))
|
||||
.foregroundStyle(AppTheme.ink)
|
||||
.kerning(2)
|
||||
.padding(.top, 52)
|
||||
|
||||
// NAS logo mark
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
||||
.fill(AppTheme.ink)
|
||||
.frame(width: 72, height: 72)
|
||||
KisaniLogoMark(size: 66)
|
||||
|
||||
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(.top, 4)
|
||||
}
|
||||
.padding(.bottom, 24)
|
||||
.padding(.horizontal, AppTheme.hPad)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.bottom, 28)
|
||||
|
||||
// Protocol picker
|
||||
// ── Protocol picker ──
|
||||
HStack(spacing: 0) {
|
||||
ForEach(NASProtocol.allCases, id: \.self) { proto in
|
||||
Button {
|
||||
withAnimation(.spring(response: 0.25, dampingFraction: 0.8)) {
|
||||
vm.selectedProtocol = proto
|
||||
vm.isConnected = false
|
||||
vm.verifyError = nil
|
||||
}
|
||||
} label: {
|
||||
Text(proto.rawValue)
|
||||
@@ -72,7 +63,7 @@ struct ConnectView: View {
|
||||
.padding(.horizontal, AppTheme.hPad)
|
||||
.padding(.bottom, 20)
|
||||
|
||||
// IP field (standalone)
|
||||
// ── Step 1: Host ──
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "network")
|
||||
@@ -86,11 +77,12 @@ struct ConnectView: View {
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.never)
|
||||
.focused($focused, equals: .host)
|
||||
.submitLabel(.next)
|
||||
.onSubmit { focused = .username }
|
||||
if !vm.host.isEmpty {
|
||||
Button { vm.host = "" } label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundStyle(AppTheme.inkQuaternary)
|
||||
.font(.system(size: 16))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -108,7 +100,7 @@ struct ConnectView: View {
|
||||
.padding(.horizontal, AppTheme.hPad)
|
||||
.padding(.bottom, 12)
|
||||
|
||||
// Credentials card (username + password combined)
|
||||
// ── Step 1: Credentials card ──
|
||||
VStack(spacing: 0) {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "person")
|
||||
@@ -118,15 +110,15 @@ struct ConnectView: View {
|
||||
TextField("Username", text: $vm.username)
|
||||
.font(AppTheme.body())
|
||||
.foregroundStyle(AppTheme.ink)
|
||||
.keyboardType(.emailAddress)
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.never)
|
||||
.focused($focused, equals: .username)
|
||||
.submitLabel(.next)
|
||||
.onSubmit { focused = .password }
|
||||
if !vm.username.isEmpty {
|
||||
Button { vm.username = "" } label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundStyle(AppTheme.inkQuaternary)
|
||||
.font(.system(size: 16))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -153,6 +145,8 @@ struct ConnectView: View {
|
||||
.font(AppTheme.body())
|
||||
.foregroundStyle(AppTheme.ink)
|
||||
.focused($focused, equals: .password)
|
||||
.submitLabel(.go)
|
||||
.onSubmit { Task { await vm.verify() } }
|
||||
Button {
|
||||
showPassword.toggle()
|
||||
} label: {
|
||||
@@ -169,8 +163,8 @@ struct ConnectView: View {
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous)
|
||||
.stroke(
|
||||
vm.isConnected ? AppTheme.positive.opacity(0.4) :
|
||||
vm.verifyError != nil ? AppTheme.destructive.opacity(0.4) :
|
||||
vm.isConnected ? AppTheme.positive.opacity(0.45) :
|
||||
vm.verifyError != nil ? AppTheme.destructive.opacity(0.45) :
|
||||
Color.clear,
|
||||
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.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)
|
||||
// ── Step 2: Destination folder — slides in after auth ──
|
||||
if vm.step != .credentials {
|
||||
Button(action: { vm.showFolderBrowser = true }) {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: vm.remotePath == "/" ? "folder" : "folder.fill")
|
||||
.font(.system(size: 14, weight: .regular))
|
||||
.foregroundStyle(vm.remotePath == "/" ? AppTheme.inkSecondary : AppTheme.positive)
|
||||
.frame(width: 20)
|
||||
Text(vm.remotePath == "/" ? "Select 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: "chevron.right")
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundStyle(AppTheme.inkQuaternary)
|
||||
}
|
||||
.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.step == .ready ? AppTheme.positive.opacity(0.45) : Color.clear,
|
||||
lineWidth: 1
|
||||
)
|
||||
)
|
||||
.shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2)
|
||||
}
|
||||
.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, 12)
|
||||
.transition(.opacity.combined(with: .move(edge: .top)))
|
||||
.animation(.spring(response: 0.35, dampingFraction: 0.8), value: vm.step)
|
||||
}
|
||||
.buttonStyle(ScaleButtonStyle())
|
||||
|
||||
// ── 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)
|
||||
|
||||
// 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.step)
|
||||
.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() } }
|
||||
focused = nil
|
||||
if vm.step == .ready {
|
||||
navigateToDashboard = true
|
||||
} else {
|
||||
Task { await vm.proceed() }
|
||||
}
|
||||
}) {
|
||||
Group {
|
||||
if vm.isVerifying {
|
||||
ProgressView().tint(.white)
|
||||
} else {
|
||||
Text(vm.isConnected ? "Continue" : "Connect")
|
||||
Text(ctaLabel)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(PrimaryButtonStyle(
|
||||
color: vm.isConnected ? AppTheme.positive : AppTheme.ink
|
||||
))
|
||||
.buttonStyle(PrimaryButtonStyle(color: ctaColor))
|
||||
.padding(.horizontal, AppTheme.hPad)
|
||||
.padding(.top, 12)
|
||||
.padding(.bottom, 32)
|
||||
.disabled(!vm.canConnect)
|
||||
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: vm.isConnected)
|
||||
.disabled(!vm.canProceed)
|
||||
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: vm.step)
|
||||
}
|
||||
.background(AppTheme.background)
|
||||
}
|
||||
@@ -269,8 +266,30 @@ struct ConnectView: View {
|
||||
.sheet(isPresented: $vm.showFolderBrowser) {
|
||||
if let conn = ConnectionStore.shared.savedConnection {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import Foundation
|
||||
|
||||
enum ConnectField { case host, username, password, folder }
|
||||
enum FieldState { case idle, valid, invalid }
|
||||
enum ConnectField { case host, username, password }
|
||||
|
||||
enum ConnectStep: Equatable {
|
||||
case credentials // not yet authenticated
|
||||
case folder // authenticated, no destination chosen
|
||||
case ready // authenticated + valid destination chosen
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class ConnectViewModel: ObservableObject {
|
||||
@@ -17,28 +22,62 @@ final class ConnectViewModel: ObservableObject {
|
||||
|
||||
init() {
|
||||
if let conn = ConnectionStore.shared.savedConnection {
|
||||
host = conn.host
|
||||
username = conn.username
|
||||
password = conn.password
|
||||
remotePath = conn.remotePath
|
||||
host = conn.host
|
||||
username = conn.username
|
||||
password = conn.password
|
||||
remotePath = conn.remotePath
|
||||
selectedProtocol = conn.nasProtocol
|
||||
isConnected = true
|
||||
isConnected = true
|
||||
} else {
|
||||
host = ""
|
||||
username = ""
|
||||
password = ""
|
||||
remotePath = "/"
|
||||
host = ""
|
||||
username = ""
|
||||
password = ""
|
||||
remotePath = "/"
|
||||
selectedProtocol = .smb
|
||||
isConnected = false
|
||||
isConnected = false
|
||||
}
|
||||
}
|
||||
|
||||
var fieldState: FieldState {
|
||||
isConnected ? .valid : (verifyError != nil ? .invalid : .idle)
|
||||
var step: ConnectStep {
|
||||
guard isConnected else { return .credentials }
|
||||
return remotePath == "/" ? .folder : .ready
|
||||
}
|
||||
|
||||
var canConnect: Bool {
|
||||
!host.isEmpty && !username.isEmpty && !password.isEmpty && !isVerifying
|
||||
var canProceed: Bool {
|
||||
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 {
|
||||
@@ -52,7 +91,7 @@ final class ConnectViewModel: ObservableObject {
|
||||
verifyError = nil
|
||||
|
||||
let connection = NASConnection(
|
||||
host: host,
|
||||
host: host.trimmingCharacters(in: .whitespaces),
|
||||
nasProtocol: selectedProtocol,
|
||||
username: username,
|
||||
password: password,
|
||||
@@ -60,8 +99,10 @@ final class ConnectViewModel: ObservableObject {
|
||||
)
|
||||
|
||||
do {
|
||||
try await transfer.connect(to: connection.host, port: connection.port,
|
||||
username: connection.username, password: connection.password)
|
||||
try await transfer.connect(
|
||||
to: connection.host, port: connection.port,
|
||||
username: connection.username, password: connection.password
|
||||
)
|
||||
transfer.disconnect()
|
||||
isConnected = true
|
||||
ConnectionStore.shared.savedConnection = connection
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
35
Shared/Components/KisaniLogoMark.swift
Normal file
35
Shared/Components/KisaniLogoMark.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
name: NASBackup
|
||||
name: Kisani
|
||||
options:
|
||||
bundleIdPrefix: com.albert
|
||||
deploymentTarget:
|
||||
|
||||
Reference in New Issue
Block a user