Apply Emil Kowalski design across all screens

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

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

View File

@@ -11,193 +11,213 @@ struct BackupView: View {
Color.white.ignoresSafeArea() Color.white.ignoresSafeArea()
ScrollView { ScrollView {
VStack(spacing: 24) { VStack(spacing: AppTheme.sectionGap) {
// Ring + counts
ringSection ringSection
nasRow
// NAS status
nasStatusRow
// Options card
optionsCard optionsCard
// Start / pause button
actionButton actionButton
} }
.padding(.horizontal, 20) .padding(.horizontal, AppTheme.hPad)
.padding(.top, 20) .padding(.top, 24)
.padding(.bottom, 40) .padding(.bottom, 48)
} }
} }
.task { .task { vm.loadPhotoCount(filter: store.backupFilter) }
vm.loadPhotoCount(filter: store.backupFilter)
}
} }
// MARK: Ring // MARK: Ring
private var ringSection: some View { private var ringSection: some View {
VStack(spacing: 16) { VStack(spacing: 20) {
ZStack { ZStack {
ProgressRing( ProgressRing(
progress: engine.job.progress, progress: engine.job.progress,
lineWidth: 14, lineWidth: 8,
size: 200, size: 180,
color: ringColor color: ringColor,
backgroundColor: Color(hex: "#EBEBEB")
) )
VStack(spacing: 4) { VStack(spacing: 3) {
Text(percentText) Text(percentText)
.font(.system(size: 36, weight: .bold)) .font(.system(size: 40, weight: .semibold, design: .rounded))
.foregroundColor(AppTheme.textPrimary) .foregroundStyle(AppTheme.ink)
.contentTransition(.numericText())
if case .running = engine.job.status { if case .running = engine.job.status {
Text("\(engine.job.uploadedFiles + engine.job.skippedFiles) / \(engine.job.totalFiles)") Text("\(engine.job.uploadedFiles + engine.job.skippedFiles) of \(engine.job.totalFiles)")
.font(AppTheme.bodyFont) .font(AppTheme.caption())
.foregroundColor(AppTheme.textSecondary) .foregroundStyle(AppTheme.inkSecondary)
if let eta = engine.job.eta { if let eta = engine.job.eta {
Text("~\(etaString(eta))") Text("~\(etaString(eta)) remaining")
.font(AppTheme.captionFont) .font(.system(size: 11, weight: .regular))
.foregroundColor(AppTheme.textTertiary) .foregroundStyle(AppTheme.inkTertiary)
} }
} else { } else {
Text("\(vm.totalPhotoCount) photos") Text("\(vm.totalPhotoCount) photos")
.font(AppTheme.bodyFont) .font(AppTheme.caption())
.foregroundColor(AppTheme.textSecondary) .foregroundStyle(AppTheme.inkSecondary)
Text("0 backed up")
.font(AppTheme.captionFont)
.foregroundColor(AppTheme.textTertiary)
} }
} }
} }
// Completion summary // Completion summary
if case .completed = engine.job.status { if case .completed = engine.job.status {
completionSummary 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 row // Progress detail
if case .running = engine.job.status { if case .running = engine.job.status {
progressDetailRow
}
}
}
private var ringColor: Color {
switch engine.job.status {
case .completed: return AppTheme.green
case .failed: return AppTheme.red
default: return AppTheme.blue
}
}
private var percentText: String {
"\(Int(engine.job.progress * 100))%"
}
// MARK: Progress detail
private var progressDetailRow: some View {
VStack(spacing: 8) { VStack(spacing: 8) {
ProgressView(value: engine.job.progress) GeometryReader { geo in
.tint(AppTheme.blue) 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 { HStack {
Text(engine.job.currentFileName) Text(engine.job.currentFileName)
.font(AppTheme.captionFont) .font(.system(size: 12, weight: .regular))
.foregroundColor(AppTheme.textSecondary) .foregroundStyle(AppTheme.inkSecondary)
.lineLimit(1) .lineLimit(1)
.truncationMode(.middle) .truncationMode(.middle)
Spacer() Spacer()
Text(formatSpeed(engine.job.speedBytesPerSecond)) Text(formatSpeed(engine.job.speedBytesPerSecond))
.font(AppTheme.captionFont) .font(.system(size: 12, weight: .regular))
.foregroundColor(AppTheme.textTertiary) .foregroundStyle(AppTheme.inkTertiary)
} }
} }
.transition(.opacity)
}
}
.animation(.spring(response: 0.4, dampingFraction: 0.8), value: engine.job.status == .completed)
}
private var ringColor: Color {
switch engine.job.status {
case .completed: return AppTheme.positive
case .failed: return AppTheme.destructive
default: return AppTheme.ink
}
} }
// MARK: Completion private var percentText: String { "\(Int(engine.job.progress * 100))%" }
private var completionSummary: some View { private func statCell(_ value: String, label: String, color: Color = AppTheme.ink) -> some View {
HStack(spacing: 24) {
statPill(count: engine.job.uploadedFiles, label: "uploaded", color: AppTheme.green)
statPill(count: engine.job.skippedFiles, label: "skipped", color: AppTheme.textSecondary)
statPill(count: engine.job.failedFiles, label: "errors", color: engine.job.failedFiles > 0 ? AppTheme.red : AppTheme.textTertiary)
}
}
private func statPill(count: Int, label: String, color: Color) -> some View {
VStack(spacing: 2) { VStack(spacing: 2) {
Text("\(count)") Text(value)
.font(.system(size: 20, weight: .bold)) .font(.system(size: 18, weight: .semibold))
.foregroundColor(color) .foregroundStyle(color)
Text(label) Text(label)
.font(AppTheme.captionFont) .font(AppTheme.micro())
.foregroundColor(AppTheme.textSecondary) .foregroundStyle(AppTheme.inkTertiary)
} }
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
} }
// MARK: NAS status row private var dividerLine: some View {
Rectangle()
.fill(AppTheme.separator)
.frame(width: 0.5)
.padding(.vertical, 12)
}
// MARK: NAS row
private var nasRow: some View {
HStack(spacing: 10) {
ZStack {
Circle()
.fill(AppTheme.surfaceSunken)
.frame(width: 32, height: 32)
Image(systemName: "externaldrive")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(AppTheme.inkSecondary)
}
private var nasStatusRow: some View {
HStack {
Image(systemName: "externaldrive.fill")
.foregroundColor(AppTheme.blue)
if let conn = store.savedConnection { if let conn = store.savedConnection {
Text("\(conn.host)\(conn.username)") VStack(alignment: .leading, spacing: 1) {
.font(AppTheme.bodyFont) Text(conn.host)
.foregroundColor(AppTheme.textPrimary) .font(AppTheme.headline())
.foregroundStyle(AppTheme.ink)
Text(conn.username)
.font(AppTheme.caption())
.foregroundStyle(AppTheme.inkSecondary)
} }
}
Spacer() Spacer()
HStack(spacing: 4) {
Circle().fill(AppTheme.green).frame(width: 8, height: 8) HStack(spacing: 5) {
Circle()
.fill(AppTheme.positive)
.frame(width: 6, height: 6)
Text("Live") Text("Live")
.font(AppTheme.captionFont) .font(AppTheme.micro())
.foregroundColor(AppTheme.green) .foregroundStyle(AppTheme.positive)
} }
} }
.padding(14) .padding(14)
.background(Color(hex: "#F9FAFB")) .background(AppTheme.surfaceSunken)
.clipShape(RoundedRectangle(cornerRadius: 10)) .clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
.overlay(RoundedRectangle(cornerRadius: 10).stroke(AppTheme.cardBorder))
} }
// MARK: Options card // MARK: Options
private var optionsCard: some View { private var optionsCard: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
optionRow(icon: "camera", title: "What to back up") {} optionRow(icon: "camera", label: "What to back up") {}
Divider().padding(.leading, 44) hairline
optionRow(icon: "doc.badge.clock", title: "New files only") {} optionRow(icon: "doc.badge.clock", label: "New files only") {}
Divider().padding(.leading, 44) hairline
// Photos access row
Button(action: { Task { await vm.requestPhotosAccess() } }) { Button(action: { Task { await vm.requestPhotosAccess() } }) {
HStack { HStack {
Image(systemName: "photo.on.rectangle") Image(systemName: "photo.on.rectangle")
.foregroundColor(vm.photosAuthStatus == .authorized ? AppTheme.blue : AppTheme.red) .font(.system(size: 14, weight: .regular))
.frame(width: 28) .foregroundStyle(vm.photosAuthStatus == .authorized
? AppTheme.inkSecondary : AppTheme.destructive)
.frame(width: 20)
Text("Photos access") Text("Photos access")
.font(AppTheme.bodyFont) .font(AppTheme.body())
.foregroundColor(AppTheme.textPrimary) .foregroundStyle(AppTheme.ink)
Spacer() Spacer()
Text(photosAccessLabel) Text(photosLabel)
.font(AppTheme.captionFont) .font(AppTheme.caption())
.foregroundColor(vm.photosAuthStatus == .authorized ? AppTheme.green : AppTheme.red) .foregroundStyle(vm.photosAuthStatus == .authorized
? AppTheme.positive : AppTheme.destructive)
Image(systemName: "chevron.right") Image(systemName: "chevron.right")
.font(.system(size: 13)) .font(.system(size: 12, weight: .medium))
.foregroundColor(AppTheme.textTertiary) .foregroundStyle(AppTheme.inkQuaternary)
} }
.padding(.horizontal, 16) .padding(.horizontal, 16)
.padding(.vertical, 14) .padding(.vertical, 14)
} }
.buttonStyle(ScaleButtonStyle())
} }
.background(Color.white) .background(Color.white)
.clipShape(RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius)) .clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
.overlay(RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius).stroke(AppTheme.cardBorder)) .shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2)
} }
private var photosAccessLabel: String { private var photosLabel: String {
switch vm.photosAuthStatus { switch vm.photosAuthStatus {
case .authorized: return "Full access" case .authorized: return "Full access"
case .limited: return "Limited" case .limited: return "Limited"
@@ -206,26 +226,35 @@ struct BackupView: View {
} }
} }
private func optionRow(icon: String, title: String, action: @escaping () -> Void) -> some View { 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) { Button(action: action) {
HStack { HStack {
Image(systemName: icon) Image(systemName: icon)
.foregroundColor(AppTheme.blue) .font(.system(size: 14, weight: .regular))
.frame(width: 28) .foregroundStyle(AppTheme.inkSecondary)
Text(title) .frame(width: 20)
.font(AppTheme.bodyFont) Text(label)
.foregroundColor(AppTheme.textPrimary) .font(AppTheme.body())
.foregroundStyle(AppTheme.ink)
Spacer() Spacer()
Image(systemName: "chevron.right") Image(systemName: "chevron.right")
.font(.system(size: 13)) .font(.system(size: 12, weight: .medium))
.foregroundColor(AppTheme.textTertiary) .foregroundStyle(AppTheme.inkQuaternary)
} }
.padding(.horizontal, 16) .padding(.horizontal, 16)
.padding(.vertical, 14) .padding(.vertical, 14)
} }
.buttonStyle(ScaleButtonStyle())
} }
// MARK: Action button // MARK: Action button
private var actionButton: some View { private var actionButton: some View {
Group { Group {
@@ -233,69 +262,50 @@ struct BackupView: View {
case .idle, .completed, .failed, .cancelled: case .idle, .completed, .failed, .cancelled:
Button(action: startBackup) { Button(action: startBackup) {
Text(engine.job.status == .completed ? "Back up again" : "Start backup") Text(engine.job.status == .completed ? "Back up again" : "Start backup")
.font(.system(size: 17, weight: .semibold))
.foregroundColor(.white)
.frame(maxWidth: .infinity)
.frame(height: 52)
.background(engine.job.status == .completed ? AppTheme.green : AppTheme.blue)
.clipShape(RoundedRectangle(cornerRadius: 14))
} }
.buttonStyle(PrimaryButtonStyle(
color: engine.job.status == .completed ? AppTheme.positive : AppTheme.ink
))
case .running: case .running:
Button(action: { engine.pause() }) { Button { engine.pause() } label: { Text("Pause") }
Text("Pause") .buttonStyle(GhostButtonStyle())
.font(.system(size: 17, weight: .semibold))
.foregroundColor(.white)
.frame(maxWidth: .infinity)
.frame(height: 52)
.background(Color(hex: "#1E3A5F"))
.clipShape(RoundedRectangle(cornerRadius: 14))
}
case .paused: case .paused:
HStack(spacing: 12) { HStack(spacing: 10) {
Button(action: { engine.resume() }) { Button { engine.resume() } label: { Text("Resume") }
Text("Resume") .buttonStyle(PrimaryButtonStyle())
.font(.system(size: 17, weight: .semibold)) Button { engine.cancel() } label: { Text("Cancel") }
.foregroundColor(.white) .buttonStyle(GhostButtonStyle())
.frame(maxWidth: .infinity)
.frame(height: 52)
.background(AppTheme.blue)
.clipShape(RoundedRectangle(cornerRadius: 14))
}
Button(action: { engine.cancel() }) {
Text("Cancel")
.font(.system(size: 17, weight: .semibold))
.foregroundColor(AppTheme.red)
.frame(height: 52)
.frame(width: 90) .frame(width: 90)
.overlay(RoundedRectangle(cornerRadius: 14).stroke(AppTheme.red))
}
} }
case .preparing: case .preparing:
HStack { HStack(spacing: 8) {
ProgressView() ProgressView()
.scaleEffect(0.8)
Text("Preparing…") Text("Preparing…")
.foregroundColor(AppTheme.textSecondary) .font(AppTheme.body())
.foregroundStyle(AppTheme.inkSecondary)
} }
.frame(height: 52) .frame(height: 50)
} }
} }
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: engine.job.status == .running)
} }
private func startBackup() { private func startBackup() {
guard let conn = store.savedConnection else { return } guard let conn = store.savedConnection else { return }
Task { Task { _ = try? await engine.run(connection: conn, filter: store.backupFilter) }
_ = try? await engine.run(connection: conn, filter: store.backupFilter)
}
} }
private func formatSpeed(_ bps: Double) -> String { private func formatSpeed(_ bps: Double) -> String {
let mbps = bps / 1_000_000 bps >= 1_000_000
return mbps >= 1 ? String(format: "%.1f MB/s", mbps) : String(format: "%.0f KB/s", bps / 1000) ? String(format: "%.1f MB/s", bps / 1_000_000)
: String(format: "%.0f KB/s", bps / 1_000)
} }
private func etaString(_ seconds: TimeInterval) -> String { private func etaString(_ seconds: TimeInterval) -> String {
let m = Int(seconds) / 60 Int(seconds) / 60 > 0 ? "\(Int(seconds) / 60)m" : "\(Int(seconds))s"
let s = Int(seconds) % 60
return m > 0 ? "\(m) min" : "\(s)s"
} }
} }

