feat: update README assets, refine feature views and design tokens
Replace incorrect README icon with actual Atlas app icon, add new screenshots (about, settings, privilege), improve feature view responsiveness and empty states, adjust design system brand tokens and localization strings, add icon generation script. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -228,6 +228,14 @@ public enum AtlasMotion {
|
||||
public enum AtlasLayout {
|
||||
/// Maximum content reading width — prevents overly long text lines.
|
||||
public static let maxReadingWidth: CGFloat = 920
|
||||
/// Wider content ceiling for split-pane workspace screens.
|
||||
public static let maxWorkspaceWidth: CGFloat = 1200
|
||||
/// Slightly wider content ceiling for workflow-heavy screens.
|
||||
public static let maxWorkflowWidth: CGFloat = 1080
|
||||
/// Switch split-pane cards into stacked mode before detail panels become cramped.
|
||||
public static let browserSplitThreshold: CGFloat = 860
|
||||
/// Keep enough readable width for text before detail-row accessories stay inline.
|
||||
public static let detailRowMinimumTextWidth: CGFloat = 240
|
||||
/// Standard 3-column metric grid definition.
|
||||
public static let metricColumns: [GridItem] = [
|
||||
GridItem(.flexible(minimum: 180), spacing: AtlasSpacing.lg),
|
||||
|
||||
@@ -66,12 +66,20 @@ public struct AtlasScreen<Content: View>: View {
|
||||
private let title: String
|
||||
private let subtitle: String
|
||||
private let useScrollView: Bool
|
||||
private let maxContentWidth: CGFloat?
|
||||
private let content: Content
|
||||
|
||||
public init(title: String, subtitle: String, useScrollView: Bool = true, @ViewBuilder content: () -> Content) {
|
||||
public init(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
useScrollView: Bool = true,
|
||||
maxContentWidth: CGFloat? = AtlasLayout.maxReadingWidth,
|
||||
@ViewBuilder content: () -> Content
|
||||
) {
|
||||
self.title = title
|
||||
self.subtitle = subtitle
|
||||
self.useScrollView = useScrollView
|
||||
self.maxContentWidth = maxContentWidth
|
||||
self.content = content()
|
||||
}
|
||||
|
||||
@@ -101,13 +109,14 @@ public struct AtlasScreen<Content: View>: View {
|
||||
}
|
||||
|
||||
private func contentStack(horizontalPadding: CGFloat, containerWidth: CGFloat) -> some View {
|
||||
let contentWidth = min(AtlasLayout.maxReadingWidth, containerWidth - horizontalPadding * 2)
|
||||
let availableWidth = max(containerWidth - horizontalPadding * 2, 0)
|
||||
let contentWidth = min(maxContentWidth ?? availableWidth, availableWidth)
|
||||
|
||||
return VStack(alignment: .leading, spacing: AtlasSpacing.xxl) {
|
||||
header
|
||||
content
|
||||
}
|
||||
.frame(maxWidth: AtlasLayout.maxReadingWidth, maxHeight: .infinity, alignment: .topLeading)
|
||||
.frame(maxWidth: maxContentWidth ?? .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.padding(.horizontal, horizontalPadding)
|
||||
.padding(.vertical, AtlasSpacing.xxl)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
@@ -335,40 +344,21 @@ public struct AtlasDetailRow<Trailing: View>: View {
|
||||
}
|
||||
|
||||
private var rowBody: some View {
|
||||
HStack(alignment: .top, spacing: AtlasSpacing.lg) {
|
||||
if let systemImage {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(tone.softFill)
|
||||
.frame(width: AtlasLayout.sidebarIconSize + 4, height: AtlasLayout.sidebarIconSize + 4)
|
||||
|
||||
Image(systemName: systemImage)
|
||||
.font(.headline)
|
||||
.foregroundStyle(tone.tint)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
AtlasAdaptiveDetailRowLayout(
|
||||
spacing: AtlasSpacing.lg,
|
||||
accessorySpacing: AtlasSpacing.md,
|
||||
minimumTextWidth: AtlasLayout.detailRowMinimumTextWidth
|
||||
) {
|
||||
AtlasDetailRowLayoutSlot {
|
||||
iconView
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: AtlasSpacing.xs) {
|
||||
Text(title)
|
||||
.font(AtlasTypography.rowTitle)
|
||||
|
||||
Text(subtitle)
|
||||
.font(AtlasTypography.body)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
if let footnote {
|
||||
Text(footnote)
|
||||
.font(AtlasTypography.captionSmall)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
AtlasDetailRowLayoutSlot {
|
||||
textStack
|
||||
}
|
||||
.layoutPriority(1)
|
||||
AtlasDetailRowLayoutSlot {
|
||||
trailing
|
||||
}
|
||||
|
||||
Spacer(minLength: AtlasSpacing.lg)
|
||||
|
||||
trailing
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(AtlasSpacing.lg)
|
||||
@@ -381,6 +371,43 @@ public struct AtlasDetailRow<Trailing: View>: View {
|
||||
.strokeBorder(AtlasColor.border, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var iconView: some View {
|
||||
if let systemImage {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(tone.softFill)
|
||||
.frame(width: AtlasLayout.sidebarIconSize + 4, height: AtlasLayout.sidebarIconSize + 4)
|
||||
|
||||
Image(systemName: systemImage)
|
||||
.font(.headline)
|
||||
.foregroundStyle(tone.tint)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
} else {
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
|
||||
private var textStack: some View {
|
||||
VStack(alignment: .leading, spacing: AtlasSpacing.xs) {
|
||||
Text(title)
|
||||
.font(AtlasTypography.rowTitle)
|
||||
|
||||
Text(subtitle)
|
||||
.font(AtlasTypography.body)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
if let footnote {
|
||||
Text(footnote)
|
||||
.font(AtlasTypography.captionSmall)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public extension AtlasDetailRow where Trailing == EmptyView {
|
||||
@@ -411,16 +438,20 @@ public struct AtlasKeyValueRow: View {
|
||||
|
||||
public var body: some View {
|
||||
VStack(alignment: .leading, spacing: AtlasSpacing.md) {
|
||||
HStack(alignment: .top, spacing: AtlasSpacing.md) {
|
||||
Text(title)
|
||||
.font(AtlasTypography.rowTitle)
|
||||
ViewThatFits(in: .horizontal) {
|
||||
HStack(alignment: .top, spacing: AtlasSpacing.md) {
|
||||
titleView
|
||||
|
||||
Spacer(minLength: AtlasSpacing.lg)
|
||||
Spacer(minLength: AtlasSpacing.lg)
|
||||
|
||||
Text(value)
|
||||
.font(AtlasTypography.label)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.trailing)
|
||||
valueView(alignment: .trailing)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: AtlasSpacing.sm) {
|
||||
titleView
|
||||
valueView(alignment: .leading)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
if let detail {
|
||||
@@ -436,6 +467,219 @@ public struct AtlasKeyValueRow: View {
|
||||
.accessibilityValue(Text(value))
|
||||
.accessibilityHint(Text(detail ?? ""))
|
||||
}
|
||||
|
||||
private var titleView: some View {
|
||||
Text(title)
|
||||
.font(AtlasTypography.rowTitle)
|
||||
}
|
||||
|
||||
private func valueView(alignment: TextAlignment) -> some View {
|
||||
Text(value)
|
||||
.font(AtlasTypography.label)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(alignment)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
|
||||
public struct AtlasMachineTextBlock: View {
|
||||
private let title: String
|
||||
private let value: String
|
||||
private let detail: String?
|
||||
|
||||
public init(title: String, value: String, detail: String? = nil) {
|
||||
self.title = title
|
||||
self.value = value
|
||||
self.detail = detail
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
VStack(alignment: .leading, spacing: AtlasSpacing.xs) {
|
||||
Text(title)
|
||||
.font(AtlasTypography.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Text(value)
|
||||
.font(.system(size: 12, weight: .regular, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
if let detail {
|
||||
Text(detail)
|
||||
.font(AtlasTypography.captionSmall)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(AtlasSpacing.md)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: AtlasRadius.md, style: .continuous)
|
||||
.fill(Color.primary.opacity(0.03))
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: AtlasRadius.md, style: .continuous)
|
||||
.strokeBorder(AtlasColor.border, lineWidth: 1)
|
||||
)
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel(Text(title))
|
||||
.accessibilityValue(Text(value))
|
||||
.accessibilityHint(Text(detail ?? ""))
|
||||
}
|
||||
}
|
||||
|
||||
private struct AtlasAdaptiveDetailRowLayout: Layout {
|
||||
let spacing: CGFloat
|
||||
let accessorySpacing: CGFloat
|
||||
let minimumTextWidth: CGFloat
|
||||
|
||||
func sizeThatFits(
|
||||
proposal: ProposedViewSize,
|
||||
subviews: Subviews,
|
||||
cache: inout Void
|
||||
) -> CGSize {
|
||||
measurements(for: proposal, subviews: subviews).size
|
||||
}
|
||||
|
||||
func placeSubviews(
|
||||
in bounds: CGRect,
|
||||
proposal: ProposedViewSize,
|
||||
subviews: Subviews,
|
||||
cache: inout Void
|
||||
) {
|
||||
let measurements = measurements(
|
||||
for: ProposedViewSize(width: bounds.width, height: proposal.height),
|
||||
subviews: subviews
|
||||
)
|
||||
|
||||
guard let textSubview = subview(at: 1, in: subviews) else {
|
||||
return
|
||||
}
|
||||
let accessorySubview = subview(at: 2, in: subviews)
|
||||
let contentX = bounds.minX + measurements.leadingOffset
|
||||
let textProposal = ProposedViewSize(width: measurements.textWidth, height: nil)
|
||||
let textSize = textSubview.sizeThatFits(textProposal)
|
||||
|
||||
if measurements.hasIcon, let iconSubview = subview(at: 0, in: subviews) {
|
||||
iconSubview.place(
|
||||
at: CGPoint(x: bounds.minX, y: bounds.minY),
|
||||
proposal: ProposedViewSize(
|
||||
width: measurements.iconSize.width,
|
||||
height: measurements.iconSize.height
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
textSubview.place(
|
||||
at: CGPoint(x: contentX, y: bounds.minY),
|
||||
proposal: textProposal
|
||||
)
|
||||
|
||||
guard measurements.hasAccessory else {
|
||||
return
|
||||
}
|
||||
|
||||
if measurements.useCompactLayout {
|
||||
accessorySubview?.place(
|
||||
at: CGPoint(
|
||||
x: contentX,
|
||||
y: bounds.minY + textSize.height + accessorySpacing
|
||||
),
|
||||
proposal: ProposedViewSize(width: measurements.textWidth, height: nil)
|
||||
)
|
||||
} else {
|
||||
accessorySubview?.place(
|
||||
at: CGPoint(
|
||||
x: bounds.maxX - measurements.accessorySize.width,
|
||||
y: bounds.minY
|
||||
),
|
||||
proposal: ProposedViewSize(
|
||||
width: measurements.accessorySize.width,
|
||||
height: measurements.accessorySize.height
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func measurements(
|
||||
for proposal: ProposedViewSize,
|
||||
subviews: Subviews
|
||||
) -> AtlasAdaptiveDetailRowMeasurements {
|
||||
let iconSize = subview(at: 0, in: subviews)?.sizeThatFits(.unspecified) ?? .zero
|
||||
let accessoryIdealSize = subview(at: 2, in: subviews)?.sizeThatFits(.unspecified) ?? .zero
|
||||
|
||||
let hasIcon = iconSize != .zero
|
||||
let hasAccessory = accessoryIdealSize != .zero
|
||||
let leadingOffset = hasIcon ? iconSize.width + spacing : 0
|
||||
let horizontalAccessoryOffset = hasAccessory ? accessoryIdealSize.width + spacing : 0
|
||||
let resolvedWidth = proposal.width
|
||||
let horizontalTextWidth = resolvedWidth.map { max($0 - leadingOffset - horizontalAccessoryOffset, 0) }
|
||||
let useCompactLayout = hasAccessory && (horizontalTextWidth.map { $0 < minimumTextWidth } ?? false)
|
||||
|
||||
if useCompactLayout {
|
||||
let textWidth = max((resolvedWidth ?? minimumTextWidth) - leadingOffset, 0)
|
||||
let textSize = subview(at: 1, in: subviews)?.sizeThatFits(ProposedViewSize(width: textWidth, height: nil)) ?? .zero
|
||||
let accessorySize = subview(at: 2, in: subviews)?.sizeThatFits(ProposedViewSize(width: textWidth, height: nil)) ?? .zero
|
||||
let contentHeight = textSize.height + accessorySpacing + accessorySize.height
|
||||
let width = resolvedWidth ?? (leadingOffset + max(textSize.width, accessorySize.width))
|
||||
|
||||
return AtlasAdaptiveDetailRowMeasurements(
|
||||
size: CGSize(width: width, height: max(iconSize.height, contentHeight)),
|
||||
iconSize: iconSize,
|
||||
accessorySize: accessorySize,
|
||||
textWidth: textWidth,
|
||||
leadingOffset: leadingOffset,
|
||||
hasIcon: hasIcon,
|
||||
hasAccessory: hasAccessory,
|
||||
useCompactLayout: true
|
||||
)
|
||||
} else {
|
||||
let textSize = subview(at: 1, in: subviews)?.sizeThatFits(ProposedViewSize(width: horizontalTextWidth, height: nil)) ?? .zero
|
||||
let width = resolvedWidth ?? (leadingOffset + textSize.width + horizontalAccessoryOffset)
|
||||
|
||||
return AtlasAdaptiveDetailRowMeasurements(
|
||||
size: CGSize(width: width, height: max(iconSize.height, textSize.height, accessoryIdealSize.height)),
|
||||
iconSize: iconSize,
|
||||
accessorySize: accessoryIdealSize,
|
||||
textWidth: max(width - leadingOffset - horizontalAccessoryOffset, 0),
|
||||
leadingOffset: leadingOffset,
|
||||
hasIcon: hasIcon,
|
||||
hasAccessory: hasAccessory,
|
||||
useCompactLayout: false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func subview(at index: Int, in subviews: Subviews) -> LayoutSubview? {
|
||||
guard subviews.indices.contains(index) else {
|
||||
return nil
|
||||
}
|
||||
return subviews[index]
|
||||
}
|
||||
}
|
||||
|
||||
private struct AtlasAdaptiveDetailRowMeasurements {
|
||||
let size: CGSize
|
||||
let iconSize: CGSize
|
||||
let accessorySize: CGSize
|
||||
let textWidth: CGFloat
|
||||
let leadingOffset: CGFloat
|
||||
let hasIcon: Bool
|
||||
let hasAccessory: Bool
|
||||
let useCompactLayout: Bool
|
||||
}
|
||||
|
||||
private struct AtlasDetailRowLayoutSlot<Content: View>: View {
|
||||
private let content: Content
|
||||
|
||||
init(@ViewBuilder content: () -> Content) {
|
||||
self.content = content()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
content
|
||||
}
|
||||
}
|
||||
|
||||
public struct AtlasStatusChip: View {
|
||||
|
||||
@@ -46,29 +46,29 @@
|
||||
"settings.notices.body" = "Atlas for Mac is released under the MIT License. This product also includes software derived from the open-source project Mole (https://github.com/tw93/mole) by tw93 and contributors, used under the MIT License.";
|
||||
"fixture.finding.derivedData.title" = "Xcode Derived Data";
|
||||
"fixture.finding.derivedData.detail" = "Build artifacts and indexes from older projects.";
|
||||
"fixture.finding.browserCaches.title" = "Browser caches";
|
||||
"fixture.finding.browserCaches.title" = "Browser Caches";
|
||||
"fixture.finding.browserCaches.detail" = "WebKit and Chromium cache folders with low recovery risk.";
|
||||
"fixture.finding.oldRuntimes.title" = "Old simulator runtimes";
|
||||
"fixture.finding.oldRuntimes.title" = "Old Simulator Runtimes";
|
||||
"fixture.finding.oldRuntimes.detail" = "Unused iOS simulator assets that need review before deletion.";
|
||||
"fixture.finding.launchAgents.title" = "Launch-agent leftovers";
|
||||
"fixture.finding.launchAgents.title" = "Launch-Agent Leftovers";
|
||||
"fixture.finding.launchAgents.detail" = "Background helpers tied to removed apps and older experiments.";
|
||||
|
||||
"fixture.plan.reclaimCommonClutter.title" = "Reclaim common clutter";
|
||||
"fixture.plan.reclaimCommonClutter.title" = "Reclaim Common Clutter";
|
||||
"fixture.plan.item.moveDerivedData.title" = "Move Xcode Derived Data to Trash";
|
||||
"fixture.plan.item.moveDerivedData.detail" = "Keep recovery available from History and Recovery.";
|
||||
"fixture.plan.item.reviewRuntimes.title" = "Review simulator runtimes";
|
||||
"fixture.plan.item.reviewRuntimes.detail" = "Ask the worker to confirm the plan details before execution.";
|
||||
"fixture.plan.item.inspectAgents.title" = "Inspect launch agents";
|
||||
"fixture.plan.item.inspectAgents.detail" = "Require helper validation before removing privileged items.";
|
||||
"fixture.plan.item.reviewRuntimes.title" = "Review Simulator Runtimes";
|
||||
"fixture.plan.item.reviewRuntimes.detail" = "Confirm the plan details before execution.";
|
||||
"fixture.plan.item.inspectAgents.title" = "Inspect Launch Agents";
|
||||
"fixture.plan.item.inspectAgents.detail" = "Require system validation before removing privileged items.";
|
||||
|
||||
"fixture.task.scan.summary" = "Scanned 214 locations and generated a cleanup plan that can free 35.3 GB.";
|
||||
"fixture.task.execute.summary" = "Moving safe cache items into recovery storage.";
|
||||
"fixture.task.permissions.summary" = "Permission status refreshed without prompting the user.";
|
||||
|
||||
"fixture.recovery.chromeCache.title" = "Chrome cache bundle";
|
||||
"fixture.recovery.chromeCache.title" = "Chrome Cache Bundle";
|
||||
"fixture.recovery.chromeCache.detail" = "Recoverable cache package from the previous Smart Clean run.";
|
||||
"fixture.recovery.chromeCache.payload" = "Recovered cache data from the previous Smart Clean run.";
|
||||
"fixture.recovery.simulatorSupport.title" = "Legacy simulator device support";
|
||||
"fixture.recovery.simulatorSupport.title" = "Legacy Simulator Device Support";
|
||||
"fixture.recovery.simulatorSupport.detail" = "This item still needs review and will clear after one week.";
|
||||
"fixture.recovery.simulatorSupport.payload" = "Simulator support files waiting for review.";
|
||||
|
||||
@@ -292,7 +292,7 @@
|
||||
"smartclean.preview.metric.space.detail.other" = "Estimated across %d planned steps.";
|
||||
"smartclean.preview.callout.safe.title" = "This plan stays mostly in the Safe lane";
|
||||
"smartclean.preview.callout.safe.detail" = "Most selected steps should remain recoverable through History and Recovery.";
|
||||
"smartclean.preview.empty.detail" = "Run a scan or update the plan to turn current findings into concrete cleanup steps. If you just ran a plan, this section shows only the remaining items.";
|
||||
"smartclean.preview.empty.detail.postExecution" = "Run a scan or update the plan to turn current findings into concrete cleanup steps. If you just ran a plan, this section shows only the remaining items.";
|
||||
"smartclean.preview.callout.review.detail" = "Check the highlighted steps before you run the plan so you understand what stays recoverable and what needs extra judgment.";
|
||||
"smartclean.preview.empty.title" = "No cleanup plan yet";
|
||||
"smartclean.preview.empty.detail" = "Run a scan or update the plan to turn current findings into concrete cleanup steps.";
|
||||
@@ -491,7 +491,7 @@
|
||||
"permissions.metric.later.title" = "Not Needed Yet";
|
||||
"permissions.metric.later.detail" = "These can stay off without putting Atlas into limited mode until a related workflow asks for them.";
|
||||
"permissions.metric.tracked.title" = "Tracked Permissions";
|
||||
"permissions.metric.tracked.detail" = "The minimum set Atlas surfaces for the frozen MVP workflows.";
|
||||
"permissions.metric.tracked.detail" = "The minimum set Atlas surfaces for the current workflows.";
|
||||
"permissions.refresh" = "Check Permission Status";
|
||||
"permissions.refresh.hint" = "Checks the current permission snapshot without opening System Settings.";
|
||||
"permissions.requiredSection.title" = "Required Now";
|
||||
@@ -544,7 +544,7 @@
|
||||
"settings.notifications.hint" = "Turns task and recovery notifications on or off.";
|
||||
"settings.distribution.title" = "Distribution";
|
||||
"settings.distribution.value" = "Developer ID + Notarization";
|
||||
"settings.distribution.detail" = "The frozen MVP assumes direct distribution rather than a Mac App Store release.";
|
||||
"settings.distribution.detail" = "This version uses direct distribution rather than a Mac App Store release.";
|
||||
"settings.exclusions.title" = "Rules & Exclusions";
|
||||
"settings.exclusions.subtitle" = "These paths stay out of scan results and cleanup plans.";
|
||||
"settings.exclusions.empty.title" = "No exclusions configured";
|
||||
@@ -607,3 +607,7 @@
|
||||
"sidebar.section.manage" = "Manage";
|
||||
"about.window.title" = "About Atlas";
|
||||
"commands.about" = "About Atlas";
|
||||
|
||||
"storage.screen.title" = "Storage";
|
||||
"storage.screen.subtitle" = "Reserved list-based storage views for a future scope decision.";
|
||||
"storage.largeItems.title" = "Large Items";
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
"permission.accessibility" = "辅助功能";
|
||||
"permission.notifications" = "通知";
|
||||
|
||||
"category.developer" = "开发";
|
||||
"category.developer" = "开发者";
|
||||
"category.system" = "系统";
|
||||
"category.apps" = "应用";
|
||||
"category.browsers" = "浏览器";
|
||||
@@ -57,9 +57,9 @@
|
||||
"fixture.plan.item.moveDerivedData.title" = "将 Xcode 派生数据移入废纸篓";
|
||||
"fixture.plan.item.moveDerivedData.detail" = "保留恢复路径,可在历史和恢复中找回。";
|
||||
"fixture.plan.item.reviewRuntimes.title" = "复核模拟器运行时";
|
||||
"fixture.plan.item.reviewRuntimes.detail" = "执行前先让 worker 确认计划细节。";
|
||||
"fixture.plan.item.reviewRuntimes.detail" = "执行前先确认计划细节。";
|
||||
"fixture.plan.item.inspectAgents.title" = "检查启动代理";
|
||||
"fixture.plan.item.inspectAgents.detail" = "移除受权限影响的项目之前,需要先通过 helper 校验。";
|
||||
"fixture.plan.item.inspectAgents.detail" = "移除受权限影响的项目之前,需要先通过系统辅助工具校验。";
|
||||
|
||||
"fixture.task.scan.summary" = "已扫描 214 个位置,并生成预计可释放 35.3 GB 的清理计划。";
|
||||
"fixture.task.execute.summary" = "正在将安全缓存项移入恢复区。";
|
||||
@@ -89,7 +89,7 @@
|
||||
"fixture.storage.movies.age" = "最近 6 个月内生成的大型媒体文件";
|
||||
"fixture.storage.installers.title" = "未使用的安装器";
|
||||
"fixture.storage.installers.age" = "大部分磁盘镜像已超过 30 天";
|
||||
"application.error.workerRejected" = "Worker 拒绝了请求(%@):%@";
|
||||
"application.error.workerRejected" = "后台服务拒绝了请求(%@):%@";
|
||||
"xpc.error.encodingFailed" = "无法编码后台请求:%@";
|
||||
"xpc.error.decodingFailed" = "无法解析后台响应:%@";
|
||||
"xpc.error.invalidResponse" = "后台工作组件返回了无效响应。请完全退出并重新打开 Atlas;若仍失败,请重新安装当前版本。";
|
||||
@@ -212,7 +212,7 @@
|
||||
"overview.callout.limited.detail" = "当前仍缺少至少一项主流程必需权限,因此 Atlas 会保持受限模式;补齐后就会自动恢复为就绪状态。";
|
||||
"overview.metric.reclaimable.title" = "预计可释放空间";
|
||||
"overview.metric.reclaimable.detail" = "基于当前清理计划和最近的工作区状态估算。执行后会按剩余项目重新计算。";
|
||||
"overview.metric.findings.title" = "待处理发现项";
|
||||
"overview.metric.findings.title" = "就绪发现项";
|
||||
"overview.metric.findings.detail" = "在真正执行前,Atlas 会先将其分为安全、复核和高级三个分区。";
|
||||
"overview.metric.permissions.title" = "权限就绪度";
|
||||
"overview.metric.permissions.ready" = "Atlas 已具备当前流程所需的权限。";
|
||||
@@ -292,7 +292,7 @@
|
||||
"smartclean.preview.metric.space.detail.other" = "按当前 %d 个计划步骤估算。";
|
||||
"smartclean.preview.callout.safe.title" = "当前计划主要来自“安全”分区";
|
||||
"smartclean.preview.callout.safe.detail" = "大多数已选步骤都可以在历史和恢复中找回。";
|
||||
"smartclean.preview.empty.detail" = "运行一次扫描或更新计划,把当前发现项变成具体的清理步骤。若刚执行完计划,这里只显示剩余项目。";
|
||||
"smartclean.preview.empty.detail.postExecution" = "运行一次扫描或更新计划,把当前发现项变成具体的清理步骤。若刚执行完计划,这里只显示剩余项目。";
|
||||
"smartclean.preview.callout.review.detail" = "建议在执行前检查高亮步骤,确认哪些仍可恢复、哪些需要额外判断。";
|
||||
"smartclean.preview.empty.title" = "还没有清理计划";
|
||||
"smartclean.preview.empty.detail" = "运行一次扫描或更新计划,把当前发现项变成具体的清理步骤。";
|
||||
@@ -375,7 +375,7 @@
|
||||
"apps.detail.empty.detail" = "从左侧列表选择一个应用,以检查占用并生成卸载计划。";
|
||||
"apps.detail.size" = "应用占用";
|
||||
"apps.detail.leftovers" = "残留文件";
|
||||
"apps.detail.path" = "Bundle 路径";
|
||||
"apps.detail.path" = "应用包路径";
|
||||
"apps.detail.callout.preview.title" = "先生成卸载计划";
|
||||
"apps.detail.callout.preview.detail" = "为了让卸载更可控,Atlas 会先展示准确步骤,再决定是否真正移除。";
|
||||
"apps.detail.callout.ready.title" = "这个应用已经可以在复核后卸载";
|
||||
@@ -491,7 +491,7 @@
|
||||
"permissions.metric.later.title" = "暂不需要";
|
||||
"permissions.metric.later.detail" = "这些权限暂未授予也不会让 Atlas 进入受限模式,只有相关流程需要时才会用到。";
|
||||
"permissions.metric.tracked.title" = "跟踪权限";
|
||||
"permissions.metric.tracked.detail" = "冻结 MVP 流程当前展示的最小权限集合。";
|
||||
"permissions.metric.tracked.detail" = "当前版本展示的最小权限集合。";
|
||||
"permissions.refresh" = "检查权限状态";
|
||||
"permissions.refresh.hint" = "检查当前权限快照,而不会直接打开系统设置。";
|
||||
"permissions.requiredSection.title" = "当前必需";
|
||||
@@ -544,7 +544,7 @@
|
||||
"settings.notifications.hint" = "打开或关闭任务和恢复通知。";
|
||||
"settings.distribution.title" = "分发方式";
|
||||
"settings.distribution.value" = "Developer ID + Notarization";
|
||||
"settings.distribution.detail" = "冻结 MVP 假设采用直接分发,而不是 Mac App Store。";
|
||||
"settings.distribution.detail" = "当前版本采用直接分发,而不是 Mac App Store。";
|
||||
"settings.exclusions.title" = "规则与排除项";
|
||||
"settings.exclusions.subtitle" = "这些路径不会出现在扫描结果和清理计划里。";
|
||||
"settings.exclusions.empty.title" = "还没有配置排除项";
|
||||
@@ -582,9 +582,9 @@
|
||||
"emptystate.action.startScan" = "开始扫描";
|
||||
"about.screen.title" = "关于";
|
||||
"about.screen.subtitle" = "Atlas for Mac 背后的开发者与愿景。";
|
||||
"about.author.title" = "关于作者";
|
||||
"about.author.title" = "开发者";
|
||||
"about.author.name" = "Lizi KK";
|
||||
"about.author.role" = "前百度 & 阿里技术高P · atomstorm.ai Founder";
|
||||
"about.author.role" = "前百度 & 阿里技术负责人 · atomstorm.ai 创始人";
|
||||
"about.author.bio" = "使用 AI Coding 构建 Atlas。每一行代码都由 AI 辅助,但都经过了人的决策、审美和架构判断。";
|
||||
"about.author.quote" = "AI 是极其强大的手和脚,但你必须做那个头脑清醒的大脑。";
|
||||
"about.product.title" = "作者的其他产品";
|
||||
@@ -607,3 +607,7 @@
|
||||
"sidebar.section.manage" = "管理";
|
||||
"about.window.title" = "关于 Atlas";
|
||||
"commands.about" = "关于 Atlas";
|
||||
|
||||
"storage.screen.title" = "存储";
|
||||
"storage.screen.subtitle" = "为未来版本预留的基于列表的存储视图。";
|
||||
"storage.largeItems.title" = "大文件";
|
||||
|
||||
@@ -9,7 +9,9 @@ final class AtlasDomainTests: XCTestCase {
|
||||
|
||||
func testPrimaryRoutesMatchFrozenMVP() {
|
||||
XCTAssertEqual(
|
||||
AtlasRoute.allCases.map(\.title),
|
||||
AtlasRoute.allCases
|
||||
.filter { $0 != .about }
|
||||
.map(\.title),
|
||||
["概览", "智能清理", "应用", "历史", "权限", "设置"]
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import AtlasDomain
|
||||
import SwiftUI
|
||||
|
||||
public struct AppsFeatureView: View {
|
||||
@Environment(\.atlasContentWidth) private var contentWidth
|
||||
|
||||
private let apps: [AppFootprint]
|
||||
private let previewPlan: ActionPlan?
|
||||
private let currentPreviewedAppID: UUID?
|
||||
@@ -15,6 +17,7 @@ public struct AppsFeatureView: View {
|
||||
private let onExecuteAppUninstall: (UUID) -> Void
|
||||
|
||||
@State private var selectedAppID: UUID?
|
||||
@State private var browserWidth: CGFloat?
|
||||
|
||||
public init(
|
||||
apps: [AppFootprint] = AtlasScaffoldFixtures.apps,
|
||||
@@ -44,7 +47,8 @@ public struct AppsFeatureView: View {
|
||||
public var body: some View {
|
||||
AtlasScreen(
|
||||
title: AtlasL10n.string("apps.screen.title"),
|
||||
subtitle: AtlasL10n.string("apps.screen.subtitle")
|
||||
subtitle: AtlasL10n.string("apps.screen.subtitle"),
|
||||
maxContentWidth: AtlasLayout.maxWorkspaceWidth
|
||||
) {
|
||||
AtlasCallout(
|
||||
title: previewPlan == nil ? AtlasL10n.string("apps.callout.default.title") : AtlasL10n.string("apps.callout.preview.title"),
|
||||
@@ -65,7 +69,7 @@ public struct AppsFeatureView: View {
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
LazyVGrid(columns: AtlasLayout.metricColumns, spacing: AtlasSpacing.lg) {
|
||||
LazyVGrid(columns: inventoryMetricColumns, spacing: AtlasSpacing.lg) {
|
||||
AtlasMetricCard(
|
||||
title: AtlasL10n.string("apps.metric.listed.title"),
|
||||
value: "\(sortedApps.count)",
|
||||
@@ -104,33 +108,38 @@ public struct AppsFeatureView: View {
|
||||
subtitle: AtlasL10n.string("apps.browser.subtitle"),
|
||||
tone: selectedAppMatchingPreview == nil ? .neutral : .warning
|
||||
) {
|
||||
GeometryReader { proxy in
|
||||
let isWide = proxy.size.width >= 680
|
||||
let sidebarWidth = min(max(proxy.size.width * 0.3, 220), 280)
|
||||
Group {
|
||||
if isWideBrowserLayout {
|
||||
HStack(alignment: .top, spacing: AtlasSpacing.xl) {
|
||||
appsSidebar
|
||||
.frame(width: sidebarWidth)
|
||||
.frame(maxHeight: .infinity)
|
||||
|
||||
Group {
|
||||
if isWide {
|
||||
HStack(alignment: .top, spacing: AtlasSpacing.xl) {
|
||||
appsSidebar
|
||||
.frame(width: sidebarWidth)
|
||||
.frame(maxHeight: .infinity)
|
||||
appDetailPanel
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
}
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: AtlasSpacing.xl) {
|
||||
appsSidebar
|
||||
.frame(minHeight: 240, idealHeight: 320, maxHeight: 400)
|
||||
|
||||
appDetailPanel
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: AtlasSpacing.xl) {
|
||||
appsSidebar
|
||||
.frame(minHeight: 260, maxHeight: 260)
|
||||
|
||||
appDetailPanel
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
appDetailPanel
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
}
|
||||
.frame(minHeight: 400, maxHeight: .infinity)
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
.frame(minHeight: isWideBrowserLayout ? 460 : nil, alignment: .topLeading)
|
||||
.background(
|
||||
GeometryReader { proxy in
|
||||
Color.clear.preference(key: BrowserWidthKey.self, value: proxy.size.width)
|
||||
}
|
||||
)
|
||||
.onPreferenceChange(BrowserWidthKey.self) { newWidth in
|
||||
if newWidth > 0 {
|
||||
browserWidth = newWidth
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear(perform: syncSelection)
|
||||
@@ -147,6 +156,30 @@ public struct AppsFeatureView: View {
|
||||
sortedApps.map(\.id)
|
||||
}
|
||||
|
||||
private var effectiveBrowserWidth: CGFloat {
|
||||
let measuredWidth = browserWidth ?? contentWidth
|
||||
return max(measuredWidth, 0)
|
||||
}
|
||||
|
||||
private var isWideBrowserLayout: Bool {
|
||||
effectiveBrowserWidth >= AtlasLayout.browserSplitThreshold
|
||||
}
|
||||
|
||||
private var sidebarWidth: CGFloat {
|
||||
min(max(effectiveBrowserWidth * 0.3, 220), 280)
|
||||
}
|
||||
|
||||
private var detailMetricColumns: [GridItem] {
|
||||
let estimatedDetailWidth = isWideBrowserLayout
|
||||
? max(effectiveBrowserWidth - sidebarWidth - AtlasSpacing.xl - (AtlasSpacing.xl * 2), 320)
|
||||
: effectiveBrowserWidth
|
||||
return AtlasLayout.adaptiveMetricColumns(for: estimatedDetailWidth)
|
||||
}
|
||||
|
||||
private var inventoryMetricColumns: [GridItem] {
|
||||
AtlasLayout.adaptiveMetricColumns(for: contentWidth)
|
||||
}
|
||||
|
||||
private var groupedApps: [AppGroup] {
|
||||
var groups: [AppGroup] = []
|
||||
let grouped = Dictionary(grouping: sortedApps, by: \.bucket)
|
||||
@@ -226,26 +259,33 @@ public struct AppsFeatureView: View {
|
||||
.font(AtlasTypography.label)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
ScrollView {
|
||||
if let selectedApp {
|
||||
AppDetailView(
|
||||
app: selectedApp,
|
||||
previewPlan: selectedAppMatchingPreview,
|
||||
isBuildingPreview: activePreviewAppID == selectedApp.id,
|
||||
isUninstalling: activeUninstallAppID == selectedApp.id,
|
||||
isBusy: isRunning,
|
||||
onPreview: { onPreviewAppUninstall(selectedApp.id) },
|
||||
onUninstall: { onExecuteAppUninstall(selectedApp.id) }
|
||||
)
|
||||
} else {
|
||||
AtlasEmptyState(
|
||||
title: AtlasL10n.string("apps.detail.empty.title"),
|
||||
detail: AtlasL10n.string("apps.detail.empty.detail"),
|
||||
systemImage: "cursorarrow.click",
|
||||
tone: .neutral
|
||||
)
|
||||
Group {
|
||||
ScrollView {
|
||||
Group {
|
||||
if let selectedApp {
|
||||
AppDetailView(
|
||||
app: selectedApp,
|
||||
previewPlan: selectedAppMatchingPreview,
|
||||
metricColumns: detailMetricColumns,
|
||||
isBuildingPreview: activePreviewAppID == selectedApp.id,
|
||||
isUninstalling: activeUninstallAppID == selectedApp.id,
|
||||
isBusy: isRunning,
|
||||
onPreview: { onPreviewAppUninstall(selectedApp.id) },
|
||||
onUninstall: { onExecuteAppUninstall(selectedApp.id) }
|
||||
)
|
||||
} else {
|
||||
AtlasEmptyState(
|
||||
title: AtlasL10n.string("apps.detail.empty.title"),
|
||||
detail: AtlasL10n.string("apps.detail.empty.detail"),
|
||||
systemImage: "cursorarrow.click",
|
||||
tone: .neutral
|
||||
)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
}
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.2), value: selectedAppID)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.padding(AtlasSpacing.xl)
|
||||
@@ -347,6 +387,7 @@ private struct AppSidebarSectionHeader: View {
|
||||
private struct AppDetailView: View {
|
||||
let app: AppFootprint
|
||||
let previewPlan: ActionPlan?
|
||||
let metricColumns: [GridItem]
|
||||
let isBuildingPreview: Bool
|
||||
let isUninstalling: Bool
|
||||
let isBusy: Bool
|
||||
@@ -357,27 +398,17 @@ private struct AppDetailView: View {
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: AtlasSpacing.xl) {
|
||||
HStack(alignment: .top, spacing: AtlasSpacing.lg) {
|
||||
VStack(alignment: .leading, spacing: AtlasSpacing.xs) {
|
||||
Text(app.name)
|
||||
.font(AtlasTypography.sectionTitle)
|
||||
|
||||
Text(app.bundleIdentifier)
|
||||
.font(AtlasTypography.body)
|
||||
.foregroundStyle(.secondary)
|
||||
ViewThatFits(in: .horizontal) {
|
||||
HStack(alignment: .top, spacing: AtlasSpacing.lg) {
|
||||
headerCopy
|
||||
Spacer(minLength: AtlasSpacing.lg)
|
||||
headerMeta
|
||||
}
|
||||
|
||||
Spacer(minLength: AtlasSpacing.lg)
|
||||
|
||||
VStack(alignment: .trailing, spacing: AtlasSpacing.sm) {
|
||||
Text(AtlasFormatters.byteCount(app.bytes))
|
||||
.font(AtlasTypography.cardMetric)
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
AtlasStatusChip(
|
||||
AtlasL10n.string("apps.list.row.leftovers", app.leftoverItems),
|
||||
tone: app.leftoverItems > 0 ? .warning : .success
|
||||
)
|
||||
VStack(alignment: .leading, spacing: AtlasSpacing.md) {
|
||||
headerCopy
|
||||
headerMeta
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -403,7 +434,7 @@ private struct AppDetailView: View {
|
||||
value: "\(app.leftoverItems)",
|
||||
detail: AtlasL10n.string("apps.metric.leftovers.detail")
|
||||
)
|
||||
AtlasKeyValueRow(
|
||||
AtlasMachineTextBlock(
|
||||
title: AtlasL10n.string("apps.detail.path"),
|
||||
value: app.bundlePath,
|
||||
detail: app.bucket.title
|
||||
@@ -416,7 +447,7 @@ private struct AppDetailView: View {
|
||||
subtitle: previewPlan.title,
|
||||
tone: .warning
|
||||
) {
|
||||
LazyVGrid(columns: AtlasLayout.metricColumns, spacing: AtlasSpacing.lg) {
|
||||
LazyVGrid(columns: metricColumns, spacing: AtlasSpacing.lg) {
|
||||
AtlasMetricCard(
|
||||
title: AtlasL10n.string("apps.preview.metric.size.title"),
|
||||
value: AtlasFormatters.byteCount(previewPlan.estimatedBytes),
|
||||
@@ -474,6 +505,31 @@ private struct AppDetailView: View {
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
private var headerCopy: some View {
|
||||
VStack(alignment: .leading, spacing: AtlasSpacing.xs) {
|
||||
Text(app.name)
|
||||
.font(AtlasTypography.sectionTitle)
|
||||
|
||||
Text(app.bundleIdentifier)
|
||||
.font(AtlasTypography.body)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
|
||||
private var headerMeta: some View {
|
||||
VStack(alignment: .trailing, spacing: AtlasSpacing.sm) {
|
||||
Text(AtlasFormatters.byteCount(app.bytes))
|
||||
.font(AtlasTypography.cardMetric)
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
AtlasStatusChip(
|
||||
AtlasL10n.string("apps.list.row.leftovers", app.leftoverItems),
|
||||
tone: app.leftoverItems > 0 ? .warning : .success
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private var previewButton: some View {
|
||||
Group {
|
||||
if previewPlan == nil {
|
||||
@@ -565,3 +621,10 @@ private extension AppFootprint {
|
||||
return .other
|
||||
}
|
||||
}
|
||||
|
||||
private struct BrowserWidthKey: PreferenceKey {
|
||||
static var defaultValue: CGFloat = 0
|
||||
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
|
||||
value = nextValue()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,11 +3,14 @@ import AtlasDomain
|
||||
import SwiftUI
|
||||
|
||||
public struct HistoryFeatureView: View {
|
||||
@Environment(\.atlasContentWidth) private var contentWidth
|
||||
|
||||
private let taskRuns: [TaskRun]
|
||||
private let recoveryItems: [RecoveryItem]
|
||||
private let restoringItemID: UUID?
|
||||
private let onRestoreItem: (UUID) -> Void
|
||||
|
||||
@State private var browserWidth: CGFloat?
|
||||
@State private var selectedSection: HistoryBrowserSection
|
||||
@State private var selectedTaskRunID: UUID?
|
||||
@State private var selectedRecoveryItemID: UUID?
|
||||
@@ -35,7 +38,8 @@ public struct HistoryFeatureView: View {
|
||||
public var body: some View {
|
||||
AtlasScreen(
|
||||
title: AtlasL10n.string("history.screen.title"),
|
||||
subtitle: AtlasL10n.string("history.screen.subtitle")
|
||||
subtitle: AtlasL10n.string("history.screen.subtitle"),
|
||||
maxContentWidth: AtlasLayout.maxWorkspaceWidth
|
||||
) {
|
||||
AtlasCallout(
|
||||
title: screenCalloutTitle,
|
||||
@@ -44,7 +48,7 @@ public struct HistoryFeatureView: View {
|
||||
systemImage: screenCalloutSystemImage
|
||||
)
|
||||
|
||||
LazyVGrid(columns: AtlasLayout.metricColumns, spacing: AtlasSpacing.lg) {
|
||||
LazyVGrid(columns: metricColumns, spacing: AtlasSpacing.lg) {
|
||||
AtlasMetricCard(
|
||||
title: AtlasL10n.string("history.metric.activity.title"),
|
||||
value: "\(visibleEventCount)",
|
||||
@@ -78,48 +82,57 @@ public struct HistoryFeatureView: View {
|
||||
tone: browserTone
|
||||
) {
|
||||
VStack(alignment: .leading, spacing: AtlasSpacing.xl) {
|
||||
HStack(alignment: .center, spacing: AtlasSpacing.lg) {
|
||||
Picker("", selection: $selectedSection) {
|
||||
ForEach(HistoryBrowserSection.allCases) { section in
|
||||
Text(section.title).tag(section)
|
||||
}
|
||||
ViewThatFits(in: .horizontal) {
|
||||
HStack(alignment: .center, spacing: AtlasSpacing.lg) {
|
||||
browserSectionPicker
|
||||
.frame(maxWidth: 320)
|
||||
|
||||
Spacer(minLength: AtlasSpacing.lg)
|
||||
|
||||
Text(browserSummary)
|
||||
.font(AtlasTypography.bodySmall)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.frame(maxWidth: 300)
|
||||
.accessibilityIdentifier("history.sectionPicker")
|
||||
|
||||
Spacer(minLength: AtlasSpacing.lg)
|
||||
VStack(alignment: .leading, spacing: AtlasSpacing.md) {
|
||||
browserSectionPicker
|
||||
|
||||
Text(browserSummary)
|
||||
.font(AtlasTypography.bodySmall)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(browserSummary)
|
||||
.font(AtlasTypography.bodySmall)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
|
||||
GeometryReader { proxy in
|
||||
let isWide = proxy.size.width >= 680
|
||||
let sidebarWidth = min(max(proxy.size.width * 0.3, 210), 270)
|
||||
Group {
|
||||
if isWideBrowserLayout {
|
||||
HStack(alignment: .top, spacing: AtlasSpacing.xl) {
|
||||
browserSidebar
|
||||
.frame(width: sidebarWidth)
|
||||
.frame(maxHeight: .infinity)
|
||||
|
||||
Group {
|
||||
if isWide {
|
||||
HStack(alignment: .top, spacing: AtlasSpacing.xl) {
|
||||
browserSidebar
|
||||
.frame(width: sidebarWidth)
|
||||
.frame(maxHeight: .infinity)
|
||||
detailPanel
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: AtlasSpacing.xl) {
|
||||
browserSidebar
|
||||
.frame(minHeight: 260, maxHeight: 260)
|
||||
detailPanel
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
detailPanel
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
}
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: AtlasSpacing.xl) {
|
||||
browserSidebar
|
||||
.frame(minHeight: 240, idealHeight: 320, maxHeight: 400)
|
||||
detailPanel
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
}
|
||||
.frame(minHeight: 400, maxHeight: .infinity)
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
.frame(minHeight: isWideBrowserLayout ? 460 : nil, alignment: .topLeading)
|
||||
.background(
|
||||
GeometryReader { proxy in
|
||||
Color.clear.preference(key: HistoryBrowserWidthKey.self, value: proxy.size.width)
|
||||
}
|
||||
)
|
||||
.onPreferenceChange(HistoryBrowserWidthKey.self) { newWidth in
|
||||
if newWidth > 0 { browserWidth = newWidth }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -154,6 +167,23 @@ public struct HistoryFeatureView: View {
|
||||
sortedRecoveryItems.map(\.id)
|
||||
}
|
||||
|
||||
private var effectiveBrowserWidth: CGFloat {
|
||||
let measuredWidth = browserWidth ?? contentWidth
|
||||
return max(measuredWidth, 0)
|
||||
}
|
||||
|
||||
private var isWideBrowserLayout: Bool {
|
||||
effectiveBrowserWidth >= AtlasLayout.browserSplitThreshold
|
||||
}
|
||||
|
||||
private var sidebarWidth: CGFloat {
|
||||
min(max(effectiveBrowserWidth * 0.3, 210), 270)
|
||||
}
|
||||
|
||||
private var metricColumns: [GridItem] {
|
||||
AtlasLayout.adaptiveMetricColumns(for: contentWidth)
|
||||
}
|
||||
|
||||
private var selectedTaskRun: TaskRun? {
|
||||
guard let selectedTaskRunID else {
|
||||
return nil
|
||||
@@ -468,6 +498,16 @@ public struct HistoryFeatureView: View {
|
||||
.overlay(sidebarBorder)
|
||||
}
|
||||
|
||||
private var browserSectionPicker: some View {
|
||||
Picker("", selection: $selectedSection) {
|
||||
ForEach(HistoryBrowserSection.allCases) { section in
|
||||
Text(section.title).tag(section)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.accessibilityIdentifier("history.sectionPicker")
|
||||
}
|
||||
|
||||
private var detailPanel: some View {
|
||||
VStack(alignment: .leading, spacing: AtlasSpacing.lg) {
|
||||
Text(AtlasL10n.string("history.detail.title"))
|
||||
@@ -475,41 +515,47 @@ public struct HistoryFeatureView: View {
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
ScrollView {
|
||||
switch selectedSection {
|
||||
case .archive:
|
||||
if let taskRun = selectedTaskRun {
|
||||
HistoryTaskDetailView(
|
||||
taskRun: taskRun,
|
||||
isLatest: sortedTaskRuns.first?.id == taskRun.id
|
||||
)
|
||||
} else {
|
||||
AtlasEmptyState(
|
||||
title: AtlasL10n.string("history.detail.empty.title"),
|
||||
detail: AtlasL10n.string("history.detail.empty.detail"),
|
||||
systemImage: "cursorarrow.click",
|
||||
tone: .neutral
|
||||
)
|
||||
}
|
||||
case .recovery:
|
||||
if let item = selectedRecoveryItem {
|
||||
HistoryRecoveryDetailView(
|
||||
item: item,
|
||||
isRestoring: restoringItemID == item.id,
|
||||
canRestore: restoringItemID == nil,
|
||||
onRestore: { onRestoreItem(item.id) }
|
||||
)
|
||||
} else {
|
||||
AtlasEmptyState(
|
||||
title: AtlasL10n.string("history.detail.empty.title"),
|
||||
detail: AtlasL10n.string("history.detail.empty.detail"),
|
||||
systemImage: "cursorarrow.click",
|
||||
tone: .neutral
|
||||
)
|
||||
Group {
|
||||
switch selectedSection {
|
||||
case .archive:
|
||||
if let taskRun = selectedTaskRun {
|
||||
HistoryTaskDetailView(
|
||||
taskRun: taskRun,
|
||||
isLatest: sortedTaskRuns.first?.id == taskRun.id
|
||||
)
|
||||
} else {
|
||||
AtlasEmptyState(
|
||||
title: AtlasL10n.string("history.detail.empty.title"),
|
||||
detail: AtlasL10n.string("history.detail.empty.detail"),
|
||||
systemImage: "cursorarrow.click",
|
||||
tone: .neutral
|
||||
)
|
||||
}
|
||||
case .recovery:
|
||||
if let item = selectedRecoveryItem {
|
||||
HistoryRecoveryDetailView(
|
||||
item: item,
|
||||
isRestoring: restoringItemID == item.id,
|
||||
canRestore: restoringItemID == nil,
|
||||
onRestore: { onRestoreItem(item.id) }
|
||||
)
|
||||
} else {
|
||||
AtlasEmptyState(
|
||||
title: AtlasL10n.string("history.detail.empty.title"),
|
||||
detail: AtlasL10n.string("history.detail.empty.detail"),
|
||||
systemImage: "cursorarrow.click",
|
||||
tone: .neutral
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.2), value: selectedTaskRunID)
|
||||
.animation(.easeInOut(duration: 0.2), value: selectedRecoveryItemID)
|
||||
.animation(.easeInOut(duration: 0.2), value: selectedSection)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.padding(AtlasSpacing.xl)
|
||||
.background(detailBackground)
|
||||
.overlay(detailBorder)
|
||||
@@ -804,28 +850,17 @@ private struct HistoryTaskDetailView: View {
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: AtlasSpacing.xl) {
|
||||
HStack(alignment: .top, spacing: AtlasSpacing.lg) {
|
||||
VStack(alignment: .leading, spacing: AtlasSpacing.xs) {
|
||||
HStack(spacing: AtlasSpacing.sm) {
|
||||
Text(taskRun.kind.title)
|
||||
.font(AtlasTypography.sectionTitle)
|
||||
|
||||
if isLatest {
|
||||
Text(AtlasL10n.string("history.timeline.latest"))
|
||||
.font(AtlasTypography.caption)
|
||||
.foregroundStyle(AtlasColor.brand)
|
||||
}
|
||||
}
|
||||
|
||||
Text(taskRun.summary)
|
||||
.font(AtlasTypography.body)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
ViewThatFits(in: .horizontal) {
|
||||
HStack(alignment: .top, spacing: AtlasSpacing.lg) {
|
||||
taskHeaderCopy
|
||||
Spacer(minLength: AtlasSpacing.lg)
|
||||
AtlasStatusChip(taskRun.status.title, tone: taskRun.status.atlasTone)
|
||||
}
|
||||
|
||||
Spacer(minLength: AtlasSpacing.lg)
|
||||
|
||||
AtlasStatusChip(taskRun.status.title, tone: taskRun.status.atlasTone)
|
||||
VStack(alignment: .leading, spacing: AtlasSpacing.md) {
|
||||
taskHeaderCopy
|
||||
AtlasStatusChip(taskRun.status.title, tone: taskRun.status.atlasTone)
|
||||
}
|
||||
}
|
||||
|
||||
AtlasCallout(
|
||||
@@ -855,6 +890,26 @@ private struct HistoryTaskDetailView: View {
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
private var taskHeaderCopy: some View {
|
||||
VStack(alignment: .leading, spacing: AtlasSpacing.xs) {
|
||||
HStack(spacing: AtlasSpacing.sm) {
|
||||
Text(taskRun.kind.title)
|
||||
.font(AtlasTypography.sectionTitle)
|
||||
|
||||
if isLatest {
|
||||
Text(AtlasL10n.string("history.timeline.latest"))
|
||||
.font(AtlasTypography.caption)
|
||||
.foregroundStyle(AtlasColor.brand)
|
||||
}
|
||||
}
|
||||
|
||||
Text(taskRun.summary)
|
||||
.font(AtlasTypography.body)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct HistoryRecoveryDetailView: View {
|
||||
@@ -865,30 +920,17 @@ private struct HistoryRecoveryDetailView: View {
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: AtlasSpacing.xl) {
|
||||
HStack(alignment: .top, spacing: AtlasSpacing.lg) {
|
||||
VStack(alignment: .leading, spacing: AtlasSpacing.xs) {
|
||||
Text(item.title)
|
||||
.font(AtlasTypography.sectionTitle)
|
||||
|
||||
Text(item.detail)
|
||||
.font(AtlasTypography.body)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
ViewThatFits(in: .horizontal) {
|
||||
HStack(alignment: .top, spacing: AtlasSpacing.lg) {
|
||||
recoveryHeaderCopy
|
||||
Spacer(minLength: AtlasSpacing.lg)
|
||||
recoveryHeaderMeta
|
||||
}
|
||||
|
||||
Spacer(minLength: AtlasSpacing.lg)
|
||||
|
||||
VStack(alignment: .trailing, spacing: AtlasSpacing.sm) {
|
||||
AtlasStatusChip(
|
||||
item.isExpiringSoon
|
||||
? AtlasL10n.string("history.recovery.badge.expiring")
|
||||
: AtlasL10n.string("history.recovery.badge.available"),
|
||||
tone: item.isExpiringSoon ? .warning : .success
|
||||
)
|
||||
|
||||
Text(AtlasFormatters.byteCount(item.bytes))
|
||||
.font(AtlasTypography.label)
|
||||
.foregroundStyle(.secondary)
|
||||
VStack(alignment: .leading, spacing: AtlasSpacing.md) {
|
||||
recoveryHeaderCopy
|
||||
recoveryHeaderMeta
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -943,20 +985,57 @@ private struct HistoryRecoveryDetailView: View {
|
||||
.strokeBorder(AtlasColor.border, lineWidth: 1)
|
||||
)
|
||||
|
||||
HStack(alignment: .center, spacing: AtlasSpacing.md) {
|
||||
Spacer(minLength: 0)
|
||||
ViewThatFits(in: .horizontal) {
|
||||
HStack(alignment: .center, spacing: AtlasSpacing.md) {
|
||||
Spacer(minLength: 0)
|
||||
|
||||
Button(isRestoring ? AtlasL10n.string("history.restore.running") : AtlasL10n.string("history.restore.action")) {
|
||||
onRestore()
|
||||
restoreButton
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: AtlasSpacing.md) {
|
||||
restoreButton
|
||||
}
|
||||
.buttonStyle(.atlasPrimary)
|
||||
.disabled(!canRestore)
|
||||
.accessibilityIdentifier("history.restore.\(item.id.uuidString)")
|
||||
.accessibilityHint(AtlasL10n.string("history.restore.hint"))
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
private var recoveryHeaderCopy: some View {
|
||||
VStack(alignment: .leading, spacing: AtlasSpacing.xs) {
|
||||
Text(item.title)
|
||||
.font(AtlasTypography.sectionTitle)
|
||||
|
||||
Text(item.detail)
|
||||
.font(AtlasTypography.body)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
|
||||
private var recoveryHeaderMeta: some View {
|
||||
VStack(alignment: .trailing, spacing: AtlasSpacing.sm) {
|
||||
AtlasStatusChip(
|
||||
item.isExpiringSoon
|
||||
? AtlasL10n.string("history.recovery.badge.expiring")
|
||||
: AtlasL10n.string("history.recovery.badge.available"),
|
||||
tone: item.isExpiringSoon ? .warning : .success
|
||||
)
|
||||
|
||||
Text(AtlasFormatters.byteCount(item.bytes))
|
||||
.font(AtlasTypography.label)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private var restoreButton: some View {
|
||||
Button(isRestoring ? AtlasL10n.string("history.restore.running") : AtlasL10n.string("history.restore.action")) {
|
||||
onRestore()
|
||||
}
|
||||
.buttonStyle(.atlasPrimary)
|
||||
.disabled(!canRestore)
|
||||
.accessibilityIdentifier("history.restore.\(item.id.uuidString)")
|
||||
.accessibilityHint(AtlasL10n.string("history.restore.hint"))
|
||||
}
|
||||
}
|
||||
|
||||
private extension TaskRun {
|
||||
@@ -1076,3 +1155,10 @@ private extension TaskStatus {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct HistoryBrowserWidthKey: PreferenceKey {
|
||||
static var defaultValue: CGFloat = 0
|
||||
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
|
||||
value = nextValue()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import AtlasDomain
|
||||
import SwiftUI
|
||||
|
||||
public struct SmartCleanFeatureView: View {
|
||||
@Environment(\.atlasContentWidth) private var contentWidth
|
||||
|
||||
private let findings: [Finding]
|
||||
private let plan: ActionPlan
|
||||
private let scanSummary: String
|
||||
@@ -49,7 +51,8 @@ public struct SmartCleanFeatureView: View {
|
||||
public var body: some View {
|
||||
AtlasScreen(
|
||||
title: AtlasL10n.string("smartclean.screen.title"),
|
||||
subtitle: AtlasL10n.string("smartclean.screen.subtitle")
|
||||
subtitle: AtlasL10n.string("smartclean.screen.subtitle"),
|
||||
maxContentWidth: AtlasLayout.maxWorkflowWidth
|
||||
) {
|
||||
AtlasCallout(
|
||||
title: statusTitle,
|
||||
@@ -106,7 +109,7 @@ public struct SmartCleanFeatureView: View {
|
||||
}
|
||||
}
|
||||
|
||||
LazyVGrid(columns: AtlasLayout.metricColumns, spacing: AtlasSpacing.lg) {
|
||||
LazyVGrid(columns: metricColumns, spacing: AtlasSpacing.lg) {
|
||||
AtlasMetricCard(
|
||||
title: AtlasL10n.string("smartclean.metric.previewSize.title"),
|
||||
value: AtlasFormatters.byteCount(resolvedPlanEstimatedBytes),
|
||||
@@ -222,6 +225,10 @@ public struct SmartCleanFeatureView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var metricColumns: [GridItem] {
|
||||
AtlasLayout.adaptiveMetricColumns(for: contentWidth)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func riskSection(_ risk: RiskLevel) -> some View {
|
||||
let items = findings.filter { $0.risk == risk }
|
||||
@@ -595,4 +602,3 @@ private enum SmartCleanPrimaryAction: Equatable {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,10 +11,10 @@ public struct StorageFeatureView: View {
|
||||
|
||||
public var body: some View {
|
||||
AtlasScreen(
|
||||
title: "Storage",
|
||||
subtitle: "Reserved list-based storage views for a future scope decision beyond the frozen MVP shell."
|
||||
title: AtlasL10n.string("storage.screen.title"),
|
||||
subtitle: AtlasL10n.string("storage.screen.subtitle")
|
||||
) {
|
||||
AtlasInfoCard(title: "Large Items") {
|
||||
AtlasInfoCard(title: AtlasL10n.string("storage.largeItems.title")) {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
ForEach(insights) { insight in
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
|
||||
Reference in New Issue
Block a user