diff --git a/Features/Backup/BackupView.swift b/Features/Backup/BackupView.swift index 4dc2a61..a5ff198 100644 --- a/Features/Backup/BackupView.swift +++ b/Features/Backup/BackupView.swift @@ -11,193 +11,213 @@ struct BackupView: View { Color.white.ignoresSafeArea() ScrollView { - VStack(spacing: 24) { - // Ring + counts + VStack(spacing: AppTheme.sectionGap) { ringSection - - // NAS status - nasStatusRow - - // Options card + nasRow optionsCard - - // Start / pause button actionButton } - .padding(.horizontal, 20) - .padding(.top, 20) - .padding(.bottom, 40) + .padding(.horizontal, AppTheme.hPad) + .padding(.top, 24) + .padding(.bottom, 48) } } - .task { - vm.loadPhotoCount(filter: store.backupFilter) - } + .task { vm.loadPhotoCount(filter: store.backupFilter) } } - // MARK: Ring + // MARK: — Ring private var ringSection: some View { - VStack(spacing: 16) { + VStack(spacing: 20) { ZStack { ProgressRing( progress: engine.job.progress, - lineWidth: 14, - size: 200, - color: ringColor + lineWidth: 8, + size: 180, + color: ringColor, + backgroundColor: Color(hex: "#EBEBEB") ) - VStack(spacing: 4) { + VStack(spacing: 3) { Text(percentText) - .font(.system(size: 36, weight: .bold)) - .foregroundColor(AppTheme.textPrimary) + .font(.system(size: 40, weight: .semibold, design: .rounded)) + .foregroundStyle(AppTheme.ink) + .contentTransition(.numericText()) if case .running = engine.job.status { - Text("\(engine.job.uploadedFiles + engine.job.skippedFiles) / \(engine.job.totalFiles)") - .font(AppTheme.bodyFont) - .foregroundColor(AppTheme.textSecondary) + 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))") - .font(AppTheme.captionFont) - .foregroundColor(AppTheme.textTertiary) + Text("~\(etaString(eta)) remaining") + .font(.system(size: 11, weight: .regular)) + .foregroundStyle(AppTheme.inkTertiary) } } else { Text("\(vm.totalPhotoCount) photos") - .font(AppTheme.bodyFont) - .foregroundColor(AppTheme.textSecondary) - Text("0 backed up") - .font(AppTheme.captionFont) - .foregroundColor(AppTheme.textTertiary) + .font(AppTheme.caption()) + .foregroundStyle(AppTheme.inkSecondary) } } } // Completion summary 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 { - progressDetailRow + 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) } } + .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.green - case .failed: return AppTheme.red - default: return AppTheme.blue + case .completed: return AppTheme.positive + case .failed: return AppTheme.destructive + default: return AppTheme.ink } } - private var percentText: String { - "\(Int(engine.job.progress * 100))%" - } + private var percentText: String { "\(Int(engine.job.progress * 100))%" } - // MARK: Progress detail - - private var progressDetailRow: some View { - VStack(spacing: 8) { - ProgressView(value: engine.job.progress) - .tint(AppTheme.blue) - - HStack { - Text(engine.job.currentFileName) - .font(AppTheme.captionFont) - .foregroundColor(AppTheme.textSecondary) - .lineLimit(1) - .truncationMode(.middle) - Spacer() - Text(formatSpeed(engine.job.speedBytesPerSecond)) - .font(AppTheme.captionFont) - .foregroundColor(AppTheme.textTertiary) - } - } - } - - // MARK: Completion - - private var completionSummary: 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 { + private func statCell(_ value: String, label: String, color: Color = AppTheme.ink) -> some View { VStack(spacing: 2) { - Text("\(count)") - .font(.system(size: 20, weight: .bold)) - .foregroundColor(color) + Text(value) + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(color) Text(label) - .font(AppTheme.captionFont) - .foregroundColor(AppTheme.textSecondary) + .font(AppTheme.micro()) + .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) + } - private var nasStatusRow: some View { - HStack { - Image(systemName: "externaldrive.fill") - .foregroundColor(AppTheme.blue) - if let conn = store.savedConnection { - Text("\(conn.host) — \(conn.username)") - .font(AppTheme.bodyFont) - .foregroundColor(AppTheme.textPrimary) + // 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) } + + if let conn = store.savedConnection { + VStack(alignment: .leading, spacing: 1) { + Text(conn.host) + .font(AppTheme.headline()) + .foregroundStyle(AppTheme.ink) + Text(conn.username) + .font(AppTheme.caption()) + .foregroundStyle(AppTheme.inkSecondary) + } + } + 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") - .font(AppTheme.captionFont) - .foregroundColor(AppTheme.green) + .font(AppTheme.micro()) + .foregroundStyle(AppTheme.positive) } } .padding(14) - .background(Color(hex: "#F9FAFB")) - .clipShape(RoundedRectangle(cornerRadius: 10)) - .overlay(RoundedRectangle(cornerRadius: 10).stroke(AppTheme.cardBorder)) + .background(AppTheme.surfaceSunken) + .clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous)) } - // MARK: Options card + // MARK: — Options private var optionsCard: some View { VStack(spacing: 0) { - optionRow(icon: "camera", title: "What to back up") {} - Divider().padding(.leading, 44) - optionRow(icon: "doc.badge.clock", title: "New files only") {} - Divider().padding(.leading, 44) - - // Photos access row + 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") - .foregroundColor(vm.photosAuthStatus == .authorized ? AppTheme.blue : AppTheme.red) - .frame(width: 28) + .font(.system(size: 14, weight: .regular)) + .foregroundStyle(vm.photosAuthStatus == .authorized + ? AppTheme.inkSecondary : AppTheme.destructive) + .frame(width: 20) Text("Photos access") - .font(AppTheme.bodyFont) - .foregroundColor(AppTheme.textPrimary) + .font(AppTheme.body()) + .foregroundStyle(AppTheme.ink) Spacer() - Text(photosAccessLabel) - .font(AppTheme.captionFont) - .foregroundColor(vm.photosAuthStatus == .authorized ? AppTheme.green : AppTheme.red) + Text(photosLabel) + .font(AppTheme.caption()) + .foregroundStyle(vm.photosAuthStatus == .authorized + ? AppTheme.positive : AppTheme.destructive) Image(systemName: "chevron.right") - .font(.system(size: 13)) - .foregroundColor(AppTheme.textTertiary) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(AppTheme.inkQuaternary) } .padding(.horizontal, 16) .padding(.vertical, 14) } + .buttonStyle(ScaleButtonStyle()) } .background(Color.white) - .clipShape(RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius)) - .overlay(RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius).stroke(AppTheme.cardBorder)) + .clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous)) + .shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2) } - private var photosAccessLabel: String { + private var photosLabel: String { switch vm.photosAuthStatus { case .authorized: return "Full access" 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) { HStack { Image(systemName: icon) - .foregroundColor(AppTheme.blue) - .frame(width: 28) - Text(title) - .font(AppTheme.bodyFont) - .foregroundColor(AppTheme.textPrimary) + .font(.system(size: 14, weight: .regular)) + .foregroundStyle(AppTheme.inkSecondary) + .frame(width: 20) + Text(label) + .font(AppTheme.body()) + .foregroundStyle(AppTheme.ink) Spacer() Image(systemName: "chevron.right") - .font(.system(size: 13)) - .foregroundColor(AppTheme.textTertiary) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(AppTheme.inkQuaternary) } .padding(.horizontal, 16) .padding(.vertical, 14) } + .buttonStyle(ScaleButtonStyle()) } - // MARK: Action button + // MARK: — Action button private var actionButton: some View { Group { @@ -233,69 +262,50 @@ struct BackupView: View { case .idle, .completed, .failed, .cancelled: Button(action: startBackup) { 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: - Button(action: { engine.pause() }) { - Text("Pause") - .font(.system(size: 17, weight: .semibold)) - .foregroundColor(.white) - .frame(maxWidth: .infinity) - .frame(height: 52) - .background(Color(hex: "#1E3A5F")) - .clipShape(RoundedRectangle(cornerRadius: 14)) - } + Button { engine.pause() } label: { Text("Pause") } + .buttonStyle(GhostButtonStyle()) + case .paused: - HStack(spacing: 12) { - Button(action: { engine.resume() }) { - Text("Resume") - .font(.system(size: 17, weight: .semibold)) - .foregroundColor(.white) - .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) - .overlay(RoundedRectangle(cornerRadius: 14).stroke(AppTheme.red)) - } + HStack(spacing: 10) { + Button { engine.resume() } label: { Text("Resume") } + .buttonStyle(PrimaryButtonStyle()) + Button { engine.cancel() } label: { Text("Cancel") } + .buttonStyle(GhostButtonStyle()) + .frame(width: 90) } + case .preparing: - HStack { + HStack(spacing: 8) { ProgressView() + .scaleEffect(0.8) 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() { guard let conn = store.savedConnection else { return } - Task { - _ = try? await engine.run(connection: conn, filter: store.backupFilter) - } + Task { _ = try? await engine.run(connection: conn, filter: store.backupFilter) } } private func formatSpeed(_ bps: Double) -> String { - let mbps = bps / 1_000_000 - return mbps >= 1 ? String(format: "%.1f MB/s", mbps) : String(format: "%.0f KB/s", bps / 1000) + 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 { - let m = Int(seconds) / 60 - let s = Int(seconds) % 60 - return m > 0 ? "\(m) min" : "\(s)s" + Int(seconds) / 60 > 0 ? "\(Int(seconds) / 60)m" : "\(Int(seconds))s" } } diff --git a/Features/Browse/BrowseView.swift b/Features/Browse/BrowseView.swift index 5948076..0827931 100644 --- a/Features/Browse/BrowseView.swift +++ b/Features/Browse/BrowseView.swift @@ -12,10 +12,6 @@ struct BrowseView: View { @State private var showCreateFolder = false @State private var newFolderName = "" - private var service: any NASTransferProtocol { - connection.nasProtocol == .smb ? SMBService() : SFTPService() - } - var body: some View { NavigationStack { ZStack { @@ -23,36 +19,39 @@ struct BrowseView: View { VStack(spacing: 0) { // Path bar - HStack { - Image(systemName: "externaldrive.fill") - .foregroundColor(AppTheme.textSecondary) - .font(.system(size: 13)) + HStack(spacing: 8) { + Image(systemName: "externaldrive") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(AppTheme.inkTertiary) Text(currentPath) - .font(AppTheme.captionFont) - .foregroundColor(AppTheme.textSecondary) + .font(AppTheme.caption()) + .foregroundStyle(AppTheme.inkSecondary) .lineLimit(1) .truncationMode(.middle) Spacer() } - .padding(.horizontal, 16) + .padding(.horizontal, AppTheme.cardPad) .padding(.vertical, 10) - .background(Color(hex: "#F9FAFB")) + .background(AppTheme.surfaceSunken) - Divider() + Rectangle() + .fill(AppTheme.separator) + .frame(height: 0.5) if isLoading { Spacer() ProgressView() + .tint(AppTheme.inkTertiary) Spacer() } else if let error { Spacer() VStack(spacing: 12) { Image(systemName: "exclamationmark.triangle") - .font(.system(size: 32)) - .foregroundColor(AppTheme.textTertiary) + .font(.system(size: 28, weight: .light)) + .foregroundStyle(AppTheme.inkQuaternary) Text(error) - .font(AppTheme.bodyFont) - .foregroundColor(AppTheme.textSecondary) + .font(AppTheme.caption()) + .foregroundStyle(AppTheme.inkSecondary) .multilineTextAlignment(.center) } .padding() @@ -61,14 +60,22 @@ struct BrowseView: View { List { if currentPath != "/" { Button(action: navigateUp) { - HStack { + HStack(spacing: 10) { Image(systemName: "arrow.up") - .foregroundColor(AppTheme.textSecondary) + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(AppTheme.inkSecondary) + .frame(width: 22) Text("Parent folder") - .font(AppTheme.bodyFont) - .foregroundColor(AppTheme.textSecondary) + .font(AppTheme.body()) + .foregroundStyle(AppTheme.inkSecondary) + Spacer() } + .padding(.horizontal, 16) + .padding(.vertical, 12) } + .buttonStyle(PlainButtonStyle()) + .listRowInsets(EdgeInsets()) + .listRowSeparator(.hidden) } ForEach(items) { item in @@ -83,7 +90,7 @@ struct BrowseView: View { } } .listRowInsets(EdgeInsets()) - .listRowSeparator(.visible) + .listRowSeparator(.hidden) } } .listStyle(.plain) @@ -95,6 +102,8 @@ struct BrowseView: View { .toolbar { ToolbarItem(placement: .navigationBarLeading) { Button("Cancel") { dismiss() } + .font(AppTheme.body()) + .foregroundStyle(AppTheme.inkSecondary) } ToolbarItem(placement: .navigationBarTrailing) { Button { @@ -102,6 +111,7 @@ struct BrowseView: View { showCreateFolder = true } label: { Image(systemName: "folder.badge.plus") + .foregroundStyle(AppTheme.inkSecondary) } } ToolbarItem(placement: .bottomBar) { @@ -109,8 +119,8 @@ struct BrowseView: View { selectedPath = currentPath dismiss() } - .font(.system(size: 17, weight: .semibold)) - .foregroundColor(AppTheme.blue) + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(AppTheme.ink) } } .alert("New folder", isPresented: $showCreateFolder) { diff --git a/Features/Connect/ConnectView.swift b/Features/Connect/ConnectView.swift index 44545d0..a538879 100644 --- a/Features/Connect/ConnectView.swift +++ b/Features/Connect/ConnectView.swift @@ -3,6 +3,7 @@ import SwiftUI struct ConnectView: View { @StateObject private var vm = ConnectViewModel() @State private var navigateToDashboard = false + @FocusState private var focused: ConnectField? var body: some View { NavigationStack { @@ -15,109 +16,143 @@ struct ConnectView: View { Image("nas_logo_clean") .resizable() .scaledToFit() - .frame(width: 56, height: 56) - .clipShape(RoundedRectangle(cornerRadius: 12)) - .padding(.top, 48) - .padding(.bottom, 28) + .frame(width: 44, height: 44) + .clipShape(RoundedRectangle(cornerRadius: 11, style: .continuous)) + .padding(.top, 44) + .padding(.bottom, 24) // Protocol picker - Picker("Protocol", selection: $vm.selectedProtocol) { - ForEach(NASProtocol.allCases, id: \.self) { Text($0.rawValue).tag($0) } + HStack(spacing: 0) { + ForEach(NASProtocol.allCases, id: \.self) { proto in + Button { + withAnimation(.spring(response: 0.25, dampingFraction: 0.8)) { + vm.selectedProtocol = proto + } + } label: { + Text(proto.rawValue) + .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()) + } } - .pickerStyle(.segmented) - .padding(.horizontal, 20) - .padding(.bottom, 20) + .padding(4) + .background(AppTheme.surfaceSunken) + .clipShape(RoundedRectangle(cornerRadius: 11, style: .continuous)) + .padding(.horizontal, AppTheme.hPad) + .padding(.bottom, 16) // Form card VStack(spacing: 0) { - fieldRow(icon: "network", placeholder: "IP Address or hostname", - text: $vm.host, keyboardType: .URL) - Divider().padding(.leading, 52) - fieldRow(icon: "person", placeholder: "Username", - text: $vm.username, keyboardType: .emailAddress) - Divider().padding(.leading, 52) - secureFieldRow(icon: "lock", placeholder: "Password", text: $vm.password) - Divider().padding(.leading, 52) - - // Folder picker row - Button(action: { if vm.isConnected { vm.showFolderBrowser = true } }) { - HStack(spacing: 12) { - Image(systemName: "folder") - .font(.system(size: 16)) - .foregroundColor(vm.isConnected ? AppTheme.blue : AppTheme.textTertiary) - .frame(width: 28) - Text(vm.remotePath == "/" ? "Destination folder" : vm.remotePath) - .font(AppTheme.bodyFont) - .foregroundColor(vm.remotePath == "/" ? AppTheme.textTertiary : AppTheme.textPrimary) - Spacer() - Image(systemName: "chevron.right") - .font(.system(size: 13)) - .foregroundColor(AppTheme.textTertiary) - } - .padding(.horizontal, 16) - .padding(.vertical, 14) - } + formField( + icon: "network", + placeholder: "IP address or hostname", + text: $vm.host, + field: .host, + keyboard: .URL + ) + hairline + formField( + icon: "person", + placeholder: "Username", + text: $vm.username, + field: .username, + keyboard: .emailAddress + ) + hairline + secureField( + icon: "lock", + placeholder: "Password", + text: $vm.password, + field: .password + ) + hairline + folderRow } .background(Color.white) - .clipShape(RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius)) + .clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous)) .overlay( - RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius) - .stroke(vm.isConnected ? AppTheme.connectedBorder : - (vm.verifyError != nil ? AppTheme.red : AppTheme.cardBorder), - lineWidth: vm.isConnected ? 1.5 : 1) + RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous) + .stroke( + vm.isConnected ? AppTheme.positive.opacity(0.4) : + 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) - if let error = vm.verifyError { - Text(error) - .font(AppTheme.captionFont) - .foregroundColor(AppTheme.red) + // Feedback + Group { + if let error = vm.verifyError { + HStack(spacing: 6) { + Image(systemName: "exclamationmark.circle") + .font(.system(size: 13)) + Text(error) + .font(AppTheme.caption()) + } + .foregroundStyle(AppTheme.destructive) .padding(.top, 10) - .padding(.horizontal, 24) - } - - if vm.isConnected { - Text("Connected successfully") - .font(AppTheme.captionFont) - .foregroundColor(AppTheme.green) + .padding(.horizontal, AppTheme.hPad) + .transition(.opacity.combined(with: .move(edge: .top))) + } else if vm.isConnected { + HStack(spacing: 6) { + Image(systemName: "checkmark.circle") + .font(.system(size: 13)) + Text("Connected successfully") + .font(AppTheme.caption()) + } + .foregroundStyle(AppTheme.positive) .padding(.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 - Spacer().frame(height: 24) + // Button — below the card with deliberate gap + Spacer().frame(height: 20) Button(action: { - if vm.isConnected { - navigateToDashboard = true - } else { - Task { await vm.verify() } - } + if vm.isConnected { navigateToDashboard = true } + else { Task { await vm.verify() } } }) { Group { if vm.isVerifying { ProgressView().tint(.white) } else { - Text(vm.isConnected ? "Connected — continue" : "Connect") - .font(.system(size: 17, weight: .semibold)) + Text(vm.isConnected ? "Continue" : "Connect") } } - .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) - .padding(.horizontal, 20) - .padding(.bottom, 40) + .animation(.spring(response: 0.3, dampingFraction: 0.8), value: vm.isConnected) + + Spacer().frame(height: 40) } } } .navigationTitle("Connect to NAS") .navigationBarTitleDisplayMode(.inline) - .navigationDestination(isPresented: $navigateToDashboard) { - MainTabView() - } + .navigationDestination(isPresented: $navigateToDashboard) { MainTabView() } .sheet(isPresented: $vm.showFolderBrowser) { if let conn = ConnectionStore.shared.savedConnection { BrowseView(connection: conn, selectedPath: $vm.remotePath) @@ -126,30 +161,76 @@ struct ConnectView: View { } } - private func fieldRow(icon: String, placeholder: String, text: Binding, 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, + field: ConnectField, + keyboard: UIKeyboardType + ) -> some View { HStack(spacing: 12) { Image(systemName: icon) - .font(.system(size: 16)) - .foregroundColor(AppTheme.textSecondary) - .frame(width: 28) + .font(.system(size: 14, weight: .regular)) + .foregroundStyle(AppTheme.inkSecondary) + .frame(width: 20) TextField(placeholder, text: text) - .font(AppTheme.bodyFont) - .keyboardType(keyboardType) + .font(AppTheme.body()) + .foregroundStyle(AppTheme.ink) + .keyboardType(keyboard) .autocorrectionDisabled() .textInputAutocapitalization(.never) + .focused($focused, equals: field) } .padding(.horizontal, 16) .padding(.vertical, 14) } - private func secureFieldRow(icon: String, placeholder: String, text: Binding) -> some View { + private func secureField( + icon: String, + placeholder: String, + text: Binding, + field: ConnectField + ) -> some View { HStack(spacing: 12) { Image(systemName: icon) - .font(.system(size: 16)) - .foregroundColor(AppTheme.textSecondary) - .frame(width: 28) + .font(.system(size: 14, weight: .regular)) + .foregroundStyle(AppTheme.inkSecondary) + .frame(width: 20) SecureField(placeholder, text: text) - .font(AppTheme.bodyFont) + .font(AppTheme.body()) + .foregroundStyle(AppTheme.ink) + .focused($focused, equals: field) } .padding(.horizontal, 16) .padding(.vertical, 14) diff --git a/Features/History/HistoryView.swift b/Features/History/HistoryView.swift index 1c0ef2c..58352e5 100644 --- a/Features/History/HistoryView.swift +++ b/Features/History/HistoryView.swift @@ -8,20 +8,20 @@ struct HistoryView: View { Color.white.ignoresSafeArea() if store.historyEntries.isEmpty { - VStack(spacing: 16) { + VStack(spacing: 12) { Image(systemName: "clock.arrow.circlepath") - .font(.system(size: 48)) - .foregroundColor(AppTheme.textTertiary) + .font(.system(size: 36, weight: .light)) + .foregroundStyle(AppTheme.inkQuaternary) Text("No backup history yet") - .font(AppTheme.bodyFont) - .foregroundColor(AppTheme.textSecondary) + .font(AppTheme.body()) + .foregroundStyle(AppTheme.inkTertiary) } } else { List { ForEach(store.historyEntries) { entry in HistoryRowView(entry: entry) - .listRowSeparator(.visible) - .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) + .listRowSeparator(.hidden) + .listRowInsets(EdgeInsets()) } .onDelete { offsets in store.removeHistoryEntries(at: offsets) @@ -35,7 +35,8 @@ struct HistoryView: View { if !store.historyEntries.isEmpty { ToolbarItem(placement: .navigationBarTrailing) { Button("Clear") { store.clearHistory() } - .foregroundColor(AppTheme.red) + .font(AppTheme.caption()) + .foregroundStyle(AppTheme.destructive) } } } @@ -45,53 +46,79 @@ struct HistoryView: View { struct HistoryRowView: View { let entry: BackupHistoryEntry + private var hasErrors: Bool { entry.failedCount > 0 } + var body: some View { - HStack(spacing: 14) { - Circle() - .fill(entry.failedCount == 0 ? AppTheme.green : AppTheme.orange) - .frame(width: 10, height: 10) + 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: 4) { - HStack { - Text(entry.date, style: .date) - .font(.system(size: 15, weight: .semibold)) - .foregroundColor(AppTheme.textPrimary) - if entry.triggeredByLAN { - Text("AUTO") - .font(.system(size: 10, weight: .bold)) - .foregroundColor(AppTheme.blue) - .padding(.horizontal, 5) - .padding(.vertical, 2) - .overlay(RoundedRectangle(cornerRadius: 4).stroke(AppTheme.blue, lineWidth: 1)) + VStack(alignment: .leading, spacing: 5) { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(entry.date, style: .date) + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(AppTheme.ink) + + if entry.triggeredByLAN { + Text("AUTO") + .font(.system(size: 9, weight: .semibold)) + .foregroundStyle(AppTheme.inkSecondary) + .padding(.horizontal, 5) + .padding(.vertical, 2) + .overlay( + RoundedRectangle(cornerRadius: 3, style: .continuous) + .stroke(AppTheme.inkQuaternary, lineWidth: 0.75) + ) + } + + Spacer() + + Text(entry.date, style: .time) + .font(AppTheme.micro(11)) + .foregroundStyle(AppTheme.inkTertiary) } - Spacer() - Text(entry.date, style: .time) - .font(AppTheme.captionFont) - .foregroundColor(AppTheme.textTertiary) + + 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 { + dot + Text("\(entry.failedCount) errors") + .font(AppTheme.caption()) + .foregroundStyle(AppTheme.destructive) + } + } + + Text(entry.nasHost) + .font(AppTheme.micro(11)) + .foregroundStyle(AppTheme.inkQuaternary) } - - HStack(spacing: 12) { - statChip("\(entry.uploadedCount) uploaded", color: AppTheme.textSecondary) - if entry.skippedCount > 0 { - statChip("\(entry.skippedCount) skipped", color: AppTheme.textTertiary) - } - if entry.failedCount > 0 { - statChip("\(entry.failedCount) errors", color: AppTheme.red) - } - } - - Text(entry.nasHost) - .font(AppTheme.captionFont) - .foregroundColor(AppTheme.textTertiary) } + .padding(.horizontal, 16) + .padding(.vertical, 14) + + Rectangle() + .fill(AppTheme.separator) + .frame(height: 0.5) + .padding(.leading, 37) } - .padding(.horizontal, 16) - .padding(.vertical, 12) } - private func statChip(_ text: String, color: Color) -> some View { - Text(text) - .font(AppTheme.captionFont) - .foregroundColor(color) + private var dot: some View { + Circle() + .fill(AppTheme.inkQuaternary) + .frame(width: 3, height: 3) } } diff --git a/Features/Login/LoginView.swift b/Features/Login/LoginView.swift index 912201a..8f20fa8 100644 --- a/Features/Login/LoginView.swift +++ b/Features/Login/LoginView.swift @@ -3,6 +3,7 @@ import SwiftUI struct LoginView: View { @StateObject private var vm = LoginViewModel() @State private var navigateToDashboard = false + @State private var logoAppeared = false var body: some View { NavigationStack { @@ -12,88 +13,96 @@ struct LoginView: View { VStack(spacing: 0) { Spacer() - // Logo + device - VStack(spacing: 16) { - Image("nas_logo_clean") - .resizable() - .scaledToFit() - .frame(width: 100, height: 100) - .clipShape(RoundedRectangle(cornerRadius: 22)) + // 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: .bold)) - .foregroundColor(AppTheme.textPrimary) + .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) + } } + .opacity(logoAppeared ? 1 : 0) + .animation(.spring(response: 0.5, dampingFraction: 0.7).delay(0.2), value: logoAppeared) 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 Button(action: { vm.authenticateWithFaceID() }) { - HStack(spacing: 10) { - Image(systemName: "faceid") - .font(.system(size: 20, weight: .medium)) - Text("Sign in with Face ID") - .font(.system(size: 17, weight: .semibold)) + 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") + } } - .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) + .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.captionFont) - .foregroundColor(AppTheme.red) + .font(AppTheme.caption()) + .foregroundStyle(AppTheme.destructive) .padding(.top, 12) + .transition(.opacity.combined(with: .move(edge: .top))) } Spacer() - // More link - Button("More") { vm.showMoreSheet = true } - .font(.system(size: 15, weight: .regular)) - .foregroundColor(AppTheme.textSecondary) - .padding(.bottom, 40) + // 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) } } - .navigationDestination(isPresented: $navigateToDashboard) { - MainTabView() - } + .navigationDestination(isPresented: $navigateToDashboard) { MainTabView() } .sheet(isPresented: $vm.showMoreSheet) { - MoreOptionsSheet(isPresented: $vm.showMoreSheet, onAuthenticated: { + MoreOptionsSheet(isPresented: $vm.showMoreSheet) { vm.showMoreSheet = false navigateToDashboard = true - }) - .presentationDetents([.height(220)]) + } + .presentationDetents([.height(200)]) + .presentationCornerRadius(20) + } + .onAppear { + vm.onAuthenticated = { navigateToDashboard = true } + logoAppeared = true } - } - .onAppear { - vm.onAuthenticated = { navigateToDashboard = true } } } } @@ -101,41 +110,51 @@ struct LoginView: View { struct MoreOptionsSheet: View { @Binding var isPresented: Bool var onAuthenticated: (() -> Void)? - @State private var showPasswordEntry = false var body: some View { VStack(spacing: 0) { Capsule() - .fill(Color(hex: "#D1D5DB")) - .frame(width: 40, height: 4) + .fill(AppTheme.inkQuaternary) + .frame(width: 32, height: 4) .padding(.top, 12) .padding(.bottom, 20) - Button("Password") { showPasswordEntry = true } - .font(.system(size: 17, weight: .semibold)) - .foregroundColor(AppTheme.textPrimary) - .frame(maxWidth: .infinity) - .padding(.vertical, 14) - - Divider() - - Button("Another account") { - isPresented = false - onAuthenticated?() + VStack(spacing: 0) { + sheetRow("Password", weight: .medium) { } + Divider().padding(.leading, 16) + sheetRow("Another account") { + isPresented = false + onAuthenticated?() + } + Divider().padding(.leading, 16) + sheetRow("Cancel", color: AppTheme.inkTertiary) { isPresented = false } } - .font(.system(size: 17, weight: .regular)) - .foregroundColor(AppTheme.textPrimary) - .frame(maxWidth: .infinity) - .padding(.vertical, 14) - - Divider() - - Button("Cancel") { isPresented = false } - .font(.system(size: 17, weight: .regular)) - .foregroundColor(AppTheme.textSecondary) - .frame(maxWidth: .infinity) - .padding(.vertical, 14) + .background(AppTheme.surfaceRaised) + .clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous) + .stroke(AppTheme.cardBorderColor, lineWidth: 0.5) + ) + .padding(.horizontal, AppTheme.hPad) + .padding(.bottom, 16) } .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()) + } } diff --git a/Features/MainTabView.swift b/Features/MainTabView.swift index 27b1601..b750fb4 100644 --- a/Features/MainTabView.swift +++ b/Features/MainTabView.swift @@ -40,7 +40,7 @@ struct iPhoneTabLayout: View { NavigationStack { SettingsView() } .tabItem { Label("Settings", systemImage: "gearshape.fill") } } - .tint(AppTheme.blue) + .tint(AppTheme.ink) } } @@ -68,6 +68,6 @@ struct iPadSidebarLayout: View { default: BackupView() } } - .tint(AppTheme.blue) + .tint(AppTheme.ink) } } diff --git a/Features/Settings/LANTriggerSection.swift b/Features/Settings/LANTriggerSection.swift index a59d9e7..6794eda 100644 --- a/Features/Settings/LANTriggerSection.swift +++ b/Features/Settings/LANTriggerSection.swift @@ -6,147 +6,150 @@ struct LANTriggerSection: View { var body: some View { VStack(alignment: .leading, spacing: 8) { - Text("AUTOMATION — LAN TRIGGER") - .font(AppTheme.smallFont) - .foregroundColor(AppTheme.textSecondary) - .padding(.leading, 4) + sectionLabel("AUTOMATION") - // Hero card — blue border, FIXED 210px height, strict top-down layout VStack(spacing: 0) { - // Header band - HStack { - Image(systemName: "wifi") - .font(.system(size: 14, weight: .medium)) - .foregroundColor(.white) - 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) - .padding(.vertical, 10) - .background(AppTheme.blue) - - // Toggle row - HStack { - VStack(alignment: .leading, spacing: 2) { - Text("Start when on trusted Wi-Fi") - .font(.system(size: 14, weight: .medium)) - .foregroundColor(AppTheme.textPrimary) - Text("Backup runs on SSID match") - .font(.system(size: 12)) - .foregroundColor(AppTheme.textSecondary) + // Master toggle row + HStack(spacing: 12) { + ZStack { + RoundedRectangle(cornerRadius: 7, style: .continuous) + .fill(store.lanTriggerEnabled ? AppTheme.ink : AppTheme.surfaceSunken) + .frame(width: 28, height: 28) + Image(systemName: "wifi") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(store.lanTriggerEnabled ? .white : AppTheme.inkTertiary) } + .animation(.spring(response: 0.25, dampingFraction: 0.8), value: store.lanTriggerEnabled) + + VStack(alignment: .leading, spacing: 1) { + Text("Auto-backup on home network") + .font(AppTheme.headline()) + .foregroundStyle(AppTheme.ink) + Text("Starts when a trusted Wi-Fi is joined") + .font(AppTheme.caption()) + .foregroundStyle(AppTheme.inkTertiary) + } + Spacer() + Toggle("", isOn: $store.lanTriggerEnabled) .labelsHidden() - .tint(AppTheme.blue) - .scaleEffect(0.85) + .tint(AppTheme.ink) } - .padding(.horizontal, 14) - .padding(.top, 10) - .padding(.bottom, 6) + .padding(AppTheme.cardPad) - Divider().padding(.horizontal, 14) + hairline // Trusted networks - VStack(alignment: .leading, spacing: 4) { + VStack(alignment: .leading, spacing: 2) { Text("TRUSTED NETWORKS") - .font(.system(size: 10, weight: .semibold)) - .foregroundColor(AppTheme.textTertiary) - .padding(.horizontal, 14) - .padding(.top, 6) + .font(AppTheme.micro(10)) + .foregroundStyle(AppTheme.inkTertiary) + .padding(.horizontal, AppTheme.cardPad) + .padding(.top, 10) ForEach(store.trustedSSIDs, id: \.self) { ssid in - HStack(spacing: 8) { + HStack(spacing: 10) { Circle() - .fill(ssid == lanMonitor.currentSSID ? AppTheme.green : AppTheme.textTertiary) - .frame(width: 7, height: 7) - Text(ssid == lanMonitor.currentSSID ? "Connected" : "Saved") - .font(.system(size: 11)) - .foregroundColor(AppTheme.textTertiary) - .frame(width: 54, alignment: .leading) + .fill(ssid == lanMonitor.currentSSID ? AppTheme.positive : AppTheme.inkQuaternary) + .frame(width: 6, height: 6) Text(ssid) - .font(.system(size: 13, weight: .medium)) - .foregroundColor(AppTheme.textPrimary) + .font(.system(size: 13, weight: .regular)) + .foregroundStyle(AppTheme.ink) + if ssid == lanMonitor.currentSSID { + Text("connected") + .font(AppTheme.micro(10)) + .foregroundStyle(AppTheme.positive) + } Spacer() Button { - store.trustedSSIDs.removeAll { $0 == ssid } + withAnimation(.spring(response: 0.25, dampingFraction: 0.8)) { + store.trustedSSIDs.removeAll { $0 == ssid } + } } label: { Image(systemName: "xmark") .font(.system(size: 11, weight: .medium)) - .foregroundColor(AppTheme.textTertiary) + .foregroundStyle(AppTheme.inkTertiary) } } - .padding(.horizontal, 14) - .padding(.vertical, 2) + .padding(.horizontal, AppTheme.cardPad) + .padding(.vertical, 7) } Button { Task { if let ssid = await lanMonitor.fetchCurrentSSID(), !store.trustedSSIDs.contains(ssid) { - store.trustedSSIDs.append(ssid) + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + store.trustedSSIDs.append(ssid) + } } } } label: { HStack(spacing: 6) { - Image(systemName: "plus.circle.fill") - .font(.system(size: 13)) - .foregroundColor(AppTheme.blue) + Image(systemName: "plus") + .font(.system(size: 11, weight: .semibold)) Text("Add current network") .font(.system(size: 13, weight: .medium)) - .foregroundColor(AppTheme.blue) } - .padding(.horizontal, 14) - .padding(.vertical, 4) + .foregroundStyle(AppTheme.inkSecondary) } + .buttonStyle(ScaleButtonStyle()) + .padding(.horizontal, AppTheme.cardPad) + .padding(.vertical, 8) } - Divider().padding(.horizontal, 14).padding(.top, 4) + hairline - // Bottom options row + // Delay picker HStack { - HStack(spacing: 6) { - Image(systemName: "timer") - .font(.system(size: 12)) - .foregroundColor(AppTheme.textSecondary) - Text("Wait before starting") - .font(.system(size: 13)) - .foregroundColor(AppTheme.textPrimary) - } + Image(systemName: "timer") + .font(.system(size: 13, weight: .regular)) + .foregroundStyle(AppTheme.inkSecondary) + .frame(width: 20) + Text("Wait before starting") + .font(AppTheme.body()) + .foregroundStyle(AppTheme.ink) Spacer() Menu { 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: { HStack(spacing: 4) { - Text("\(store.startDelaySeconds)s") + Text(store.startDelaySeconds == 0 ? "Immediately" : "\(store.startDelaySeconds)s") .font(.system(size: 13, weight: .medium)) - .foregroundColor(AppTheme.blue) - Image(systemName: "chevron.right") - .font(.system(size: 11)) - .foregroundColor(AppTheme.textTertiary) + .foregroundStyle(AppTheme.inkSecondary) + Image(systemName: "chevron.up.chevron.down") + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(AppTheme.inkQuaternary) } } } - .padding(.horizontal, 14) - .padding(.vertical, 8) + .padding(.horizontal, AppTheme.cardPad) + .padding(.vertical, 12) } - .frame(height: 210) - .clipShape(RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius)) - .overlay( - RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius) - .stroke(AppTheme.lanBorder, lineWidth: 1.5) - ) + .background(Color.white) + .clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous)) + .shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2) } } + + 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) } diff --git a/Features/Settings/SettingsView.swift b/Features/Settings/SettingsView.swift index 960d7d0..1497b15 100644 --- a/Features/Settings/SettingsView.swift +++ b/Features/Settings/SettingsView.swift @@ -8,101 +8,85 @@ struct SettingsView: View { Color.white.ignoresSafeArea() ScrollView { - VStack(spacing: AppTheme.sectionSpacing) { - // LAN trigger hero section + VStack(spacing: AppTheme.sectionGap) { LANTriggerSection() - - // What to back up mediaSection - - // Quiet hours quietHoursSection - - // Notification section notificationSection - - Spacer(minLength: 40) } - .padding(.horizontal, 20) + .padding(.horizontal, AppTheme.hPad) .padding(.top, 20) + .padding(.bottom, 48) } } .navigationTitle("Settings") .navigationBarTitleDisplayMode(.inline) } - // MARK: Media section + // MARK: — Media private var mediaSection: some View { VStack(alignment: .leading, spacing: 8) { - Text("WHAT TO BACK UP") - .font(AppTheme.smallFont) - .foregroundColor(AppTheme.textSecondary) - .padding(.leading, 4) + sectionLabel("WHAT TO BACK UP") VStack(spacing: 0) { ToggleRow(icon: "photo", title: "Photos", isOn: $store.backupFilter.includePhotos) - Divider().padding(.leading, 44) + hairline ToggleRow(icon: "video", title: "Videos", isOn: $store.backupFilter.includeVideos) - Divider().padding(.leading, 44) + hairline ToggleRow(icon: "camera.viewfinder", title: "Screenshots", isOn: $store.backupFilter.includeScreenshots) - Divider().padding(.leading, 44) + hairline ToggleRow(icon: "rays", title: "RAW", isOn: $store.backupFilter.includeRAW) - Divider().padding(.leading, 44) + hairline ToggleRow(icon: "doc.badge.clock", title: "New files only", subtitle: "Skip files already on NAS", isOn: $store.backupFilter.newFilesOnly) } - .padding(.horizontal, 16) .background(Color.white) - .clipShape(RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius)) - .overlay(RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius).stroke(AppTheme.cardBorder)) + .clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous)) + .shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2) } } - // MARK: Quiet hours + // MARK: — Quiet hours private var quietHoursSection: some View { VStack(alignment: .leading, spacing: 8) { - Text("QUIET HOURS") - .font(AppTheme.smallFont) - .foregroundColor(AppTheme.textSecondary) - .padding(.leading, 4) + sectionLabel("QUIET HOURS") VStack(spacing: 0) { - ToggleRow(icon: "moon.fill", title: "Quiet hours", + ToggleRow(icon: "moon", title: "Quiet hours", subtitle: "No auto-backup during this window", isOn: $store.quietHoursEnabled) if store.quietHoursEnabled { - Divider().padding(.leading, 44) + hairline timePickerRow(label: "Start", hour: $store.quietHoursStart) - Divider().padding(.leading, 44) + hairline timePickerRow(label: "End", hour: $store.quietHoursEnd) } - Divider().padding(.leading, 44) - ToggleRow(icon: "bolt.fill", title: "Only while charging", + hairline + ToggleRow(icon: "bolt", title: "Only while charging", subtitle: "Preserve battery on large libraries", isOn: $store.chargingOnlyMode) } - .padding(.horizontal, 16) .background(Color.white) - .clipShape(RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius)) - .overlay(RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius).stroke(AppTheme.cardBorder)) + .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) } } private func timePickerRow(label: String, hour: Binding) -> some View { HStack { Text(label) - .font(AppTheme.bodyFont) - .foregroundColor(AppTheme.textPrimary) - .frame(width: 80, alignment: .leading) + .font(AppTheme.body()) + .foregroundStyle(AppTheme.ink) Spacer() Picker(label, selection: hour) { ForEach(0..<24, id: \.self) { h in @@ -110,47 +94,53 @@ struct SettingsView: View { } } .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 { VStack(alignment: .leading, spacing: 8) { - Text("NOTIFICATIONS") - .font(AppTheme.smallFont) - .foregroundColor(AppTheme.textSecondary) - .padding(.leading, 4) + sectionLabel("NOTIFICATIONS") VStack(spacing: 0) { - staticRow(icon: "bell.fill", title: "On start", value: "Off") - Divider().padding(.leading, 44) - staticRow(icon: "checkmark.circle.fill", title: "On complete", value: "On") - Divider().padding(.leading, 44) - staticRow(icon: "exclamationmark.triangle.fill", title: "On errors", value: "On") + notifRow(icon: "bell", title: "On start", value: "Off") + hairline + notifRow(icon: "checkmark.circle", title: "On complete", value: "On") + hairline + notifRow(icon: "exclamationmark.triangle", title: "On errors", value: "On") } - .padding(.horizontal, 16) .background(Color.white) - .clipShape(RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius)) - .overlay(RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius).stroke(AppTheme.cardBorder)) + .clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous)) + .shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2) } } - private func staticRow(icon: String, title: String, value: String) -> some View { - HStack { + private func notifRow(icon: String, title: String, value: String) -> some View { + HStack(spacing: 12) { Image(systemName: icon) - .foregroundColor(AppTheme.blue) - .frame(width: 28) + .font(.system(size: 14, weight: .regular)) + .foregroundStyle(AppTheme.inkSecondary) + .frame(width: 20) Text(title) - .font(AppTheme.bodyFont) - .foregroundColor(AppTheme.textPrimary) + .font(AppTheme.body()) + .foregroundStyle(AppTheme.ink) Spacer() Text(value) - .font(AppTheme.bodyFont) - .foregroundColor(AppTheme.textSecondary) + .font(AppTheme.caption()) + .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) } } diff --git a/NASBackup.xcodeproj/project.pbxproj b/NASBackup.xcodeproj/project.pbxproj index db1ae0f..41d70d9 100644 --- a/NASBackup.xcodeproj/project.pbxproj +++ b/NASBackup.xcodeproj/project.pbxproj @@ -65,7 +65,7 @@ A8F24C7DD7A503A0D561F3FB /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = ""; }; AC73AD75762D4357D171CDA4 /* BackupViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupViewModel.swift; sourceTree = ""; }; B36E23852EF12DC14A2DF2DC /* NASBackupApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NASBackupApp.swift; sourceTree = ""; }; - 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 = ""; }; D87319355D902007618A91AE /* ConnectionStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionStore.swift; sourceTree = ""; }; D947C8F8945A2AB081BBD0EF /* BrowseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowseView.swift; sourceTree = ""; }; @@ -307,7 +307,6 @@ LastUpgradeCheck = 1500; TargetAttributes = { 32316B985B8906FE4CB97730 = { - DevelopmentTeam = ""; ProvisioningStyle = Automatic; }; }; @@ -383,6 +382,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = NASBackup.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; + DEVELOPMENT_TEAM = K8BLMMR883; INFOPLIST_FILE = App/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -467,6 +467,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = NASBackup.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; + DEVELOPMENT_TEAM = K8BLMMR883; INFOPLIST_FILE = App/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", diff --git a/Shared/Components/ButtonStyles.swift b/Shared/Components/ButtonStyles.swift new file mode 100644 index 0000000..7b4363f --- /dev/null +++ b/Shared/Components/ButtonStyles.swift @@ -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) + } +} diff --git a/Shared/Components/FolderRow.swift b/Shared/Components/FolderRow.swift index 63f9498..08eb0dc 100644 --- a/Shared/Components/FolderRow.swift +++ b/Shared/Components/FolderRow.swift @@ -8,31 +8,37 @@ struct FolderRow: View { var body: some View { Button(action: onTap) { HStack(spacing: 12) { - Image(systemName: item.isDirectory ? "folder.fill" : "doc.fill") - .font(.system(size: 18)) - .foregroundColor(isSelected ? .white : (item.isDirectory ? AppTheme.blue : AppTheme.textSecondary)) + Image(systemName: item.isDirectory ? "folder" : "doc") + .font(.system(size: 15, weight: .regular)) + .foregroundStyle(isSelected ? .white : AppTheme.inkSecondary) + .frame(width: 22) Text(item.name) - .font(AppTheme.bodyFont) - .fontWeight(isSelected ? .semibold : .regular) - .foregroundColor(isSelected ? .white : AppTheme.textPrimary) + .font(.system(size: 15, weight: isSelected ? .medium : .regular)) + .foregroundStyle(isSelected ? .white : AppTheme.ink) + .lineLimit(1) Spacer() if isSelected { Image(systemName: "checkmark") - .font(.system(size: 13, weight: .bold)) - .foregroundColor(.white) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.white) } else if item.isDirectory { Image(systemName: "chevron.right") - .font(.system(size: 13, weight: .medium)) - .foregroundColor(AppTheme.textTertiary) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(AppTheme.inkQuaternary) } } .padding(.horizontal, 16) .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()) } } diff --git a/Shared/Components/ProgressRing.swift b/Shared/Components/ProgressRing.swift index 1b5b49f..11e1100 100644 --- a/Shared/Components/ProgressRing.swift +++ b/Shared/Components/ProgressRing.swift @@ -2,24 +2,21 @@ import SwiftUI struct ProgressRing: View { let progress: Double - var lineWidth: CGFloat = 14 + var lineWidth: CGFloat = 8 var size: CGFloat = 200 - var color: Color = AppTheme.blue - var backgroundColor: Color = Color(hex: "#E5E7EB") + var color: Color = AppTheme.ink + var backgroundColor: Color = Color(hex: "#EBEBEB") var body: some View { ZStack { Circle() - .stroke(backgroundColor, lineWidth: lineWidth) + .stroke(backgroundColor, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)) Circle() .trim(from: 0, to: CGFloat(min(progress, 1.0))) - .stroke( - color, - style: StrokeStyle(lineWidth: lineWidth, lineCap: .round) - ) + .stroke(color, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)) .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) } diff --git a/Shared/Components/ToggleRow.swift b/Shared/Components/ToggleRow.swift index 14d3ee5..4aca7a2 100644 --- a/Shared/Components/ToggleRow.swift +++ b/Shared/Components/ToggleRow.swift @@ -9,18 +9,18 @@ struct ToggleRow: View { var body: some View { HStack(spacing: 12) { Image(systemName: icon) - .font(.system(size: 16, weight: .medium)) - .foregroundColor(AppTheme.blue) - .frame(width: 24) + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(AppTheme.inkSecondary) + .frame(width: 20) - VStack(alignment: .leading, spacing: 2) { + VStack(alignment: .leading, spacing: 1) { Text(title) - .font(AppTheme.bodyFont) - .foregroundColor(AppTheme.textPrimary) + .font(AppTheme.body()) + .foregroundStyle(AppTheme.ink) if let subtitle { Text(subtitle) - .font(AppTheme.captionFont) - .foregroundColor(AppTheme.textSecondary) + .font(AppTheme.caption()) + .foregroundStyle(AppTheme.inkTertiary) } } @@ -28,8 +28,8 @@ struct ToggleRow: View { Toggle("", isOn: $isOn) .labelsHidden() - .tint(AppTheme.blue) + .tint(AppTheme.ink) } - .padding(.vertical, 4) + .padding(.vertical, 2) } } diff --git a/Shared/Theme/AppTheme.swift b/Shared/Theme/AppTheme.swift index 4e5663e..ab8259e 100644 --- a/Shared/Theme/AppTheme.swift +++ b/Shared/Theme/AppTheme.swift @@ -1,38 +1,94 @@ import SwiftUI enum AppTheme { - // Brand - 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") + // MARK: — Surfaces + static let background = Color.white + static let surfaceRaised = Color.white + static let surfaceSunken = Color(hex: "#F5F5F5") - // Backgrounds - static let background = Color.white - static let cardBackground = Color.white - static let cardBorder = Color(hex: "#E5E7EB") + // MARK: — Ink + static let ink = Color(hex: "#0F0F0F") + static let inkSecondary = Color(hex: "#6B6B6B") + static let inkTertiary = Color(hex: "#ABABAB") + static let inkQuaternary = Color(hex: "#D4D4D4") - // Text - static let textPrimary = Color(hex: "#111827") - static let textSecondary = Color(hex: "#6B7280") - static let textTertiary = Color(hex: "#9CA3AF") + // MARK: — Semantic + static let positive = Color(hex: "#16A34A") + static let destructive = Color(hex: "#DC2626") + static let interactive = Color(hex: "#2563EB") - // Status - static let connectedBorder = Color(hex: "#16A34A") - static let lanBorder = Color(hex: "#2563EB") + // MARK: — Chrome + static let separator = Color.black.opacity(0.07) + static let cardShadow = Color.black.opacity(0.05) + static let cardBorderColor = Color.black.opacity(0.07) - // Typography - static let titleFont = Font.system(size: 28, weight: .bold, design: .default) - static let headlineFont = Font.system(size: 17, weight: .semibold) - static let bodyFont = Font.system(size: 15, weight: .regular) - static let captionFont = Font.system(size: 13, weight: .regular) - static let smallFont = Font.system(size: 11, weight: .medium) + // 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 - static let cardCornerRadius: CGFloat = 12 - static let cardPadding: CGFloat = 16 - static let sectionSpacing: CGFloat = 24 + // 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 bodyFont = Font.system(size: 15, weight: .regular) + static let captionFont = Font.system(size: 13, weight: .regular) + static let smallFont = Font.system(size: 11, weight: .medium) + static let sectionSpacing: CGFloat = 28 +} + +// MARK: — Card modifier +struct CardStyle: ViewModifier { + 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`(_ condition: Bool, transform: (Self) -> T) -> some View { + if condition { transform(self) } else { self } + } } extension Color { @@ -40,9 +96,10 @@ extension Color { var h = hex.trimmingCharacters(in: .init(charactersIn: "#")) if h.count == 3 { h = h.map { "\($0)\($0)" }.joined() } let n = UInt64(h, radix: 16) ?? 0 - let r = Double((n >> 16) & 0xFF) / 255 - let g = Double((n >> 8) & 0xFF) / 255 - let b = Double(n & 0xFF) / 255 - self.init(red: r, green: g, blue: b) + self.init( + red: Double((n >> 16) & 0xFF) / 255, + green: Double((n >> 8) & 0xFF) / 255, + blue: Double( n & 0xFF) / 255 + ) } }