diff --git a/mac-app/Sources/CloudMachineApp/CloudMachineApp.swift b/mac-app/Sources/CloudMachineApp/CloudMachineApp.swift index 5a81372..e7455da 100644 --- a/mac-app/Sources/CloudMachineApp/CloudMachineApp.swift +++ b/mac-app/Sources/CloudMachineApp/CloudMachineApp.swift @@ -16,8 +16,11 @@ struct CloudMachineApp: App { DashboardView() .environmentObject(controller) .preferredColorScheme(.dark) - .frame(minWidth: 780, minHeight: 560) + .frame(minWidth: 820, minHeight: 600) } - .defaultSize(width: 920, height: 680) + // No title bar: the sidebar runs up under the traffic lights, as in the + // RenaCode window design. + .windowStyle(.hiddenTitleBar) + .defaultSize(width: 980, height: 760) } } diff --git a/mac-app/Sources/CloudMachineApp/Views/DashboardView.swift b/mac-app/Sources/CloudMachineApp/Views/DashboardView.swift index bbd76cb..06ed943 100644 --- a/mac-app/Sources/CloudMachineApp/Views/DashboardView.swift +++ b/mac-app/Sources/CloudMachineApp/Views/DashboardView.swift @@ -3,12 +3,66 @@ import SwiftUI /// The main interface of the CloudMachine app, kept in the modern RenaCode /// visual system (consistent with Dietetyk-AI and Trader-AI). +/// +/// Observes `AppStatus` itself, not only the controller: the status is a +/// separate `ObservableObject`, and its changes do not pass through the +/// controller's `objectWillChange`. Observing only the controller, the window +/// redrew when `credentials` was assigned - at the START of each refresh - so it +/// showed the readings of the previous cycle, 10 s late. struct DashboardView: View { @EnvironmentObject private var controller: CloudMachineController + + var body: some View { + DashboardContent(status: controller.status) + .task { controller.startAutoRefresh() } + } +} + +/// The sections of the sidebar. +enum DashboardSection: String, CaseIterable, Identifiable { + case overview + case backups + case storage + case settings + + var id: String { rawValue } + + var title: String { + switch self { + case .overview: return L10n.tr("Overview") + case .backups: return L10n.tr("Backups") + case .storage: return L10n.tr("Storage") + case .settings: return L10n.tr("Settings") + } + } + + var systemImage: String { + switch self { + case .overview: return "house" + case .backups: return "clock.arrow.circlepath" + case .storage: return "internaldrive" + case .settings: return "gearshape" + } + } + + var shortcut: KeyEquivalent { + switch self { + case .overview: return "1" + case .backups: return "2" + case .storage: return "3" + case .settings: return "4" + } + } +} + +private struct DashboardContent: View { + @EnvironmentObject private var controller: CloudMachineController + @ObservedObject var status: AppStatus + /// The window reopens on the section it was closed on. + @AppStorage("dashboardSection") private var section: DashboardSection = .overview @State private var clientID = "" @State private var clientSecret = "" @State private var credentialsMessage: String? - @State private var credentialsExpanded = false /// Logical size of a new backup image. Sparse: Drive holds only what is written. @State private var imageSizeGB = "4000" /// This Mac's folder on Google Drive, chosen before connecting. Empty means @@ -18,149 +72,482 @@ struct DashboardView: View { @State private var limitText = "" var body: some View { - ZStack { - // Glowing RenaCode background - AmbientGlowBackground() + HStack(spacing: 0) { + sidebar + + Rectangle() + .fill(RenaCodeTheme.borderGlass) + .frame(width: 1) VStack(spacing: 0) { - // Top bar / header with the logo and tabs - topHeaderBar + windowTopBar - // Main content - dashboardContent + ScrollView { + VStack(alignment: .leading, spacing: 18) { + Text(section == .overview ? "CloudMachine" : section.title) + .font(.system(size: 34, weight: .bold)) + .foregroundStyle(RenaCodeTheme.textMain) + .accessibilityAddTraits(.isHeader) + .padding(.bottom, 2) + + // An error is shown in every section: switching tabs must not hide it. + if let error = status.errorMessage { + errorBanner(error) + } + + switch section { + case .overview: overviewSection + case .backups: backupsSection + case .storage: storageSection + case .settings: settingsSection + } + } + .padding(.horizontal, 28) + .padding(.bottom, 28) + .frame(maxWidth: .infinity, alignment: .leading) + } } } - .task { controller.startAutoRefresh() } + .background(AmbientGlowBackground()) + .overlay(GradientWindowFrame()) + .ignoresSafeArea() } - // MARK: - Top Navigation Bar + // MARK: - Sidebar - private var topHeaderBar: some View { - VStack(spacing: 12) { - HStack(spacing: 16) { - // Logo icon with a violet and cyan glow - ZStack { - RoundedRectangle(cornerRadius: 12) - .fill(RenaCodeTheme.aiGradient) - .frame(width: 42, height: 42) - .shadow(color: RenaCodeTheme.colorPrimary.opacity(0.45), radius: 12, x: 0, y: 4) - - Image(systemName: "icloud.and.arrow.up.fill") - .font(.system(size: 20, weight: .semibold)) - .foregroundStyle(.white) + private var sidebar: some View { + VStack(spacing: 6) { + ForEach(DashboardSection.allCases) { item in + sidebarItem(item) + } + Spacer() + } + // Below the traffic lights: the window has no title bar of its own. + .padding(.top, 52) + .frame(width: 96) + .background(RenaCodeTheme.bgSidebar.opacity(0.7)) + } + + private func sidebarItem(_ item: DashboardSection) -> some View { + let selected = section == item + let alert = sectionAlert(item) + return Button { + section = item + } label: { + HStack(spacing: 0) { + // The violet accent on the edge of the sidebar marks the active section. + Capsule() + .fill(RenaCodeTheme.frameGradient) + .frame(width: 3, height: 34) + .shadow(color: RenaCodeTheme.colorPrimary.opacity(0.8), radius: 4) + .opacity(selected ? 1 : 0) + + VStack(spacing: 5) { + Image(systemName: item.systemImage) + .font(.system(size: 18, weight: selected ? .semibold : .regular)) + .frame(height: 22) + .overlay(alignment: .topTrailing) { + if let alert { + StatusDot(alert, size: 7) + .offset(x: 6, y: -3) + } + } + Text(item.title) + .font(.system(size: 12, weight: selected ? .semibold : .regular)) + .lineLimit(1) + .minimumScaleFactor(0.8) } + .foregroundStyle(selected ? RenaCodeTheme.textMain : RenaCodeTheme.textMuted) + .frame(width: 78, height: 60) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(Color.white.opacity(selected ? 0.09 : 0)) + ) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(Color.white.opacity(selected ? 0.10 : 0), lineWidth: 1) + ) + .padding(.leading, 6) - VStack(alignment: .leading, spacing: 2) { - HStack(spacing: 8) { - Text("CloudMachine") - .font(.system(size: 20, weight: .bold, design: .rounded)) - .foregroundStyle(RenaCodeTheme.textMain) + Spacer(minLength: 0) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .keyboardShortcut(item.shortcut, modifiers: .command) + .accessibilityLabel(item.title) + .accessibilityValue(alert == nil ? "" : L10n.tr("Attention")) + .accessibilityAddTraits(selected ? .isSelected : []) + } - RenaCodePillBadge( - text: "Time Machine", - icon: "cloud.fill", - color: RenaCodeTheme.colorPrimary - ) - - RenaCodePillBadge( - text: controller.status.healthy ? L10n.tr("Healthy") : L10n.tr("Attention"), - color: controller.status.healthy - ? RenaCodeTheme.colorSuccess : RenaCodeTheme.colorWarning - ) - } + /// A dot on a section that holds a problem, in the problem's color. No dot + /// when all is well: a row of green dots would say nothing. + private func sectionAlert(_ item: DashboardSection) -> StatusTone? { + switch item { + case .overview, .settings: + return nil + case .backups: + let tone = backupCycleTone.worst(status.watchdogRunning ? .success : .danger) + return tone == .success ? nil : tone + case .storage: + let tone = uploadTone(status.buffer.uploadState) + .worst(freeSpaceTone) + .worst(status.budgetLimitGB == nil || budgetOK ? .success : .warning) + return tone == .success ? nil : tone + } + } + + // MARK: - Top Bar + + private var windowTopBar: some View { + HStack(spacing: 10) { + Spacer() - Text(L10n.tr("Local SSD buffer & backup to Google Drive")) + if status.isBusy { + HStack(spacing: 6) { + ProgressView().controlSize(.small) + Text(status.busyLabel) .font(.system(size: 12, weight: .medium)) - .foregroundStyle(RenaCodeTheme.textMuted) + .foregroundStyle(RenaCodeTheme.colorCyan) } + .padding(.horizontal, 10) + .padding(.vertical, 4) + .background(RenaCodeTheme.colorCyan.opacity(0.12)) + .clipShape(Capsule()) + } + + // A frozen view looks exactly like a failure - the time of the last + // reading tells them apart. + if let at = status.lastRefresh { + Text(L10n.tr("Refreshed %@", at.formatted(date: .omitted, time: .standard))) + .font(.system(size: 11, weight: .medium).monospacedDigit()) + .foregroundStyle(RenaCodeTheme.textMuted) + } + + Button(action: { Task { await controller.refreshAll() } }) { + Image(systemName: "arrow.clockwise") + } + .buttonStyle(IconGlassButtonStyle()) + .help(L10n.tr("Refresh")) + .accessibilityLabel(L10n.tr("Refresh")) + .keyboardShortcut("r", modifiers: .command) + } + .padding(.horizontal, 16) + .padding(.top, 10) + .frame(height: 48, alignment: .top) + } + + // MARK: - Overview + + private var overviewSection: some View { + VStack(alignment: .leading, spacing: 16) { + // Until these are done nothing else works, so they come first. + if !setupSteps.isEmpty { + setupCard + } + HStack(alignment: .top, spacing: 16) { + bufferSummaryCard + driveSummaryCard + } + .fixedSize(horizontal: false, vertical: true) + + HStack(alignment: .top, spacing: 16) { + healthCard + disksCard + } + .fixedSize(horizontal: false, vertical: true) + + // Whether the backup reaches the Drive - and why not, if it does not + uploadStateCard + + HStack(spacing: 12) { + backupButton Spacer() + } + } + } - // Refresh information and activity indicator - HStack(spacing: 12) { - if let at = controller.status.lastRefresh { - HStack(spacing: 5) { - Image(systemName: "arrow.clockwise.circle") - .font(.system(size: 11)) - Text(L10n.tr("Refreshed %@", at.formatted(date: .omitted, time: .standard))) - .font(.system(size: 11, weight: .medium, design: .monospaced)) - } - .foregroundStyle(RenaCodeTheme.textDim) - } + private var bufferTone: StatusTone { + if status.buffer.outOfSpace { return .danger } + return freeSpaceTone == .success ? .brand : freeSpaceTone + } - if controller.status.isBusy { - HStack(spacing: 6) { - ProgressView().controlSize(.small) - Text(controller.status.busyLabel) - .font(.system(size: 12, weight: .medium)) - .foregroundStyle(RenaCodeTheme.colorCyan) - } - .padding(.horizontal, 10) - .padding(.vertical, 4) - .background(RenaCodeTheme.colorCyan.opacity(0.12)) - .clipShape(Capsule()) - } + /// The same 80 GB threshold the details row has always used. + private var freeSpaceTone: StatusTone { + guard let free = status.buffer.freeDiskGB else { return .warning } + return free > 80 ? .success : .danger + } + + private var bufferSummaryCard: some View { + VStack(alignment: .leading, spacing: 14) { + HStack(spacing: 10) { + IconTile( + systemImage: "internaldrive.fill", + gradient: LinearGradient( + colors: [RenaCodeTheme.colorCyan, RenaCodeTheme.colorIndigo], + startPoint: .topLeading, endPoint: .bottomTrailing)) + Text(L10n.tr("SSD buffer")) + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(RenaCodeTheme.textMain) + } + + HStack(alignment: .firstTextBaseline) { + Text(L10n.tr("%@ GB", "\(status.buffer.sizeGB)")) + .font(.system(size: 34, weight: .bold).monospacedDigit()) + .foregroundStyle(RenaCodeTheme.textMain) + .lineLimit(1) + .minimumScaleFactor(0.6) + + Spacer(minLength: 8) + + // Where the mockup has a sparkline: the app keeps no history of the + // buffer, so the card shows the queue, which it does read. + VStack(alignment: .trailing, spacing: 2) { + Text(L10n.tr("Waiting to upload")) + .font(.system(size: 11)) + .foregroundStyle(RenaCodeTheme.textMuted) + Text(queueText) + .font(.system(size: 13, weight: .semibold).monospacedDigit()) + .foregroundStyle(queueTone == .success ? RenaCodeTheme.textMain : queueTone.color) } } + // A missing measurement must look different from a number - see + // `BufferGuardService.freeGB()`. Simply putting the optional value + // into the text gave "Free on disk: Optional(427) GB", and the compiler + // reported it ONLY as a warning, so no test would have caught it. + Text( + L10n.tr( + "Free on disk: %@", + status.buffer.freeDiskGB.map { L10n.tr("%@ GB", "\($0)") } ?? L10n.tr("not measured")) + ) + .font(.system(size: 13)) + .foregroundStyle(freeSpaceTone == .success ? RenaCodeTheme.textMuted : freeSpaceTone.color) } - .padding(.horizontal, 22) - .padding(.top, 18) - .padding(.bottom, 14) - .background( - RenaCodeTheme.bgDark.opacity(0.85) - ) - .overlay( - Rectangle() - .fill(RenaCodeTheme.borderGlass) - .frame(height: 1), - alignment: .bottom - ) + .accessibilityElement(children: .combine) + .toneCard(bufferTone) } - // MARK: - Dashboard Content + /// Without a queue reading there is no right to say "nothing" - the zeros + /// are then a missing measurement, not a result. + private var queueText: String { + guard status.buffer.queueKnown else { return "?" } + return status.buffer.draining + ? L10n.tr("%@ files", "\(status.buffer.uploadsQueued)") : L10n.tr("nothing") + } - private var dashboardContent: some View { - ScrollView { - VStack(alignment: .leading, spacing: 20) { + /// Cyan while the queue drains: that is the normal state during a backup, + /// and amber there made a working upload look like a warning. + private var queueTone: StatusTone { + guard status.buffer.queueKnown else { return .warning } + return status.buffer.draining ? .info : .success + } - // Banner for an error, if any - if let error = controller.status.errorMessage { - errorBanner(error) + private var driveSummaryCard: some View { + let upload = status.buffer.uploadState + return VStack(alignment: .leading, spacing: 14) { + HStack(spacing: 10) { + IconTile(systemImage: "icloud.fill", gradient: RenaCodeTheme.aiGradient) + Text(verbatim: "Google Drive Time Machine") + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(RenaCodeTheme.textMain) + .lineLimit(1) + .minimumScaleFactor(0.85) + } + + HStack(alignment: .firstTextBaseline, spacing: 8) { + StatusDot(uploadTone(upload), live: upload.isMovingData) + Text(upload.headline) + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(RenaCodeTheme.textMain) + .fixedSize(horizontal: false, vertical: true) + } + + if let progress = status.backupProgress { + VStack(alignment: .leading, spacing: 8) { + if let percent = progress.percent { + GradientProgressBar(fraction: percent) + } + Text(progressCaption(progress)) + .font(.system(size: 13).monospacedDigit()) + .foregroundStyle(RenaCodeTheme.textMuted) + } + } else { + // No bar when nothing runs: an empty bar would read as "0%". + HStack(spacing: 8) { + StatusDot(backupCycleTone) + Text(L10n.tr("Last completed backup")) + .foregroundStyle(RenaCodeTheme.textMuted) + Spacer(minLength: 4) + Text(status.backupCycle.ageText()) + .fontWeight(.semibold) + .foregroundStyle( + backupCycleTone == .success ? RenaCodeTheme.textMain : backupCycleTone.color) } + .font(.system(size: 13).monospacedDigit()) + } + } + .accessibilityElement(children: .combine) + .toneCard(uploadTone(upload) == .success ? .brand : uploadTone(upload)) + } - // Grid of KPI stat cards (top row) - kpiSummaryGrid + /// "84% | Backing up: 3.4 GB of 4 GB", with whatever tmutil reported. + private func progressCaption(_ progress: BackupProgressInfo) -> String { + var parts: [String] = [] + if let percent = progress.percent { + parts.append(String(format: "%.0f%%", percent * 100)) + } + if let done = progress.bytesDone, let total = progress.bytesTotal, total > 0 { + parts.append( + L10n.tr( + "Backing up: %@ of %@", Self.bytes(done), Self.bytes(total))) + } else { + parts.append(L10n.tr("Backup in progress")) + } + return parts.joined(separator: " | ") + } - // Whether the backup reaches the Drive - and why not, if it does not - uploadStateCard + private static func bytes(_ value: Double) -> String { + ByteCountFormatter.string(fromByteCount: Int64(value), countStyle: .file) + } - // Card with the progress of the running backup (if one is running) - if let progress = controller.status.backupProgress { - progressCard(progress) - } + /// Green only when the state is really good; a hard failure is red, the rest + /// (unknown, not yet checked, passes by itself) amber. + private var healthTone: StatusTone { + if status.healthy { return .success } + if case .missing = status.dependencyState { return .danger } + if !status.remoteConfigured || !status.buffer.mounted || !status.buffer.imageAttached { + return .danger + } + if case .notRegistered = status.timeMachineState { return .danger } + if status.buffer.uploadState.needsAttention { return .danger } + if status.backupCycle.known && !status.backupCycle.isFresh() { return .danger } + return .warning + } - // Card with setup steps ("To do") - if !setupSteps.isEmpty { - setupCard - } + private var healthCard: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text(L10n.tr("System Status")) + .font(.system(size: 13, weight: .bold)) + .textCase(.uppercase) + .tracking(0.6) + .foregroundStyle(RenaCodeTheme.textMuted) + Spacer() + StatusPill( + healthTone == .success + ? L10n.tr("Healthy") + : (healthTone == .danger ? L10n.tr("Action needed") : L10n.tr("Attention")), + tone: healthTone) + } + + Text(L10n.tr("Current status: %@", status.headline)) + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(RenaCodeTheme.textMain) + .fixedSize(horizontal: false, vertical: true) + } + .accessibilityElement(children: .combine) + .toneCard(healthTone) + } - // Buffer and upload details - bufferCard + private var timeMachineTone: StatusTone { + switch status.timeMachineState { + case .registered: return .success + case .notRegistered: return .danger + case .noAnswer: return .warning + case .unknown: return .neutral + } + } - // This Mac's space limit on Google Drive - if controller.status.remoteConfigured { - budgetCard - } + private var timeMachineText: String { + switch status.timeMachineState { + case .registered: return L10n.tr("Points to CloudMachine") + case .notRegistered: return L10n.tr("Does not point to CloudMachine") + case .noAnswer: return L10n.tr("tmutil did not answer") + case .unknown: return L10n.tr("not checked") + } + } + + private var disksTone: StatusTone { + (status.buffer.mounted ? StatusTone.success : .danger) + .worst(status.buffer.imageAttached ? .success : .danger) + .worst(timeMachineTone == .neutral ? .warning : timeMachineTone) + } + + private var disksPill: String { + if !status.buffer.mounted { return L10n.tr("Drive disconnected") } + if !status.buffer.imageAttached { return L10n.tr("Not attached") } + switch disksTone { + case .success: return L10n.tr("Attached") + case .danger: return L10n.tr("Action needed") + default: return L10n.tr("Attention") + } + } - // Google OAuth credentials - credentialsCard + private var disksCard: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text(L10n.tr("Disks")) + .font(.system(size: 13, weight: .bold)) + .textCase(.uppercase) + .tracking(0.6) + .foregroundStyle(RenaCodeTheme.textMuted) + Spacer() + StatusPill(disksPill, tone: disksTone) + } - // Bottom action bar - actionsToolbar + VStack(alignment: .leading, spacing: 6) { + compactRow( + "Google Drive", + status.buffer.mounted ? L10n.tr("Mounted") : L10n.tr("Inactive"), + tone: status.buffer.mounted ? .success : .danger) + compactRow( + L10n.tr("Backup image"), + status.buffer.imageAttached ? L10n.tr("Attached") : L10n.tr("Detached"), + tone: status.buffer.imageAttached ? .success : .danger) + compactRow("Time Machine", timeMachineText, tone: timeMachineTone) } - .padding(22) - .frame(maxWidth: .infinity, alignment: .leading) + } + .accessibilityElement(children: .combine) + .toneCard(disksTone) + } + + private func compactRow(_ label: String, _ value: String, tone: StatusTone) -> some View { + HStack(spacing: 8) { + StatusDot(tone, size: 6) + Text(label) + .foregroundStyle(RenaCodeTheme.textMuted) + Spacer(minLength: 4) + Text(value) + .fontWeight(.medium) + .foregroundStyle( + tone == .success || tone == .neutral ? RenaCodeTheme.textMain : tone.color + ) + .multilineTextAlignment(.trailing) + } + .font(.system(size: 13)) + } + + @ViewBuilder + private var backupButton: some View { + if status.backupProgress == nil { + Button(action: { Task { await controller.startBackup() } }) { + HStack(spacing: 6) { + Image(systemName: "play.fill") + Text(L10n.tr("Back up now")) + } + } + .buttonStyle(PrimaryGradientButtonStyle()) + .disabled(!status.healthy || status.isBusy) + } else { + Button(action: { Task { await controller.stopBackup() } }) { + HStack(spacing: 6) { + Image(systemName: "stop.fill") + Text(L10n.tr("Stop backup")) + } + } + .buttonStyle(SecondaryGlassButtonStyle()) } } @@ -171,110 +558,76 @@ struct DashboardView: View { Image(systemName: "exclamationmark.triangle.fill") .font(.system(size: 18)) .foregroundStyle(RenaCodeTheme.colorDanger) + .accessibilityHidden(true) Text(message) .font(.system(size: 13, weight: .medium)) .foregroundStyle(RenaCodeTheme.textMain) .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) - Spacer() + Spacer(minLength: 0) } - .padding(14) - .background(RenaCodeTheme.colorDanger.opacity(0.12)) - .clipShape(RoundedRectangle(cornerRadius: 12)) - .overlay( - RoundedRectangle(cornerRadius: 12) - .stroke(RenaCodeTheme.colorDanger.opacity(0.35), lineWidth: 1) - ) + .toneCard(.danger, cornerRadius: 12, padding: 14) } - // MARK: - KPI Summary Grid - - private var kpiSummaryGrid: some View { - LazyVGrid( - columns: [ - GridItem(.flexible(), spacing: 14), - GridItem(.flexible(), spacing: 14), - GridItem(.flexible(), spacing: 14), - GridItem(.flexible(), spacing: 14), - ], spacing: 14 - ) { - StatCard( - title: L10n.tr("System Status"), - value: controller.status.healthy ? L10n.tr("Healthy") : L10n.tr("Action needed"), - subtitle: controller.status.headline, - systemImage: controller.status.healthy - ? "checkmark.shield.fill" : "exclamationmark.shield.fill", - iconColor: controller.status.healthy - ? RenaCodeTheme.colorSuccess : RenaCodeTheme.colorWarning - ) + // MARK: - Backups - StatCard( - title: L10n.tr("Buffer Size"), - value: "\(controller.status.buffer.sizeGB) GB", - // A missing measurement must look different from a number - see - // `BufferGuardService.freeGB()`. Simply putting the optional value - // into the text gave "Free on disk: Optional(427) GB", and the compiler - // reported it ONLY as a warning, so no test would have caught it. - subtitle: L10n.tr( - "Free on disk: %@", - controller.status.buffer.freeDiskGB.map { L10n.tr("%@ GB", "\($0)") } - ?? L10n.tr("not measured")), - systemImage: "internaldrive.fill", - iconColor: RenaCodeTheme.colorCyan - ) + private var backupCycleTone: StatusTone { + if status.backupCycle.isFresh() { return .success } + return status.backupCycle.known ? .danger : .warning + } - StatCard( - title: L10n.tr("Upload Queue"), - // Without a queue reading this card has no right to say "Nothing - // pending" - the zeros are then a missing measurement, not a result. - value: !controller.status.buffer.queueKnown - ? "—" - : (controller.status.buffer.draining - ? L10n.tr("%@ queued", "\(controller.status.buffer.uploadsQueued)") - : L10n.tr("Nothing pending")), - subtitle: !controller.status.buffer.queueKnown - ? L10n.tr("rclone did not answer") - : (controller.status.buffer.draining - ? L10n.tr("%@ transfers in progress", "\(controller.status.buffer.uploadsInProgress)") - : L10n.tr("Everything in the cloud")), - systemImage: "icloud.and.arrow.up.fill", - iconColor: !controller.status.buffer.queueKnown - ? RenaCodeTheme.colorWarning - : (controller.status.buffer.draining - ? RenaCodeTheme.colorWarning : RenaCodeTheme.colorSuccess) - ) + private var backupsSection: some View { + VStack(alignment: .leading, spacing: 16) { + if let progress = status.backupProgress { + progressCard(progress) + } else { + idleBackupCard + } - StatCard( - title: "Time Machine", - value: controller.status.buffer.imageAttached - ? L10n.tr("Attached") : L10n.tr("Not attached"), - subtitle: controller.status.buffer.mounted - ? L10n.tr("Google Drive mounted") : L10n.tr("Drive disconnected"), - systemImage: "clock.arrow.circlepath", - iconColor: controller.status.buffer.imageAttached - ? RenaCodeTheme.colorPrimaryLight : RenaCodeTheme.textDim - ) + backupCycleCard + + HStack(spacing: 12) { + backupButton + + Button(action: { Task { await controller.verifyImage() } }) { + HStack(spacing: 6) { + Image(systemName: "checkmark.shield") + Text(L10n.tr("Check image consistency")) + } + } + .buttonStyle(SecondaryGlassButtonStyle()) + .disabled(status.buffer.imageAttached || status.isBusy) + + Spacer() + } } } - // MARK: - Backup Progress + private var idleBackupCard: some View { + HStack(spacing: 12) { + IconTile(systemImage: "pause.fill", gradient: RenaCodeTheme.aiGradient) + VStack(alignment: .leading, spacing: 3) { + Text(L10n.tr("No backup is running")) + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(RenaCodeTheme.textMain) + Text(L10n.tr("Time Machine starts the next one on its own schedule.")) + .font(.system(size: 13)) + .foregroundStyle(RenaCodeTheme.textMuted) + } + } + .accessibilityElement(children: .combine) + .toneCard(.neutral) + } private func progressCard(_ progress: BackupProgressInfo) -> some View { VStack(alignment: .leading, spacing: 14) { HStack { - HStack(spacing: 8) { - ZStack { - Circle() - .fill(RenaCodeTheme.colorCyan.opacity(0.2)) - .frame(width: 28, height: 28) - Image(systemName: "arrow.triangle.2.circlepath") - .font(.system(size: 13, weight: .bold)) - .foregroundStyle(RenaCodeTheme.colorCyan) - } - + HStack(spacing: 10) { + IconTile(systemImage: "arrow.triangle.2.circlepath", gradient: RenaCodeTheme.aiGradient) Text(L10n.tr("Backup in Progress")) - .font(.system(size: 16, weight: .bold, design: .rounded)) + .font(.system(size: 16, weight: .semibold)) .foregroundStyle(RenaCodeTheme.textMain) } @@ -282,65 +635,106 @@ struct DashboardView: View { if let percent = progress.percent { Text(String(format: "%.1f%%", percent * 100)) - .font(.system(size: 18, weight: .bold, design: .monospaced)) - .foregroundStyle(RenaCodeTheme.colorCyan) + .font(.system(size: 22, weight: .bold).monospacedDigit()) + .foregroundStyle(RenaCodeTheme.textMain) } } if let percent = progress.percent { - GeometryReader { geo in - ZStack(alignment: .leading) { - RoundedRectangle(cornerRadius: 6) - .fill(RenaCodeTheme.bgInset) - .frame(height: 10) - - RoundedRectangle(cornerRadius: 6) - .fill(RenaCodeTheme.cyanGradient) - .frame( - width: max(0, min(geo.size.width * CGFloat(percent), geo.size.width)), height: 10 - ) - .shadow(color: RenaCodeTheme.colorCyan.opacity(0.5), radius: 6, x: 0, y: 0) - } - } - .frame(height: 10) + GradientProgressBar(fraction: percent, height: 10) } - HStack(spacing: 24) { + HStack(alignment: .top, spacing: 28) { + if let done = progress.bytesDone, let total = progress.bytesTotal, total > 0 { + metric(L10n.tr("Copied"), L10n.tr("%@ of %@", Self.bytes(done), Self.bytes(total))) + } + if let done = progress.filesDone, let total = progress.filesTotal, total > 0 { - VStack(alignment: .leading, spacing: 2) { - Text(L10n.tr("Files processed")) - .font(.system(size: 11)) - .foregroundStyle(RenaCodeTheme.textMuted) - Text("\(done) / \(total)") - .font(.system(size: 13, weight: .semibold, design: .monospaced)) - .foregroundStyle(RenaCodeTheme.textMain) - } + metric(L10n.tr("Files processed"), "\(done) / \(total)") } if let rate = progress.transferRateMBs { - VStack(alignment: .leading, spacing: 2) { - Text(L10n.tr("Write speed")) - .font(.system(size: 11)) - .foregroundStyle(RenaCodeTheme.textMuted) - Text(String(format: "%.1f MB/s", rate)) - .font(.system(size: 13, weight: .semibold, design: .monospaced)) - .foregroundStyle(RenaCodeTheme.colorSuccess) - } + metric(L10n.tr("Write speed"), String(format: "%.1f MB/s", rate)) } if let phase = progress.phase { - VStack(alignment: .leading, spacing: 2) { - Text(L10n.tr("Operation phase")) - .font(.system(size: 11)) - .foregroundStyle(RenaCodeTheme.textMuted) - Text(phase) - .font(.system(size: 13, weight: .medium)) - .foregroundStyle(RenaCodeTheme.textMain) - } + metric(L10n.tr("Operation phase"), phase) } } } - .glassCard(borderColor: RenaCodeTheme.colorCyan.opacity(0.35)) + .accessibilityElement(children: .combine) + .toneCard(.brand) + } + + private func metric(_ label: String, _ value: String) -> some View { + VStack(alignment: .leading, spacing: 3) { + Text(label) + .font(.system(size: 11)) + .foregroundStyle(RenaCodeTheme.textMuted) + Text(value) + .font(.system(size: 14, weight: .semibold).monospacedDigit()) + .foregroundStyle(RenaCodeTheme.textMain) + } + } + + /// Whether a backup was MADE, and whether anyone still checks that. Every + /// other card describes the devices at this moment and can be green while + /// Time Machine has not finished a backup for two days. + private var backupCycleCard: some View { + VStack(alignment: .leading, spacing: 12) { + cardTitle(L10n.tr("Backup cycle"), systemImage: "clock.arrow.circlepath") + + row( + L10n.tr("Last completed backup"), + status.backupCycle.ageText(), + tone: backupCycleTone) + + Divider().background(RenaCodeTheme.borderGlass) + + // Who watches the watchdog. The watchdog runs without KeepAlive, so + // when unloaded or hung it gives no symptom other than silence - see + // `WatchdogHeartbeat`. + row( + L10n.tr("Last backup watchdog run"), + status.watchdog.map { StatusLines.watchdogRun($0) } ?? L10n.tr("not checked"), + tone: status.watchdogRunning ? .success : .danger) + + // Ready-made sentences from the watchdog's report. + ForEach(status.backupCycle.problems, id: \.self) { problem in + HStack(alignment: .firstTextBaseline, spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 11)) + .accessibilityHidden(true) + Text(problem) + .font(.system(size: 13)) + .fixedSize(horizontal: false, vertical: true) + .textSelection(.enabled) + } + .foregroundStyle(RenaCodeTheme.colorDanger) + } + } + .toneCard(backupCycleTone.worst(status.watchdogRunning ? .success : .danger)) + } + + // MARK: - Storage + + private var storageSection: some View { + VStack(alignment: .leading, spacing: 16) { + uploadStateCard + bufferCard + // This Mac's space limit on Google Drive + if status.remoteConfigured { + budgetCard + } + } + } + + // MARK: - Settings + + private var settingsSection: some View { + VStack(alignment: .leading, spacing: 16) { + credentialsCard + } } // MARK: - Setup Steps ("To Do") @@ -366,12 +760,12 @@ struct DashboardView: View { .font(.system(size: 12, weight: .semibold)) } .buttonStyle(SecondaryGlassButtonStyle()) - .disabled(controller.status.isBusy || (action == .createImage && parsedImageSize == nil)) + .disabled(status.isBusy || (action == .createImage && parsedImageSize == nil)) } } private var effectiveDriveFolder: String { - driveFolder.isEmpty ? controller.status.suggestedDriveFolder : driveFolder + driveFolder.isEmpty ? status.suggestedDriveFolder : driveFolder } /// Each Mac on the same Google account needs its own folder, otherwise @@ -386,7 +780,7 @@ struct DashboardView: View { Text("CloudMachine/") .font(.system(size: 12, design: .monospaced)) .foregroundStyle(RenaCodeTheme.textMain.opacity(0.6)) - TextField(controller.status.suggestedDriveFolder, text: $driveFolder) + TextField(status.suggestedDriveFolder, text: $driveFolder) .textFieldStyle(.roundedBorder) .font(.system(size: 12, design: .monospaced)) .frame(width: 180) @@ -400,7 +794,7 @@ struct DashboardView: View { .font(.system(size: 11)) .foregroundStyle( DriveFolder.isValid(effectiveDriveFolder) - ? RenaCodeTheme.textMain.opacity(0.6) : RenaCodeTheme.colorWarning) + ? RenaCodeTheme.textMuted : RenaCodeTheme.colorWarning) } } @@ -435,16 +829,12 @@ struct DashboardView: View { private var setupCard: some View { VStack(alignment: .leading, spacing: 14) { - HStack(spacing: 8) { - Image(systemName: "wrench.and.screwdriver.fill") - .font(.system(size: 15)) - .foregroundStyle(RenaCodeTheme.colorWarning) - Text(L10n.tr("Required Setup Steps")) - .font(.system(size: 15, weight: .bold, design: .rounded)) - .foregroundStyle(RenaCodeTheme.textMain) - } + cardTitle( + L10n.tr("Required Setup Steps"), systemImage: "wrench.and.screwdriver.fill", + tone: .warning) VStack(alignment: .leading, spacing: 12) { + // Numbered, because the steps ARE a sequence: each needs the one before. ForEach(Array(setupSteps.enumerated()), id: \.offset) { index, step in VStack(alignment: .leading, spacing: 6) { HStack(spacing: 8) { @@ -452,7 +842,7 @@ struct DashboardView: View { .font(.system(size: 11, weight: .bold)) .foregroundStyle(.white) .frame(width: 20, height: 20) - .background(RenaCodeTheme.colorWarning) + .background(RenaCodeTheme.fillWarning) .clipShape(Circle()) Text(step.title) @@ -471,81 +861,78 @@ struct DashboardView: View { if let command = step.choosesFolder ? controller.connectDriveCommand(folder: effectiveDriveFolder) : step.command { - HStack { - Text(command) - .font(.system(size: 12, design: .monospaced)) - .foregroundStyle(RenaCodeTheme.textMain) - .textSelection(.enabled) - Spacer() - Button(action: { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(command, forType: .string) - }) { - HStack(spacing: 4) { - Image(systemName: "doc.on.doc") - .font(.system(size: 11)) - Text(L10n.tr("Copy")) - .font(.system(size: 11, weight: .medium)) - } - } - .buttonStyle(SecondaryGlassButtonStyle()) - } - .padding(10) - .background(RenaCodeTheme.bgInset) - .clipShape(RoundedRectangle(cornerRadius: 8)) - .overlay( - RoundedRectangle(cornerRadius: 8) - .stroke(RenaCodeTheme.borderGlass, lineWidth: 1) - ) + commandBox(command) } } } } } - .glassCard(borderColor: RenaCodeTheme.colorWarning.opacity(0.35)) + .toneCard(.warning) } - // MARK: - Buffer and Upload Details + /// A command the user has to run themselves, with a Copy button. + private func commandBox(_ command: String) -> some View { + HStack { + Text(command) + .font(.system(size: 12, design: .monospaced)) + .foregroundStyle(RenaCodeTheme.textMain) + .textSelection(.enabled) + Spacer() + Button(action: { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(command, forType: .string) + }) { + HStack(spacing: 4) { + Image(systemName: "doc.on.doc") + .font(.system(size: 11)) + Text(L10n.tr("Copy")) + .font(.system(size: 11, weight: .medium)) + } + } + .buttonStyle(SecondaryGlassButtonStyle()) + } + .padding(10) + .background(RenaCodeTheme.bgInset) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(RenaCodeTheme.borderGlass, lineWidth: 1) + ) + } + + // MARK: - Space Limit /// Several Macs on one Google account share its space; each gets a limit. /// Time Machine enforces it through its quota (deleting the oldest /// backups), the watchdog warns from 90% of the real usage on Drive. private var budgetCard: some View { VStack(alignment: .leading, spacing: 12) { - HStack(spacing: 8) { - Image(systemName: "chart.pie.fill") - .font(.system(size: 15)) - .foregroundStyle(RenaCodeTheme.colorCyan) - Text(L10n.tr("Space limit for this Mac")) - .font(.system(size: 15, weight: .bold, design: .rounded)) - .foregroundStyle(RenaCodeTheme.textMain) - } + cardTitle(L10n.tr("Space limit for this Mac"), systemImage: "chart.pie.fill") row( L10n.tr("Used on Google Drive"), - MachineBudget.summary( - limitGB: controller.status.budgetLimitGB, usage: controller.status.budgetUsage), - ok: budgetOK) + MachineBudget.summary(limitGB: status.budgetLimitGB, usage: status.budgetUsage), + tone: budgetOK ? .success : .danger) - if let usage = controller.status.budgetUsage { + if let usage = status.budgetUsage { Text( L10n.tr( "Measured %@. The watchdog measures again every few hours.", usage.measuredAt.formatted(date: .abbreviated, time: .shortened)) ) .font(.system(size: 11)) - .foregroundStyle(RenaCodeTheme.textMain.opacity(0.6)) + .foregroundStyle(RenaCodeTheme.textMuted) } HStack(spacing: 8) { Text(L10n.tr("Limit (GB)")) .font(.system(size: 12)) .foregroundStyle(RenaCodeTheme.textMain) - TextField( - controller.status.budgetLimitGB.map { "\($0)" } ?? "1500", text: $limitText - ) - .textFieldStyle(.roundedBorder) - .frame(width: 90) + // `String(_:)`, not an interpolated literal: that one became a + // LocalizedStringKey and showed the placeholder as "1.500". + TextField(status.budgetLimitGB.map { String($0) } ?? "1500", text: $limitText) + .textFieldStyle(.roundedBorder) + .frame(width: 90) Button(action: { if let gb = Int(limitText.trimmingCharacters(in: .whitespaces)), gb > 0 { Task { @@ -557,24 +944,22 @@ struct DashboardView: View { Text(L10n.tr("Save")).font(.system(size: 12, weight: .semibold)) } .buttonStyle(SecondaryGlassButtonStyle()) - .disabled( - controller.status.isBusy - || (Int(limitText.trimmingCharacters(in: .whitespaces)) ?? 0) <= 0) + .disabled(status.isBusy || (Int(limitText.trimmingCharacters(in: .whitespaces)) ?? 0) <= 0) Button(action: { Task { await controller.measureDriveUsage() } }) { Text(L10n.tr("Measure now")).font(.system(size: 12, weight: .semibold)) } .buttonStyle(SecondaryGlassButtonStyle()) - .disabled(controller.status.isBusy) + .disabled(status.isBusy) } - if let limit = controller.status.budgetLimitGB { + if let limit = status.budgetLimitGB { Text( L10n.tr( "Time Machine quota: %@ GB (70%% of the limit - the copy on Drive is about a third larger than the backup inside it). Time Machine deletes the oldest backups to stay within it.", "\(MachineBudget.timeMachineQuotaGB(forLimitGB: limit))") ) .font(.system(size: 11)) - .foregroundStyle(RenaCodeTheme.textMain.opacity(0.7)) + .foregroundStyle(RenaCodeTheme.textMuted) .fixedSize(horizontal: false, vertical: true) } @@ -582,313 +967,173 @@ struct DashboardView: View { Text(L10n.tr("Set the Time Machine quota: run this in Terminal (needs sudo)")) .font(.system(size: 12, weight: .medium)) .foregroundStyle(RenaCodeTheme.colorWarning) - HStack { - Text(command) - .font(.system(size: 12, design: .monospaced)) - .foregroundStyle(RenaCodeTheme.textMain) - .textSelection(.enabled) - Spacer() - Button(action: { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(command, forType: .string) - }) { - Text(L10n.tr("Copy")).font(.system(size: 11, weight: .medium)) - } - .buttonStyle(SecondaryGlassButtonStyle()) - } - .padding(10) - .background(RenaCodeTheme.bgInset) - .clipShape(RoundedRectangle(cornerRadius: 8)) + commandBox(command) } } - .glassCard(borderColor: RenaCodeTheme.colorCyan.opacity(0.35)) + .toneCard(budgetOK ? .neutral : .warning) } private var budgetOK: Bool { - guard let limit = controller.status.budgetLimitGB, let usage = controller.status.budgetUsage - else { return controller.status.budgetLimitGB != nil } + guard let limit = status.budgetLimitGB, let usage = status.budgetUsage + else { return status.budgetLimitGB != nil } return MachineBudget.level(usageBytes: usage.bytes, limitGB: limit) == .ok } + // MARK: - Buffer and Upload Details + private var bufferCard: some View { - VStack(alignment: .leading, spacing: 14) { - HStack { - HStack(spacing: 8) { - Image(systemName: "server.rack") - .font(.system(size: 15)) - .foregroundStyle(RenaCodeTheme.colorCyan) - Text(L10n.tr("Local Buffer & Upload Status")) - .font(.system(size: 15, weight: .bold, design: .rounded)) - .foregroundStyle(RenaCodeTheme.textMain) - } + VStack(alignment: .leading, spacing: 10) { + cardTitle(L10n.tr("Local Buffer & Upload Status"), systemImage: "server.rack") + .padding(.bottom, 4) - Spacer() - } + row( + L10n.tr("Google Drive mount (FUSE-T)"), + status.buffer.mounted ? L10n.tr("Mounted") : L10n.tr("Inactive"), + tone: status.buffer.mounted ? .success : .danger) - VStack(spacing: 10) { - row( - L10n.tr("Google Drive mount (FUSE-T)"), - controller.status.buffer.mounted ? L10n.tr("Mounted") : L10n.tr("Inactive"), - ok: controller.status.buffer.mounted - ) + Divider().background(RenaCodeTheme.borderGlass) - Divider().background(RenaCodeTheme.borderGlass) + if status.remoteConfigured { + row(L10n.tr("Folder on Google Drive"), status.driveFolderPath, tone: .neutral) - if controller.status.remoteConfigured { - row( - L10n.tr("Folder on Google Drive"), - controller.status.driveFolderPath, - ok: true - ) + Divider().background(RenaCodeTheme.borderGlass) + } - Divider().background(RenaCodeTheme.borderGlass) - } + row( + L10n.tr("Backup disk image (.sparsebundle)"), + status.buffer.imageAttached ? L10n.tr("Attached to the system") : L10n.tr("Detached"), + tone: status.buffer.imageAttached ? .success : .danger) - row( - L10n.tr("Backup disk image (.sparsebundle)"), - controller.status.buffer.imageAttached - ? L10n.tr("Attached to the system") : L10n.tr("Detached"), - ok: controller.status.buffer.imageAttached - ) + Divider().background(RenaCodeTheme.borderGlass) - Divider().background(RenaCodeTheme.borderGlass) + row( + L10n.tr("Buffer allocated on the SSD"), + L10n.tr("%@ GB", "\(status.buffer.sizeGB)"), + tone: status.buffer.outOfSpace ? .danger : .neutral) - row( - L10n.tr("Buffer allocated on the SSD"), - L10n.tr("%@ GB", "\(controller.status.buffer.sizeGB)"), - ok: true - ) + Divider().background(RenaCodeTheme.borderGlass) - Divider().background(RenaCodeTheme.borderGlass) + // A missing measurement MUST look different from "0 GB" - see + // `BufferGuardService.freeGB()`. A failed statfs is a failure of the buffer + // guard, not information about an empty disk. + row( + L10n.tr("Free space on the local volume"), + status.buffer.freeDiskGB.map { L10n.tr("%@ GB", "\($0)") } ?? L10n.tr("not measured"), + tone: (status.buffer.freeDiskGB ?? 0) > 80 ? .success : .danger) - // A missing measurement MUST look different from "0 GB" - see - // `BufferGuardService.freeGB()`. A failed statfs is a failure of the buffer - // guard, not information about an empty disk. - row( - L10n.tr("Free space on the local volume"), - controller.status.buffer.freeDiskGB.map { L10n.tr("%@ GB", "\($0)") } - ?? L10n.tr("not measured"), - ok: (controller.status.buffer.freeDiskGB ?? 0) > 80 - ) + Divider().background(RenaCodeTheme.borderGlass) + row( + L10n.tr("Cloud sync queue"), + !status.buffer.queueKnown + ? L10n.tr("not read") + : (status.buffer.draining + ? L10n.tr( + "%@ in progress, %@ queued", "\(status.buffer.uploadsInProgress)", + "\(status.buffer.uploadsQueued)") + : L10n.tr("Everything uploaded")), + tone: status.buffer.queueKnown && status.buffer.erroredFiles == 0 ? .success : .danger) + + if status.buffer.erroredFiles > 0 { Divider().background(RenaCodeTheme.borderGlass) - - // The only row that answers the question "WAS a backup made". - // All the others describe the state of the devices and can be green while - // Time Machine has not finished a backup for two days. row( - L10n.tr("Last completed backup"), - controller.status.backupCycle.ageText(), - ok: controller.status.backupCycle.isFresh() - ) + L10n.tr("File upload errors"), + L10n.tr("%@ files", "\(status.buffer.erroredFiles)"), + tone: .danger) + } + if status.buffer.driveFull { Divider().background(RenaCodeTheme.borderGlass) + row(L10n.tr("Space on Google Drive"), L10n.tr("Out of space"), tone: .danger) + } - // Who watches the watchdog. The row above says whether a backup was made; this one says - // whether anyone is still CHECKING that. The watchdog runs without KeepAlive, so - // when unloaded or hung it gives no symptom other than silence - see - // `WatchdogHeartbeat`. - row( - L10n.tr("Last backup watchdog run"), - controller.status.watchdog.map { StatusLines.watchdogRun($0) } - ?? L10n.tr("not checked"), - ok: controller.status.watchdogRunning - ) - + if status.buffer.dailyQuotaExhausted { Divider().background(RenaCodeTheme.borderGlass) - - row( - L10n.tr("Cloud sync queue"), - !controller.status.buffer.queueKnown - ? L10n.tr("not read") - : (controller.status.buffer.draining - ? L10n.tr( - "%@ in progress, %@ queued", "\(controller.status.buffer.uploadsInProgress)", - "\(controller.status.buffer.uploadsQueued)") - : L10n.tr("Everything uploaded")), - ok: controller.status.buffer.queueKnown && controller.status.buffer.erroredFiles == 0 - ) - - if controller.status.buffer.erroredFiles > 0 { - Divider().background(RenaCodeTheme.borderGlass) - row( - L10n.tr("File upload errors"), - L10n.tr("%@ files", "\(controller.status.buffer.erroredFiles)"), - ok: false - ) - } - - if controller.status.buffer.driveFull { - Divider().background(RenaCodeTheme.borderGlass) - row(L10n.tr("Space on Google Drive"), L10n.tr("Out of space"), ok: false) - } - - if controller.status.buffer.dailyQuotaExhausted { - Divider().background(RenaCodeTheme.borderGlass) - row( - L10n.tr("Google Drive limit"), - L10n.tr("Daily 750 GB exhausted"), - ok: false - ) - } + // Amber, not red: the limit passes by itself - see `UploadState`. + row(L10n.tr("Google Drive limit"), L10n.tr("Daily 750 GB exhausted"), tone: .warning) } } - .glassCard() + .toneCard(.neutral) } // MARK: - Google OAuth Credentials - /// The credentials are collapsed by default. They are entered ONCE, when setting up - /// your own OAuth client, and then never again - keeping two password fields - /// on top of a panel that is meant to answer the question about the backup's state only - /// distracts. The badge next to the header says whether there is anything to expand. + /// Entered ONCE, when setting up your own OAuth client. Settings is their own + /// section now, so they no longer sit folded on top of the backup state. private var credentialsCard: some View { VStack(alignment: .leading, spacing: 14) { - Button { - withAnimation(.easeInOut(duration: 0.18)) { credentialsExpanded.toggle() } - } label: { - HStack(spacing: 8) { - Image(systemName: "key.fill") - .font(.system(size: 15)) - .foregroundStyle(RenaCodeTheme.colorPrimaryLight) - - Text(L10n.tr("Google Drive Credentials (OAuth 2.0)")) - .font(.system(size: 15, weight: .bold, design: .rounded)) - .foregroundStyle(RenaCodeTheme.textMain) - - Spacer() + HStack(spacing: 8) { + cardTitle(L10n.tr("Google Drive Credentials (OAuth 2.0)"), systemImage: "key.fill") - RenaCodePillBadge( - text: controller.credentials.isComplete - ? L10n.tr("Keychain OK") : L10n.tr("No custom keys"), - icon: controller.credentials.isComplete ? "checkmark.seal.fill" : "lock.open.fill", - color: controller.credentials.isComplete - ? RenaCodeTheme.colorSuccess : RenaCodeTheme.colorWarning - ) + Spacer() - Image(systemName: "chevron.right") - .font(.system(size: 12, weight: .semibold)) - .foregroundStyle(RenaCodeTheme.textMuted) - .rotationEffect(.degrees(credentialsExpanded ? 90 : 0)) - } - .contentShape(Rectangle()) + StatusPill( + controller.credentials.isComplete + ? L10n.tr("Keychain OK") : L10n.tr("No custom keys"), + tone: controller.credentials.isComplete ? .success : .warning, + icon: controller.credentials.isComplete ? "checkmark.seal.fill" : "lock.open.fill") } - .buttonStyle(.plain) - - if credentialsExpanded { - - Text(controller.credentials.summary) - .font(.system(size: 12)) - .foregroundStyle(RenaCodeTheme.textMuted) - .fixedSize(horizontal: false, vertical: true) - - VStack(spacing: 10) { - HStack { - Text("client_id:") - .font(.system(size: 12, weight: .medium, design: .monospaced)) - .foregroundStyle(RenaCodeTheme.textMuted) - .frame(width: 100, alignment: .leading) - - SecureField(L10n.tr("Paste client_id..."), text: $clientID) - .textFieldStyle(.plain) - .padding(8) - .background(RenaCodeTheme.bgInset) - .clipShape(RoundedRectangle(cornerRadius: 8)) - .overlay( - RoundedRectangle(cornerRadius: 8) - .stroke(RenaCodeTheme.borderGlass, lineWidth: 1) - ) - } - - HStack { - Text("client_secret:") - .font(.system(size: 12, weight: .medium, design: .monospaced)) - .foregroundStyle(RenaCodeTheme.textMuted) - .frame(width: 100, alignment: .leading) - - SecureField(L10n.tr("Paste client_secret..."), text: $clientSecret) - .textFieldStyle(.plain) - .padding(8) - .background(RenaCodeTheme.bgInset) - .clipShape(RoundedRectangle(cornerRadius: 8)) - .overlay( - RoundedRectangle(cornerRadius: 8) - .stroke(RenaCodeTheme.borderGlass, lineWidth: 1) - ) - } - } - - HStack { - Button(L10n.tr("Save securely in the Keychain")) { - let id = clientID - let secret = clientSecret - Task { - credentialsMessage = await controller.saveCredentials( - clientID: id, clientSecret: secret) - clientID = "" - clientSecret = "" - } - } - .buttonStyle(PrimaryGradientButtonStyle()) - .disabled( - clientID.trimmingCharacters(in: .whitespaces).isEmpty - || clientSecret.trimmingCharacters(in: .whitespaces).isEmpty) - Spacer() - } + Text(controller.credentials.summary) + .font(.system(size: 12)) + .foregroundStyle(RenaCodeTheme.textMuted) + .fixedSize(horizontal: false, vertical: true) - if let message = credentialsMessage { - Text(message) - .font(.system(size: 12)) - .foregroundStyle(RenaCodeTheme.colorCyan) - .fixedSize(horizontal: false, vertical: true) - } + VStack(spacing: 10) { + credentialField("client_id:", L10n.tr("Paste client_id..."), text: $clientID) + credentialField("client_secret:", L10n.tr("Paste client_secret..."), text: $clientSecret) } - } - .glassCard() - } - - // MARK: - Bottom Action Bar - private var actionsToolbar: some View { - HStack(spacing: 12) { - if controller.status.backupProgress == nil { - Button(action: { Task { await controller.startBackup() } }) { - HStack(spacing: 6) { - Image(systemName: "play.fill") - Text(L10n.tr("Back up now")) + HStack { + Button(L10n.tr("Save securely in the Keychain")) { + let id = clientID + let secret = clientSecret + Task { + credentialsMessage = await controller.saveCredentials( + clientID: id, clientSecret: secret) + clientID = "" + clientSecret = "" } } .buttonStyle(PrimaryGradientButtonStyle()) - .disabled(!controller.status.healthy || controller.status.isBusy) - } else { - Button(action: { Task { await controller.stopBackup() } }) { - HStack(spacing: 6) { - Image(systemName: "stop.fill") - Text(L10n.tr("Stop backup")) - } - } - .buttonStyle(SecondaryGlassButtonStyle()) - } + .disabled( + clientID.trimmingCharacters(in: .whitespaces).isEmpty + || clientSecret.trimmingCharacters(in: .whitespaces).isEmpty) - Button(action: { Task { await controller.verifyImage() } }) { - HStack(spacing: 6) { - Image(systemName: "checkmark.shield") - Text(L10n.tr("Check image consistency")) - } + Spacer() } - .buttonStyle(SecondaryGlassButtonStyle()) - .disabled(controller.status.buffer.imageAttached || controller.status.isBusy) - Button(action: { Task { await controller.refreshAll() } }) { - HStack(spacing: 6) { - Image(systemName: "arrow.clockwise") - Text(L10n.tr("Refresh")) - } + if let message = credentialsMessage { + Text(message) + .font(.system(size: 12)) + .foregroundStyle(RenaCodeTheme.colorCyan) + .fixedSize(horizontal: false, vertical: true) } - .buttonStyle(SecondaryGlassButtonStyle()) + } + .toneCard(.neutral) + } - Spacer() + private func credentialField(_ label: String, _ prompt: String, text: Binding) + -> some View + { + HStack { + Text(label) + .font(.system(size: 12, weight: .medium, design: .monospaced)) + .foregroundStyle(RenaCodeTheme.textMuted) + .lineLimit(1) + .fixedSize() + .frame(width: 120, alignment: .leading) + + SecureField(prompt, text: text) + .textFieldStyle(.plain) + .padding(8) + .background(RenaCodeTheme.bgInset) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(RenaCodeTheme.borderGlass, lineWidth: 1) + ) + .accessibilityLabel(label) } } @@ -901,32 +1146,32 @@ struct DashboardView: View { /// not nominal, but do nothing, it will pass on its own (the Google daily limit). /// Red - you need to act. Without the middle state an exhausted limit /// would have to pretend to be either a failure or all-clear, and it is neither. - /// - /// The texts deliberately do not go through `.localized`: most variants insert - /// a number into the sentence, so they would not have matched the translation dictionary anyway. private var uploadStateCard: some View { - let state = controller.status.buffer.uploadState - let accent = uploadAccent(state) + let state = status.buffer.uploadState + let tone = uploadTone(state) return VStack(alignment: .leading, spacing: 12) { HStack(spacing: 12) { ZStack { Circle() - .fill(accent.opacity(0.15)) + .fill(tone.color.opacity(0.15)) .frame(width: 40, height: 40) Image(systemName: uploadIcon(state)) .font(.system(size: 18, weight: .semibold)) - .foregroundStyle(accent) + .foregroundStyle(tone.color) } + .accessibilityHidden(true) VStack(alignment: .leading, spacing: 3) { - Text(uploadBadge(state)) - .font(.system(size: 10, weight: .bold)) - .foregroundStyle(accent) + // The label comes straight from `UploadState`. Assembling it here from + // two bools limited the interface to three variants, and there are more. + Text(state.badge) + .font(.system(size: 11, weight: .bold)) + .foregroundStyle(tone.color) Text(state.headline) - .font(.system(size: 15, weight: .semibold)) + .font(.system(size: 16, weight: .semibold)) .foregroundStyle(RenaCodeTheme.textMain) .fixedSize(horizontal: false, vertical: true) } @@ -940,20 +1185,15 @@ struct DashboardView: View { .fixedSize(horizontal: false, vertical: true) .textSelection(.enabled) } - .frame(maxWidth: .infinity, alignment: .leading) - .glassCard(borderColor: accent.opacity(0.35)) + .toneCard(tone) } - private func uploadAccent(_ state: UploadState) -> Color { - if state.needsAttention { return RenaCodeTheme.colorDanger } - if !state.isNominal { return RenaCodeTheme.colorWarning } - return RenaCodeTheme.colorSuccess + private func uploadTone(_ state: UploadState) -> StatusTone { + if state.needsAttention { return .danger } + if !state.isNominal { return .warning } + return .success } - /// The label comes straight from `UploadState`. Assembling it here from two bools - /// limited the interface to three variants, and there are more states. - private func uploadBadge(_ state: UploadState) -> String { state.badge } - private func uploadIcon(_ state: UploadState) -> String { switch state { case .mountDown: return "icloud.slash.fill" @@ -967,25 +1207,42 @@ struct DashboardView: View { } } - // MARK: - Helper Table Row + // MARK: - Helpers - private func row(_ label: String, _ value: String, ok: Bool) -> some View { - HStack { - HStack(spacing: 8) { - Circle() - .fill(ok ? RenaCodeTheme.colorSuccess : RenaCodeTheme.colorDanger) - .frame(width: 7, height: 7) + private func cardTitle( + _ title: String, systemImage: String, tone: StatusTone = .brand + ) -> some View { + HStack(spacing: 8) { + Image(systemName: systemImage) + .font(.system(size: 15)) + .foregroundStyle(tone == .brand ? RenaCodeTheme.colorCyan : tone.color) + .accessibilityHidden(true) + Text(title) + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(RenaCodeTheme.textMain) + .accessibilityAddTraits(.isHeader) + } + } + private func row(_ label: String, _ value: String, tone: StatusTone) -> some View { + HStack(alignment: .firstTextBaseline) { + HStack(spacing: 8) { + StatusDot(tone, size: 7) Text(label) .font(.system(size: 13, weight: .medium)) .foregroundStyle(RenaCodeTheme.textMuted) } - Spacer() + Spacer(minLength: 12) Text(value) - .font(.system(size: 13, weight: .semibold, design: .monospaced)) - .foregroundStyle(ok ? RenaCodeTheme.textMain : RenaCodeTheme.colorDanger) + .font(.system(size: 13, weight: .semibold).monospacedDigit()) + .foregroundStyle( + tone == .success || tone == .neutral ? RenaCodeTheme.textMain : tone.color + ) + .multilineTextAlignment(.trailing) + .textSelection(.enabled) } + .accessibilityElement(children: .combine) } } diff --git a/mac-app/Sources/CloudMachineApp/Views/MenuBarContentView.swift b/mac-app/Sources/CloudMachineApp/Views/MenuBarContentView.swift index 57bdaec..942130a 100644 --- a/mac-app/Sources/CloudMachineApp/Views/MenuBarContentView.swift +++ b/mac-app/Sources/CloudMachineApp/Views/MenuBarContentView.swift @@ -3,8 +3,20 @@ import SwiftUI /// Contents of the menu bar (MenuBar Extra), kept in the modern /// RenaCode visual style. +/// +/// Observes `AppStatus` directly for the same reason as `DashboardView`. struct MenuBarContentView: View { @EnvironmentObject private var controller: CloudMachineController + + var body: some View { + MenuBarPanel(status: controller.status) + .task { controller.startAutoRefresh(interval: 15) } + } +} + +private struct MenuBarPanel: View { + @EnvironmentObject private var controller: CloudMachineController + @ObservedObject var status: AppStatus @Environment(\.openWindow) private var openWindow /// Opens the panel and brings it TO THE FRONT. @@ -25,110 +37,111 @@ struct MenuBarContentView: View { } } + /// Green only for a really good state - the same rule as the window's + /// health card. + private var tone: StatusTone { + if status.healthy { return .success } + if case .missing = status.dependencyState { return .danger } + if !status.remoteConfigured || !status.buffer.mounted || !status.buffer.imageAttached { + return .danger + } + if case .notRegistered = status.timeMachineState { return .danger } + if status.buffer.uploadState.needsAttention { return .danger } + if status.backupCycle.known && !status.backupCycle.isFresh() { return .danger } + return .warning + } + + private var cycleTone: StatusTone { + if status.backupCycle.isFresh() { return .success } + return status.backupCycle.known ? .danger : .warning + } + var body: some View { VStack(alignment: .leading, spacing: 12) { - // Header with the logo and the health indicator + // Header with the logo and the health verdict HStack(spacing: 10) { - ZStack { - RoundedRectangle(cornerRadius: 8) - .fill(RenaCodeTheme.aiGradient) - .frame(width: 28, height: 28) - Image(systemName: "icloud.and.arrow.up.fill") - .font(.system(size: 13, weight: .bold)) - .foregroundStyle(.white) - } + IconTile(systemImage: "icloud.and.arrow.up.fill", gradient: RenaCodeTheme.frameGradient) - VStack(alignment: .leading, spacing: 1) { - Text("CloudMachine") - .font(.system(size: 13, weight: .bold, design: .rounded)) - .foregroundStyle(RenaCodeTheme.textMain) - - Text(controller.status.headline) - .font(.system(size: 11, weight: .medium)) - .foregroundStyle( - controller.status.healthy - ? RenaCodeTheme.colorSuccess : RenaCodeTheme.colorWarning) - } + Text(verbatim: "CloudMachine") + .font(.system(size: 15, weight: .bold)) + .foregroundStyle(RenaCodeTheme.textMain) + .lineLimit(1) + .fixedSize() Spacer() - Circle() - .fill( - controller.status.healthy ? RenaCodeTheme.colorSuccess : RenaCodeTheme.colorWarning - ) - .frame(width: 8, height: 8) - .shadow( - color: (controller.status.healthy - ? RenaCodeTheme.colorSuccess : RenaCodeTheme.colorWarning).opacity(0.6), radius: 4 - ) + StatusPill( + tone == .success + ? L10n.tr("Healthy") + : (tone == .danger ? L10n.tr("Action needed") : L10n.tr("Attention")), + tone: tone) } - Divider().background(RenaCodeTheme.borderGlass) + // The one-sentence answer to "is my data safe", in the verdict's color. + HStack(alignment: .firstTextBaseline, spacing: 8) { + StatusDot(tone, live: status.buffer.uploadState.isMovingData) + Text(status.headline) + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(tone == .success ? RenaCodeTheme.textMain : tone.color) + .fixedSize(horizontal: false, vertical: true) + } + .accessibilityElement(children: .combine) // Progress of the running backup - if let progress = controller.status.backupProgress, let percent = progress.percent { - VStack(alignment: .leading, spacing: 4) { + if let progress = status.backupProgress, let percent = progress.percent { + VStack(alignment: .leading, spacing: 6) { HStack { - Text(L10n.tr("Backup in progress")) - .font(.system(size: 12, weight: .medium)) - .foregroundStyle(RenaCodeTheme.textMuted) + // Not repeated when the headline above already says it. + if status.headline != L10n.tr("Backup in progress") { + Text(L10n.tr("Backup in progress")) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(RenaCodeTheme.textMuted) + } Spacer() Text(String(format: "%.1f%%", percent * 100)) - .font(.system(size: 12, weight: .bold, design: .monospaced)) - .foregroundStyle(RenaCodeTheme.colorCyan) + .font(.system(size: 12, weight: .bold).monospacedDigit()) + .foregroundStyle(RenaCodeTheme.textMain) } - - GeometryReader { geo in - ZStack(alignment: .leading) { - RoundedRectangle(cornerRadius: 3) - .fill(RenaCodeTheme.bgInset) - - RoundedRectangle(cornerRadius: 3) - .fill(RenaCodeTheme.cyanGradient) - .frame( - width: max(0, min(geo.size.width * CGFloat(percent), geo.size.width)), height: 5) - } - } - .frame(height: 5) + GradientProgressBar(fraction: percent, height: 6) } + .accessibilityElement(children: .combine) } - // Queue and buffer state - VStack(spacing: 6) { - HStack { - Text(L10n.tr("Waiting to upload")) - .font(.system(size: 12)) - .foregroundStyle(RenaCodeTheme.textMuted) - Spacer() - Text( - !controller.status.buffer.queueKnown - ? "?" - : (controller.status.buffer.draining - ? L10n.tr("%@ files", "\(controller.status.buffer.uploadsQueued)") - : L10n.tr("nothing")) - ) - .font(.system(size: 12, weight: .semibold, design: .monospaced)) - .foregroundStyle( - !controller.status.buffer.queueKnown || controller.status.buffer.draining - ? RenaCodeTheme.colorWarning : RenaCodeTheme.textMain) - } + // Queue and buffer state, as two tiles like the window's cards + HStack(spacing: 8) { + tile( + L10n.tr("Waiting to upload"), + !status.buffer.queueKnown + ? "?" + : (status.buffer.draining + ? L10n.tr("%@ files", "\(status.buffer.uploadsQueued)") : L10n.tr("nothing")), + // Cyan while draining - the normal state during a backup; amber only + // when the queue could not be read. + valueColor: !status.buffer.queueKnown + ? RenaCodeTheme.colorWarning + : (status.buffer.draining ? RenaCodeTheme.colorCyan : RenaCodeTheme.textMain)) + tile( + L10n.tr("SSD buffer"), L10n.tr("%@ GB", "\(status.buffer.sizeGB)"), + valueColor: RenaCodeTheme.textMain) + } - HStack { - Text(L10n.tr("SSD buffer")) - .font(.system(size: 12)) - .foregroundStyle(RenaCodeTheme.textMuted) - Spacer() - Text("\(controller.status.buffer.sizeGB) GB") - .font(.system(size: 12, weight: .semibold, design: .monospaced)) - .foregroundStyle(RenaCodeTheme.textMain) - } + HStack(spacing: 8) { + StatusDot(cycleTone, size: 6) + Text(L10n.tr("Last completed backup")) + .foregroundStyle(RenaCodeTheme.textMuted) + Spacer(minLength: 4) + Text(status.backupCycle.ageText()) + .fontWeight(.semibold) + .foregroundStyle(cycleTone == .success ? RenaCodeTheme.textMain : cycleTone.color) } + .font(.system(size: 12).monospacedDigit()) + .accessibilityElement(children: .combine) Divider().background(RenaCodeTheme.borderGlass) // Action buttons VStack(spacing: 6) { - if controller.status.backupProgress == nil { + if status.backupProgress == nil { Button(action: { Task { await controller.startBackup() } }) { HStack { Image(systemName: "play.fill") @@ -137,7 +150,7 @@ struct MenuBarContentView: View { } } .buttonStyle(PrimaryGradientButtonStyle()) - .disabled(!controller.status.healthy) + .disabled(!status.healthy) } else { Button(action: { Task { await controller.stopBackup() } }) { HStack { @@ -149,39 +162,51 @@ struct MenuBarContentView: View { .buttonStyle(SecondaryGlassButtonStyle()) } - Button(action: showDashboard) { - HStack { - Image(systemName: "macwindow") - Text(L10n.tr("Open CloudMachine")) - Spacer() + HStack(spacing: 6) { + Button(action: showDashboard) { + HStack { + Image(systemName: "macwindow") + Text(L10n.tr("Open CloudMachine")) + Spacer() + } } - } - .buttonStyle(SecondaryGlassButtonStyle()) + .buttonStyle(SecondaryGlassButtonStyle()) - Button(action: { NSApplication.shared.terminate(nil) }) { - HStack { + Button(action: { NSApplication.shared.terminate(nil) }) { Image(systemName: "power") - Text(L10n.tr("Quit")) - Spacer() } + .buttonStyle(SecondaryGlassButtonStyle()) + .help(L10n.tr("Quit")) + .accessibilityLabel(L10n.tr("Quit")) } - .buttonStyle(SecondaryGlassButtonStyle()) } } .padding(14) - .frame(width: 280) + .frame(width: 300) .background( ZStack { RenaCodeTheme.bgDark RenaCodeTheme.cardGradient } ) + .overlay(GradientWindowFrame(cornerRadius: 14, lineWidth: 1)) .clipShape(RoundedRectangle(cornerRadius: 14)) - .overlay( - RoundedRectangle(cornerRadius: 14) - .stroke(RenaCodeTheme.borderGlassStrong, lineWidth: 1) - ) - .shadow(color: Color.black.opacity(0.45), radius: 16, x: 0, y: 8) - .task { controller.startAutoRefresh(interval: 15) } + } + + private func tile(_ label: String, _ value: String, valueColor: Color) -> some View { + VStack(alignment: .leading, spacing: 3) { + Text(label) + .font(.system(size: 11)) + .foregroundStyle(RenaCodeTheme.textMuted) + .lineLimit(1) + .minimumScaleFactor(0.8) + Text(value) + .font(.system(size: 17, weight: .bold).monospacedDigit()) + .foregroundStyle(valueColor) + .lineLimit(1) + .minimumScaleFactor(0.7) + } + .accessibilityElement(children: .combine) + .toneCard(.brand, cornerRadius: 10, padding: 10) } } diff --git a/mac-app/Sources/CloudMachineApp/Views/RenaCodeTheme.swift b/mac-app/Sources/CloudMachineApp/Views/RenaCodeTheme.swift index 876f33b..b6824a2 100644 --- a/mac-app/Sources/CloudMachineApp/Views/RenaCodeTheme.swift +++ b/mac-app/Sources/CloudMachineApp/Views/RenaCodeTheme.swift @@ -9,34 +9,45 @@ public enum RenaCodeTheme { public static let bgDarkEnd = Color(red: 13 / 255, green: 17 / 255, blue: 39 / 255) // #0D1127 // #121629 public static let bgCard = Color(red: 18 / 255, green: 22 / 255, blue: 41 / 255).opacity(0.75) - public static let bgCardHover = Color(red: 26 / 255, green: 32 / 255, blue: 58 / 255).opacity( - 0.85) + // #121629, for Reduce Transparency + public static let bgCardSolid = Color(red: 18 / 255, green: 22 / 255, blue: 41 / 255) public static let bgInset = Color(red: 9 / 255, green: 12 / 255, blue: 24 / 255).opacity(0.65) + // #0F1324 sidebar + public static let bgSidebar = Color(red: 15 / 255, green: 19 / 255, blue: 36 / 255) public static let borderGlass = Color.white.opacity(0.08) public static let borderGlassStrong = Color.white.opacity(0.14) - public static let borderGlassGlow = Color(red: 124 / 255, green: 58 / 255, blue: 237 / 255) - .opacity(0.35) // Color accents // #7C3AED AI violet public static let colorPrimary = Color(red: 124 / 255, green: 58 / 255, blue: 237 / 255) // #C084FC public static let colorPrimaryLight = Color(red: 192 / 255, green: 132 / 255, blue: 252 / 255) + // #6366F1 indigo, the middle of the window frame + public static let colorIndigo = Color(red: 99 / 255, green: 102 / 255, blue: 241 / 255) // #06B6D4 Activity cyan public static let colorCyan = Color(red: 6 / 255, green: 182 / 255, blue: 212 / 255) // #34D399 Emerald public static let colorSuccess = Color(red: 52 / 255, green: 211 / 255, blue: 153 / 255) - // #10B981 - public static let colorSuccessDark = Color(red: 16 / 255, green: 185 / 255, blue: 129 / 255) // #F59E0B Orange public static let colorWarning = Color(red: 245 / 255, green: 158 / 255, blue: 11 / 255) // #F87171 Red public static let colorDanger = Color(red: 248 / 255, green: 113 / 255, blue: 113 / 255) + // Pill fills: dark enough for white text to keep a 4.5:1 contrast. + // #047857 + public static let fillSuccess = Color(red: 4 / 255, green: 120 / 255, blue: 87 / 255) + // #B45309 + public static let fillWarning = Color(red: 180 / 255, green: 83 / 255, blue: 9 / 255) + // #B91C1C + public static let fillDanger = Color(red: 185 / 255, green: 28 / 255, blue: 28 / 255) + // #6D28D9 + public static let fillPrimary = Color(red: 109 / 255, green: 40 / 255, blue: 217 / 255) + // #475569 + public static let fillNeutral = Color(red: 71 / 255, green: 85 / 255, blue: 105 / 255) + public static let textMain = Color(red: 241 / 255, green: 245 / 255, blue: 249 / 255) // #F1F5F9 public static let textMuted = Color(red: 148 / 255, green: 163 / 255, blue: 184 / 255) // #94A3B8 - public static let textDim = Color(red: 100 / 255, green: 116 / 255, blue: 139 / 255) // #64748B // MARK: - Gradients @@ -46,8 +57,15 @@ public enum RenaCodeTheme { endPoint: .bottomTrailing ) - public static let cyanGradient = LinearGradient( - colors: [colorCyan, colorSuccess], + /// Violet into cyan: the window frame, progress bars and the active state. + public static let frameGradient = LinearGradient( + colors: [colorPrimary, colorIndigo, colorCyan], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + + public static let progressGradient = LinearGradient( + colors: [colorCyan, colorIndigo, colorPrimary], startPoint: .leading, endPoint: .trailing ) @@ -59,12 +77,97 @@ public enum RenaCodeTheme { ) public static let cardGradient = LinearGradient( - colors: [Color.white.opacity(0.04), Color.white.opacity(0.01)], + colors: [Color.white.opacity(0.05), Color.white.opacity(0.01)], startPoint: .topLeading, endPoint: .bottomTrailing ) } +// MARK: - Status Tone + +/// What a card or a badge says about the state, and therefore its color. +/// +/// Green is reserved for a state that is really good. `.brand` is a card +/// that reports a quantity rather than a verdict (the buffer size, a running +/// backup), so it is neither green nor alarming. +public enum StatusTone: Equatable { + case success + case warning + case danger + case neutral + case brand + /// Activity that is expected, such as a queue draining. + case info + + public var color: Color { + switch self { + case .success: return RenaCodeTheme.colorSuccess + case .warning: return RenaCodeTheme.colorWarning + case .danger: return RenaCodeTheme.colorDanger + case .neutral: return RenaCodeTheme.textMuted + case .brand: return RenaCodeTheme.colorPrimaryLight + case .info: return RenaCodeTheme.colorCyan + } + } + + /// Solid fill for a pill with white text. + public var fill: Color { + switch self { + case .success: return RenaCodeTheme.fillSuccess + case .warning: return RenaCodeTheme.fillWarning + case .danger: return RenaCodeTheme.fillDanger + case .neutral: return RenaCodeTheme.fillNeutral + case .brand: return RenaCodeTheme.fillPrimary + // #0E7490 + case .info: return Color(red: 14 / 255, green: 116 / 255, blue: 144 / 255) + } + } + + var border: LinearGradient { + switch self { + case .brand: + return LinearGradient( + colors: [ + RenaCodeTheme.colorCyan.opacity(0.75), RenaCodeTheme.colorPrimary.opacity(0.75), + ], + startPoint: .topLeading, endPoint: .bottomTrailing) + case .neutral: + return LinearGradient( + colors: [RenaCodeTheme.borderGlassStrong, RenaCodeTheme.borderGlass], + startPoint: .topLeading, endPoint: .bottomTrailing) + case .success, .warning, .danger, .info: + return LinearGradient( + colors: [color.opacity(0.8), color.opacity(0.45)], + startPoint: .topLeading, endPoint: .bottomTrailing) + } + } + + /// The wash behind a status card; the green cards of the mockup. + var tint: Color { + switch self { + case .success, .warning, .danger, .info: return color.opacity(0.12) + case .brand: return RenaCodeTheme.colorPrimary.opacity(0.08) + case .neutral: return Color.clear + } + } + + /// The worse of two tones: a card made of several facts shows its worst one. + public func worst(_ other: StatusTone) -> StatusTone { + rank >= other.rank ? self : other + } + + private var rank: Int { + switch self { + case .neutral: return 0 + case .brand: return 1 + case .info: return 2 + case .success: return 2 + case .warning: return 3 + case .danger: return 4 + } + } +} + // MARK: - Ambient Orbs Background public struct AmbientGlowBackground: View { @@ -75,133 +178,217 @@ public struct AmbientGlowBackground: View { RenaCodeTheme.darkGradient .ignoresSafeArea() - // Violet orb on the left + // Violet glow at the top left Circle() - .fill(RenaCodeTheme.colorPrimary.opacity(0.18)) - .frame(width: 380, height: 380) - .blur(radius: 90) - .offset(x: -220, y: -180) + .fill(RenaCodeTheme.colorPrimary.opacity(0.20)) + .frame(width: 420, height: 420) + .blur(radius: 110) + .offset(x: -260, y: -260) - // Cyan orb on the right + // Cyan glow at the top right Circle() .fill(RenaCodeTheme.colorCyan.opacity(0.14)) - .frame(width: 340, height: 340) - .blur(radius: 85) - .offset(x: 240, y: 160) + .frame(width: 380, height: 380) + .blur(radius: 110) + .offset(x: 300, y: -200) } .allowsHitTesting(false) } } -// MARK: - Glassmorphic Card Modifier (GlassCard) +// MARK: - Window Frame -public struct GlassCardModifier: ViewModifier { +/// The violet-to-cyan frame around the whole window, with a soft inner glow. +public struct GradientWindowFrame: View { + @Environment(\.accessibilityReduceTransparency) private var reduceTransparency var cornerRadius: CGFloat - var borderColor: Color - var padding: CGFloat + var lineWidth: CGFloat - public init( - cornerRadius: CGFloat = 16, - borderColor: Color = RenaCodeTheme.borderGlass, - padding: CGFloat = 18 - ) { + public init(cornerRadius: CGFloat = 16, lineWidth: CGFloat = 1.5) { self.cornerRadius = cornerRadius - self.borderColor = borderColor - self.padding = padding + self.lineWidth = lineWidth + } + + public var body: some View { + ZStack { + if !reduceTransparency { + RoundedRectangle(cornerRadius: cornerRadius) + .stroke(RenaCodeTheme.frameGradient, lineWidth: 6) + .blur(radius: 8) + .opacity(0.55) + } + RoundedRectangle(cornerRadius: cornerRadius) + .strokeBorder(RenaCodeTheme.frameGradient, lineWidth: lineWidth) + } + .allowsHitTesting(false) + .accessibilityHidden(true) } +} + +// MARK: - Tone Card + +/// A glass card with a gradient border; the tone says whether its content is +/// good, waiting or broken. +public struct ToneCardModifier: ViewModifier { + @Environment(\.accessibilityReduceTransparency) private var reduceTransparency + var tone: StatusTone + var cornerRadius: CGFloat + var padding: CGFloat public func body(content: Content) -> some View { content .padding(padding) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .background( ZStack { RoundedRectangle(cornerRadius: cornerRadius) - .fill(RenaCodeTheme.bgCard) + .fill(reduceTransparency ? RenaCodeTheme.bgCardSolid : RenaCodeTheme.bgCard) + RoundedRectangle(cornerRadius: cornerRadius) + .fill(tone.tint) RoundedRectangle(cornerRadius: cornerRadius) .fill(RenaCodeTheme.cardGradient) } ) .overlay( RoundedRectangle(cornerRadius: cornerRadius) - .stroke( - LinearGradient( - colors: [borderColor, borderColor.opacity(0.3)], - startPoint: .topLeading, - endPoint: .bottomTrailing - ), - lineWidth: 1 - ) + .strokeBorder(tone.border, lineWidth: tone == .neutral ? 1 : 1.5) ) - .shadow(color: Color.black.opacity(0.4), radius: 18, x: 0, y: 8) + .shadow( + color: tone == .neutral ? Color.black.opacity(0.35) : tone.color.opacity(0.18), + radius: 16, x: 0, y: 6) } } extension View { - public func glassCard( - cornerRadius: CGFloat = 16, - borderColor: Color = RenaCodeTheme.borderGlass, - padding: CGFloat = 18 + public func toneCard( + _ tone: StatusTone = .neutral, cornerRadius: CGFloat = 16, padding: CGFloat = 18 ) -> some View { - self.modifier( - GlassCardModifier(cornerRadius: cornerRadius, borderColor: borderColor, padding: padding)) + modifier(ToneCardModifier(tone: tone, cornerRadius: cornerRadius, padding: padding)) } } -// MARK: - KPI Stat Card Component (StatCard) +// MARK: - Icon Tile -public struct StatCard: View { - let title: String - let value: String - let subtitle: String +/// The rounded square with a symbol that opens a card heading. +public struct IconTile: View { let systemImage: String - let iconColor: Color - - public init( - title: String, - value: String, - subtitle: String, - systemImage: String, - iconColor: Color = RenaCodeTheme.colorPrimary - ) { - self.title = title - self.value = value - self.subtitle = subtitle + let gradient: LinearGradient + var size: CGFloat + + public init(systemImage: String, gradient: LinearGradient, size: CGFloat = 32) { self.systemImage = systemImage - self.iconColor = iconColor + self.gradient = gradient + self.size = size } public var body: some View { - VStack(alignment: .leading, spacing: 10) { - HStack { - ZStack { - RoundedRectangle(cornerRadius: 10) - .fill(iconColor.opacity(0.15)) - .frame(width: 34, height: 34) - Image(systemName: systemImage) - .font(.system(size: 16, weight: .bold)) - .foregroundStyle(iconColor) - } + ZStack { + RoundedRectangle(cornerRadius: size * 0.28) + .fill(gradient) + Image(systemName: systemImage) + .font(.system(size: size * 0.48, weight: .semibold)) + .foregroundStyle(.white) + } + .frame(width: size, height: size) + .accessibilityHidden(true) + } +} - Spacer() +// MARK: - Status Pill - Text(title.uppercased()) - .font(.system(size: 10, weight: .bold, design: .rounded)) - .tracking(0.8) - .foregroundStyle(RenaCodeTheme.textMuted) - } +/// A solid pill with white text, as in the mockup ("Healthy", "Attached"). +public struct StatusPill: View { + let text: String + let tone: StatusTone + var icon: String? - Text(value) - .font(.system(size: 20, weight: .bold, design: .monospaced)) - .foregroundStyle(RenaCodeTheme.textMain) - .lineLimit(1) - .minimumScaleFactor(0.8) + public init(_ text: String, tone: StatusTone, icon: String? = nil) { + self.text = text + self.tone = tone + self.icon = icon + } - Text(subtitle) - .font(.system(size: 11, weight: .medium)) - .foregroundStyle(RenaCodeTheme.textMuted) + public var body: some View { + HStack(spacing: 4) { + if let icon { + Image(systemName: icon) + .font(.system(size: 10, weight: .bold)) + .accessibilityHidden(true) + } + Text(text) + .font(.system(size: 12, weight: .semibold)) .lineLimit(1) } - .glassCard(cornerRadius: 14, padding: 14) + // A verdict is never truncated; the text next to it gives way instead. + .fixedSize() + .padding(.horizontal, 9) + .padding(.vertical, 3) + .foregroundStyle(.white) + .background(RoundedRectangle(cornerRadius: 6).fill(tone.fill)) + } +} + +// MARK: - Status Dot + +/// A colored dot; while `live`, a ring pulses around it (not with Reduce Motion). +public struct StatusDot: View { + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @State private var pulsing = false + let tone: StatusTone + var live: Bool + var size: CGFloat + + public init(_ tone: StatusTone, live: Bool = false, size: CGFloat = 8) { + self.tone = tone + self.live = live + self.size = size + } + + public var body: some View { + Circle() + .fill(tone.color) + .frame(width: size, height: size) + .shadow(color: tone.color.opacity(0.7), radius: 4) + .background( + Circle() + .stroke(tone.color.opacity(pulsing ? 0 : 0.6), lineWidth: 1.5) + .scaleEffect(pulsing ? 2.4 : 1) + .opacity(live && !reduceMotion ? 1 : 0) + ) + .task(id: live && !reduceMotion) { + pulsing = false + guard live, !reduceMotion else { return } + withAnimation(.easeOut(duration: 1.6).repeatForever(autoreverses: false)) { + pulsing = true + } + } + .accessibilityHidden(true) + } +} + +// MARK: - Progress Bar + +public struct GradientProgressBar: View { + let fraction: Double + var height: CGFloat + + public init(fraction: Double, height: CGFloat = 8) { + self.fraction = fraction + self.height = height + } + + public var body: some View { + GeometryReader { geo in + ZStack(alignment: .leading) { + Capsule() + .fill(Color.white.opacity(0.10)) + Capsule() + .fill(RenaCodeTheme.progressGradient) + .frame(width: max(0, min(geo.size.width * CGFloat(fraction), geo.size.width))) + .shadow(color: RenaCodeTheme.colorIndigo.opacity(0.6), radius: 5) + } + } + .frame(height: height) } } @@ -223,10 +410,10 @@ public struct RenaCodePillBadge: View { if let icon = icon { Image(systemName: icon) .font(.system(size: 10, weight: .bold)) + .accessibilityHidden(true) } - Text(text.uppercased()) - .font(.system(size: 10, weight: .bold)) - .tracking(0.6) + Text(text) + .font(.system(size: 11, weight: .semibold)) } .padding(.horizontal, 10) .padding(.vertical, 4) @@ -244,6 +431,7 @@ public struct RenaCodePillBadge: View { public struct PrimaryGradientButtonStyle: ButtonStyle { @Environment(\.isEnabled) private var isEnabled + @Environment(\.accessibilityReduceMotion) private var reduceMotion public init() {} @@ -256,7 +444,7 @@ public struct PrimaryGradientButtonStyle: ButtonStyle { .background( ZStack { if isEnabled { - RenaCodeTheme.aiGradient + RenaCodeTheme.frameGradient } else { Color.gray.opacity(0.3) } @@ -267,21 +455,22 @@ public struct PrimaryGradientButtonStyle: ButtonStyle { color: isEnabled ? RenaCodeTheme.colorPrimary.opacity(0.4) : Color.clear, radius: 10, x: 0, y: 4 ) - .scaleEffect(configuration.isPressed ? 0.97 : 1.0) + .scaleEffect(configuration.isPressed && !reduceMotion ? 0.97 : 1.0) .opacity(isEnabled ? (configuration.isPressed ? 0.9 : 1.0) : 0.5) - .animation(.easeOut(duration: 0.15), value: configuration.isPressed) + .animation(reduceMotion ? nil : .easeOut(duration: 0.15), value: configuration.isPressed) } } public struct SecondaryGlassButtonStyle: ButtonStyle { @Environment(\.isEnabled) private var isEnabled + @Environment(\.accessibilityReduceMotion) private var reduceMotion public init() {} public func makeBody(configuration: Configuration) -> some View { configuration.label .font(.system(size: 13, weight: .medium)) - .foregroundStyle(isEnabled ? RenaCodeTheme.textMain : RenaCodeTheme.textDim) + .foregroundStyle(isEnabled ? RenaCodeTheme.textMain : RenaCodeTheme.textMuted) .padding(.horizontal, 14) .padding(.vertical, 8) .background( @@ -292,8 +481,31 @@ public struct SecondaryGlassButtonStyle: ButtonStyle { RoundedRectangle(cornerRadius: 10) .stroke(RenaCodeTheme.borderGlassStrong, lineWidth: 1) ) - .scaleEffect(configuration.isPressed ? 0.97 : 1.0) + .scaleEffect(configuration.isPressed && !reduceMotion ? 0.97 : 1.0) + .opacity(isEnabled ? 1.0 : 0.5) + .animation(reduceMotion ? nil : .easeOut(duration: 0.15), value: configuration.isPressed) + } +} + +/// A square icon button for the top-right corner of the window. +public struct IconGlassButtonStyle: ButtonStyle { + @Environment(\.isEnabled) private var isEnabled + + public init() {} + + public func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(isEnabled ? RenaCodeTheme.textMain : RenaCodeTheme.textMuted) + .frame(width: 32, height: 28) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(Color.white.opacity(configuration.isPressed ? 0.14 : 0.07)) + ) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(RenaCodeTheme.borderGlassStrong, lineWidth: 1) + ) .opacity(isEnabled ? 1.0 : 0.5) - .animation(.easeOut(duration: 0.15), value: configuration.isPressed) } } diff --git a/mac-app/Sources/CloudMachineCore/L10nPolish+App.swift b/mac-app/Sources/CloudMachineCore/L10nPolish+App.swift index f8dfece..a459d60 100644 --- a/mac-app/Sources/CloudMachineCore/L10nPolish+App.swift +++ b/mac-app/Sources/CloudMachineCore/L10nPolish+App.swift @@ -100,24 +100,15 @@ extension L10nPolish { // DashboardView "Healthy": "Sprawny", "Attention": "Uwaga", - "Local SSD buffer & backup to Google Drive": - "Lokalny bufor SSD & kopia zapasowa na Google Drive", "Refreshed %@": "Odświeżono %@", "System Status": "Stan Systemu", "Action needed": "Wymaga akcji", - "Buffer Size": "Rozmiar Bufora", "Free on disk: %@": "Wolne na dysku: %@", "%@ GB": "%@ GB", "not measured": "nie zmierzono", - "Upload Queue": "Kolejka Wysyłki", - "%@ queued": "%@ w kolejce", - "Nothing pending": "Brak zaległości", "rclone did not answer": "rclone nie odpowiedział", - "%@ transfers in progress": "%@ transferów w toku", - "Everything in the cloud": "Wszystko w chmurze", "Attached": "Podpięty", "Not attached": "Niepodpięty", - "Google Drive mounted": "Google Drive zamontowany", "Drive disconnected": "Drive rozłączony", "Backup in Progress": "Kopia Zapasowa w Toku", "Files processed": "Przetworzone pliki", @@ -153,5 +144,23 @@ extension L10nPolish { "Save securely in the Keychain": "Zapisz bezpiecznie w Keychainie", "Check image consistency": "Sprawdź spójność obrazu", "Refresh": "Odśwież", + // Sidebar and overview cards + "Overview": "Przegląd", + "Backups": "Kopie", + "Storage": "Miejsce", + "Settings": "Ustawienia", + "Current status: %@": "Bieżący stan: %@", + "Disks": "Dyski", + "Backup image": "Obraz kopii", + "Points to CloudMachine": "Wskazuje na CloudMachine", + "Does not point to CloudMachine": "Nie wskazuje na CloudMachine", + "tmutil did not answer": "tmutil nie odpowiedział", + "Backing up: %@ of %@": "Kopiowanie: %@ z %@", + "%@ of %@": "%@ z %@", + "Copied": "Skopiowano", + "No backup is running": "Backup teraz nie trwa", + "Time Machine starts the next one on its own schedule.": + "Time Machine uruchomi kolejny według własnego harmonogramu.", + "Backup cycle": "Cykl kopii", ] }