From 9655bc75c5d812edbbb99c6da395c19d831b32fa Mon Sep 17 00:00:00 2001 From: Marcin Beczynski Date: Fri, 9 Oct 2026 13:46:50 +0200 Subject: [PATCH] feat(ui): window with a sidebar and status cards, after the chosen mockup The window follows the mockup the owner picked: a violet-to-cyan frame, a sidebar (Overview, Backups, Storage, Settings) under the traffic lights, a large title and glass cards with gradient borders. Every card shows what the app already reads; nothing is made up: - SSD buffer: buffer size, free disk space and the upload queue (where the mockup has a sparkline - the app keeps no buffer history). - Google Drive Time Machine: the upload state from `UploadState`, a progress bar only while Time Machine runs (no empty "0%" bar), otherwise the age of the last completed backup. - System status and Disks: green only when the state really is good; a hard failure (image detached, Drive not mounted, no backup for hours, upload needing action) is red, an unknown or self-passing one amber. - The mockup's backup history chart is left out: no history exists. Nothing was removed: the setup steps, the error banner (now in every section), the upload card, buffer details, space limit, OAuth credentials and every button are still there, sorted into the sections. Sections with a problem get a coloured dot in the sidebar. The backup watchdog's problem sentences, which the window never showed, are listed under Backups. The views now observe `AppStatus` themselves. It is a separate ObservableObject, so the window redrew only when the controller assigned `credentials` at the start of a refresh, and showed the previous cycle. The menu bar popover gets the same frame, pill and tiles, plus the age of the last completed backup. Pulsing status dots and button animations respect Reduce Motion, cards turn opaque with Reduce Transparency, and pills use fills dark enough for white text. Co-Authored-By: Claude Opus 5.5 --- .../CloudMachineApp/CloudMachineApp.swift | 7 +- .../CloudMachineApp/Views/DashboardView.swift | 1435 ++++++++++------- .../Views/MenuBarContentView.swift | 223 +-- .../CloudMachineApp/Views/RenaCodeTheme.swift | 408 +++-- .../CloudMachineCore/L10nPolish+App.swift | 27 +- 5 files changed, 1303 insertions(+), 797 deletions(-) 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", ] }