feat: Kisani rebrand, step-based onboarding flow, dark mode fixes
- Rename app to Kisani in all UI text and Face ID prompt - Replace flat 2-state RootView with 5-step flow: ConnectView → LoginView → FolderSetupView → PhotoPermissionView → MainTabView - Remove NavigationStack from LoginView (was causing nested-stack crash); drive navigation via ConnectionStore.isSessionActive - Add FolderSetupView and PhotoPermissionView onboarding screens with step indicator (2/3, 3/3), spring entrance animations, and UGreen connection chip - Fix dark mode button invisibility: PrimaryButtonStyle and PillButtonStyle now use AppTheme.inkInverse; GhostButtonStyle border uses ink.opacity(0.25) - Add AppTheme.inkInverse adaptive token (dark bg ink on dark, white on light) - Add ConnectionStore.isSessionActive (non-persisted) and onboardingPhotoShown (UserDefaults-persisted) - Add os_log logging in LoginViewModel at auth start/success/failure - Remove dead Help button from ConnectView - UGreenCompatibilityBadge, KeychainStore, SavedConnections, extensions, PrivacyInfo.xcprivacy, unit test target and test stubs - AppTheme: full UIColor dynamic provider tokens for dark/light adaptive color - AppearanceMode enum + segmented picker in SettingsView - BackupView: enlarged ring, removed options card and linear progress bar - HistoryView: duration and bytes formatting, improved typography hierarchy Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -8,106 +8,117 @@ struct BackupView: View {
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color.white.ignoresSafeArea()
|
||||
AppTheme.background.ignoresSafeArea()
|
||||
|
||||
ScrollView {
|
||||
VStack(spacing: AppTheme.sectionGap) {
|
||||
ringSection
|
||||
nasRow
|
||||
optionsCard
|
||||
actionButton
|
||||
VStack(spacing: 0) {
|
||||
Spacer(minLength: 0)
|
||||
|
||||
// ─── Ring hero ───────────────────────────────────────
|
||||
ringHero
|
||||
.padding(.bottom, 28)
|
||||
|
||||
// ─── Completion summary (only on .completed) ─────────
|
||||
if case .completed = engine.job.status {
|
||||
completionStrip
|
||||
.padding(.bottom, 28)
|
||||
.transition(.opacity.combined(with: .scale(scale: 0.97)))
|
||||
}
|
||||
.padding(.horizontal, AppTheme.hPad)
|
||||
.padding(.top, 24)
|
||||
.padding(.bottom, 48)
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
// ─── NAS status ───────────────────────────────────────
|
||||
nasStatusRow
|
||||
.padding(.horizontal, AppTheme.hPad)
|
||||
.padding(.bottom, 16)
|
||||
|
||||
// ─── Photos access nudge ──────────────────────────────
|
||||
if vm.photosAuthStatus != .authorized && vm.photosAuthStatus != .limited {
|
||||
photosAccessRow
|
||||
.padding(.horizontal, AppTheme.hPad)
|
||||
.padding(.bottom, 16)
|
||||
.transition(.opacity.combined(with: .move(edge: .bottom)))
|
||||
}
|
||||
|
||||
// ─── Action ───────────────────────────────────────────
|
||||
actionButton
|
||||
.padding(.horizontal, AppTheme.hPad)
|
||||
.padding(.bottom, 32)
|
||||
}
|
||||
}
|
||||
.animation(.spring(response: 0.35, dampingFraction: 0.82), value: engine.job.status == .completed)
|
||||
.animation(.spring(response: 0.35, dampingFraction: 0.82), value: vm.photosAuthStatus == .authorized)
|
||||
.task { vm.loadPhotoCount(filter: store.backupFilter) }
|
||||
}
|
||||
|
||||
// MARK: — Ring
|
||||
// MARK: — Ring hero
|
||||
|
||||
private var ringSection: some View {
|
||||
VStack(spacing: 20) {
|
||||
ZStack {
|
||||
ProgressRing(
|
||||
progress: engine.job.progress,
|
||||
lineWidth: 8,
|
||||
size: 180,
|
||||
color: ringColor,
|
||||
backgroundColor: Color(hex: "#EBEBEB")
|
||||
)
|
||||
private var ringHero: some View {
|
||||
ZStack {
|
||||
ProgressRing(
|
||||
progress: engine.job.progress,
|
||||
lineWidth: 7,
|
||||
size: 220,
|
||||
color: ringColor,
|
||||
backgroundColor: AppTheme.inkQuaternary.opacity(0.4)
|
||||
)
|
||||
.accessibilityLabel("Backup progress")
|
||||
.accessibilityValue("\(Int(engine.job.progress * 100)) percent")
|
||||
|
||||
VStack(spacing: 3) {
|
||||
Text(percentText)
|
||||
.font(.system(size: 40, weight: .semibold, design: .rounded))
|
||||
.foregroundStyle(AppTheme.ink)
|
||||
.contentTransition(.numericText())
|
||||
VStack(spacing: 4) {
|
||||
Text(percentText)
|
||||
.font(.system(size: 44, weight: .semibold, design: .rounded))
|
||||
.foregroundStyle(AppTheme.ink)
|
||||
.contentTransition(.numericText())
|
||||
.monospacedDigit()
|
||||
|
||||
if case .running = engine.job.status {
|
||||
Text("\(engine.job.uploadedFiles + engine.job.skippedFiles) of \(engine.job.totalFiles)")
|
||||
.font(AppTheme.caption())
|
||||
.foregroundStyle(AppTheme.inkSecondary)
|
||||
if let eta = engine.job.eta {
|
||||
Text("~\(etaString(eta)) remaining")
|
||||
.font(.system(size: 11, weight: .regular))
|
||||
.foregroundStyle(AppTheme.inkTertiary)
|
||||
}
|
||||
} else {
|
||||
Text("\(vm.totalPhotoCount) photos")
|
||||
.font(AppTheme.caption())
|
||||
.foregroundStyle(AppTheme.inkSecondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Completion summary
|
||||
if case .completed = engine.job.status {
|
||||
HStack(spacing: 0) {
|
||||
statCell("\(engine.job.uploadedFiles)", label: "uploaded")
|
||||
dividerLine
|
||||
statCell("\(engine.job.skippedFiles)", label: "skipped")
|
||||
dividerLine
|
||||
statCell("\(engine.job.failedFiles)", label: "errors",
|
||||
color: engine.job.failedFiles > 0 ? AppTheme.destructive : AppTheme.inkTertiary)
|
||||
}
|
||||
.background(AppTheme.surfaceSunken)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
|
||||
.transition(.opacity.combined(with: .scale(scale: 0.95)))
|
||||
}
|
||||
|
||||
// Progress detail
|
||||
if case .running = engine.job.status {
|
||||
VStack(spacing: 8) {
|
||||
GeometryReader { geo in
|
||||
ZStack(alignment: .leading) {
|
||||
RoundedRectangle(cornerRadius: 2, style: .continuous)
|
||||
.fill(Color(hex: "#EBEBEB"))
|
||||
.frame(height: 3)
|
||||
RoundedRectangle(cornerRadius: 2, style: .continuous)
|
||||
.fill(AppTheme.ink)
|
||||
.frame(width: geo.size.width * engine.job.progress, height: 3)
|
||||
.animation(.spring(response: 0.4, dampingFraction: 0.8), value: engine.job.progress)
|
||||
}
|
||||
}
|
||||
.frame(height: 3)
|
||||
|
||||
HStack {
|
||||
Text(engine.job.currentFileName)
|
||||
.font(.system(size: 12, weight: .regular))
|
||||
.foregroundStyle(AppTheme.inkSecondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
Spacer()
|
||||
Text(formatSpeed(engine.job.speedBytesPerSecond))
|
||||
.font(.system(size: 12, weight: .regular))
|
||||
.foregroundStyle(AppTheme.inkTertiary)
|
||||
}
|
||||
}
|
||||
.transition(.opacity)
|
||||
ringSubtitle
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
.animation(.spring(response: 0.4, dampingFraction: 0.8), value: engine.job.status == .completed)
|
||||
.animation(.spring(response: 0.4, dampingFraction: 0.8), value: engine.job.progress)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var ringSubtitle: some View {
|
||||
switch engine.job.status {
|
||||
case .running:
|
||||
VStack(spacing: 2) {
|
||||
Text("\(engine.job.uploadedFiles + engine.job.skippedFiles) of \(engine.job.totalFiles)")
|
||||
.font(AppTheme.caption())
|
||||
.foregroundStyle(AppTheme.inkSecondary)
|
||||
if let eta = engine.job.eta {
|
||||
Text("~\(etaString(eta)) remaining")
|
||||
.font(.system(size: 11, weight: .regular))
|
||||
.foregroundStyle(AppTheme.inkTertiary)
|
||||
}
|
||||
Text(engine.job.currentFileName)
|
||||
.font(.system(size: 11, weight: .regular))
|
||||
.foregroundStyle(AppTheme.inkTertiary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
.frame(maxWidth: 160)
|
||||
}
|
||||
case .preparing:
|
||||
Text("Preparing…")
|
||||
.font(AppTheme.caption())
|
||||
.foregroundStyle(AppTheme.inkTertiary)
|
||||
case .completed:
|
||||
Text("Done")
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundStyle(AppTheme.positive)
|
||||
case .failed:
|
||||
Text("Failed")
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundStyle(AppTheme.destructive)
|
||||
case .cancelled:
|
||||
Text("Cancelled")
|
||||
.font(AppTheme.caption())
|
||||
.foregroundStyle(AppTheme.inkTertiary)
|
||||
default:
|
||||
Text(vm.totalPhotoCount > 0 ? "\(vm.totalPhotoCount) photos ready" : "Ready")
|
||||
.font(AppTheme.caption())
|
||||
.foregroundStyle(AppTheme.inkTertiary)
|
||||
}
|
||||
}
|
||||
|
||||
private var ringColor: Color {
|
||||
@@ -120,11 +131,32 @@ struct BackupView: View {
|
||||
|
||||
private var percentText: String { "\(Int(engine.job.progress * 100))%" }
|
||||
|
||||
private func statCell(_ value: String, label: String, color: Color = AppTheme.ink) -> some View {
|
||||
VStack(spacing: 2) {
|
||||
// MARK: — Completion strip
|
||||
|
||||
private var completionStrip: some View {
|
||||
HStack(spacing: 0) {
|
||||
statCell("\(engine.job.uploadedFiles)", label: "uploaded")
|
||||
thinDivider
|
||||
statCell("\(engine.job.skippedFiles)", label: "skipped")
|
||||
thinDivider
|
||||
statCell(
|
||||
"\(engine.job.failedFiles)",
|
||||
label: "errors",
|
||||
valueColor: engine.job.failedFiles > 0 ? AppTheme.destructive : AppTheme.ink
|
||||
)
|
||||
}
|
||||
.padding(.horizontal, AppTheme.hPad)
|
||||
.background(AppTheme.surfaceSunken)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
|
||||
.padding(.horizontal, AppTheme.hPad)
|
||||
}
|
||||
|
||||
private func statCell(_ value: String, label: String, valueColor: Color = AppTheme.ink) -> some View {
|
||||
VStack(spacing: 3) {
|
||||
Text(value)
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
.foregroundStyle(color)
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundStyle(valueColor)
|
||||
.monospacedDigit()
|
||||
Text(label)
|
||||
.font(AppTheme.micro())
|
||||
.foregroundStyle(AppTheme.inkTertiary)
|
||||
@@ -133,21 +165,21 @@ struct BackupView: View {
|
||||
.padding(.vertical, 14)
|
||||
}
|
||||
|
||||
private var dividerLine: some View {
|
||||
private var thinDivider: some View {
|
||||
Rectangle()
|
||||
.fill(AppTheme.separator)
|
||||
.frame(width: 0.5)
|
||||
.padding(.vertical, 12)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
|
||||
// MARK: — NAS row
|
||||
// MARK: — NAS status row
|
||||
|
||||
private var nasRow: some View {
|
||||
private var nasStatusRow: some View {
|
||||
HStack(spacing: 10) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(AppTheme.surfaceSunken)
|
||||
.frame(width: 32, height: 32)
|
||||
.frame(width: 34, height: 34)
|
||||
Image(systemName: "externaldrive")
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundStyle(AppTheme.inkSecondary)
|
||||
@@ -158,98 +190,54 @@ struct BackupView: View {
|
||||
Text(conn.host)
|
||||
.font(AppTheme.headline())
|
||||
.foregroundStyle(AppTheme.ink)
|
||||
Text(conn.username)
|
||||
.font(AppTheme.caption())
|
||||
.foregroundStyle(AppTheme.inkSecondary)
|
||||
Text(conn.nasProtocol.rawValue)
|
||||
.font(AppTheme.micro(10))
|
||||
.foregroundStyle(AppTheme.inkTertiary)
|
||||
}
|
||||
} else {
|
||||
Text("No NAS configured")
|
||||
.font(AppTheme.body())
|
||||
.foregroundStyle(AppTheme.inkTertiary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
HStack(spacing: 5) {
|
||||
Circle()
|
||||
.fill(AppTheme.positive)
|
||||
.fill(engine.job.status.isActive ? AppTheme.positive : AppTheme.inkQuaternary)
|
||||
.frame(width: 6, height: 6)
|
||||
Text("Live")
|
||||
Text(engine.job.status.isActive ? "Active" : "Ready")
|
||||
.font(AppTheme.micro())
|
||||
.foregroundStyle(AppTheme.positive)
|
||||
.foregroundStyle(engine.job.status.isActive ? AppTheme.positive : AppTheme.inkTertiary)
|
||||
}
|
||||
}
|
||||
.padding(14)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 12)
|
||||
.background(AppTheme.surfaceSunken)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
|
||||
}
|
||||
|
||||
// MARK: — Options
|
||||
// MARK: — Photos access nudge
|
||||
|
||||
private var optionsCard: some View {
|
||||
VStack(spacing: 0) {
|
||||
optionRow(icon: "camera", label: "What to back up") {}
|
||||
hairline
|
||||
optionRow(icon: "doc.badge.clock", label: "New files only") {}
|
||||
hairline
|
||||
Button(action: { Task { await vm.requestPhotosAccess() } }) {
|
||||
HStack {
|
||||
Image(systemName: "photo.on.rectangle")
|
||||
.font(.system(size: 14, weight: .regular))
|
||||
.foregroundStyle(vm.photosAuthStatus == .authorized
|
||||
? AppTheme.inkSecondary : AppTheme.destructive)
|
||||
.frame(width: 20)
|
||||
Text("Photos access")
|
||||
.font(AppTheme.body())
|
||||
.foregroundStyle(AppTheme.ink)
|
||||
Spacer()
|
||||
Text(photosLabel)
|
||||
.font(AppTheme.caption())
|
||||
.foregroundStyle(vm.photosAuthStatus == .authorized
|
||||
? AppTheme.positive : AppTheme.destructive)
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundStyle(AppTheme.inkQuaternary)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 14)
|
||||
}
|
||||
.buttonStyle(ScaleButtonStyle())
|
||||
}
|
||||
.background(Color.white)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
|
||||
.shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2)
|
||||
}
|
||||
|
||||
private var photosLabel: String {
|
||||
switch vm.photosAuthStatus {
|
||||
case .authorized: return "Full access"
|
||||
case .limited: return "Limited"
|
||||
case .denied: return "Denied"
|
||||
default: return "Not set"
|
||||
}
|
||||
}
|
||||
|
||||
private var hairline: some View {
|
||||
Rectangle()
|
||||
.fill(AppTheme.separator)
|
||||
.frame(height: 0.5)
|
||||
.padding(.leading, 36)
|
||||
}
|
||||
|
||||
private func optionRow(icon: String, label: String, action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
HStack {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 14, weight: .regular))
|
||||
.foregroundStyle(AppTheme.inkSecondary)
|
||||
private var photosAccessRow: some View {
|
||||
Button(action: { Task { await vm.requestPhotosAccess() } }) {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "photo.on.rectangle")
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundStyle(AppTheme.destructive)
|
||||
.frame(width: 20)
|
||||
Text(label)
|
||||
.font(AppTheme.body())
|
||||
.foregroundStyle(AppTheme.ink)
|
||||
Text("Grant photo library access to enable backup")
|
||||
.font(AppTheme.caption())
|
||||
.foregroundStyle(AppTheme.inkSecondary)
|
||||
Spacer()
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.foregroundStyle(AppTheme.inkQuaternary)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 14)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 11)
|
||||
.background(AppTheme.destructive.opacity(0.06))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||
}
|
||||
.buttonStyle(ScaleButtonStyle())
|
||||
}
|
||||
@@ -266,24 +254,27 @@ struct BackupView: View {
|
||||
.buttonStyle(PrimaryButtonStyle(
|
||||
color: engine.job.status == .completed ? AppTheme.positive : AppTheme.ink
|
||||
))
|
||||
.accessibilityHint("Starts backing up your photos to the NAS")
|
||||
|
||||
case .running:
|
||||
Button { engine.pause() } label: { Text("Pause") }
|
||||
.buttonStyle(GhostButtonStyle())
|
||||
.accessibilityLabel("Pause backup")
|
||||
|
||||
case .paused:
|
||||
HStack(spacing: 10) {
|
||||
Button { engine.resume() } label: { Text("Resume") }
|
||||
.buttonStyle(PrimaryButtonStyle())
|
||||
.accessibilityLabel("Resume backup")
|
||||
Button { engine.cancel() } label: { Text("Cancel") }
|
||||
.buttonStyle(GhostButtonStyle())
|
||||
.frame(width: 90)
|
||||
.accessibilityLabel("Cancel backup")
|
||||
}
|
||||
|
||||
case .preparing:
|
||||
HStack(spacing: 8) {
|
||||
ProgressView()
|
||||
.scaleEffect(0.8)
|
||||
ProgressView().scaleEffect(0.8).tint(AppTheme.inkSecondary)
|
||||
Text("Preparing…")
|
||||
.font(AppTheme.body())
|
||||
.foregroundStyle(AppTheme.inkSecondary)
|
||||
@@ -291,7 +282,7 @@ struct BackupView: View {
|
||||
.frame(height: 50)
|
||||
}
|
||||
}
|
||||
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: engine.job.status == .running)
|
||||
.animation(.spring(response: 0.3, dampingFraction: 0.82), value: engine.job.status == .running)
|
||||
}
|
||||
|
||||
private func startBackup() {
|
||||
@@ -299,12 +290,6 @@ struct BackupView: View {
|
||||
Task { _ = try? await engine.run(connection: conn, filter: store.backupFilter) }
|
||||
}
|
||||
|
||||
private func formatSpeed(_ bps: Double) -> String {
|
||||
bps >= 1_000_000
|
||||
? String(format: "%.1f MB/s", bps / 1_000_000)
|
||||
: String(format: "%.0f KB/s", bps / 1_000)
|
||||
}
|
||||
|
||||
private func etaString(_ seconds: TimeInterval) -> String {
|
||||
Int(seconds) / 60 > 0 ? "\(Int(seconds) / 60)m" : "\(Int(seconds))s"
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ struct BrowseView: View {
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
Color.white.ignoresSafeArea()
|
||||
AppTheme.background.ignoresSafeArea()
|
||||
|
||||
VStack(spacing: 0) {
|
||||
// Path bar
|
||||
@@ -40,21 +40,31 @@ struct BrowseView: View {
|
||||
|
||||
if isLoading {
|
||||
Spacer()
|
||||
ProgressView()
|
||||
.tint(AppTheme.inkTertiary)
|
||||
VStack(spacing: 10) {
|
||||
ProgressView()
|
||||
.tint(AppTheme.inkTertiary)
|
||||
Text("Loading…")
|
||||
.font(AppTheme.caption())
|
||||
.foregroundStyle(AppTheme.inkTertiary)
|
||||
}
|
||||
Spacer()
|
||||
} else if let error {
|
||||
Spacer()
|
||||
VStack(spacing: 12) {
|
||||
Image(systemName: "exclamationmark.triangle")
|
||||
.font(.system(size: 28, weight: .light))
|
||||
.font(.system(size: 28, weight: .ultraLight))
|
||||
.foregroundStyle(AppTheme.inkQuaternary)
|
||||
Text(error)
|
||||
.font(AppTheme.caption())
|
||||
.foregroundStyle(AppTheme.inkSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
VStack(spacing: 4) {
|
||||
Text("Could not load folder")
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundStyle(AppTheme.inkSecondary)
|
||||
Text(error)
|
||||
.font(AppTheme.caption())
|
||||
.foregroundStyle(AppTheme.inkTertiary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.padding(.horizontal, 32)
|
||||
Spacer()
|
||||
} else {
|
||||
List {
|
||||
|
||||
@@ -9,30 +9,33 @@ struct ConnectView: View {
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
AppTheme.surfaceSunken.ignoresSafeArea()
|
||||
AppTheme.background.ignoresSafeArea()
|
||||
|
||||
ScrollView {
|
||||
VStack(spacing: 0) {
|
||||
|
||||
// Logo + title
|
||||
VStack(spacing: 10) {
|
||||
// Header
|
||||
VStack(spacing: 20) {
|
||||
// App icon
|
||||
Image("nas_logo_clean")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 44, height: 44)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 11, style: .continuous))
|
||||
.frame(width: 52, height: 52)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 13, style: .continuous))
|
||||
.shadow(color: .black.opacity(0.08), radius: 14, x: 0, y: 4)
|
||||
.padding(.top, 44)
|
||||
|
||||
Text("NAS Connect")
|
||||
.font(.system(size: 26, weight: .semibold))
|
||||
.foregroundStyle(AppTheme.ink)
|
||||
// Title
|
||||
VStack(spacing: 6) {
|
||||
Text("Kisani")
|
||||
.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)
|
||||
// UGreen NAS compatibility badge
|
||||
UGreenCompatibilityBadge()
|
||||
}
|
||||
}
|
||||
.padding(.bottom, 32)
|
||||
.padding(.bottom, 28)
|
||||
.padding(.horizontal, AppTheme.hPad)
|
||||
|
||||
// Protocol picker
|
||||
@@ -50,7 +53,7 @@ struct ConnectView: View {
|
||||
.padding(.vertical, 7)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 7, style: .continuous)
|
||||
.fill(vm.selectedProtocol == proto ? Color.white : Color.clear)
|
||||
.fill(vm.selectedProtocol == proto ? AppTheme.surfaceRaised : Color.clear)
|
||||
.shadow(
|
||||
color: vm.selectedProtocol == proto ? .black.opacity(0.06) : .clear,
|
||||
radius: 3, x: 0, y: 1
|
||||
@@ -61,7 +64,7 @@ struct ConnectView: View {
|
||||
}
|
||||
}
|
||||
.padding(3)
|
||||
.background(Color.black.opacity(0.06))
|
||||
.background(AppTheme.surfaceSunken)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||
.padding(.horizontal, AppTheme.hPad)
|
||||
.padding(.bottom, 20)
|
||||
@@ -90,19 +93,14 @@ struct ConnectView: View {
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 14)
|
||||
.background(Color.white)
|
||||
.background(AppTheme.surfaceRaised)
|
||||
.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)
|
||||
Text("e.g. 192.168.1.10 or MY_NAS")
|
||||
.font(AppTheme.micro(11))
|
||||
.foregroundStyle(AppTheme.inkTertiary)
|
||||
.padding(.leading, 4)
|
||||
}
|
||||
.padding(.horizontal, AppTheme.hPad)
|
||||
.padding(.bottom, 12)
|
||||
@@ -163,7 +161,7 @@ struct ConnectView: View {
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 14)
|
||||
}
|
||||
.background(Color.white)
|
||||
.background(AppTheme.surfaceRaised)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous)
|
||||
@@ -199,7 +197,7 @@ struct ConnectView: View {
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 14)
|
||||
.background(Color.white)
|
||||
.background(AppTheme.surfaceRaised)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
|
||||
.shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2)
|
||||
}
|
||||
@@ -255,25 +253,11 @@ struct ConnectView: View {
|
||||
.disabled(!vm.canConnect)
|
||||
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: vm.isConnected)
|
||||
|
||||
// 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)
|
||||
Spacer().frame(height: 48)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("NAS Connect")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationBarHidden(true)
|
||||
.navigationDestination(isPresented: $navigateToDashboard) { MainTabView() }
|
||||
.sheet(isPresented: $vm.showFolderBrowser) {
|
||||
if let conn = ConnectionStore.shared.savedConnection {
|
||||
|
||||
@@ -24,6 +24,11 @@ final class ConnectViewModel: ObservableObject {
|
||||
}
|
||||
|
||||
func verify() async {
|
||||
let service: any NASTransferProtocol = selectedProtocol == .smb ? SMBService() : SFTPService()
|
||||
await verifyWith(transfer: service)
|
||||
}
|
||||
|
||||
func verifyWith(transfer: any NASTransferProtocol) async {
|
||||
isVerifying = true
|
||||
isConnected = false
|
||||
verifyError = nil
|
||||
@@ -36,11 +41,10 @@ final class ConnectViewModel: ObservableObject {
|
||||
remotePath: remotePath
|
||||
)
|
||||
|
||||
let service: any NASTransferProtocol = selectedProtocol == .smb ? SMBService() : SFTPService()
|
||||
do {
|
||||
try await service.connect(to: connection.host, port: connection.port,
|
||||
username: connection.username, password: connection.password)
|
||||
service.disconnect()
|
||||
try await transfer.connect(to: connection.host, port: connection.port,
|
||||
username: connection.username, password: connection.password)
|
||||
transfer.disconnect()
|
||||
isConnected = true
|
||||
ConnectionStore.shared.savedConnection = connection
|
||||
} catch let e as BackupError {
|
||||
|
||||
@@ -5,29 +5,22 @@ struct HistoryView: View {
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color.white.ignoresSafeArea()
|
||||
AppTheme.background.ignoresSafeArea()
|
||||
|
||||
if store.historyEntries.isEmpty {
|
||||
VStack(spacing: 12) {
|
||||
Image(systemName: "clock.arrow.circlepath")
|
||||
.font(.system(size: 36, weight: .light))
|
||||
.foregroundStyle(AppTheme.inkQuaternary)
|
||||
Text("No backup history yet")
|
||||
.font(AppTheme.body())
|
||||
.foregroundStyle(AppTheme.inkTertiary)
|
||||
}
|
||||
emptyState
|
||||
} else {
|
||||
List {
|
||||
ForEach(store.historyEntries) { entry in
|
||||
HistoryRowView(entry: entry)
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowInsets(EdgeInsets())
|
||||
.listRowBackground(AppTheme.background)
|
||||
}
|
||||
.onDelete { offsets in
|
||||
store.removeHistoryEntries(at: offsets)
|
||||
}
|
||||
.onDelete { store.removeHistoryEntries(at: $0) }
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.scrollContentBackground(.hidden)
|
||||
}
|
||||
}
|
||||
.navigationTitle("History")
|
||||
@@ -36,32 +29,60 @@ struct HistoryView: View {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Clear") { store.clearHistory() }
|
||||
.font(AppTheme.caption())
|
||||
.foregroundStyle(AppTheme.destructive)
|
||||
.foregroundStyle(AppTheme.inkTertiary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var emptyState: some View {
|
||||
VStack(spacing: 14) {
|
||||
Image(systemName: "clock.arrow.circlepath")
|
||||
.font(.system(size: 32, weight: .light))
|
||||
.foregroundStyle(AppTheme.inkQuaternary)
|
||||
VStack(spacing: 5) {
|
||||
Text("No backups yet")
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
.foregroundStyle(AppTheme.inkSecondary)
|
||||
Text("Your backup history will appear here")
|
||||
.font(AppTheme.caption())
|
||||
.foregroundStyle(AppTheme.inkTertiary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: — Row
|
||||
|
||||
struct HistoryRowView: View {
|
||||
let entry: BackupHistoryEntry
|
||||
|
||||
private var hasErrors: Bool { entry.failedCount > 0 }
|
||||
private var statusColor: Color { hasErrors ? AppTheme.destructive : AppTheme.positive }
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack(alignment: .top, spacing: 14) {
|
||||
// Status indicator
|
||||
Circle()
|
||||
.fill(hasErrors ? AppTheme.destructive : AppTheme.positive)
|
||||
.frame(width: 7, height: 7)
|
||||
.padding(.top, 5)
|
||||
.fill(statusColor)
|
||||
.frame(width: 6, height: 6)
|
||||
.padding(.top, 6)
|
||||
|
||||
// Content
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||
Text(entry.date, style: .date)
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
// Date + badges
|
||||
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
||||
Text(entry.date, format: .dateTime.month(.abbreviated).day().year())
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundStyle(AppTheme.ink)
|
||||
|
||||
Text(entry.date, format: .dateTime.hour().minute())
|
||||
.font(.system(size: 13, weight: .regular))
|
||||
.foregroundStyle(AppTheme.inkTertiary)
|
||||
|
||||
Spacer()
|
||||
|
||||
if entry.triggeredByLAN {
|
||||
Text("AUTO")
|
||||
.font(.system(size: 9, weight: .semibold))
|
||||
@@ -73,37 +94,25 @@ struct HistoryRowView: View {
|
||||
.stroke(AppTheme.inkQuaternary, lineWidth: 0.75)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(entry.date, style: .time)
|
||||
.font(AppTheme.micro(11))
|
||||
.foregroundStyle(AppTheme.inkTertiary)
|
||||
}
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Text("\(entry.uploadedCount) uploaded")
|
||||
.font(AppTheme.caption())
|
||||
.foregroundStyle(AppTheme.inkSecondary)
|
||||
// Stat line
|
||||
statLine
|
||||
|
||||
if entry.skippedCount > 0 {
|
||||
dot
|
||||
Text("\(entry.skippedCount) skipped")
|
||||
.font(AppTheme.caption())
|
||||
.foregroundStyle(AppTheme.inkTertiary)
|
||||
}
|
||||
|
||||
if entry.failedCount > 0 {
|
||||
dot
|
||||
Text("\(entry.failedCount) errors")
|
||||
.font(AppTheme.caption())
|
||||
.foregroundStyle(AppTheme.destructive)
|
||||
// Secondary info: host · duration · size
|
||||
HStack(spacing: 6) {
|
||||
Text(entry.nasHost)
|
||||
.foregroundStyle(AppTheme.inkQuaternary)
|
||||
midDot
|
||||
Text(formatDuration(entry.durationSeconds))
|
||||
.foregroundStyle(AppTheme.inkQuaternary)
|
||||
if entry.totalBytes > 0 {
|
||||
midDot
|
||||
Text(formatBytes(entry.totalBytes))
|
||||
.foregroundStyle(AppTheme.inkQuaternary)
|
||||
}
|
||||
}
|
||||
|
||||
Text(entry.nasHost)
|
||||
.font(AppTheme.micro(11))
|
||||
.foregroundStyle(AppTheme.inkQuaternary)
|
||||
.font(.system(size: 11, weight: .regular))
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
@@ -112,13 +121,48 @@ struct HistoryRowView: View {
|
||||
Rectangle()
|
||||
.fill(AppTheme.separator)
|
||||
.frame(height: 0.5)
|
||||
.padding(.leading, 37)
|
||||
.padding(.leading, 36)
|
||||
}
|
||||
}
|
||||
|
||||
private var dot: some View {
|
||||
@ViewBuilder
|
||||
private var statLine: some View {
|
||||
HStack(spacing: 6) {
|
||||
Text("\(entry.uploadedCount) uploaded")
|
||||
.foregroundStyle(AppTheme.inkSecondary)
|
||||
|
||||
if entry.skippedCount > 0 {
|
||||
midDot
|
||||
Text("\(entry.skippedCount) skipped")
|
||||
.foregroundStyle(AppTheme.inkTertiary)
|
||||
}
|
||||
|
||||
if hasErrors {
|
||||
midDot
|
||||
Text("\(entry.failedCount) errors")
|
||||
.foregroundStyle(AppTheme.destructive)
|
||||
}
|
||||
}
|
||||
.font(AppTheme.caption())
|
||||
}
|
||||
|
||||
private var midDot: some View {
|
||||
Circle()
|
||||
.fill(AppTheme.inkQuaternary)
|
||||
.frame(width: 3, height: 3)
|
||||
}
|
||||
|
||||
private func formatDuration(_ s: Double) -> String {
|
||||
let m = Int(s) / 60
|
||||
let sec = Int(s) % 60
|
||||
return m > 0 ? "\(m)m \(sec)s" : "\(sec)s"
|
||||
}
|
||||
|
||||
private func formatBytes(_ bytes: Int64) -> String {
|
||||
let gb = Double(bytes) / 1_073_741_824
|
||||
if gb >= 1 { return String(format: "%.1f GB", gb) }
|
||||
let mb = Double(bytes) / 1_048_576
|
||||
if mb >= 1 { return String(format: "%.0f MB", mb) }
|
||||
return String(format: "%.0f KB", Double(bytes) / 1024)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,108 +1,94 @@
|
||||
import SwiftUI
|
||||
|
||||
struct LoginView: View {
|
||||
@EnvironmentObject var store: ConnectionStore
|
||||
@StateObject private var vm = LoginViewModel()
|
||||
@State private var navigateToDashboard = false
|
||||
@State private var logoAppeared = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
Color.white.ignoresSafeArea()
|
||||
ZStack {
|
||||
AppTheme.background.ignoresSafeArea()
|
||||
|
||||
VStack(spacing: 0) {
|
||||
Spacer()
|
||||
VStack(spacing: 0) {
|
||||
Spacer()
|
||||
|
||||
// Logo
|
||||
Image("nas_logo_clean")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 72, height: 72)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
|
||||
.shadow(color: .black.opacity(0.08), radius: 16, x: 0, y: 4)
|
||||
.scaleEffect(logoAppeared ? 1 : 0.8)
|
||||
.opacity(logoAppeared ? 1 : 0)
|
||||
.animation(.spring(response: 0.5, dampingFraction: 0.7).delay(0.1), value: logoAppeared)
|
||||
|
||||
Spacer().frame(height: 32)
|
||||
|
||||
// Device + account
|
||||
VStack(spacing: 6) {
|
||||
Text("Forge")
|
||||
.font(.system(size: 22, weight: .semibold))
|
||||
.foregroundStyle(AppTheme.ink)
|
||||
|
||||
HStack(spacing: 8) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(AppTheme.surfaceSunken)
|
||||
.frame(width: 28, height: 28)
|
||||
Text("AB")
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.foregroundStyle(AppTheme.inkSecondary)
|
||||
}
|
||||
Text("Albert")
|
||||
.font(AppTheme.caption())
|
||||
.foregroundStyle(AppTheme.inkSecondary)
|
||||
}
|
||||
}
|
||||
// Logo
|
||||
Image("nas_logo_clean")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 72, height: 72)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
|
||||
.shadow(color: .black.opacity(0.08), radius: 16, x: 0, y: 4)
|
||||
.scaleEffect(logoAppeared ? 1 : 0.8)
|
||||
.opacity(logoAppeared ? 1 : 0)
|
||||
.animation(.spring(response: 0.5, dampingFraction: 0.7).delay(0.2), value: logoAppeared)
|
||||
.animation(.spring(response: 0.5, dampingFraction: 0.7).delay(0.1), value: logoAppeared)
|
||||
|
||||
Spacer().frame(height: 40)
|
||||
Spacer().frame(height: 32)
|
||||
|
||||
// Face ID button
|
||||
Button(action: { vm.authenticateWithFaceID() }) {
|
||||
HStack(spacing: 8) {
|
||||
if vm.isAuthenticating {
|
||||
ProgressView()
|
||||
.tint(.white)
|
||||
.scaleEffect(0.8)
|
||||
} else {
|
||||
Image(systemName: "faceid")
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
Text("Sign in with Face ID")
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(PrimaryButtonStyle())
|
||||
.padding(.horizontal, AppTheme.hPad)
|
||||
.disabled(vm.isAuthenticating)
|
||||
.opacity(logoAppeared ? 1 : 0)
|
||||
.animation(.spring(response: 0.5, dampingFraction: 0.7).delay(0.3), value: logoAppeared)
|
||||
|
||||
if let error = vm.authError {
|
||||
Text(error)
|
||||
.font(AppTheme.caption())
|
||||
.foregroundStyle(AppTheme.destructive)
|
||||
.padding(.top, 12)
|
||||
.transition(.opacity.combined(with: .move(edge: .top)))
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// More
|
||||
Button("More options") { vm.showMoreSheet = true }
|
||||
.font(.system(size: 13, weight: .regular))
|
||||
// Title + account
|
||||
VStack(spacing: 5) {
|
||||
Text("Kisani")
|
||||
.font(.system(size: 22, weight: .semibold))
|
||||
.foregroundStyle(AppTheme.ink)
|
||||
Text("Sign in to continue")
|
||||
.font(AppTheme.caption())
|
||||
.foregroundStyle(AppTheme.inkTertiary)
|
||||
.padding(.bottom, 44)
|
||||
.opacity(logoAppeared ? 1 : 0)
|
||||
.animation(.spring(response: 0.5, dampingFraction: 0.7).delay(0.4), value: logoAppeared)
|
||||
}
|
||||
}
|
||||
.navigationDestination(isPresented: $navigateToDashboard) { MainTabView() }
|
||||
.sheet(isPresented: $vm.showMoreSheet) {
|
||||
MoreOptionsSheet(isPresented: $vm.showMoreSheet) {
|
||||
vm.showMoreSheet = false
|
||||
navigateToDashboard = true
|
||||
.opacity(logoAppeared ? 1 : 0)
|
||||
.animation(.spring(response: 0.5, dampingFraction: 0.7).delay(0.2), value: logoAppeared)
|
||||
|
||||
Spacer().frame(height: 40)
|
||||
|
||||
// Face ID button
|
||||
Button(action: { vm.authenticateWithFaceID() }) {
|
||||
HStack(spacing: 8) {
|
||||
if vm.isAuthenticating {
|
||||
ProgressView()
|
||||
.tint(AppTheme.inkInverse)
|
||||
.scaleEffect(0.8)
|
||||
} else {
|
||||
Image(systemName: "faceid")
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
Text("Sign in with Face ID")
|
||||
}
|
||||
}
|
||||
}
|
||||
.presentationDetents([.height(200)])
|
||||
.modifier(SheetCornerRadius(20))
|
||||
.buttonStyle(PrimaryButtonStyle())
|
||||
.padding(.horizontal, AppTheme.hPad)
|
||||
.disabled(vm.isAuthenticating)
|
||||
.opacity(logoAppeared ? 1 : 0)
|
||||
.animation(.spring(response: 0.5, dampingFraction: 0.7).delay(0.3), value: logoAppeared)
|
||||
|
||||
if let error = vm.authError {
|
||||
Text(error)
|
||||
.font(AppTheme.caption())
|
||||
.foregroundStyle(AppTheme.destructive)
|
||||
.padding(.top, 12)
|
||||
.transition(.opacity.combined(with: .move(edge: .top)))
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// More
|
||||
Button("More options") { vm.showMoreSheet = true }
|
||||
.font(.system(size: 13, weight: .regular))
|
||||
.foregroundStyle(AppTheme.inkTertiary)
|
||||
.padding(.bottom, 44)
|
||||
.opacity(logoAppeared ? 1 : 0)
|
||||
.animation(.spring(response: 0.5, dampingFraction: 0.7).delay(0.4), value: logoAppeared)
|
||||
}
|
||||
.onAppear {
|
||||
vm.onAuthenticated = { navigateToDashboard = true }
|
||||
logoAppeared = true
|
||||
}
|
||||
.sheet(isPresented: $vm.showMoreSheet) {
|
||||
MoreOptionsSheet(isPresented: $vm.showMoreSheet) {
|
||||
vm.showMoreSheet = false
|
||||
store.isSessionActive = true
|
||||
}
|
||||
.presentationDetents([.height(200)])
|
||||
.modifier(SheetCornerRadius(20))
|
||||
}
|
||||
.onAppear {
|
||||
vm.onAuthenticated = { store.isSessionActive = true }
|
||||
logoAppeared = true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -138,7 +124,7 @@ struct MoreOptionsSheet: View {
|
||||
.padding(.horizontal, AppTheme.hPad)
|
||||
.padding(.bottom, 16)
|
||||
}
|
||||
.background(Color.white)
|
||||
.background(AppTheme.background)
|
||||
}
|
||||
|
||||
private func sheetRow(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Foundation
|
||||
import LocalAuthentication
|
||||
import os.log
|
||||
|
||||
@MainActor
|
||||
final class LoginViewModel: ObservableObject {
|
||||
@@ -9,26 +10,34 @@ final class LoginViewModel: ObservableObject {
|
||||
|
||||
var onAuthenticated: (() -> Void)?
|
||||
|
||||
private let logger = Logger(subsystem: "com.albert.nasbackup", category: "Login")
|
||||
|
||||
func authenticateWithFaceID() {
|
||||
logger.info("Face ID authentication started")
|
||||
isAuthenticating = true
|
||||
authError = nil
|
||||
let context = LAContext()
|
||||
var error: NSError?
|
||||
guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) else {
|
||||
let reason = error?.localizedDescription ?? "unknown"
|
||||
logger.error("Face ID not available: \(reason, privacy: .public)")
|
||||
authError = "Face ID not available"
|
||||
isAuthenticating = false
|
||||
return
|
||||
}
|
||||
context.evaluatePolicy(
|
||||
.deviceOwnerAuthenticationWithBiometrics,
|
||||
localizedReason: "Sign in to NASBackup"
|
||||
localizedReason: "Sign in to Kisani"
|
||||
) { [weak self] success, evalError in
|
||||
Task { @MainActor in
|
||||
self?.isAuthenticating = false
|
||||
if success {
|
||||
self?.logger.info("Face ID authentication succeeded — advancing session")
|
||||
self?.onAuthenticated?()
|
||||
} else {
|
||||
self?.authError = evalError?.localizedDescription ?? "Authentication failed"
|
||||
let reason = evalError?.localizedDescription ?? "Authentication failed"
|
||||
self?.logger.error("Face ID authentication failed: \(reason, privacy: .public)")
|
||||
self?.authError = reason
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
151
Features/Onboarding/FolderSetupView.swift
Normal file
151
Features/Onboarding/FolderSetupView.swift
Normal file
@@ -0,0 +1,151 @@
|
||||
import SwiftUI
|
||||
|
||||
struct FolderSetupView: View {
|
||||
@EnvironmentObject var store: ConnectionStore
|
||||
@State private var showBrowser = false
|
||||
@State private var selectedPath: String = "/"
|
||||
@State private var appeared = false
|
||||
|
||||
private var connection: NASConnection? { store.savedConnection }
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
AppTheme.background.ignoresSafeArea()
|
||||
|
||||
VStack(spacing: 0) {
|
||||
// Step indicator
|
||||
stepIndicator
|
||||
.padding(.top, 20)
|
||||
.padding(.bottom, 36)
|
||||
.opacity(appeared ? 1 : 0)
|
||||
.animation(.spring(response: 0.4, dampingFraction: 0.8).delay(0.05), value: appeared)
|
||||
|
||||
// Icon
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(AppTheme.surfaceSunken)
|
||||
.frame(width: 80, height: 80)
|
||||
Image(systemName: "folder.badge.plus")
|
||||
.font(.system(size: 32, weight: .light))
|
||||
.foregroundStyle(AppTheme.ink)
|
||||
}
|
||||
.scaleEffect(appeared ? 1 : 0.85)
|
||||
.opacity(appeared ? 1 : 0)
|
||||
.animation(.spring(response: 0.5, dampingFraction: 0.7).delay(0.1), value: appeared)
|
||||
|
||||
Spacer().frame(height: 28)
|
||||
|
||||
// Title + description
|
||||
VStack(spacing: 8) {
|
||||
Text("Choose a destination")
|
||||
.font(.system(size: 22, weight: .semibold))
|
||||
.foregroundStyle(AppTheme.ink)
|
||||
Text("Select the folder on your NAS where photos will be backed up.")
|
||||
.font(AppTheme.body())
|
||||
.foregroundStyle(AppTheme.inkSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 32)
|
||||
}
|
||||
.opacity(appeared ? 1 : 0)
|
||||
.animation(.spring(response: 0.4, dampingFraction: 0.8).delay(0.15), value: appeared)
|
||||
|
||||
Spacer().frame(height: 32)
|
||||
|
||||
// Connection chip
|
||||
if let conn = connection {
|
||||
connectionChip(conn)
|
||||
.opacity(appeared ? 1 : 0)
|
||||
.animation(.spring(response: 0.4, dampingFraction: 0.8).delay(0.2), value: appeared)
|
||||
}
|
||||
|
||||
Spacer().frame(height: 16)
|
||||
|
||||
// Selected path display / browse button
|
||||
Button(action: { showBrowser = true }) {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: selectedPath == "/" ? "folder" : "folder.fill")
|
||||
.font(.system(size: 14, weight: .regular))
|
||||
.foregroundStyle(selectedPath == "/" ? AppTheme.inkQuaternary : AppTheme.interactive)
|
||||
.frame(width: 20)
|
||||
Text(selectedPath == "/" ? "Tap to browse folders" : selectedPath)
|
||||
.font(AppTheme.body())
|
||||
.foregroundStyle(selectedPath == "/" ? 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))
|
||||
.shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2)
|
||||
}
|
||||
.buttonStyle(ScaleButtonStyle())
|
||||
.padding(.horizontal, AppTheme.hPad)
|
||||
.opacity(appeared ? 1 : 0)
|
||||
.animation(.spring(response: 0.4, dampingFraction: 0.8).delay(0.25), value: appeared)
|
||||
|
||||
Spacer()
|
||||
|
||||
// Confirm button
|
||||
Button(action: confirmFolder) {
|
||||
Text("Save This Folder")
|
||||
}
|
||||
.buttonStyle(PrimaryButtonStyle())
|
||||
.padding(.horizontal, AppTheme.hPad)
|
||||
.disabled(selectedPath == "/")
|
||||
.opacity(selectedPath == "/" ? 0.4 : 1)
|
||||
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: selectedPath == "/")
|
||||
.opacity(appeared ? 1 : 0)
|
||||
.animation(.spring(response: 0.4, dampingFraction: 0.8).delay(0.3), value: appeared)
|
||||
|
||||
Spacer().frame(height: 48)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showBrowser) {
|
||||
if let conn = connection {
|
||||
BrowseView(connection: conn, selectedPath: $selectedPath)
|
||||
}
|
||||
}
|
||||
.onAppear { appeared = true }
|
||||
}
|
||||
|
||||
private var stepIndicator: some View {
|
||||
HStack(spacing: 6) {
|
||||
ForEach(1...3, id: \.self) { step in
|
||||
Capsule()
|
||||
.fill(step == 2 ? AppTheme.ink : AppTheme.inkQuaternary)
|
||||
.frame(width: step == 2 ? 20 : 6, height: 6)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func connectionChip(_ conn: NASConnection) -> some View {
|
||||
HStack(spacing: 6) {
|
||||
Circle()
|
||||
.fill(AppTheme.positive)
|
||||
.frame(width: 6, height: 6)
|
||||
Text(conn.host)
|
||||
.font(AppTheme.micro())
|
||||
.foregroundStyle(AppTheme.inkSecondary)
|
||||
Text("·")
|
||||
.foregroundStyle(AppTheme.inkQuaternary)
|
||||
Text(conn.nasProtocol.rawValue)
|
||||
.font(AppTheme.micro())
|
||||
.foregroundStyle(AppTheme.inkTertiary)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(AppTheme.surfaceSunken)
|
||||
.clipShape(Capsule(style: .continuous))
|
||||
}
|
||||
|
||||
private func confirmFolder() {
|
||||
guard selectedPath != "/", var conn = store.savedConnection else { return }
|
||||
conn.remotePath = selectedPath
|
||||
store.savedConnection = conn
|
||||
}
|
||||
}
|
||||
129
Features/Onboarding/PhotoPermissionView.swift
Normal file
129
Features/Onboarding/PhotoPermissionView.swift
Normal file
@@ -0,0 +1,129 @@
|
||||
import SwiftUI
|
||||
import Photos
|
||||
|
||||
struct PhotoPermissionView: View {
|
||||
@EnvironmentObject var store: ConnectionStore
|
||||
@State private var appeared = false
|
||||
@State private var isRequesting = false
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
AppTheme.background.ignoresSafeArea()
|
||||
|
||||
VStack(spacing: 0) {
|
||||
// Step indicator
|
||||
stepIndicator
|
||||
.padding(.top, 20)
|
||||
.padding(.bottom, 36)
|
||||
.opacity(appeared ? 1 : 0)
|
||||
.animation(.spring(response: 0.4, dampingFraction: 0.8).delay(0.05), value: appeared)
|
||||
|
||||
Spacer()
|
||||
|
||||
// Icon
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(AppTheme.surfaceSunken)
|
||||
.frame(width: 96, height: 96)
|
||||
Image(systemName: "photo.stack")
|
||||
.font(.system(size: 38, weight: .light))
|
||||
.foregroundStyle(AppTheme.ink)
|
||||
}
|
||||
.scaleEffect(appeared ? 1 : 0.85)
|
||||
.opacity(appeared ? 1 : 0)
|
||||
.animation(.spring(response: 0.5, dampingFraction: 0.7).delay(0.1), value: appeared)
|
||||
|
||||
Spacer().frame(height: 32)
|
||||
|
||||
// Title + description
|
||||
VStack(spacing: 10) {
|
||||
Text("Access your photos")
|
||||
.font(.system(size: 24, weight: .semibold))
|
||||
.foregroundStyle(AppTheme.ink)
|
||||
|
||||
Text("Kisani needs read access to your photo library to find and back up your photos and videos to the NAS.")
|
||||
.font(AppTheme.body())
|
||||
.foregroundStyle(AppTheme.inkSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 32)
|
||||
|
||||
// Detail chips
|
||||
HStack(spacing: 8) {
|
||||
detailChip(icon: "eye.slash", label: "Read-only")
|
||||
detailChip(icon: "lock.shield", label: "Stays on-device")
|
||||
detailChip(icon: "icloud.slash", label: "No cloud upload")
|
||||
}
|
||||
.padding(.top, 8)
|
||||
}
|
||||
.opacity(appeared ? 1 : 0)
|
||||
.animation(.spring(response: 0.4, dampingFraction: 0.8).delay(0.18), value: appeared)
|
||||
|
||||
Spacer()
|
||||
|
||||
// Buttons
|
||||
VStack(spacing: 12) {
|
||||
Button(action: requestAccess) {
|
||||
Group {
|
||||
if isRequesting {
|
||||
ProgressView().tint(AppTheme.inkInverse)
|
||||
} else {
|
||||
Text("Allow Photo Access")
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(PrimaryButtonStyle())
|
||||
.disabled(isRequesting)
|
||||
|
||||
Button(action: skipAndContinue) {
|
||||
Text("Not Now")
|
||||
}
|
||||
.buttonStyle(GhostButtonStyle())
|
||||
}
|
||||
.padding(.horizontal, AppTheme.hPad)
|
||||
.opacity(appeared ? 1 : 0)
|
||||
.animation(.spring(response: 0.4, dampingFraction: 0.8).delay(0.28), value: appeared)
|
||||
|
||||
Spacer().frame(height: 48)
|
||||
}
|
||||
}
|
||||
.onAppear { appeared = true }
|
||||
}
|
||||
|
||||
private var stepIndicator: some View {
|
||||
HStack(spacing: 6) {
|
||||
ForEach(1...3, id: \.self) { step in
|
||||
Capsule()
|
||||
.fill(step == 3 ? AppTheme.ink : AppTheme.inkQuaternary)
|
||||
.frame(width: step == 3 ? 20 : 6, height: 6)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func detailChip(icon: String, label: String) -> some View {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 10, weight: .medium))
|
||||
Text(label)
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
}
|
||||
.foregroundStyle(AppTheme.inkSecondary)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 5)
|
||||
.background(AppTheme.surfaceSunken)
|
||||
.clipShape(Capsule(style: .continuous))
|
||||
}
|
||||
|
||||
private func requestAccess() {
|
||||
isRequesting = true
|
||||
PHPhotoLibrary.requestAuthorization(for: .readWrite) { _ in
|
||||
DispatchQueue.main.async {
|
||||
isRequesting = false
|
||||
store.onboardingPhotoShown = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func skipAndContinue() {
|
||||
store.onboardingPhotoShown = true
|
||||
}
|
||||
}
|
||||
@@ -131,7 +131,7 @@ struct LANTriggerSection: View {
|
||||
.padding(.horizontal, AppTheme.cardPad)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.background(Color.white)
|
||||
.background(AppTheme.surfaceRaised)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
|
||||
.shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2)
|
||||
}
|
||||
|
||||
@@ -6,10 +6,11 @@ struct SettingsView: View {
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color.white.ignoresSafeArea()
|
||||
AppTheme.background.ignoresSafeArea()
|
||||
|
||||
ScrollView {
|
||||
VStack(spacing: AppTheme.sectionGap) {
|
||||
appearanceSection
|
||||
LANTriggerSection()
|
||||
mediaSection
|
||||
quietHoursSection
|
||||
@@ -26,6 +27,45 @@ struct SettingsView: View {
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
// MARK: — Appearance
|
||||
|
||||
private var appearanceSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
sectionLabel("APPEARANCE")
|
||||
|
||||
HStack(spacing: 0) {
|
||||
ForEach(AppearanceMode.allCases, id: \.self) { mode in
|
||||
Button {
|
||||
withAnimation(.spring(response: 0.25, dampingFraction: 0.8)) {
|
||||
store.appearanceMode = mode
|
||||
}
|
||||
} label: {
|
||||
let selected = store.appearanceMode == mode
|
||||
HStack(spacing: 5) {
|
||||
Image(systemName: mode.icon)
|
||||
.font(.system(size: 12, weight: selected ? .semibold : .regular))
|
||||
Text(mode.label)
|
||||
.font(.system(size: 12, weight: selected ? .semibold : .regular))
|
||||
}
|
||||
.foregroundStyle(selected ? AppTheme.ink : AppTheme.inkTertiary)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 9)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||
.fill(selected ? AppTheme.surfaceRaised : Color.clear)
|
||||
.shadow(color: selected ? .black.opacity(0.08) : .clear,
|
||||
radius: 4, x: 0, y: 1)
|
||||
)
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
}
|
||||
}
|
||||
.padding(3)
|
||||
.background(AppTheme.surfaceSunken)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 11, style: .continuous))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: — Media
|
||||
|
||||
private var mediaSection: some View {
|
||||
@@ -49,7 +89,7 @@ struct SettingsView: View {
|
||||
subtitle: "Skip files already on NAS",
|
||||
isOn: $store.backupFilter.newFilesOnly)
|
||||
}
|
||||
.background(Color.white)
|
||||
.background(AppTheme.surfaceRaised)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
|
||||
.shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2)
|
||||
}
|
||||
@@ -78,7 +118,7 @@ struct SettingsView: View {
|
||||
subtitle: "Preserve battery on large libraries",
|
||||
isOn: $store.chargingOnlyMode)
|
||||
}
|
||||
.background(Color.white)
|
||||
.background(AppTheme.surfaceRaised)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
|
||||
.shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2)
|
||||
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: store.quietHoursEnabled)
|
||||
@@ -135,7 +175,7 @@ struct SettingsView: View {
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
}
|
||||
.background(Color.white)
|
||||
.background(AppTheme.surfaceRaised)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
|
||||
.shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2)
|
||||
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: lanMonitor.isOnCellular)
|
||||
@@ -206,13 +246,15 @@ struct SettingsView: View {
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous))
|
||||
}
|
||||
.buttonStyle(ScaleButtonStyle())
|
||||
.accessibilityLabel("Open Tailscale app")
|
||||
.accessibilityHint("Opens the Tailscale app to connect the VPN")
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, AppTheme.cardPad)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
}
|
||||
.background(Color.white)
|
||||
.background(AppTheme.surfaceRaised)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
|
||||
.shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2)
|
||||
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: store.useTailscaleWhenRemote)
|
||||
@@ -233,7 +275,7 @@ struct SettingsView: View {
|
||||
hairline
|
||||
notifRow(icon: "exclamationmark.triangle", title: "On errors", value: "On")
|
||||
}
|
||||
.background(Color.white)
|
||||
.background(AppTheme.surfaceRaised)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
|
||||
.shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user