View File

@@ -12,10 +12,6 @@ struct BrowseView: View {
@State private var showCreateFolder = false @State private var showCreateFolder = false
@State private var newFolderName = "" @State private var newFolderName = ""
private var service: any NASTransferProtocol {
connection.nasProtocol == .smb ? SMBService() : SFTPService()
}
var body: some View { var body: some View {
NavigationStack { NavigationStack {
ZStack { ZStack {
@@ -23,36 +19,39 @@ struct BrowseView: View {
VStack(spacing: 0) { VStack(spacing: 0) {
// Path bar // Path bar
HStack { HStack(spacing: 8) {
Image(systemName: "externaldrive.fill") Image(systemName: "externaldrive")
.foregroundColor(AppTheme.textSecondary) .font(.system(size: 12, weight: .medium))
.font(.system(size: 13)) .foregroundStyle(AppTheme.inkTertiary)
Text(currentPath) Text(currentPath)
.font(AppTheme.captionFont) .font(AppTheme.caption())
.foregroundColor(AppTheme.textSecondary) .foregroundStyle(AppTheme.inkSecondary)
.lineLimit(1) .lineLimit(1)
.truncationMode(.middle) .truncationMode(.middle)
Spacer() Spacer()
} }
.padding(.horizontal, 16) .padding(.horizontal, AppTheme.cardPad)
.padding(.vertical, 10) .padding(.vertical, 10)
.background(Color(hex: "#F9FAFB")) .background(AppTheme.surfaceSunken)
Divider() Rectangle()
.fill(AppTheme.separator)
.frame(height: 0.5)
if isLoading { if isLoading {
Spacer() Spacer()
ProgressView() ProgressView()
.tint(AppTheme.inkTertiary)
Spacer() Spacer()
} else if let error { } else if let error {
Spacer() Spacer()
VStack(spacing: 12) { VStack(spacing: 12) {
Image(systemName: "exclamationmark.triangle") Image(systemName: "exclamationmark.triangle")
.font(.system(size: 32)) .font(.system(size: 28, weight: .light))
.foregroundColor(AppTheme.textTertiary) .foregroundStyle(AppTheme.inkQuaternary)
Text(error) Text(error)
.font(AppTheme.bodyFont) .font(AppTheme.caption())
.foregroundColor(AppTheme.textSecondary) .foregroundStyle(AppTheme.inkSecondary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
} }
.padding() .padding()
@@ -61,14 +60,22 @@ struct BrowseView: View {
List { List {
if currentPath != "/" { if currentPath != "/" {
Button(action: navigateUp) { Button(action: navigateUp) {
HStack { HStack(spacing: 10) {
Image(systemName: "arrow.up") Image(systemName: "arrow.up")
.foregroundColor(AppTheme.textSecondary) .font(.system(size: 13, weight: .medium))
.foregroundStyle(AppTheme.inkSecondary)
.frame(width: 22)
Text("Parent folder") Text("Parent folder")
.font(AppTheme.bodyFont) .font(AppTheme.body())
.foregroundColor(AppTheme.textSecondary) .foregroundStyle(AppTheme.inkSecondary)
Spacer()
} }
.padding(.horizontal, 16)
.padding(.vertical, 12)
} }
.buttonStyle(PlainButtonStyle())
.listRowInsets(EdgeInsets())
.listRowSeparator(.hidden)
} }
ForEach(items) { item in ForEach(items) { item in
@@ -83,7 +90,7 @@ struct BrowseView: View {
} }
} }
.listRowInsets(EdgeInsets()) .listRowInsets(EdgeInsets())
.listRowSeparator(.visible) .listRowSeparator(.hidden)
} }
} }
.listStyle(.plain) .listStyle(.plain)
@@ -95,6 +102,8 @@ struct BrowseView: View {
.toolbar { .toolbar {
ToolbarItem(placement: .navigationBarLeading) { ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") { dismiss() } Button("Cancel") { dismiss() }
.font(AppTheme.body())
.foregroundStyle(AppTheme.inkSecondary)
} }
ToolbarItem(placement: .navigationBarTrailing) { ToolbarItem(placement: .navigationBarTrailing) {
Button { Button {
@@ -102,6 +111,7 @@ struct BrowseView: View {
showCreateFolder = true showCreateFolder = true
} label: { } label: {
Image(systemName: "folder.badge.plus") Image(systemName: "folder.badge.plus")
.foregroundStyle(AppTheme.inkSecondary)
} }
} }
ToolbarItem(placement: .bottomBar) { ToolbarItem(placement: .bottomBar) {
@@ -109,8 +119,8 @@ struct BrowseView: View {
selectedPath = currentPath selectedPath = currentPath
dismiss() dismiss()
} }
.font(.system(size: 17, weight: .semibold)) .font(.system(size: 15, weight: .semibold))
.foregroundColor(AppTheme.blue) .foregroundStyle(AppTheme.ink)
} }
} }
.alert("New folder", isPresented: $showCreateFolder) { .alert("New folder", isPresented: $showCreateFolder) {

View File

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

View File

@@ -8,20 +8,20 @@ struct HistoryView: View {
Color.white.ignoresSafeArea() Color.white.ignoresSafeArea()
if store.historyEntries.isEmpty { if store.historyEntries.isEmpty {
VStack(spacing: 16) { VStack(spacing: 12) {
Image(systemName: "clock.arrow.circlepath") Image(systemName: "clock.arrow.circlepath")
.font(.system(size: 48)) .font(.system(size: 36, weight: .light))
.foregroundColor(AppTheme.textTertiary) .foregroundStyle(AppTheme.inkQuaternary)
Text("No backup history yet") Text("No backup history yet")
.font(AppTheme.bodyFont) .font(AppTheme.body())
.foregroundColor(AppTheme.textSecondary) .foregroundStyle(AppTheme.inkTertiary)
} }
} else { } else {
List { List {
ForEach(store.historyEntries) { entry in ForEach(store.historyEntries) { entry in
HistoryRowView(entry: entry) HistoryRowView(entry: entry)
.listRowSeparator(.visible) .listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) .listRowInsets(EdgeInsets())
} }
.onDelete { offsets in .onDelete { offsets in
store.removeHistoryEntries(at: offsets) store.removeHistoryEntries(at: offsets)
@@ -35,7 +35,8 @@ struct HistoryView: View {
if !store.historyEntries.isEmpty { if !store.historyEntries.isEmpty {
ToolbarItem(placement: .navigationBarTrailing) { ToolbarItem(placement: .navigationBarTrailing) {
Button("Clear") { store.clearHistory() } Button("Clear") { store.clearHistory() }
.foregroundColor(AppTheme.red) .font(AppTheme.caption())
.foregroundStyle(AppTheme.destructive)
} }
} }
} }
@@ -45,53 +46,79 @@ struct HistoryView: View {
struct HistoryRowView: View { struct HistoryRowView: View {
let entry: BackupHistoryEntry let entry: BackupHistoryEntry
var body: some View { private var hasErrors: Bool { entry.failedCount > 0 }
HStack(spacing: 14) {
Circle()
.fill(entry.failedCount == 0 ? AppTheme.green : AppTheme.orange)
.frame(width: 10, height: 10)
VStack(alignment: .leading, spacing: 4) { var body: some View {
HStack { VStack(spacing: 0) {
HStack(alignment: .top, spacing: 14) {
Circle()
.fill(hasErrors ? AppTheme.destructive : AppTheme.positive)
.frame(width: 7, height: 7)
.padding(.top, 5)
VStack(alignment: .leading, spacing: 5) {
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text(entry.date, style: .date) Text(entry.date, style: .date)
.font(.system(size: 15, weight: .semibold)) .font(.system(size: 15, weight: .medium))
.foregroundColor(AppTheme.textPrimary) .foregroundStyle(AppTheme.ink)
if entry.triggeredByLAN { if entry.triggeredByLAN {
Text("AUTO") Text("AUTO")
.font(.system(size: 10, weight: .bold)) .font(.system(size: 9, weight: .semibold))
.foregroundColor(AppTheme.blue) .foregroundStyle(AppTheme.inkSecondary)
.padding(.horizontal, 5) .padding(.horizontal, 5)
.padding(.vertical, 2) .padding(.vertical, 2)
.overlay(RoundedRectangle(cornerRadius: 4).stroke(AppTheme.blue, lineWidth: 1)) .overlay(
} RoundedRectangle(cornerRadius: 3, style: .continuous)
Spacer() .stroke(AppTheme.inkQuaternary, lineWidth: 0.75)
Text(entry.date, style: .time) )
.font(AppTheme.captionFont)
.foregroundColor(AppTheme.textTertiary)
} }
HStack(spacing: 12) { Spacer()
statChip("\(entry.uploadedCount) uploaded", color: AppTheme.textSecondary)
if entry.skippedCount > 0 { Text(entry.date, style: .time)
statChip("\(entry.skippedCount) skipped", color: AppTheme.textTertiary) .font(AppTheme.micro(11))
.foregroundStyle(AppTheme.inkTertiary)
} }
HStack(spacing: 8) {
Text("\(entry.uploadedCount) uploaded")
.font(AppTheme.caption())
.foregroundStyle(AppTheme.inkSecondary)
if entry.skippedCount > 0 {
dot
Text("\(entry.skippedCount) skipped")
.font(AppTheme.caption())
.foregroundStyle(AppTheme.inkTertiary)
}
if entry.failedCount > 0 { if entry.failedCount > 0 {
statChip("\(entry.failedCount) errors", color: AppTheme.red) dot
Text("\(entry.failedCount) errors")
.font(AppTheme.caption())
.foregroundStyle(AppTheme.destructive)
} }
} }
Text(entry.nasHost) Text(entry.nasHost)
.font(AppTheme.captionFont) .font(AppTheme.micro(11))
.foregroundColor(AppTheme.textTertiary) .foregroundStyle(AppTheme.inkQuaternary)
} }
} }
.padding(.horizontal, 16) .padding(.horizontal, 16)
.padding(.vertical, 12) .padding(.vertical, 14)
Rectangle()
.fill(AppTheme.separator)
.frame(height: 0.5)
.padding(.leading, 37)
}
} }
private func statChip(_ text: String, color: Color) -> some View { private var dot: some View {
Text(text) Circle()
.font(AppTheme.captionFont) .fill(AppTheme.inkQuaternary)
.foregroundColor(color) .frame(width: 3, height: 3)
} }
} }

View File

@@ -3,6 +3,7 @@ import SwiftUI
struct LoginView: View { struct LoginView: View {
@StateObject private var vm = LoginViewModel() @StateObject private var vm = LoginViewModel()
@State private var navigateToDashboard = false @State private var navigateToDashboard = false
@State private var logoAppeared = false
var body: some View { var body: some View {
NavigationStack { NavigationStack {
@@ -12,88 +13,96 @@ struct LoginView: View {
VStack(spacing: 0) { VStack(spacing: 0) {
Spacer() Spacer()
// Logo + device // Logo
VStack(spacing: 16) {
Image("nas_logo_clean") Image("nas_logo_clean")
.resizable() .resizable()
.scaledToFit() .scaledToFit()
.frame(width: 100, height: 100) .frame(width: 72, height: 72)
.clipShape(RoundedRectangle(cornerRadius: 22)) .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") Text("Forge")
.font(.system(size: 22, weight: .bold)) .font(.system(size: 22, weight: .semibold))
.foregroundColor(AppTheme.textPrimary) .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)
}
}
.opacity(logoAppeared ? 1 : 0)
.animation(.spring(response: 0.5, dampingFraction: 0.7).delay(0.2), value: logoAppeared)
Spacer().frame(height: 40) Spacer().frame(height: 40)
// Account avatar + name
HStack(spacing: 12) {
ZStack {
Circle()
.fill(AppTheme.blue)
.frame(width: 48, height: 48)
Text("AB")
.font(.system(size: 16, weight: .bold))
.foregroundColor(.white)
}
VStack(alignment: .leading, spacing: 2) {
Text("Albert")
.font(.system(size: 17, weight: .semibold))
.foregroundColor(AppTheme.textPrimary)
Text("NAS Account")
.font(AppTheme.captionFont)
.foregroundColor(AppTheme.textSecondary)
}
}
.padding(.bottom, 32)
// Face ID button // Face ID button
Button(action: { vm.authenticateWithFaceID() }) { Button(action: { vm.authenticateWithFaceID() }) {
HStack(spacing: 10) { HStack(spacing: 8) {
if vm.isAuthenticating {
ProgressView()
.tint(.white)
.scaleEffect(0.8)
} else {
Image(systemName: "faceid") Image(systemName: "faceid")
.font(.system(size: 20, weight: .medium)) .font(.system(size: 16, weight: .medium))
Text("Sign in with Face ID") Text("Sign in with Face ID")
.font(.system(size: 17, weight: .semibold))
} }
.foregroundColor(.white)
.frame(maxWidth: .infinity)
.frame(height: 52)
.background(AppTheme.blue)
.clipShape(RoundedRectangle(cornerRadius: 14))
} }
.padding(.horizontal, 32) }
.buttonStyle(PrimaryButtonStyle())
.padding(.horizontal, AppTheme.hPad)
.disabled(vm.isAuthenticating) .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 { if let error = vm.authError {
Text(error) Text(error)
.font(AppTheme.captionFont) .font(AppTheme.caption())
.foregroundColor(AppTheme.red) .foregroundStyle(AppTheme.destructive)
.padding(.top, 12) .padding(.top, 12)
.transition(.opacity.combined(with: .move(edge: .top)))
} }
Spacer() Spacer()
// More link // More
Button("More") { vm.showMoreSheet = true } Button("More options") { vm.showMoreSheet = true }
.font(.system(size: 15, weight: .regular)) .font(.system(size: 13, weight: .regular))
.foregroundColor(AppTheme.textSecondary) .foregroundStyle(AppTheme.inkTertiary)
.padding(.bottom, 40) .padding(.bottom, 44)
.opacity(logoAppeared ? 1 : 0)
.animation(.spring(response: 0.5, dampingFraction: 0.7).delay(0.4), value: logoAppeared)
} }
} }
.navigationDestination(isPresented: $navigateToDashboard) { .navigationDestination(isPresented: $navigateToDashboard) { MainTabView() }
MainTabView()
}
.sheet(isPresented: $vm.showMoreSheet) { .sheet(isPresented: $vm.showMoreSheet) {
MoreOptionsSheet(isPresented: $vm.showMoreSheet, onAuthenticated: { MoreOptionsSheet(isPresented: $vm.showMoreSheet) {
vm.showMoreSheet = false vm.showMoreSheet = false
navigateToDashboard = true navigateToDashboard = true
})
.presentationDetents([.height(220)])
} }
.presentationDetents([.height(200)])
.presentationCornerRadius(20)
} }
.onAppear { .onAppear {
vm.onAuthenticated = { navigateToDashboard = true } vm.onAuthenticated = { navigateToDashboard = true }
logoAppeared = true
}
} }
} }
} }
@@ -101,41 +110,51 @@ struct LoginView: View {
struct MoreOptionsSheet: View { struct MoreOptionsSheet: View {
@Binding var isPresented: Bool @Binding var isPresented: Bool
var onAuthenticated: (() -> Void)? var onAuthenticated: (() -> Void)?
@State private var showPasswordEntry = false
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
Capsule() Capsule()
.fill(Color(hex: "#D1D5DB")) .fill(AppTheme.inkQuaternary)
.frame(width: 40, height: 4) .frame(width: 32, height: 4)
.padding(.top, 12) .padding(.top, 12)
.padding(.bottom, 20) .padding(.bottom, 20)
Button("Password") { showPasswordEntry = true } VStack(spacing: 0) {
.font(.system(size: 17, weight: .semibold)) sheetRow("Password", weight: .medium) { }
.foregroundColor(AppTheme.textPrimary) Divider().padding(.leading, 16)
.frame(maxWidth: .infinity) sheetRow("Another account") {
.padding(.vertical, 14)
Divider()
Button("Another account") {
isPresented = false isPresented = false
onAuthenticated?() onAuthenticated?()
} }
.font(.system(size: 17, weight: .regular)) Divider().padding(.leading, 16)
.foregroundColor(AppTheme.textPrimary) sheetRow("Cancel", color: AppTheme.inkTertiary) { isPresented = false }
.frame(maxWidth: .infinity) }
.padding(.vertical, 14) .background(AppTheme.surfaceRaised)
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
Divider() .overlay(
RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous)
Button("Cancel") { isPresented = false } .stroke(AppTheme.cardBorderColor, lineWidth: 0.5)
.font(.system(size: 17, weight: .regular)) )
.foregroundColor(AppTheme.textSecondary) .padding(.horizontal, AppTheme.hPad)
.frame(maxWidth: .infinity) .padding(.bottom, 16)
.padding(.vertical, 14)
} }
.background(Color.white) .background(Color.white)
} }
private func sheetRow(
_ title: String,
weight: Font.Weight = .regular,
color: Color = AppTheme.ink,
action: @escaping () -> Void
) -> some View {
Button(action: action) {
Text(title)
.font(.system(size: 15, weight: weight))
.foregroundStyle(color)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 16)
.padding(.vertical, 14)
}
.buttonStyle(ScaleButtonStyle())
}
} }

View File

@@ -40,7 +40,7 @@ struct iPhoneTabLayout: View {
NavigationStack { SettingsView() } NavigationStack { SettingsView() }
.tabItem { Label("Settings", systemImage: "gearshape.fill") } .tabItem { Label("Settings", systemImage: "gearshape.fill") }
} }
.tint(AppTheme.blue) .tint(AppTheme.ink)
} }
} }
@@ -68,6 +68,6 @@ struct iPadSidebarLayout: View {
default: BackupView() default: BackupView()
} }
} }
.tint(AppTheme.blue) .tint(AppTheme.ink)
} }
} }

