release(ios): 发布 0.2.1 并重构图标与界面细节
- 应用图标改用与 Web favicon 同源的扁平标记,金色 SIM 卡体加三格递增信号,29pt 下仍可读 - 标签统一为胶囊形状,提高深色主题下的描边权重,避免标签在暗色下消失 - 消息卡片与详情分区卡片收紧行距,单屏信息密度明显提升 - 详情头部指标精简为 4 行,数据模块改为单条读数 - 批量选择勾选框移出名称区域,工具栏控件统一为等高 34pt - 分组编辑器改为点选即生效,移除说明文字与常驻禁用按钮
This commit is contained in:
@@ -25,7 +25,7 @@ public struct AICard<Content: View>: View {
|
||||
)
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AITheme.cardRadius, style: .continuous))
|
||||
.shadow(color: .black.opacity(0.07), radius: 18, x: 0, y: 8)
|
||||
.shadow(color: AITheme.cardShadow, radius: 14, x: 0, y: 6)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: AITheme.cardRadius, style: .continuous)
|
||||
.strokeBorder(AITheme.line)
|
||||
@@ -61,12 +61,14 @@ public struct AITag: View {
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
.padding(.horizontal, 10)
|
||||
.frame(minHeight: 30)
|
||||
.background(tint.opacity(0.10))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||
// 与节点卡片上的标签胶囊同一形状,同一高度;深色底上 10% 色雾几乎看不见,
|
||||
// 所以描边权重必须够,不能让标签在暗色主题里消失。
|
||||
.frame(height: 28)
|
||||
.background(tint.opacity(0.14))
|
||||
.clipShape(Capsule(style: .continuous))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.strokeBorder(tint.opacity(0.16))
|
||||
Capsule(style: .continuous)
|
||||
.strokeBorder(tint.opacity(0.30))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -127,7 +129,6 @@ public struct AISectionTitle: View {
|
||||
.accessibilityHidden(true)
|
||||
Text(title)
|
||||
.font(.headline.weight(.semibold))
|
||||
.kerning(-0.1)
|
||||
.foregroundStyle(AITheme.ink)
|
||||
.lineLimit(1)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
@@ -158,6 +159,41 @@ public struct AIFieldLabel: View {
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
/// 卡片内的二级面板:极浅底色 + 更浅的描边,只做"分组",不抢主信息的对比度。
|
||||
func aiInsetSurface(cornerRadius: CGFloat = 12) -> some View {
|
||||
self
|
||||
.background(AITheme.tile)
|
||||
.clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
|
||||
.strokeBorder(AITheme.tileLine)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 带语义色的内嵌指标块:底色/描边都由 tint 决定,浅色下是一层很淡的色洗。
|
||||
struct AITintTile<Content: View>: View {
|
||||
private let tint: Color
|
||||
private let content: Content
|
||||
|
||||
init(tint: Color, @ViewBuilder content: () -> Content) {
|
||||
self.tint = tint
|
||||
self.content = content()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
content
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(tint.opacity(0.09))
|
||||
.clipShape(RoundedRectangle(cornerRadius: AITheme.tileRadius, style: .continuous))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: AITheme.tileRadius, style: .continuous)
|
||||
.strokeBorder(tint.opacity(0.20))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct AIStatCard: View {
|
||||
private let title: String
|
||||
private let value: String
|
||||
@@ -180,33 +216,36 @@ public struct AIStatCard: View {
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
AICard(padding: 12) {
|
||||
HStack(alignment: .center, spacing: 10) {
|
||||
if let systemImage {
|
||||
Image(systemName: systemImage)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(tint)
|
||||
.frame(width: 32, height: 32)
|
||||
.background(tint.opacity(0.10))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title)
|
||||
.font(.caption2.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.85)
|
||||
.foregroundStyle(AITheme.muted)
|
||||
Text(value)
|
||||
.font(.headline.weight(.semibold))
|
||||
.foregroundStyle(value == "0" || value.hasPrefix("0/") ? AITheme.muted : tint)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.75)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
// 卡内不再叠一张卡:双层阴影 + 双层描边是浅色发灰的主因。
|
||||
HStack(alignment: .center, spacing: 10) {
|
||||
if let systemImage {
|
||||
Image(systemName: systemImage)
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(tint)
|
||||
.frame(width: 30, height: 30)
|
||||
.background(tint.opacity(0.12))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 9, style: .continuous))
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title)
|
||||
.font(.caption2.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.85)
|
||||
.foregroundStyle(AITheme.muted)
|
||||
Text(value)
|
||||
.font(.headline.weight(.semibold))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(value == "0" || value.hasPrefix("0/") ? AITheme.muted : tint)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.75)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.padding(12)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.aiInsetSurface(cornerRadius: AITheme.tileRadius)
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -247,11 +286,34 @@ public struct AIEmptyState: View {
|
||||
private let systemImage: String
|
||||
private let title: String
|
||||
private let message: String
|
||||
private let actionTitle: String?
|
||||
private let actionSystemImage: String
|
||||
private let action: (() -> Void)?
|
||||
|
||||
public init(systemImage: String, title: String, message: String) {
|
||||
self.systemImage = systemImage
|
||||
self.title = title
|
||||
self.message = message
|
||||
self.actionTitle = nil
|
||||
self.actionSystemImage = "arrow.clockwise"
|
||||
self.action = nil
|
||||
}
|
||||
|
||||
/// 失败/空态带一个就地重试入口,避免"红色横幅 + 暂无数据"互相矛盾。
|
||||
public init(
|
||||
systemImage: String,
|
||||
title: String,
|
||||
message: String,
|
||||
actionTitle: String,
|
||||
actionSystemImage: String = "arrow.clockwise",
|
||||
action: @escaping () -> Void
|
||||
) {
|
||||
self.systemImage = systemImage
|
||||
self.title = title
|
||||
self.message = message
|
||||
self.actionTitle = actionTitle
|
||||
self.actionSystemImage = actionSystemImage
|
||||
self.action = action
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
@@ -271,6 +333,13 @@ public struct AIEmptyState: View {
|
||||
.foregroundStyle(AITheme.muted)
|
||||
.multilineTextAlignment(.center)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
if let actionTitle, let action {
|
||||
Button(action: action) {
|
||||
Label(actionTitle, systemImage: actionSystemImage)
|
||||
}
|
||||
.buttonStyle(AIButtonStyle(kind: .secondary, size: .compact))
|
||||
.padding(.top, 4)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 16)
|
||||
@@ -375,7 +444,7 @@ public struct AILoadingCard: View {
|
||||
)
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AITheme.cardRadius, style: .continuous))
|
||||
.shadow(color: .black.opacity(0.07), radius: 18, x: 0, y: 8)
|
||||
.shadow(color: AITheme.brand.opacity(0.08), radius: 20, x: 0, y: 10)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: AITheme.cardRadius, style: .continuous)
|
||||
.strokeBorder(AITheme.line)
|
||||
@@ -460,11 +529,13 @@ public struct AIKeyValueRow: View {
|
||||
private let title: String
|
||||
private let value: String
|
||||
private let valueTint: Color
|
||||
private let monospaced: Bool
|
||||
|
||||
public init(_ title: String, value: String, tint: Color = AITheme.ink) {
|
||||
public init(_ title: String, value: String, tint: Color = AITheme.ink, monospaced: Bool = false) {
|
||||
self.title = title
|
||||
self.value = value
|
||||
self.valueTint = tint
|
||||
self.monospaced = monospaced
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
@@ -476,7 +547,12 @@ public struct AIKeyValueRow: View {
|
||||
.layoutPriority(1)
|
||||
Spacer(minLength: 12)
|
||||
Text(value)
|
||||
.font(.subheadline.weight(.medium))
|
||||
.font(
|
||||
monospaced
|
||||
? Font.system(.subheadline, design: .monospaced, weight: .medium)
|
||||
: Font.subheadline.weight(.medium)
|
||||
)
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(valueTint)
|
||||
.multilineTextAlignment(.trailing)
|
||||
.lineLimit(2)
|
||||
@@ -505,7 +581,7 @@ public struct AISegmentedControl<Value: Hashable>: View {
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
HStack(spacing: 4) {
|
||||
HStack(spacing: 3) {
|
||||
ForEach(options, id: \.value) { option in
|
||||
let isSelected = selection == option.value
|
||||
|
||||
@@ -513,22 +589,16 @@ public struct AISegmentedControl<Value: Hashable>: View {
|
||||
selection = option.value
|
||||
} label: {
|
||||
Text(option.title)
|
||||
.font(.body.weight(isSelected ? .semibold : .medium))
|
||||
.font(.subheadline.weight(isSelected ? .semibold : .medium))
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.75)
|
||||
.foregroundStyle(isSelected ? AITheme.onPrimary : AITheme.muted)
|
||||
.foregroundStyle(isSelected ? AITheme.ink : AITheme.muted)
|
||||
.frame(maxWidth: .infinity, minHeight: AITheme.segmentedControlHeight)
|
||||
// 分段控件是"视图切换"而不是主操作:抬起的中性块,避免和标签筛选抢注意力。
|
||||
.background {
|
||||
if isSelected {
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [AITheme.primaryTop, AITheme.primaryBottom],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
)
|
||||
}
|
||||
RoundedRectangle(cornerRadius: 11, style: .continuous)
|
||||
.fill(isSelected ? AITheme.segmentSelected : Color.clear)
|
||||
.shadow(color: isSelected ? AITheme.cardShadow : .clear, radius: 3, x: 0, y: 1)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
@@ -537,12 +607,12 @@ public struct AISegmentedControl<Value: Hashable>: View {
|
||||
.accessibilityAddTraits(isSelected ? .isSelected : [])
|
||||
}
|
||||
}
|
||||
.padding(4)
|
||||
.background(AITheme.control)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||
.padding(3)
|
||||
.background(AITheme.segmentTrack)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
.strokeBorder(AITheme.line)
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.strokeBorder(AITheme.tileLine)
|
||||
}
|
||||
.animation(.easeOut(duration: 0.16), value: selection)
|
||||
.accessibilityElement(children: .contain)
|
||||
@@ -567,15 +637,18 @@ public struct AISearchField: View {
|
||||
private let placeholder: String
|
||||
@Binding private var text: String
|
||||
private var onSubmit: (() -> Void)?
|
||||
private let minHeight: CGFloat
|
||||
@FocusState private var isFocused: Bool
|
||||
|
||||
public init(
|
||||
_ placeholder: String,
|
||||
text: Binding<String>,
|
||||
minHeight: CGFloat = AITheme.buttonHeight,
|
||||
onSubmit: (() -> Void)? = nil
|
||||
) {
|
||||
self.placeholder = placeholder
|
||||
self._text = text
|
||||
self.minHeight = minHeight
|
||||
self.onSubmit = onSubmit
|
||||
}
|
||||
|
||||
@@ -607,7 +680,7 @@ public struct AISearchField: View {
|
||||
.accessibilityLabel("清除搜索")
|
||||
}
|
||||
}
|
||||
.aiInputSurface(minHeight: AITheme.buttonHeight, isFocused: isFocused)
|
||||
.aiInputSurface(minHeight: minHeight, isFocused: isFocused)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -651,14 +724,14 @@ extension View {
|
||||
}
|
||||
}
|
||||
|
||||
func aiSelectSurface() -> some View {
|
||||
func aiSelectSurface(compact: Bool = false) -> some View {
|
||||
self
|
||||
.tint(AITheme.ink)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.font(compact ? .footnote.weight(.semibold) : .subheadline.weight(.semibold))
|
||||
.foregroundStyle(AITheme.ink)
|
||||
.labelsHidden()
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.aiInputSurface()
|
||||
.aiInputSurface(minHeight: compact ? AITheme.compactControlHeight : AITheme.inputHeight)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -727,3 +800,45 @@ public struct AIIconButton: View {
|
||||
.accessibilityLabel(title)
|
||||
}
|
||||
}
|
||||
|
||||
/// 列表行内的轻量图标动作:同色淡底小方块,触区仍补足 44pt。
|
||||
/// 行内操作用实心药丸会把一行读成"按钮墙",这里让颜色只表达危险程度。
|
||||
public struct AISymbolAction: View {
|
||||
private let title: String
|
||||
private let systemImage: String
|
||||
private let tint: Color
|
||||
private let action: () -> Void
|
||||
|
||||
public init(
|
||||
_ title: String,
|
||||
systemImage: String,
|
||||
tint: Color = AITheme.muted,
|
||||
action: @escaping () -> Void
|
||||
) {
|
||||
self.title = title
|
||||
self.systemImage = systemImage
|
||||
self.tint = tint
|
||||
self.action = action
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
Button {
|
||||
action()
|
||||
} label: {
|
||||
Image(systemName: systemImage)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(tint)
|
||||
.frame(width: AITheme.iconButtonSize, height: AITheme.iconButtonSize)
|
||||
.background(tint.opacity(0.10))
|
||||
.clipShape(RoundedRectangle(cornerRadius: AITheme.iconButtonRadius, style: .continuous))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: AITheme.iconButtonRadius, style: .continuous)
|
||||
.strokeBorder(tint.opacity(0.18))
|
||||
}
|
||||
.frame(width: 44, height: 44)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(title)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user