feat(design-system): adaptive grid layout, content width env, and UI polish
Fix AtlasSpacing.screenH value (24 -> 28). Add adaptiveMetricColumns for responsive grid layouts and atlasContentWidth environment key. Enhance AtlasEmptyState with optional action button. Brand-tint ProgressView controls. Add AtlasPresentationHelpers. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -135,7 +135,7 @@ public enum AtlasSpacing {
|
||||
/// 24pt — screen-level vertical rhythm.
|
||||
public static let xxl: CGFloat = 24
|
||||
/// 28pt — screen horizontal margin.
|
||||
public static let screenH: CGFloat = 24
|
||||
public static let screenH: CGFloat = 28
|
||||
/// 32pt — large section separation.
|
||||
public static let section: CGFloat = 32
|
||||
}
|
||||
@@ -244,6 +244,45 @@ public enum AtlasLayout {
|
||||
public static let sidebarIdealWidth: CGFloat = 220
|
||||
/// Sidebar icon container size (pill-style like System Settings).
|
||||
public static let sidebarIconSize: CGFloat = 32
|
||||
|
||||
/// Returns an adaptive column layout based on available width.
|
||||
/// - 3 columns for widths >= 640
|
||||
/// - 2 columns for widths >= 420
|
||||
/// - 1 column for narrower widths
|
||||
public static func adaptiveMetricColumns(for width: CGFloat) -> [GridItem] {
|
||||
let spacing = AtlasSpacing.lg
|
||||
switch width {
|
||||
case 640...:
|
||||
return [
|
||||
GridItem(.flexible(minimum: 180), spacing: spacing),
|
||||
GridItem(.flexible(minimum: 180), spacing: spacing),
|
||||
GridItem(.flexible(minimum: 180), spacing: spacing),
|
||||
]
|
||||
case 420...:
|
||||
return [
|
||||
GridItem(.flexible(minimum: 180), spacing: spacing),
|
||||
GridItem(.flexible(minimum: 180), spacing: spacing),
|
||||
]
|
||||
default:
|
||||
return [
|
||||
GridItem(.flexible(minimum: 180), spacing: spacing),
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Content Width Environment Key
|
||||
|
||||
private struct AtlasContentWidthKey: EnvironmentKey {
|
||||
static let defaultValue: CGFloat = 920
|
||||
}
|
||||
|
||||
public extension EnvironmentValues {
|
||||
/// The actual content width injected by `AtlasScreen`.
|
||||
var atlasContentWidth: CGFloat {
|
||||
get { self[AtlasContentWidthKey.self] }
|
||||
set { self[AtlasContentWidthKey.self] = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Icon Tokens
|
||||
|
||||
@@ -90,18 +90,20 @@ public struct AtlasScreen<Content: View>: View {
|
||||
Group {
|
||||
if useScrollView {
|
||||
ScrollView {
|
||||
contentStack(horizontalPadding: horizontalPadding)
|
||||
contentStack(horizontalPadding: horizontalPadding, containerWidth: proxy.size.width)
|
||||
}
|
||||
} else {
|
||||
contentStack(horizontalPadding: horizontalPadding)
|
||||
contentStack(horizontalPadding: horizontalPadding, containerWidth: proxy.size.width)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func contentStack(horizontalPadding: CGFloat) -> some View {
|
||||
VStack(alignment: .leading, spacing: AtlasSpacing.xxl) {
|
||||
private func contentStack(horizontalPadding: CGFloat, containerWidth: CGFloat) -> some View {
|
||||
let contentWidth = min(AtlasLayout.maxReadingWidth, containerWidth - horizontalPadding * 2)
|
||||
|
||||
return VStack(alignment: .leading, spacing: AtlasSpacing.xxl) {
|
||||
header
|
||||
content
|
||||
}
|
||||
@@ -109,6 +111,7 @@ public struct AtlasScreen<Content: View>: View {
|
||||
.padding(.horizontal, horizontalPadding)
|
||||
.padding(.vertical, AtlasSpacing.xxl)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.environment(\.atlasContentWidth, max(0, contentWidth))
|
||||
}
|
||||
|
||||
private func resolvedHorizontalPadding(for width: CGFloat) -> CGFloat {
|
||||
@@ -468,12 +471,16 @@ public struct AtlasEmptyState: View {
|
||||
private let detail: String
|
||||
private let systemImage: String
|
||||
private let tone: AtlasTone
|
||||
private let actionTitle: String?
|
||||
private let onAction: (() -> Void)?
|
||||
|
||||
public init(title: String, detail: String, systemImage: String, tone: AtlasTone = .neutral) {
|
||||
public init(title: String, detail: String, systemImage: String, tone: AtlasTone = .neutral, actionTitle: String? = nil, onAction: (() -> Void)? = nil) {
|
||||
self.title = title
|
||||
self.detail = detail
|
||||
self.systemImage = systemImage
|
||||
self.tone = tone
|
||||
self.actionTitle = actionTitle
|
||||
self.onAction = onAction
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
@@ -509,6 +516,13 @@ public struct AtlasEmptyState: View {
|
||||
.multilineTextAlignment(.center)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
if let actionTitle, let onAction {
|
||||
Button(actionTitle) {
|
||||
onAction()
|
||||
}
|
||||
.buttonStyle(.atlasSecondary)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(AtlasSpacing.section)
|
||||
@@ -520,7 +534,7 @@ public struct AtlasEmptyState: View {
|
||||
RoundedRectangle(cornerRadius: AtlasRadius.xl, style: .continuous)
|
||||
.strokeBorder(Color.primary.opacity(0.06), lineWidth: 1)
|
||||
)
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityElement(children: onAction != nil ? .contain : .ignore)
|
||||
.accessibilityLabel(Text(title))
|
||||
.accessibilityValue(Text(detail))
|
||||
}
|
||||
@@ -543,6 +557,7 @@ public struct AtlasLoadingState: View {
|
||||
HStack(spacing: AtlasSpacing.md) {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
.tint(AtlasColor.brand)
|
||||
.accessibilityHidden(true)
|
||||
|
||||
Text(title)
|
||||
@@ -557,6 +572,7 @@ public struct AtlasLoadingState: View {
|
||||
if let progress {
|
||||
ProgressView(value: progress, total: 1)
|
||||
.controlSize(.large)
|
||||
.tint(AtlasColor.brand)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import AtlasDomain
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Shared Domain → UI Mappings
|
||||
//
|
||||
// These public extensions centralise the mapping from domain enums
|
||||
// to AtlasTone and SF Symbol strings so every feature module shares
|
||||
// the same visual language without duplicating private extensions.
|
||||
|
||||
public extension TaskStatus {
|
||||
var atlasTone: AtlasTone {
|
||||
switch self {
|
||||
case .queued:
|
||||
return .neutral
|
||||
case .running:
|
||||
return .warning
|
||||
case .completed:
|
||||
return .success
|
||||
case .failed, .cancelled:
|
||||
return .danger
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public extension RiskLevel {
|
||||
var atlasTone: AtlasTone {
|
||||
switch self {
|
||||
case .safe:
|
||||
return .success
|
||||
case .review:
|
||||
return .warning
|
||||
case .advanced:
|
||||
return .danger
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public extension TaskKind {
|
||||
var atlasSystemImage: String {
|
||||
switch self {
|
||||
case .scan:
|
||||
return "sparkles"
|
||||
case .executePlan:
|
||||
return "play.circle"
|
||||
case .uninstallApp:
|
||||
return "trash"
|
||||
case .restore:
|
||||
return "arrow.uturn.backward.circle"
|
||||
case .inspectPermissions:
|
||||
return "lock.shield"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public extension ActionItem.Kind {
|
||||
var atlasSystemImage: String {
|
||||
switch self {
|
||||
case .removeCache:
|
||||
return "trash"
|
||||
case .removeApp:
|
||||
return "app.badge.minus"
|
||||
case .archiveFile:
|
||||
return "archivebox"
|
||||
case .inspectPermission:
|
||||
return "lock.shield"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum AtlasCategoryIcon {
|
||||
public static func systemImage(for category: String) -> String {
|
||||
switch category.lowercased() {
|
||||
case "developer":
|
||||
return "hammer"
|
||||
case "system":
|
||||
return "gearshape.2"
|
||||
case "apps":
|
||||
return "square.stack.3d.up"
|
||||
case "browsers":
|
||||
return "globe"
|
||||
default:
|
||||
return "sparkles"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user