- AITag 拆为两类:不传状态色时是中性归类胶囊(tile 底、发丝描边、墨字,可带 10pt 图标), 传状态色时才用 14% 色雾与 30% 描边。颜色只服务状态语义。 - 详情页标签改用 AITag(tag, systemImage: "tag"),与节点卡片同一枚形状;头部 VStack 间距 14 -> 12,读数格 minHeight 62 -> 56,加载完成后不再明显小于下方卡片。 - 消息中心「收件 / 发件」由绿/墨配色改回中性胶囊配 arrow.down.left / arrow.up.right, 绿色重新只表示健康状态。 - 批量标签表在无勾选时给出「先在节点列表勾选要归类的节点」,不再留空白行。 - 文档:0.2.2 增量记录与校验值更新为 0e4ffb74…;版本号后不再带括号, 0.2.0/0.2.1/0.2.2 与 README、现行说明统一改写为「0.2.x 构建 N」。
858 lines
24 KiB
Swift
858 lines
24 KiB
Swift
import SwiftUI
|
|
|
|
public struct AICard<Content: View>: View {
|
|
private let content: Content
|
|
private let padding: CGFloat
|
|
private let emphasis: Bool
|
|
|
|
public init(
|
|
padding: CGFloat = 16,
|
|
emphasis: Bool = false,
|
|
@ViewBuilder content: () -> Content
|
|
) {
|
|
self.padding = padding
|
|
self.emphasis = emphasis
|
|
self.content = content()
|
|
}
|
|
|
|
public var body: some View {
|
|
content
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.padding(padding)
|
|
.background(
|
|
AICard.backgroundGradient(
|
|
raisedTop: emphasis
|
|
)
|
|
)
|
|
.clipShape(RoundedRectangle(cornerRadius: AITheme.cardRadius, style: .continuous))
|
|
.shadow(color: AITheme.cardShadow, radius: 14, x: 0, y: 6)
|
|
.overlay {
|
|
RoundedRectangle(cornerRadius: AITheme.cardRadius, style: .continuous)
|
|
.strokeBorder(AITheme.line)
|
|
}
|
|
}
|
|
}
|
|
|
|
extension AICard {
|
|
fileprivate static func backgroundGradient(raisedTop: Bool) -> LinearGradient {
|
|
LinearGradient(
|
|
colors: raisedTop
|
|
? [AITheme.surfaceRaised, AITheme.surfaceRaised.opacity(0.94)]
|
|
: [AITheme.surfaceRaised, AITheme.surface],
|
|
startPoint: .top,
|
|
endPoint: .bottom
|
|
)
|
|
}
|
|
}
|
|
|
|
public struct AITag: View {
|
|
private let text: String
|
|
private let tint: Color?
|
|
private let systemImage: String?
|
|
|
|
/// 不传 `tint` 时是中性归类标签(标签、方向这类不带健康含义的信息),
|
|
/// 传了 `tint` 才是状态标签,用同色色雾与描边。颜色只留给后者。
|
|
public init(_ text: String, tint: Color? = nil, systemImage: String? = nil) {
|
|
self.text = text
|
|
self.tint = tint
|
|
self.systemImage = systemImage
|
|
}
|
|
|
|
public var body: some View {
|
|
let tint = self.tint
|
|
HStack(spacing: 4) {
|
|
if let systemImage {
|
|
Image(systemName: systemImage)
|
|
.font(.system(size: 10, weight: .semibold))
|
|
.accessibilityHidden(true)
|
|
}
|
|
Text(text)
|
|
.font(.caption2.weight(.semibold))
|
|
.lineLimit(1)
|
|
.truncationMode(.middle)
|
|
}
|
|
.padding(.horizontal, 10)
|
|
// 与节点卡片上的标签胶囊同一形状,同一高度;深色底上 10% 色雾几乎看不见,
|
|
// 所以描边权重必须够,不能让标签在暗色主题里消失。
|
|
.frame(height: 28)
|
|
.foregroundStyle(tint == nil ? AITheme.ink : tint!)
|
|
.background(tint.map { $0.opacity(0.14) } ?? AITheme.tile)
|
|
.clipShape(Capsule(style: .continuous))
|
|
.overlay {
|
|
Capsule(style: .continuous)
|
|
.strokeBorder(tint.map { $0.opacity(0.30) } ?? AITheme.tileLine)
|
|
}
|
|
.accessibilityLabel(text)
|
|
}
|
|
}
|
|
|
|
public struct AISectionTitle: View {
|
|
private let title: String
|
|
private let systemImage: String
|
|
private let detail: String?
|
|
private let footer: Footer
|
|
public init(
|
|
_ title: String,
|
|
systemImage: String,
|
|
detail: String? = nil,
|
|
actionTitle: String? = nil,
|
|
actionSystemImage: String? = nil,
|
|
actionKind: AIButtonStyle.Kind = .secondary,
|
|
action: (() -> Void)? = nil
|
|
) {
|
|
self.title = title
|
|
self.systemImage = systemImage
|
|
self.detail = detail
|
|
self.footer = Footer(
|
|
title: actionTitle,
|
|
systemImage: actionSystemImage,
|
|
kind: actionKind,
|
|
action: action
|
|
)
|
|
}
|
|
|
|
private struct Footer: View {
|
|
let title: String?
|
|
let systemImage: String?
|
|
let kind: AIButtonStyle.Kind
|
|
let action: (() -> Void)?
|
|
|
|
var body: some View {
|
|
if let title, let action {
|
|
Button {
|
|
action()
|
|
} label: {
|
|
if let systemImage {
|
|
Label(title, systemImage: systemImage)
|
|
} else {
|
|
Text(title)
|
|
}
|
|
}
|
|
.buttonStyle(AIButtonStyle(kind: kind, size: .compact))
|
|
}
|
|
}
|
|
}
|
|
|
|
public var body: some View {
|
|
HStack(spacing: 8) {
|
|
Image(systemName: systemImage)
|
|
.font(.subheadline.weight(.semibold))
|
|
.foregroundStyle(AITheme.primary)
|
|
.frame(width: 22, height: 22)
|
|
.accessibilityHidden(true)
|
|
Text(title)
|
|
.font(.headline.weight(.semibold))
|
|
.foregroundStyle(AITheme.ink)
|
|
.lineLimit(1)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
if let detail {
|
|
Text(detail)
|
|
.font(.caption.weight(.medium))
|
|
.foregroundStyle(AITheme.muted)
|
|
.lineLimit(1)
|
|
}
|
|
if Footer.self != EmptyView.self {
|
|
footer
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
public struct AIFieldLabel: View {
|
|
private let title: String
|
|
|
|
public init(_ title: String) {
|
|
self.title = title
|
|
}
|
|
|
|
public var body: some View {
|
|
Text(title)
|
|
.font(.subheadline.weight(.semibold))
|
|
.foregroundStyle(AITheme.ink)
|
|
}
|
|
}
|
|
|
|
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
|
|
private let detail: String?
|
|
private let tint: Color
|
|
private let systemImage: String?
|
|
|
|
public init(
|
|
title: String,
|
|
value: String,
|
|
detail: String? = nil,
|
|
systemImage: String? = nil,
|
|
tint: Color
|
|
) {
|
|
self.title = title
|
|
self.value = value
|
|
self.detail = detail
|
|
self.systemImage = systemImage
|
|
self.tint = tint
|
|
}
|
|
|
|
public var body: some View {
|
|
// 卡内不再叠一张卡:双层阴影 + 双层描边是浅色发灰的主因。
|
|
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)
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
|
|
public struct AIStatusBanner: View {
|
|
private let message: String
|
|
private let tint: Color
|
|
private let systemImage: String
|
|
|
|
public init(_ message: String, tint: Color, systemImage: String) {
|
|
self.message = message
|
|
self.tint = tint
|
|
self.systemImage = systemImage
|
|
}
|
|
|
|
public var body: some View {
|
|
HStack(alignment: .top, spacing: 10) {
|
|
Image(systemName: systemImage)
|
|
.font(.subheadline.weight(.semibold))
|
|
.foregroundStyle(tint)
|
|
.accessibilityHidden(true)
|
|
Text(message)
|
|
.font(.subheadline.weight(.medium))
|
|
.foregroundStyle(tint)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
.padding(15)
|
|
.background(tint.opacity(0.10))
|
|
.clipShape(RoundedRectangle(cornerRadius: AITheme.cardRadius - 6, style: .continuous))
|
|
.overlay {
|
|
RoundedRectangle(cornerRadius: AITheme.cardRadius - 6, style: .continuous)
|
|
.strokeBorder(tint.opacity(0.22))
|
|
}
|
|
.accessibilityElement(children: .combine)
|
|
}
|
|
}
|
|
|
|
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 {
|
|
VStack(spacing: 10) {
|
|
Image(systemName: systemImage)
|
|
.font(.system(size: 27, weight: .medium))
|
|
.foregroundStyle(AITheme.primary)
|
|
.frame(width: 54, height: 54)
|
|
.background(AITheme.primary.opacity(0.10))
|
|
.clipShape(Circle())
|
|
.accessibilityHidden(true)
|
|
Text(title)
|
|
.font(.headline.weight(.semibold))
|
|
.foregroundStyle(AITheme.ink)
|
|
Text(message)
|
|
.font(.subheadline)
|
|
.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)
|
|
.accessibilityElement(children: .combine)
|
|
}
|
|
}
|
|
|
|
public struct AIListCard<Footer: View>: View {
|
|
private let title: String
|
|
private let subtitle: String?
|
|
private let status: String?
|
|
private let statusTint: Color?
|
|
private let details: [String]
|
|
private let footer: Footer
|
|
|
|
public init(
|
|
title: String,
|
|
subtitle: String? = nil,
|
|
status: String? = nil,
|
|
statusTint: Color? = nil,
|
|
details: [String] = [],
|
|
@ViewBuilder footer: () -> Footer = { EmptyView() }
|
|
) {
|
|
self.title = title
|
|
self.subtitle = subtitle
|
|
self.status = status
|
|
self.statusTint = statusTint
|
|
self.details = details
|
|
self.footer = footer()
|
|
}
|
|
|
|
public var body: some View {
|
|
AICard {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
HStack(alignment: .firstTextBaseline, spacing: 10) {
|
|
Text(title)
|
|
.font(.subheadline.weight(.semibold))
|
|
.foregroundStyle(AITheme.ink)
|
|
.lineLimit(2)
|
|
.truncationMode(.middle)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
|
|
if let status {
|
|
AITag(status, tint: statusTint ?? AITheme.muted)
|
|
}
|
|
}
|
|
|
|
if let subtitle {
|
|
Text(subtitle)
|
|
.font(.caption)
|
|
.foregroundStyle(AITheme.muted)
|
|
.lineLimit(2)
|
|
.truncationMode(.middle)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
|
|
if !details.isEmpty {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
ForEach(details, id: \.self) { detail in
|
|
Text(detail)
|
|
.font(.caption)
|
|
.foregroundStyle(AITheme.muted)
|
|
.lineLimit(2)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
}
|
|
}
|
|
|
|
if Footer.self != EmptyView.self {
|
|
footer
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
public struct AILoadingCard: View {
|
|
private let message: String
|
|
|
|
public init(_ message: String) {
|
|
self.message = message
|
|
}
|
|
|
|
public var body: some View {
|
|
VStack(spacing: 10) {
|
|
ProgressView()
|
|
.controlSize(.regular)
|
|
.frame(width: 44, height: 44)
|
|
.background(AITheme.control)
|
|
.clipShape(Circle())
|
|
Text(message)
|
|
.font(.subheadline)
|
|
.foregroundStyle(AITheme.muted)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(16)
|
|
.background(
|
|
LinearGradient(
|
|
colors: [AITheme.surfaceRaised, AITheme.surface],
|
|
startPoint: .top,
|
|
endPoint: .bottom
|
|
)
|
|
)
|
|
.clipShape(RoundedRectangle(cornerRadius: AITheme.cardRadius, style: .continuous))
|
|
.shadow(color: AITheme.brand.opacity(0.08), radius: 20, x: 0, y: 10)
|
|
.overlay {
|
|
RoundedRectangle(cornerRadius: AITheme.cardRadius, style: .continuous)
|
|
.strokeBorder(AITheme.line)
|
|
}
|
|
.accessibilityElement(children: .combine)
|
|
}
|
|
}
|
|
|
|
public struct AIActionRow<Content: View>: View {
|
|
private let spacing: CGFloat
|
|
private let content: Content
|
|
|
|
public init(
|
|
spacing: CGFloat = 10,
|
|
@ViewBuilder content: () -> Content
|
|
) {
|
|
self.spacing = spacing
|
|
self.content = content()
|
|
}
|
|
|
|
public var body: some View {
|
|
AIEqualWidthLayout(spacing: spacing) {
|
|
content
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct AIEqualWidthLayout: Layout {
|
|
let spacing: CGFloat
|
|
|
|
func sizeThatFits(
|
|
proposal: ProposedViewSize,
|
|
subviews: Subviews,
|
|
cache: inout ()
|
|
) -> CGSize {
|
|
guard !subviews.isEmpty else { return .zero }
|
|
|
|
let count = subviews.count
|
|
let width: CGFloat
|
|
if let proposedWidth = proposal.width {
|
|
width = proposedWidth
|
|
} else {
|
|
let idealWidths = subviews.map { $0.sizeThatFits(.unspecified).width }
|
|
width = idealWidths.reduce(0, +) + spacing * CGFloat(count - 1)
|
|
}
|
|
|
|
let equalWidth = max(0, (width - spacing * CGFloat(count - 1)) / CGFloat(count))
|
|
let height =
|
|
subviews.map {
|
|
$0.sizeThatFits(ProposedViewSize(width: equalWidth, height: nil)).height
|
|
}.max() ?? 0
|
|
|
|
return CGSize(width: width, height: height)
|
|
}
|
|
|
|
func placeSubviews(
|
|
in bounds: CGRect,
|
|
proposal: ProposedViewSize,
|
|
subviews: Subviews,
|
|
cache: inout ()
|
|
) {
|
|
guard !subviews.isEmpty else { return }
|
|
|
|
let equalWidth = max(
|
|
0,
|
|
(bounds.width - spacing * CGFloat(subviews.count - 1)) / CGFloat(subviews.count)
|
|
)
|
|
var x = bounds.minX
|
|
|
|
for subview in subviews {
|
|
let size = subview.sizeThatFits(ProposedViewSize(width: equalWidth, height: nil))
|
|
subview.place(
|
|
at: CGPoint(x: x, y: bounds.midY - size.height / 2),
|
|
proposal: ProposedViewSize(width: equalWidth, height: size.height)
|
|
)
|
|
x += equalWidth + spacing
|
|
}
|
|
}
|
|
}
|
|
|
|
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, monospaced: Bool = false) {
|
|
self.title = title
|
|
self.value = value
|
|
self.valueTint = tint
|
|
self.monospaced = monospaced
|
|
}
|
|
|
|
public var body: some View {
|
|
HStack(alignment: .firstTextBaseline, spacing: 12) {
|
|
Text(title)
|
|
.font(.subheadline)
|
|
.foregroundStyle(AITheme.muted)
|
|
.lineLimit(2)
|
|
.layoutPriority(1)
|
|
Spacer(minLength: 12)
|
|
Text(value)
|
|
.font(
|
|
monospaced
|
|
? Font.system(.subheadline, design: .monospaced, weight: .medium)
|
|
: Font.subheadline.weight(.medium)
|
|
)
|
|
.monospacedDigit()
|
|
.foregroundStyle(valueTint)
|
|
.multilineTextAlignment(.trailing)
|
|
.lineLimit(2)
|
|
.truncationMode(.middle)
|
|
.minimumScaleFactor(0.85)
|
|
.textSelection(.enabled)
|
|
.layoutPriority(2)
|
|
}
|
|
.accessibilityElement(children: .combine)
|
|
}
|
|
}
|
|
|
|
public struct AISegmentedControl<Value: Hashable>: View {
|
|
private let title: String
|
|
private let options: [(value: Value, title: String)]
|
|
@Binding private var selection: Value
|
|
|
|
public init(
|
|
_ title: String,
|
|
selection: Binding<Value>,
|
|
options: [(value: Value, title: String)]
|
|
) {
|
|
self.title = title
|
|
self._selection = selection
|
|
self.options = options
|
|
}
|
|
|
|
public var body: some View {
|
|
HStack(spacing: 3) {
|
|
ForEach(options, id: \.value) { option in
|
|
let isSelected = selection == option.value
|
|
|
|
Button {
|
|
selection = option.value
|
|
} label: {
|
|
Text(option.title)
|
|
.font(.subheadline.weight(isSelected ? .semibold : .medium))
|
|
.lineLimit(1)
|
|
.minimumScaleFactor(0.75)
|
|
.foregroundStyle(isSelected ? AITheme.ink : AITheme.muted)
|
|
.frame(maxWidth: .infinity, minHeight: AITheme.segmentedControlHeight)
|
|
// 分段控件是"视图切换"而不是主操作:抬起的中性块,避免和标签筛选抢注意力。
|
|
.background {
|
|
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)
|
|
.contentShape(Rectangle())
|
|
.accessibilityLabel(option.title)
|
|
.accessibilityAddTraits(isSelected ? .isSelected : [])
|
|
}
|
|
}
|
|
.padding(3)
|
|
.background(AITheme.segmentTrack)
|
|
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
|
|
.overlay {
|
|
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
|
.strokeBorder(AITheme.tileLine)
|
|
}
|
|
.animation(.easeOut(duration: 0.16), value: selection)
|
|
.accessibilityElement(children: .contain)
|
|
.accessibilityLabel(title)
|
|
}
|
|
}
|
|
|
|
public struct AIFieldStyle: TextFieldStyle {
|
|
@FocusState private var isFocused: Bool
|
|
|
|
public init() {}
|
|
|
|
public func _body(configuration: TextField<Self._Label>) -> some View {
|
|
configuration
|
|
.textFieldStyle(.plain)
|
|
.focused($isFocused)
|
|
.aiInputSurface()
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
public var body: some View {
|
|
HStack(spacing: 6) {
|
|
Image(systemName: "magnifyingglass")
|
|
.font(.system(size: 15, weight: .medium))
|
|
.foregroundStyle(AITheme.muted)
|
|
.accessibilityHidden(true)
|
|
TextField(placeholder, text: $text)
|
|
.textFieldStyle(.plain)
|
|
.focused($isFocused)
|
|
.submitLabel(.search)
|
|
.onSubmit {
|
|
onSubmit?()
|
|
}
|
|
if !text.isEmpty {
|
|
Button {
|
|
text = ""
|
|
onSubmit?()
|
|
} label: {
|
|
Image(systemName: "xmark.circle.fill")
|
|
.foregroundStyle(AITheme.muted)
|
|
.font(.system(size: 16, weight: .medium))
|
|
}
|
|
.buttonStyle(.plain)
|
|
.frame(width: AITheme.iconButtonSize, height: AITheme.iconButtonSize)
|
|
.contentShape(Rectangle())
|
|
.accessibilityLabel("清除搜索")
|
|
}
|
|
}
|
|
.aiInputSurface(minHeight: minHeight, isFocused: isFocused)
|
|
}
|
|
}
|
|
|
|
extension View {
|
|
func aiInputSurface(
|
|
minHeight: CGFloat = AITheme.inputHeight,
|
|
isFocused: Bool = false
|
|
) -> some View {
|
|
self
|
|
.padding(.horizontal, 12)
|
|
.frame(minHeight: minHeight, alignment: .leading)
|
|
.background(isFocused ? AITheme.surfaceRaised : AITheme.control)
|
|
.clipShape(RoundedRectangle(cornerRadius: AITheme.inputRadius, style: .continuous))
|
|
.overlay {
|
|
RoundedRectangle(cornerRadius: AITheme.inputRadius, style: .continuous)
|
|
.strokeBorder(isFocused ? AITheme.primary.opacity(0.55) : AITheme.line)
|
|
}
|
|
.animation(.easeOut(duration: 0.15), value: isFocused)
|
|
}
|
|
|
|
func aiBottomActionBar<Content: View>(@ViewBuilder content: () -> Content) -> some View {
|
|
safeAreaInset(edge: .bottom, spacing: 0) {
|
|
content()
|
|
.padding(.horizontal, 16)
|
|
.padding(.top, 12)
|
|
.padding(.bottom, 10)
|
|
.background {
|
|
ZStack {
|
|
Rectangle()
|
|
.fill(.ultraThinMaterial)
|
|
Rectangle()
|
|
.fill(AITheme.surface.opacity(0.98))
|
|
}
|
|
.ignoresSafeArea(edges: .bottom)
|
|
}
|
|
.overlay(alignment: .top) {
|
|
Rectangle()
|
|
.fill(AITheme.line)
|
|
.frame(height: 1)
|
|
}
|
|
}
|
|
}
|
|
|
|
func aiSelectSurface(compact: Bool = false) -> some View {
|
|
self
|
|
.tint(AITheme.ink)
|
|
.font(compact ? .footnote.weight(.semibold) : .subheadline.weight(.semibold))
|
|
.foregroundStyle(AITheme.ink)
|
|
.labelsHidden()
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.aiInputSurface(minHeight: compact ? AITheme.compactControlHeight : AITheme.inputHeight)
|
|
}
|
|
}
|
|
|
|
public struct AIToolbarButton: View {
|
|
private let title: String
|
|
private let systemImage: String?
|
|
private let kind: AIButtonStyle.Kind
|
|
private let action: () -> Void
|
|
|
|
public init(
|
|
_ title: String,
|
|
systemImage: String? = nil,
|
|
kind: AIButtonStyle.Kind = .secondary,
|
|
action: @escaping () -> Void
|
|
) {
|
|
self.title = title
|
|
self.systemImage = systemImage
|
|
self.kind = kind
|
|
self.action = action
|
|
}
|
|
|
|
public var body: some View {
|
|
Button {
|
|
action()
|
|
} label: {
|
|
if let systemImage {
|
|
Label(title, systemImage: systemImage)
|
|
.labelStyle(.titleAndIcon)
|
|
.font(.system(size: 15, weight: .semibold))
|
|
} else {
|
|
Text(title)
|
|
.font(.system(size: 15, weight: .semibold))
|
|
}
|
|
}
|
|
.buttonStyle(AIButtonStyle(kind: kind, size: .toolbarInline))
|
|
.accessibilityLabel(title)
|
|
}
|
|
}
|
|
|
|
public struct AIIconButton: View {
|
|
private let title: String
|
|
private let systemImage: String
|
|
private let kind: AIButtonStyle.Kind
|
|
private let action: () -> Void
|
|
|
|
public init(
|
|
_ title: String,
|
|
systemImage: String,
|
|
kind: AIButtonStyle.Kind = .secondary,
|
|
action: @escaping () -> Void
|
|
) {
|
|
self.title = title
|
|
self.systemImage = systemImage
|
|
self.kind = kind
|
|
self.action = action
|
|
}
|
|
|
|
public var body: some View {
|
|
Button {
|
|
action()
|
|
} label: {
|
|
Image(systemName: systemImage)
|
|
.font(.system(size: 16, weight: .semibold))
|
|
}
|
|
.buttonStyle(AIButtonStyle(kind: kind, size: .icon))
|
|
.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)
|
|
}
|
|
}
|