View File

@@ -6,147 +6,150 @@ struct LANTriggerSection: View {
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
Text("AUTOMATION — LAN TRIGGER") sectionLabel("AUTOMATION")
.font(AppTheme.smallFont)
.foregroundColor(AppTheme.textSecondary)
.padding(.leading, 4)
// Hero card blue border, FIXED 210px height, strict top-down layout
VStack(spacing: 0) { VStack(spacing: 0) {
// Header band // Master toggle row
HStack { HStack(spacing: 12) {
ZStack {
RoundedRectangle(cornerRadius: 7, style: .continuous)
.fill(store.lanTriggerEnabled ? AppTheme.ink : AppTheme.surfaceSunken)
.frame(width: 28, height: 28)
Image(systemName: "wifi") Image(systemName: "wifi")
.font(.system(size: 14, weight: .medium)) .font(.system(size: 12, weight: .medium))
.foregroundColor(.white) .foregroundStyle(store.lanTriggerEnabled ? .white : AppTheme.inkTertiary)
Text("Auto-backup on home network")
.font(.system(size: 14, weight: .semibold))
.foregroundColor(.white)
Spacer()
Text(store.lanTriggerEnabled ? "On" : "Off")
.font(.system(size: 12, weight: .bold))
.foregroundColor(.white)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(store.lanTriggerEnabled ? Color.white.opacity(0.25) : Color.white.opacity(0.15))
.clipShape(Capsule())
} }
.padding(.horizontal, 14) .animation(.spring(response: 0.25, dampingFraction: 0.8), value: store.lanTriggerEnabled)
.padding(.vertical, 10)
.background(AppTheme.blue)
// Toggle row VStack(alignment: .leading, spacing: 1) {
HStack { Text("Auto-backup on home network")
VStack(alignment: .leading, spacing: 2) { .font(AppTheme.headline())
Text("Start when on trusted Wi-Fi") .foregroundStyle(AppTheme.ink)
.font(.system(size: 14, weight: .medium)) Text("Starts when a trusted Wi-Fi is joined")
.foregroundColor(AppTheme.textPrimary) .font(AppTheme.caption())
Text("Backup runs on SSID match") .foregroundStyle(AppTheme.inkTertiary)
.font(.system(size: 12))
.foregroundColor(AppTheme.textSecondary)
} }
Spacer() Spacer()
Toggle("", isOn: $store.lanTriggerEnabled) Toggle("", isOn: $store.lanTriggerEnabled)
.labelsHidden() .labelsHidden()
.tint(AppTheme.blue) .tint(AppTheme.ink)
.scaleEffect(0.85)
} }
.padding(.horizontal, 14) .padding(AppTheme.cardPad)
.padding(.top, 10)
.padding(.bottom, 6)
Divider().padding(.horizontal, 14) hairline
// Trusted networks // Trusted networks
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 2) {
Text("TRUSTED NETWORKS") Text("TRUSTED NETWORKS")
.font(.system(size: 10, weight: .semibold)) .font(AppTheme.micro(10))
.foregroundColor(AppTheme.textTertiary) .foregroundStyle(AppTheme.inkTertiary)
.padding(.horizontal, 14) .padding(.horizontal, AppTheme.cardPad)
.padding(.top, 6) .padding(.top, 10)
ForEach(store.trustedSSIDs, id: \.self) { ssid in ForEach(store.trustedSSIDs, id: \.self) { ssid in
HStack(spacing: 8) { HStack(spacing: 10) {
Circle() Circle()
.fill(ssid == lanMonitor.currentSSID ? AppTheme.green : AppTheme.textTertiary) .fill(ssid == lanMonitor.currentSSID ? AppTheme.positive : AppTheme.inkQuaternary)
.frame(width: 7, height: 7) .frame(width: 6, height: 6)
Text(ssid == lanMonitor.currentSSID ? "Connected" : "Saved")
.font(.system(size: 11))
.foregroundColor(AppTheme.textTertiary)
.frame(width: 54, alignment: .leading)
Text(ssid) Text(ssid)
.font(.system(size: 13, weight: .medium)) .font(.system(size: 13, weight: .regular))
.foregroundColor(AppTheme.textPrimary) .foregroundStyle(AppTheme.ink)
if ssid == lanMonitor.currentSSID {
Text("connected")
.font(AppTheme.micro(10))
.foregroundStyle(AppTheme.positive)
}
Spacer() Spacer()
Button { Button {
withAnimation(.spring(response: 0.25, dampingFraction: 0.8)) {
store.trustedSSIDs.removeAll { $0 == ssid } store.trustedSSIDs.removeAll { $0 == ssid }
}
} label: { } label: {
Image(systemName: "xmark") Image(systemName: "xmark")
.font(.system(size: 11, weight: .medium)) .font(.system(size: 11, weight: .medium))
.foregroundColor(AppTheme.textTertiary) .foregroundStyle(AppTheme.inkTertiary)
} }
} }
.padding(.horizontal, 14) .padding(.horizontal, AppTheme.cardPad)
.padding(.vertical, 2) .padding(.vertical, 7)
} }
Button { Button {
Task { Task {
if let ssid = await lanMonitor.fetchCurrentSSID(), if let ssid = await lanMonitor.fetchCurrentSSID(),
!store.trustedSSIDs.contains(ssid) { !store.trustedSSIDs.contains(ssid) {
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
store.trustedSSIDs.append(ssid) store.trustedSSIDs.append(ssid)
} }
} }
}
} label: { } label: {
HStack(spacing: 6) { HStack(spacing: 6) {
Image(systemName: "plus.circle.fill") Image(systemName: "plus")
.font(.system(size: 13)) .font(.system(size: 11, weight: .semibold))
.foregroundColor(AppTheme.blue)
Text("Add current network") Text("Add current network")
.font(.system(size: 13, weight: .medium)) .font(.system(size: 13, weight: .medium))
.foregroundColor(AppTheme.blue)
} }
.padding(.horizontal, 14) .foregroundStyle(AppTheme.inkSecondary)
.padding(.vertical, 4)
} }
.buttonStyle(ScaleButtonStyle())
.padding(.horizontal, AppTheme.cardPad)
.padding(.vertical, 8)
} }
Divider().padding(.horizontal, 14).padding(.top, 4) hairline
// Bottom options row // Delay picker
HStack { HStack {
HStack(spacing: 6) {
Image(systemName: "timer") Image(systemName: "timer")
.font(.system(size: 12)) .font(.system(size: 13, weight: .regular))
.foregroundColor(AppTheme.textSecondary) .foregroundStyle(AppTheme.inkSecondary)
.frame(width: 20)
Text("Wait before starting") Text("Wait before starting")
.font(.system(size: 13)) .font(AppTheme.body())
.foregroundColor(AppTheme.textPrimary) .foregroundStyle(AppTheme.ink)
}
Spacer() Spacer()
Menu { Menu {
ForEach([0, 15, 30, 60, 120], id: \.self) { sec in ForEach([0, 15, 30, 60, 120], id: \.self) { sec in
Button("\(sec)s") { store.startDelaySeconds = sec } Button("\(sec == 0 ? "Immediately" : "\(sec)s")") {
store.startDelaySeconds = sec
}
} }
} label: { } label: {
HStack(spacing: 4) { HStack(spacing: 4) {
Text("\(store.startDelaySeconds)s") Text(store.startDelaySeconds == 0 ? "Immediately" : "\(store.startDelaySeconds)s")
.font(.system(size: 13, weight: .medium)) .font(.system(size: 13, weight: .medium))
.foregroundColor(AppTheme.blue) .foregroundStyle(AppTheme.inkSecondary)
Image(systemName: "chevron.right") Image(systemName: "chevron.up.chevron.down")
.font(.system(size: 11)) .font(.system(size: 10, weight: .medium))
.foregroundColor(AppTheme.textTertiary) .foregroundStyle(AppTheme.inkQuaternary)
} }
} }
} }
.padding(.horizontal, 14) .padding(.horizontal, AppTheme.cardPad)
.padding(.vertical, 8) .padding(.vertical, 12)
} }
.frame(height: 210) .background(Color.white)
.clipShape(RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius)) .clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
.overlay( .shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2)
RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius)
.stroke(AppTheme.lanBorder, lineWidth: 1.5)
)
} }
} }
private var hairline: some View {
Rectangle()
.fill(AppTheme.separator)
.frame(height: 0.5)
.padding(.leading, AppTheme.cardPad)
}
}
// MARK: Shared section header
func sectionLabel(_ text: String) -> some View {
Text(text)
.font(.system(size: 11, weight: .medium))
.foregroundStyle(AppTheme.inkTertiary)
.kerning(0.5)
.padding(.leading, 4)
} }

