Overhaul Connect flow with proper state machine and progressive UX
Introduce ConnectionState enum (idle → authenticating → authenticated → destinationSelected → failed) replacing the old Bool isConnected flag. The entire UI renders reactively from this single state value. Authentication: vm.authenticate() drives the state, haptic feedback fires on success, any field edit resets back to idle so the user always knows their current auth is stale after editing. Connected banner: slides in below the credentials card after auth showing "Connected to [host]" with a green dot and a Disconnect button that resets state without clearing credentials. Destination row: always visible at 40% opacity with a lock icon and placeholder text "Connect to your NAS first" — activates to full opacity with chevron and browsable state after auth. Green border + subtitle "Backup destination ready" appears after selection. Taps blocked via allowsHitTesting until authenticated. CTA progression: idle/failed → "Connect" (enabled when fields filled) authenticating → spinner, disabled authenticated → "Choose Destination" (opens folder browser) destinationSelected → "Get Started" in green → navigates to dashboard After folder browser dismissal, confirmDestination() writes the path to ConnectionStore and fires a light haptic. Navigation to MainTabView happens only when state == .destinationSelected. Co-Authored-By: Kutesir <kutesir@provoc.ug> Co-Authored-By: Sentry <sentry@provoc.ug>
This commit is contained in:
@@ -4,8 +4,12 @@ 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
|
@State private var showPassword = false
|
||||||
|
@State private var folderPickerPath = "/"
|
||||||
@FocusState private var focused: ConnectField?
|
@FocusState private var focused: ConnectField?
|
||||||
|
|
||||||
|
private var isAuthenticated: Bool { vm.connectionState.isAuthenticated }
|
||||||
|
private var isFailed: Bool { vm.connectionState.errorMessage != nil }
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
ZStack {
|
ZStack {
|
||||||
@@ -13,31 +17,50 @@ struct ConnectView: View {
|
|||||||
|
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
|
headerSection
|
||||||
|
protocolPicker
|
||||||
|
hostField
|
||||||
|
credentialsCard
|
||||||
|
connectedBanner
|
||||||
|
destinationRow
|
||||||
|
statusLine
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.scrollDismissesKeyboard(.interactively)
|
||||||
|
.safeAreaInset(edge: .bottom) { ctaBar }
|
||||||
|
}
|
||||||
|
.navigationBarHidden(true)
|
||||||
|
.navigationDestination(isPresented: $navigateToDashboard) { MainTabView() }
|
||||||
|
.sheet(isPresented: $vm.showFolderBrowser) { folderSheet }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Header — identical treatment to BackupView ──
|
// MARK: – Header
|
||||||
|
|
||||||
|
private var headerSection: some View {
|
||||||
VStack(spacing: 10) {
|
VStack(spacing: 10) {
|
||||||
Text("kisani.")
|
Text("kisani.")
|
||||||
.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)
|
||||||
.padding(.top, 52)
|
.padding(.top, 52)
|
||||||
|
|
||||||
KisaniLogoMark(size: 66)
|
KisaniLogoMark(size: 66)
|
||||||
|
|
||||||
UGreenCompatibilityBadge(selectedProtocol: vm.selectedProtocol)
|
UGreenCompatibilityBadge(selectedProtocol: vm.selectedProtocol)
|
||||||
.padding(.top, 4)
|
.padding(.top, 4)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(.bottom, 28)
|
.padding(.bottom, 28)
|
||||||
|
}
|
||||||
|
|
||||||
// ── Protocol picker ──
|
// MARK: – Protocol picker
|
||||||
|
|
||||||
|
private var protocolPicker: some View {
|
||||||
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.resetAuth()
|
||||||
vm.verifyError = nil
|
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
Text(proto.rawValue)
|
Text(proto.rawValue)
|
||||||
@@ -47,11 +70,9 @@ struct ConnectView: View {
|
|||||||
.padding(.vertical, 7)
|
.padding(.vertical, 7)
|
||||||
.background(
|
.background(
|
||||||
RoundedRectangle(cornerRadius: 7, style: .continuous)
|
RoundedRectangle(cornerRadius: 7, style: .continuous)
|
||||||
.fill(vm.selectedProtocol == proto ? AppTheme.surfaceRaised : Color.clear)
|
.fill(vm.selectedProtocol == proto ? AppTheme.surfaceRaised : .clear)
|
||||||
.shadow(
|
.shadow(color: vm.selectedProtocol == proto ? .black.opacity(0.06) : .clear,
|
||||||
color: vm.selectedProtocol == proto ? .black.opacity(0.06) : .clear,
|
radius: 3, x: 0, y: 1)
|
||||||
radius: 3, x: 0, y: 1
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.buttonStyle(PlainButtonStyle())
|
.buttonStyle(PlainButtonStyle())
|
||||||
@@ -62,12 +83,15 @@ struct ConnectView: View {
|
|||||||
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||||
.padding(.horizontal, AppTheme.hPad)
|
.padding(.horizontal, AppTheme.hPad)
|
||||||
.padding(.bottom, 20)
|
.padding(.bottom, 20)
|
||||||
|
}
|
||||||
|
|
||||||
// ── Step 1: Host ──
|
// MARK: – Host field
|
||||||
|
|
||||||
|
private var hostField: some View {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
Image(systemName: "network")
|
Image(systemName: "network")
|
||||||
.font(.system(size: 14, weight: .regular))
|
.font(.system(size: 14))
|
||||||
.foregroundStyle(AppTheme.inkSecondary)
|
.foregroundStyle(AppTheme.inkSecondary)
|
||||||
.frame(width: 20)
|
.frame(width: 20)
|
||||||
TextField("IP address or hostname", text: $vm.host)
|
TextField("IP address or hostname", text: $vm.host)
|
||||||
@@ -79,15 +103,14 @@ struct ConnectView: View {
|
|||||||
.focused($focused, equals: .host)
|
.focused($focused, equals: .host)
|
||||||
.submitLabel(.next)
|
.submitLabel(.next)
|
||||||
.onSubmit { focused = .username }
|
.onSubmit { focused = .username }
|
||||||
|
.onChange(of: vm.host) { _ in vm.resetAuth() }
|
||||||
if !vm.host.isEmpty {
|
if !vm.host.isEmpty {
|
||||||
Button { vm.host = "" } label: {
|
Button { vm.host = ""; vm.resetAuth() } label: {
|
||||||
Image(systemName: "xmark.circle.fill")
|
Image(systemName: "xmark.circle.fill").foregroundStyle(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))
|
||||||
.shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2)
|
.shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2)
|
||||||
@@ -99,12 +122,15 @@ struct ConnectView: View {
|
|||||||
}
|
}
|
||||||
.padding(.horizontal, AppTheme.hPad)
|
.padding(.horizontal, AppTheme.hPad)
|
||||||
.padding(.bottom, 12)
|
.padding(.bottom, 12)
|
||||||
|
}
|
||||||
|
|
||||||
// ── Step 1: Credentials card ──
|
// MARK: – Credentials card
|
||||||
|
|
||||||
|
private var credentialsCard: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
Image(systemName: "person")
|
Image(systemName: "person")
|
||||||
.font(.system(size: 14, weight: .regular))
|
.font(.system(size: 14))
|
||||||
.foregroundStyle(AppTheme.inkSecondary)
|
.foregroundStyle(AppTheme.inkSecondary)
|
||||||
.frame(width: 20)
|
.frame(width: 20)
|
||||||
TextField("Username", text: $vm.username)
|
TextField("Username", text: $vm.username)
|
||||||
@@ -115,24 +141,20 @@ struct ConnectView: View {
|
|||||||
.focused($focused, equals: .username)
|
.focused($focused, equals: .username)
|
||||||
.submitLabel(.next)
|
.submitLabel(.next)
|
||||||
.onSubmit { focused = .password }
|
.onSubmit { focused = .password }
|
||||||
|
.onChange(of: vm.username) { _ in vm.resetAuth() }
|
||||||
if !vm.username.isEmpty {
|
if !vm.username.isEmpty {
|
||||||
Button { vm.username = "" } label: {
|
Button { vm.username = ""; vm.resetAuth() } label: {
|
||||||
Image(systemName: "xmark.circle.fill")
|
Image(systemName: "xmark.circle.fill").foregroundStyle(AppTheme.inkQuaternary)
|
||||||
.foregroundStyle(AppTheme.inkQuaternary)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16).padding(.vertical, 14)
|
||||||
.padding(.vertical, 14)
|
|
||||||
|
|
||||||
Rectangle()
|
Rectangle().fill(AppTheme.separator).frame(height: 0.5).padding(.leading, 44)
|
||||||
.fill(AppTheme.separator)
|
|
||||||
.frame(height: 0.5)
|
|
||||||
.padding(.leading, 44)
|
|
||||||
|
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
Image(systemName: "lock")
|
Image(systemName: "lock")
|
||||||
.font(.system(size: 14, weight: .regular))
|
.font(.system(size: 14))
|
||||||
.foregroundStyle(AppTheme.inkSecondary)
|
.foregroundStyle(AppTheme.inkSecondary)
|
||||||
.frame(width: 20)
|
.frame(width: 20)
|
||||||
Group {
|
Group {
|
||||||
@@ -146,108 +168,165 @@ struct ConnectView: View {
|
|||||||
.foregroundStyle(AppTheme.ink)
|
.foregroundStyle(AppTheme.ink)
|
||||||
.focused($focused, equals: .password)
|
.focused($focused, equals: .password)
|
||||||
.submitLabel(.go)
|
.submitLabel(.go)
|
||||||
.onSubmit { Task { await vm.verify() } }
|
.onSubmit { focused = nil; Task { await vm.authenticate() } }
|
||||||
Button {
|
.onChange(of: vm.password) { _ in vm.resetAuth() }
|
||||||
showPassword.toggle()
|
Button { showPassword.toggle() } label: {
|
||||||
} label: {
|
|
||||||
Image(systemName: showPassword ? "eye.slash" : "eye")
|
Image(systemName: showPassword ? "eye.slash" : "eye")
|
||||||
.font(.system(size: 14))
|
.font(.system(size: 14))
|
||||||
.foregroundStyle(AppTheme.inkTertiary)
|
.foregroundStyle(AppTheme.inkTertiary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.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(
|
.overlay(
|
||||||
RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous)
|
RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous)
|
||||||
.stroke(
|
.stroke(cardBorderColor, lineWidth: 1)
|
||||||
vm.isConnected ? AppTheme.positive.opacity(0.45) :
|
|
||||||
vm.verifyError != nil ? AppTheme.destructive.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)
|
||||||
.padding(.horizontal, AppTheme.hPad)
|
.padding(.horizontal, AppTheme.hPad)
|
||||||
.padding(.bottom, 12)
|
.padding(.bottom, 12)
|
||||||
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: vm.isConnected)
|
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: isAuthenticated)
|
||||||
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: vm.verifyError != nil)
|
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: isFailed)
|
||||||
|
}
|
||||||
|
|
||||||
// ── Destination folder — always visible, active only after auth ──
|
private var cardBorderColor: Color {
|
||||||
Button(action: { if vm.isConnected { vm.showFolderBrowser = true } }) {
|
if isAuthenticated { return AppTheme.positive.opacity(0.5) }
|
||||||
HStack(spacing: 12) {
|
if isFailed { return AppTheme.destructive.opacity(0.4) }
|
||||||
Image(systemName: vm.remotePath == "/" ? "folder" : "folder.fill")
|
return .clear
|
||||||
.font(.system(size: 14, weight: .regular))
|
}
|
||||||
.foregroundStyle(
|
|
||||||
!vm.isConnected ? AppTheme.inkQuaternary :
|
// MARK: – Connected banner (appears after auth)
|
||||||
vm.remotePath == "/" ? AppTheme.inkSecondary : AppTheme.positive
|
|
||||||
)
|
@ViewBuilder
|
||||||
.frame(width: 20)
|
private var connectedBanner: some View {
|
||||||
Text(vm.remotePath == "/" ? "Destination folder" : vm.remotePath)
|
if isAuthenticated {
|
||||||
.font(AppTheme.body())
|
HStack(spacing: 8) {
|
||||||
.foregroundStyle(
|
Circle()
|
||||||
!vm.isConnected ? AppTheme.inkQuaternary :
|
.fill(AppTheme.positive)
|
||||||
vm.remotePath == "/" ? AppTheme.inkTertiary : AppTheme.ink
|
.frame(width: 7, height: 7)
|
||||||
)
|
Text("Connected to \(vm.host)")
|
||||||
.lineLimit(2)
|
.font(AppTheme.micro())
|
||||||
.multilineTextAlignment(.leading)
|
.foregroundStyle(AppTheme.inkSecondary)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
Spacer()
|
||||||
Image(systemName: "folder.badge.plus")
|
Button {
|
||||||
.font(.system(size: 16))
|
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { vm.resetAuth() }
|
||||||
.foregroundStyle(vm.isConnected ? AppTheme.inkSecondary : AppTheme.inkQuaternary)
|
} label: {
|
||||||
|
Text("Disconnect")
|
||||||
|
.font(AppTheme.micro())
|
||||||
|
.foregroundStyle(AppTheme.inkTertiary)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
.padding(.vertical, 14)
|
.padding(.vertical, 10)
|
||||||
|
.background(AppTheme.surfaceSunken)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
|
||||||
|
.padding(.horizontal, AppTheme.hPad)
|
||||||
|
.padding(.bottom, 12)
|
||||||
|
.transition(.opacity.combined(with: .move(edge: .top)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: – Destination row
|
||||||
|
|
||||||
|
private var destinationRow: some View {
|
||||||
|
Button(action: { if isAuthenticated { vm.showFolderBrowser = true } }) {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Image(systemName: vm.selectedDestination.isEmpty ? "folder" : "folder.fill")
|
||||||
|
.font(.system(size: 14))
|
||||||
|
.foregroundStyle(destinationIconColor)
|
||||||
|
.frame(width: 20)
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(vm.selectedDestination.isEmpty
|
||||||
|
? (isAuthenticated ? "Select destination folder" : "Connect to your NAS first")
|
||||||
|
: vm.selectedDestination)
|
||||||
|
.font(AppTheme.body())
|
||||||
|
.foregroundStyle(destinationTextColor)
|
||||||
|
.lineLimit(2)
|
||||||
|
.multilineTextAlignment(.leading)
|
||||||
|
if vm.connectionState == .destinationSelected {
|
||||||
|
Text("Backup destination ready")
|
||||||
|
.font(AppTheme.micro(11))
|
||||||
|
.foregroundStyle(AppTheme.positive)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
Image(systemName: isAuthenticated ? "chevron.right" : "lock")
|
||||||
|
.font(.system(size: isAuthenticated ? 12 : 11, weight: .medium))
|
||||||
|
.foregroundStyle(AppTheme.inkQuaternary)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16).padding(.vertical, 14)
|
||||||
.background(AppTheme.surfaceRaised)
|
.background(AppTheme.surfaceRaised)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
|
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
|
||||||
.overlay(
|
.overlay(
|
||||||
RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous)
|
RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous)
|
||||||
.stroke(
|
.stroke(vm.connectionState == .destinationSelected
|
||||||
vm.step == .ready ? AppTheme.positive.opacity(0.45) : Color.clear,
|
? AppTheme.positive.opacity(0.5) : Color.clear, lineWidth: 1)
|
||||||
lineWidth: 1
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
.shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2)
|
.shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2)
|
||||||
.opacity(vm.isConnected ? 1 : 0.45)
|
.opacity(isAuthenticated ? 1 : 0.4)
|
||||||
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: vm.isConnected)
|
|
||||||
}
|
}
|
||||||
.buttonStyle(ScaleButtonStyle())
|
.buttonStyle(ScaleButtonStyle())
|
||||||
.padding(.horizontal, AppTheme.hPad)
|
.padding(.horizontal, AppTheme.hPad)
|
||||||
.padding(.bottom, 12)
|
.padding(.bottom, 20)
|
||||||
|
.allowsHitTesting(isAuthenticated)
|
||||||
|
.animation(.spring(response: 0.35, dampingFraction: 0.8), value: isAuthenticated)
|
||||||
|
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: vm.connectionState)
|
||||||
|
}
|
||||||
|
|
||||||
// ── Status message ──
|
private var destinationIconColor: Color {
|
||||||
|
if !isAuthenticated { return AppTheme.inkQuaternary }
|
||||||
|
return vm.selectedDestination.isEmpty ? AppTheme.inkSecondary : AppTheme.positive
|
||||||
|
}
|
||||||
|
|
||||||
|
private var destinationTextColor: Color {
|
||||||
|
if !isAuthenticated { return AppTheme.inkQuaternary }
|
||||||
|
return vm.selectedDestination.isEmpty ? AppTheme.inkTertiary : AppTheme.ink
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: – Status line
|
||||||
|
|
||||||
|
private var statusLine: some View {
|
||||||
HStack(spacing: 6) {
|
HStack(spacing: 6) {
|
||||||
Image(systemName: vm.statusIsError ? "exclamationmark.circle" :
|
Image(systemName: statusIcon)
|
||||||
vm.statusIsPositive ? "checkmark.circle" : "circle.dotted")
|
.font(.system(size: 12))
|
||||||
.font(.system(size: 13))
|
|
||||||
Text(vm.statusMessage)
|
Text(vm.statusMessage)
|
||||||
.font(AppTheme.caption())
|
.font(AppTheme.caption())
|
||||||
}
|
}
|
||||||
.foregroundStyle(
|
.foregroundStyle(statusColor)
|
||||||
vm.statusIsError ? AppTheme.destructive :
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
vm.statusIsPositive ? AppTheme.positive :
|
|
||||||
AppTheme.inkTertiary
|
|
||||||
)
|
|
||||||
.padding(.horizontal, AppTheme.hPad)
|
.padding(.horizontal, AppTheme.hPad)
|
||||||
.padding(.bottom, 20)
|
.padding(.bottom, 24)
|
||||||
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: vm.step)
|
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: vm.connectionState)
|
||||||
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: vm.verifyError)
|
}
|
||||||
|
|
||||||
|
private var statusIcon: String {
|
||||||
|
switch vm.connectionState {
|
||||||
|
case .idle: return "circle.dotted"
|
||||||
|
case .authenticating: return "arrow.trianglehead.2.clockwise"
|
||||||
|
case .authenticated: return "checkmark.circle"
|
||||||
|
case .destinationSelected: return "checkmark.circle.fill"
|
||||||
|
case .failed: return "exclamationmark.circle"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.safeAreaInset(edge: .bottom) {
|
|
||||||
|
private var statusColor: Color {
|
||||||
|
switch vm.connectionState {
|
||||||
|
case .idle, .authenticating: return AppTheme.inkTertiary
|
||||||
|
case .authenticated: return AppTheme.positive
|
||||||
|
case .destinationSelected: return AppTheme.positive
|
||||||
|
case .failed: return AppTheme.destructive
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: – CTA bar
|
||||||
|
|
||||||
|
private var ctaBar: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
Button(action: {
|
Button(action: handleCTA) {
|
||||||
focused = nil
|
|
||||||
if vm.step == .ready {
|
|
||||||
navigateToDashboard = true
|
|
||||||
} else {
|
|
||||||
Task { await vm.proceed() }
|
|
||||||
}
|
|
||||||
}) {
|
|
||||||
Group {
|
Group {
|
||||||
if vm.isVerifying {
|
if vm.connectionState == .authenticating {
|
||||||
ProgressView().tint(.white)
|
ProgressView().tint(.white)
|
||||||
} else {
|
} else {
|
||||||
Text(ctaLabel)
|
Text(ctaLabel)
|
||||||
@@ -259,41 +338,63 @@ struct ConnectView: View {
|
|||||||
.padding(.horizontal, AppTheme.hPad)
|
.padding(.horizontal, AppTheme.hPad)
|
||||||
.padding(.top, 12)
|
.padding(.top, 12)
|
||||||
.padding(.bottom, 32)
|
.padding(.bottom, 32)
|
||||||
.disabled(!vm.canProceed)
|
.disabled(!ctaEnabled)
|
||||||
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: vm.step)
|
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: vm.connectionState)
|
||||||
}
|
}
|
||||||
.background(AppTheme.background)
|
.background(AppTheme.background)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
.navigationBarHidden(true)
|
|
||||||
.navigationDestination(isPresented: $navigateToDashboard) { MainTabView() }
|
|
||||||
.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 {
|
private var ctaLabel: String {
|
||||||
switch vm.step {
|
switch vm.connectionState {
|
||||||
case .credentials: return "Connect"
|
case .idle, .failed: return "Connect"
|
||||||
case .folder: return "Select Destination"
|
case .authenticating: return "Connecting…"
|
||||||
case .ready: return "Get Started"
|
case .authenticated: return "Choose Destination"
|
||||||
|
case .destinationSelected: return "Get Started"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var ctaColor: Color {
|
private var ctaColor: Color {
|
||||||
switch vm.step {
|
vm.connectionState == .destinationSelected ? AppTheme.positive : AppTheme.ink
|
||||||
case .credentials: return AppTheme.ink
|
}
|
||||||
case .folder: return AppTheme.ink
|
|
||||||
case .ready: return AppTheme.positive
|
private var ctaEnabled: Bool {
|
||||||
|
switch vm.connectionState {
|
||||||
|
case .idle, .failed: return vm.canConnect
|
||||||
|
case .authenticating: return false
|
||||||
|
case .authenticated: return true
|
||||||
|
case .destinationSelected: return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleCTA() {
|
||||||
|
focused = nil
|
||||||
|
switch vm.connectionState {
|
||||||
|
case .idle, .failed:
|
||||||
|
Task { await vm.authenticate() }
|
||||||
|
case .authenticating:
|
||||||
|
break
|
||||||
|
case .authenticated:
|
||||||
|
vm.showFolderBrowser = true
|
||||||
|
case .destinationSelected:
|
||||||
|
navigateToDashboard = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: – Folder sheet
|
||||||
|
|
||||||
|
private var folderSheet: some View {
|
||||||
|
Group {
|
||||||
|
if let conn = ConnectionStore.shared.savedConnection {
|
||||||
|
BrowseView(connection: conn, selectedPath: $folderPickerPath, isPickerMode: true)
|
||||||
|
.onDisappear {
|
||||||
|
if folderPickerPath != "/" {
|
||||||
|
withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) {
|
||||||
|
vm.confirmDestination(folderPickerPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,23 +1,32 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import UIKit
|
||||||
|
|
||||||
enum ConnectField { case host, username, password }
|
enum ConnectField { case host, username, password }
|
||||||
|
|
||||||
enum ConnectStep: Equatable {
|
enum ConnectionState: Equatable {
|
||||||
case credentials // not yet authenticated
|
case idle
|
||||||
case folder // authenticated, no destination chosen
|
case authenticating
|
||||||
case ready // authenticated + valid destination chosen
|
case authenticated
|
||||||
|
case destinationSelected
|
||||||
|
case failed(String)
|
||||||
|
|
||||||
|
var isAuthenticated: Bool {
|
||||||
|
switch self { case .authenticated, .destinationSelected: return true; default: return false }
|
||||||
|
}
|
||||||
|
var errorMessage: String? {
|
||||||
|
if case .failed(let msg) = self { return msg }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class ConnectViewModel: ObservableObject {
|
final class ConnectViewModel: ObservableObject {
|
||||||
@Published var host: String
|
@Published var host: String = ""
|
||||||
@Published var username: String
|
@Published var username: String = ""
|
||||||
@Published var password: String
|
@Published var password: String = ""
|
||||||
@Published var remotePath: String
|
@Published var selectedProtocol: NASProtocol = .smb
|
||||||
@Published var selectedProtocol: NASProtocol
|
@Published var selectedDestination: String = ""
|
||||||
@Published var isVerifying = false
|
@Published var connectionState: ConnectionState = .idle
|
||||||
@Published var isConnected: Bool
|
|
||||||
@Published var verifyError: String? = nil
|
|
||||||
@Published var showFolderBrowser = false
|
@Published var showFolderBrowser = false
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
@@ -25,92 +34,74 @@ final class ConnectViewModel: ObservableObject {
|
|||||||
host = conn.host
|
host = conn.host
|
||||||
username = conn.username
|
username = conn.username
|
||||||
password = conn.password
|
password = conn.password
|
||||||
remotePath = conn.remotePath
|
|
||||||
selectedProtocol = conn.nasProtocol
|
selectedProtocol = conn.nasProtocol
|
||||||
isConnected = true
|
selectedDestination = conn.remotePath == "/" ? "" : conn.remotePath
|
||||||
} else {
|
connectionState = conn.remotePath == "/" ? .authenticated : .destinationSelected
|
||||||
host = ""
|
|
||||||
username = ""
|
|
||||||
password = ""
|
|
||||||
remotePath = "/"
|
|
||||||
selectedProtocol = .smb
|
|
||||||
isConnected = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var step: ConnectStep {
|
var canConnect: Bool {
|
||||||
guard isConnected else { return .credentials }
|
!host.trimmingCharacters(in: .whitespaces).isEmpty
|
||||||
return remotePath == "/" ? .folder : .ready
|
|
||||||
}
|
|
||||||
|
|
||||||
var canProceed: Bool {
|
|
||||||
switch step {
|
|
||||||
case .credentials:
|
|
||||||
return !host.trimmingCharacters(in: .whitespaces).isEmpty
|
|
||||||
&& !username.isEmpty
|
&& !username.isEmpty
|
||||||
&& !password.isEmpty
|
&& !password.isEmpty
|
||||||
&& !isVerifying
|
&& connectionState != .authenticating
|
||||||
case .folder, .ready:
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var canContinue: Bool {
|
||||||
|
connectionState == .destinationSelected && !selectedDestination.isEmpty
|
||||||
}
|
}
|
||||||
|
|
||||||
var statusMessage: String {
|
var statusMessage: String {
|
||||||
switch step {
|
switch connectionState {
|
||||||
case .credentials:
|
case .idle: return "Connect to your NAS to choose a destination"
|
||||||
return verifyError != nil ? verifyError! : "Enter your NAS credentials"
|
case .authenticating: return "Connecting to NAS…"
|
||||||
case .folder:
|
case .authenticated: return "Authenticated — select a destination folder"
|
||||||
return "Authenticated · select a destination folder"
|
case .destinationSelected: return "Backup destination ready"
|
||||||
case .ready:
|
case .failed(let msg): return msg
|
||||||
return "Ready to sync"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var statusIsError: Bool { verifyError != nil && step == .credentials }
|
func authenticate() async {
|
||||||
var statusIsPositive: Bool { step == .folder || step == .ready }
|
let trimmedHost = host.trimmingCharacters(in: .whitespaces)
|
||||||
|
connectionState = .authenticating
|
||||||
|
|
||||||
func proceed() async {
|
|
||||||
switch step {
|
|
||||||
case .credentials:
|
|
||||||
await verify()
|
|
||||||
case .folder:
|
|
||||||
showFolderBrowser = true
|
|
||||||
case .ready:
|
|
||||||
break // navigation handled in the view
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func verify() async {
|
|
||||||
let service: any NASTransferProtocol = selectedProtocol == .smb ? SMBService() : SFTPService()
|
let service: any NASTransferProtocol = selectedProtocol == .smb ? SMBService() : SFTPService()
|
||||||
await verifyWith(transfer: service)
|
|
||||||
}
|
|
||||||
|
|
||||||
func verifyWith(transfer: any NASTransferProtocol) async {
|
|
||||||
isVerifying = true
|
|
||||||
isConnected = false
|
|
||||||
verifyError = nil
|
|
||||||
|
|
||||||
let connection = NASConnection(
|
let connection = NASConnection(
|
||||||
host: host.trimmingCharacters(in: .whitespaces),
|
host: trimmedHost,
|
||||||
nasProtocol: selectedProtocol,
|
nasProtocol: selectedProtocol,
|
||||||
username: username,
|
username: username,
|
||||||
password: password,
|
password: password,
|
||||||
remotePath: remotePath
|
remotePath: selectedDestination.isEmpty ? "/" : selectedDestination
|
||||||
)
|
)
|
||||||
|
|
||||||
do {
|
do {
|
||||||
try await transfer.connect(
|
try await service.connect(
|
||||||
to: connection.host, port: connection.port,
|
to: connection.host, port: connection.port,
|
||||||
username: connection.username, password: connection.password
|
username: connection.username, password: connection.password
|
||||||
)
|
)
|
||||||
transfer.disconnect()
|
service.disconnect()
|
||||||
isConnected = true
|
|
||||||
ConnectionStore.shared.savedConnection = connection
|
ConnectionStore.shared.savedConnection = connection
|
||||||
|
connectionState = .authenticated
|
||||||
|
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
|
||||||
} catch let e as BackupError {
|
} catch let e as BackupError {
|
||||||
verifyError = e.errorDescription
|
connectionState = .failed(e.errorDescription ?? e.localizedDescription)
|
||||||
} catch {
|
} catch {
|
||||||
verifyError = error.localizedDescription
|
connectionState = .failed(error.localizedDescription)
|
||||||
}
|
}
|
||||||
isVerifying = false
|
}
|
||||||
|
|
||||||
|
func confirmDestination(_ path: String) {
|
||||||
|
guard !path.isEmpty, path != "/" else { return }
|
||||||
|
selectedDestination = path
|
||||||
|
connectionState = .destinationSelected
|
||||||
|
guard var conn = ConnectionStore.shared.savedConnection else { return }
|
||||||
|
conn.remotePath = path
|
||||||
|
ConnectionStore.shared.savedConnection = conn
|
||||||
|
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||||||
|
}
|
||||||
|
|
||||||
|
func resetAuth() {
|
||||||
|
connectionState = .idle
|
||||||
|
selectedDestination = ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user