release(ios): 发布 0.2.1 并重构图标与界面细节

- 应用图标改用与 Web favicon 同源的扁平标记,金色 SIM 卡体加三格递增信号,29pt 下仍可读
- 标签统一为胶囊形状,提高深色主题下的描边权重,避免标签在暗色下消失
- 消息卡片与详情分区卡片收紧行距,单屏信息密度明显提升
- 详情头部指标精简为 4 行,数据模块改为单条读数
- 批量选择勾选框移出名称区域,工具栏控件统一为等高 34pt
- 分组编辑器改为点选即生效,移除说明文字与常驻禁用按钮
This commit is contained in:
chick
2026-09-14 16:30:39 +08:00
parent 27687868ef
commit 3607eddc9f
34 changed files with 2656 additions and 1213 deletions
+172 -57
View File
@@ -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)
}
}