View File

@@ -8,101 +8,85 @@ struct SettingsView: View {
Color.white.ignoresSafeArea() Color.white.ignoresSafeArea()
ScrollView { ScrollView {
VStack(spacing: AppTheme.sectionSpacing) { VStack(spacing: AppTheme.sectionGap) {
// LAN trigger hero section
LANTriggerSection() LANTriggerSection()
// What to back up
mediaSection mediaSection
// Quiet hours
quietHoursSection quietHoursSection
// Notification section
notificationSection notificationSection
Spacer(minLength: 40)
} }
.padding(.horizontal, 20) .padding(.horizontal, AppTheme.hPad)
.padding(.top, 20) .padding(.top, 20)
.padding(.bottom, 48)
} }
} }
.navigationTitle("Settings") .navigationTitle("Settings")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
} }
// MARK: Media section // MARK: Media
private var mediaSection: some View { private var mediaSection: some View {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
Text("WHAT TO BACK UP") sectionLabel("WHAT TO BACK UP")
.font(AppTheme.smallFont)
.foregroundColor(AppTheme.textSecondary)
.padding(.leading, 4)
VStack(spacing: 0) { VStack(spacing: 0) {
ToggleRow(icon: "photo", title: "Photos", ToggleRow(icon: "photo", title: "Photos",
isOn: $store.backupFilter.includePhotos) isOn: $store.backupFilter.includePhotos)
Divider().padding(.leading, 44) hairline
ToggleRow(icon: "video", title: "Videos", ToggleRow(icon: "video", title: "Videos",
isOn: $store.backupFilter.includeVideos) isOn: $store.backupFilter.includeVideos)
Divider().padding(.leading, 44) hairline
ToggleRow(icon: "camera.viewfinder", title: "Screenshots", ToggleRow(icon: "camera.viewfinder", title: "Screenshots",
isOn: $store.backupFilter.includeScreenshots) isOn: $store.backupFilter.includeScreenshots)
Divider().padding(.leading, 44) hairline
ToggleRow(icon: "rays", title: "RAW", ToggleRow(icon: "rays", title: "RAW",
isOn: $store.backupFilter.includeRAW) isOn: $store.backupFilter.includeRAW)
Divider().padding(.leading, 44) hairline
ToggleRow(icon: "doc.badge.clock", title: "New files only", ToggleRow(icon: "doc.badge.clock", title: "New files only",
subtitle: "Skip files already on NAS", subtitle: "Skip files already on NAS",
isOn: $store.backupFilter.newFilesOnly) isOn: $store.backupFilter.newFilesOnly)
} }
.padding(.horizontal, 16)
.background(Color.white) .background(Color.white)
.clipShape(RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius)) .clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
.overlay(RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius).stroke(AppTheme.cardBorder)) .shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2)
} }
} }
// MARK: Quiet hours // MARK: Quiet hours
private var quietHoursSection: some View { private var quietHoursSection: some View {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
Text("QUIET HOURS") sectionLabel("QUIET HOURS")
.font(AppTheme.smallFont)
.foregroundColor(AppTheme.textSecondary)
.padding(.leading, 4)
VStack(spacing: 0) { VStack(spacing: 0) {
ToggleRow(icon: "moon.fill", title: "Quiet hours", ToggleRow(icon: "moon", title: "Quiet hours",
subtitle: "No auto-backup during this window", subtitle: "No auto-backup during this window",
isOn: $store.quietHoursEnabled) isOn: $store.quietHoursEnabled)
if store.quietHoursEnabled { if store.quietHoursEnabled {
Divider().padding(.leading, 44) hairline
timePickerRow(label: "Start", hour: $store.quietHoursStart) timePickerRow(label: "Start", hour: $store.quietHoursStart)
Divider().padding(.leading, 44) hairline
timePickerRow(label: "End", hour: $store.quietHoursEnd) timePickerRow(label: "End", hour: $store.quietHoursEnd)
} }
Divider().padding(.leading, 44) hairline
ToggleRow(icon: "bolt.fill", title: "Only while charging", ToggleRow(icon: "bolt", title: "Only while charging",
subtitle: "Preserve battery on large libraries", subtitle: "Preserve battery on large libraries",
isOn: $store.chargingOnlyMode) isOn: $store.chargingOnlyMode)
} }
.padding(.horizontal, 16)
.background(Color.white) .background(Color.white)
.clipShape(RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius)) .clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
.overlay(RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius).stroke(AppTheme.cardBorder)) .shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2)
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: store.quietHoursEnabled)
} }
} }
private func timePickerRow(label: String, hour: Binding<Int>) -> some View { private func timePickerRow(label: String, hour: Binding<Int>) -> some View {
HStack { HStack {
Text(label) Text(label)
.font(AppTheme.bodyFont) .font(AppTheme.body())
.foregroundColor(AppTheme.textPrimary) .foregroundStyle(AppTheme.ink)
.frame(width: 80, alignment: .leading)
Spacer() Spacer()
Picker(label, selection: hour) { Picker(label, selection: hour) {
ForEach(0..<24, id: \.self) { h in ForEach(0..<24, id: \.self) { h in
@@ -110,47 +94,53 @@ struct SettingsView: View {
} }
} }
.pickerStyle(.menu) .pickerStyle(.menu)
.tint(AppTheme.blue) .tint(AppTheme.inkSecondary)
} }
.padding(.vertical, 8) .padding(.horizontal, AppTheme.cardPad)
.padding(.vertical, 12)
} }
// MARK: Notifications // MARK: Notifications
private var notificationSection: some View { private var notificationSection: some View {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
Text("NOTIFICATIONS") sectionLabel("NOTIFICATIONS")
.font(AppTheme.smallFont)
.foregroundColor(AppTheme.textSecondary)
.padding(.leading, 4)
VStack(spacing: 0) { VStack(spacing: 0) {
staticRow(icon: "bell.fill", title: "On start", value: "Off") notifRow(icon: "bell", title: "On start", value: "Off")
Divider().padding(.leading, 44) hairline
staticRow(icon: "checkmark.circle.fill", title: "On complete", value: "On") notifRow(icon: "checkmark.circle", title: "On complete", value: "On")
Divider().padding(.leading, 44) hairline
staticRow(icon: "exclamationmark.triangle.fill", title: "On errors", value: "On") notifRow(icon: "exclamationmark.triangle", title: "On errors", value: "On")
} }
.padding(.horizontal, 16)
.background(Color.white) .background(Color.white)
.clipShape(RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius)) .clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
.overlay(RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius).stroke(AppTheme.cardBorder)) .shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2)
} }
} }
private func staticRow(icon: String, title: String, value: String) -> some View { private func notifRow(icon: String, title: String, value: String) -> some View {
HStack { HStack(spacing: 12) {
Image(systemName: icon) Image(systemName: icon)
.foregroundColor(AppTheme.blue) .font(.system(size: 14, weight: .regular))
.frame(width: 28) .foregroundStyle(AppTheme.inkSecondary)
.frame(width: 20)
Text(title) Text(title)
.font(AppTheme.bodyFont) .font(AppTheme.body())
.foregroundColor(AppTheme.textPrimary) .foregroundStyle(AppTheme.ink)
Spacer() Spacer()
Text(value) Text(value)
.font(AppTheme.bodyFont) .font(AppTheme.caption())
.foregroundColor(AppTheme.textSecondary) .foregroundStyle(AppTheme.inkTertiary)
} }
.padding(.vertical, 12) .padding(.horizontal, AppTheme.cardPad)
.padding(.vertical, 14)
}
private var hairline: some View {
Rectangle()
.fill(AppTheme.separator)
.frame(height: 0.5)
.padding(.leading, AppTheme.cardPad)
} }
} }

View File

@@ -65,7 +65,7 @@
A8F24C7DD7A503A0D561F3FB /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = "<group>"; }; A8F24C7DD7A503A0D561F3FB /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = "<group>"; };
AC73AD75762D4357D171CDA4 /* BackupViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupViewModel.swift; sourceTree = "<group>"; }; AC73AD75762D4357D171CDA4 /* BackupViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupViewModel.swift; sourceTree = "<group>"; };
B36E23852EF12DC14A2DF2DC /* NASBackupApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NASBackupApp.swift; sourceTree = "<group>"; }; B36E23852EF12DC14A2DF2DC /* NASBackupApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NASBackupApp.swift; sourceTree = "<group>"; };
C755BFDD42D46A3FB490A4BB /* NASBackup.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = NASBackup.app; sourceTree = BUILT_PRODUCTS_DIR; }; C755BFDD42D46A3FB490A4BB /* NASBackup.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = NASBackup.app; sourceTree = BUILT_PRODUCTS_DIR; };
C955FEF6710E3667C7A73A02 /* ProgressRing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressRing.swift; sourceTree = "<group>"; }; C955FEF6710E3667C7A73A02 /* ProgressRing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressRing.swift; sourceTree = "<group>"; };
D87319355D902007618A91AE /* ConnectionStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionStore.swift; sourceTree = "<group>"; }; D87319355D902007618A91AE /* ConnectionStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionStore.swift; sourceTree = "<group>"; };
D947C8F8945A2AB081BBD0EF /* BrowseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowseView.swift; sourceTree = "<group>"; }; D947C8F8945A2AB081BBD0EF /* BrowseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowseView.swift; sourceTree = "<group>"; };
@@ -307,7 +307,6 @@
LastUpgradeCheck = 1500; LastUpgradeCheck = 1500;
TargetAttributes = { TargetAttributes = {
32316B985B8906FE4CB97730 = { 32316B985B8906FE4CB97730 = {
DevelopmentTeam = "";
ProvisioningStyle = Automatic; ProvisioningStyle = Automatic;
}; };
}; };
@@ -383,6 +382,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = NASBackup.entitlements; CODE_SIGN_ENTITLEMENTS = NASBackup.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_IDENTITY = "iPhone Developer";
DEVELOPMENT_TEAM = K8BLMMR883;
INFOPLIST_FILE = App/Info.plist; INFOPLIST_FILE = App/Info.plist;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
@@ -467,6 +467,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = NASBackup.entitlements; CODE_SIGN_ENTITLEMENTS = NASBackup.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_IDENTITY = "iPhone Developer";
DEVELOPMENT_TEAM = K8BLMMR883;
INFOPLIST_FILE = App/Info.plist; INFOPLIST_FILE = App/Info.plist;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",

View File

@@ -0,0 +1,63 @@
import SwiftUI
// MARK: Primary (black fill)
struct PrimaryButtonStyle: ButtonStyle {
var color: Color = AppTheme.ink
var height: CGFloat = 50
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(.system(size: 15, weight: .medium))
.foregroundColor(.white)
.frame(maxWidth: .infinity)
.frame(height: height)
.background(color)
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
.scaleEffect(configuration.isPressed ? 0.97 : 1)
.animation(.spring(response: 0.25, dampingFraction: 0.7), value: configuration.isPressed)
}
}
// MARK: Ghost (outline)
struct GhostButtonStyle: ButtonStyle {
var height: CGFloat = 50
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(.system(size: 15, weight: .medium))
.foregroundColor(AppTheme.ink)
.frame(maxWidth: .infinity)
.frame(height: height)
.overlay(
RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous)
.stroke(AppTheme.cardBorderColor, lineWidth: 1)
)
.scaleEffect(configuration.isPressed ? 0.97 : 1)
.animation(.spring(response: 0.25, dampingFraction: 0.7), value: configuration.isPressed)
}
}
// MARK: Scale (tap anywhere, just scales)
struct ScaleButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.scaleEffect(configuration.isPressed ? 0.97 : 1)
.animation(.spring(response: 0.25, dampingFraction: 0.7), value: configuration.isPressed)
}
}
// MARK: Pill (compact, rounded)
struct PillButtonStyle: ButtonStyle {
var color: Color = AppTheme.ink
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(.system(size: 13, weight: .medium))
.foregroundColor(.white)
.padding(.horizontal, 14)
.padding(.vertical, 7)
.background(color)
.clipShape(Capsule())
.scaleEffect(configuration.isPressed ? 0.96 : 1)
.animation(.spring(response: 0.2, dampingFraction: 0.7), value: configuration.isPressed)
}
}

View File

@@ -8,31 +8,37 @@ struct FolderRow: View {
var body: some View { var body: some View {
Button(action: onTap) { Button(action: onTap) {
HStack(spacing: 12) { HStack(spacing: 12) {
Image(systemName: item.isDirectory ? "folder.fill" : "doc.fill") Image(systemName: item.isDirectory ? "folder" : "doc")
.font(.system(size: 18)) .font(.system(size: 15, weight: .regular))
.foregroundColor(isSelected ? .white : (item.isDirectory ? AppTheme.blue : AppTheme.textSecondary)) .foregroundStyle(isSelected ? .white : AppTheme.inkSecondary)
.frame(width: 22)
Text(item.name) Text(item.name)
.font(AppTheme.bodyFont) .font(.system(size: 15, weight: isSelected ? .medium : .regular))
.fontWeight(isSelected ? .semibold : .regular) .foregroundStyle(isSelected ? .white : AppTheme.ink)
.foregroundColor(isSelected ? .white : AppTheme.textPrimary) .lineLimit(1)
Spacer() Spacer()
if isSelected { if isSelected {
Image(systemName: "checkmark") Image(systemName: "checkmark")
.font(.system(size: 13, weight: .bold)) .font(.system(size: 12, weight: .semibold))
.foregroundColor(.white) .foregroundStyle(.white)
} else if item.isDirectory { } else if item.isDirectory {
Image(systemName: "chevron.right") Image(systemName: "chevron.right")
.font(.system(size: 13, weight: .medium)) .font(.system(size: 12, weight: .medium))
.foregroundColor(AppTheme.textTertiary) .foregroundStyle(AppTheme.inkQuaternary)
} }
} }
.padding(.horizontal, 16) .padding(.horizontal, 16)
.padding(.vertical, 12) .padding(.vertical, 12)
.background(isSelected ? AppTheme.blue : Color.clear) .background(
isSelected
? AppTheme.ink
: Color.clear
)
.animation(.spring(response: 0.2, dampingFraction: 0.8), value: isSelected)
} }
.buttonStyle(.plain) .buttonStyle(PlainButtonStyle())
} }
} }

View File

@@ -2,24 +2,21 @@ import SwiftUI
struct ProgressRing: View { struct ProgressRing: View {
let progress: Double let progress: Double
var lineWidth: CGFloat = 14 var lineWidth: CGFloat = 8
var size: CGFloat = 200 var size: CGFloat = 200
var color: Color = AppTheme.blue var color: Color = AppTheme.ink
var backgroundColor: Color = Color(hex: "#E5E7EB") var backgroundColor: Color = Color(hex: "#EBEBEB")
var body: some View { var body: some View {
ZStack { ZStack {
Circle() Circle()
.stroke(backgroundColor, lineWidth: lineWidth) .stroke(backgroundColor, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round))
Circle() Circle()
.trim(from: 0, to: CGFloat(min(progress, 1.0))) .trim(from: 0, to: CGFloat(min(progress, 1.0)))
.stroke( .stroke(color, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round))
color,
style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)
)
.rotationEffect(.degrees(-90)) .rotationEffect(.degrees(-90))
.animation(.easeInOut(duration: 0.4), value: progress) .animation(.spring(response: 0.5, dampingFraction: 0.8), value: progress)
} }
.frame(width: size, height: size) .frame(width: size, height: size)
} }

View File

@@ -9,18 +9,18 @@ struct ToggleRow: View {
var body: some View { var body: some View {
HStack(spacing: 12) { HStack(spacing: 12) {
Image(systemName: icon) Image(systemName: icon)
.font(.system(size: 16, weight: .medium)) .font(.system(size: 14, weight: .medium))
.foregroundColor(AppTheme.blue) .foregroundStyle(AppTheme.inkSecondary)
.frame(width: 24) .frame(width: 20)
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 1) {
Text(title) Text(title)
.font(AppTheme.bodyFont) .font(AppTheme.body())
.foregroundColor(AppTheme.textPrimary) .foregroundStyle(AppTheme.ink)
if let subtitle { if let subtitle {
Text(subtitle) Text(subtitle)
.font(AppTheme.captionFont) .font(AppTheme.caption())
.foregroundColor(AppTheme.textSecondary) .foregroundStyle(AppTheme.inkTertiary)
} }
} }
@@ -28,8 +28,8 @@ struct ToggleRow: View {
Toggle("", isOn: $isOn) Toggle("", isOn: $isOn)
.labelsHidden() .labelsHidden()
.tint(AppTheme.blue) .tint(AppTheme.ink)
} }
.padding(.vertical, 4) .padding(.vertical, 2)
} }
} }

View File

@@ -1,38 +1,94 @@
import SwiftUI import SwiftUI
enum AppTheme { enum AppTheme {
// Brand // MARK: Surfaces
static let blue = Color(hex: "#2563EB")
static let blueLight = Color(hex: "#3B82F6")
static let green = Color(hex: "#16A34A")
static let greenLight = Color(hex: "#22C55E")
static let red = Color(hex: "#DC2626")
static let orange = Color(hex: "#EA580C")
// Backgrounds
static let background = Color.white static let background = Color.white
static let cardBackground = Color.white static let surfaceRaised = Color.white
static let cardBorder = Color(hex: "#E5E7EB") static let surfaceSunken = Color(hex: "#F5F5F5")
// Text // MARK: Ink
static let textPrimary = Color(hex: "#111827") static let ink = Color(hex: "#0F0F0F")
static let textSecondary = Color(hex: "#6B7280") static let inkSecondary = Color(hex: "#6B6B6B")
static let textTertiary = Color(hex: "#9CA3AF") static let inkTertiary = Color(hex: "#ABABAB")
static let inkQuaternary = Color(hex: "#D4D4D4")
// Status // MARK: Semantic
static let connectedBorder = Color(hex: "#16A34A") static let positive = Color(hex: "#16A34A")
static let lanBorder = Color(hex: "#2563EB") static let destructive = Color(hex: "#DC2626")
static let interactive = Color(hex: "#2563EB")
// Typography // MARK: Chrome
static let titleFont = Font.system(size: 28, weight: .bold, design: .default) static let separator = Color.black.opacity(0.07)
static let cardShadow = Color.black.opacity(0.05)
static let cardBorderColor = Color.black.opacity(0.07)
// MARK: Metrics
static let radius: CGFloat = 14
static let radiusLarge: CGFloat = 18
static let hPad: CGFloat = 20
static let cardPad: CGFloat = 16
static let sectionGap: CGFloat = 28
// MARK: Typography
static func display(_ size: CGFloat = 32) -> Font {
.system(size: size, weight: .semibold, design: .default)
}
static func title(_ size: CGFloat = 20) -> Font {
.system(size: size, weight: .semibold)
}
static func headline(_ size: CGFloat = 15) -> Font {
.system(size: size, weight: .medium)
}
static func body(_ size: CGFloat = 15) -> Font {
.system(size: size, weight: .regular)
}
static func caption(_ size: CGFloat = 13) -> Font {
.system(size: size, weight: .regular)
}
static func micro(_ size: CGFloat = 11) -> Font {
.system(size: size, weight: .medium)
}
// MARK: Back-compat aliases used by existing call sites
static let blue = interactive
static let green = positive
static let red = destructive
static let textPrimary = ink
static let textSecondary = inkSecondary
static let textTertiary = inkTertiary
static let cardBorder = cardBorderColor
static let connectedBorder = positive
static let lanBorder = interactive
static let cardCornerRadius = radius
static let cardBackground = surfaceRaised
static let titleFont = Font.system(size: 28, weight: .bold)
static let headlineFont = Font.system(size: 17, weight: .semibold) static let headlineFont = Font.system(size: 17, weight: .semibold)
static let bodyFont = Font.system(size: 15, weight: .regular) static let bodyFont = Font.system(size: 15, weight: .regular)
static let captionFont = Font.system(size: 13, weight: .regular) static let captionFont = Font.system(size: 13, weight: .regular)
static let smallFont = Font.system(size: 11, weight: .medium) static let smallFont = Font.system(size: 11, weight: .medium)
static let sectionSpacing: CGFloat = 28
}
static let cardCornerRadius: CGFloat = 12 // MARK: Card modifier
static let cardPadding: CGFloat = 16 struct CardStyle: ViewModifier {
static let sectionSpacing: CGFloat = 24 var padded: Bool = true
func body(content: Content) -> some View {
content
.if(padded) { $0.padding(AppTheme.cardPad) }
.background(AppTheme.surfaceRaised)
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
.shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2)
}
}
extension View {
func card(padded: Bool = true) -> some View {
modifier(CardStyle(padded: padded))
}
@ViewBuilder func `if`<T: View>(_ condition: Bool, transform: (Self) -> T) -> some View {
if condition { transform(self) } else { self }
}
} }
extension Color { extension Color {
@@ -40,9 +96,10 @@ extension Color {
var h = hex.trimmingCharacters(in: .init(charactersIn: "#")) var h = hex.trimmingCharacters(in: .init(charactersIn: "#"))
if h.count == 3 { h = h.map { "\($0)\($0)" }.joined() } if h.count == 3 { h = h.map { "\($0)\($0)" }.joined() }
let n = UInt64(h, radix: 16) ?? 0 let n = UInt64(h, radix: 16) ?? 0
let r = Double((n >> 16) & 0xFF) / 255 self.init(
let g = Double((n >> 8) & 0xFF) / 255 red: Double((n >> 16) & 0xFF) / 255,
let b = Double(n & 0xFF) / 255 green: Double((n >> 8) & 0xFF) / 255,
self.init(red: r, green: g, blue: b) blue: Double( n & 0xFF) / 255
)
} }
} }