import SwiftUI public struct AICard: View { private let content: Content private let padding: CGFloat public init(padding: CGFloat = 16, @ViewBuilder content: () -> Content) { self.padding = padding self.content = content() } public var body: some View { content .padding(padding) .background(AITheme.surface) .clipShape(RoundedRectangle(cornerRadius: AITheme.cardRadius, style: .continuous)) .shadow(color: .black.opacity(0.07), radius: 18, x: 0, y: 8) .overlay { RoundedRectangle(cornerRadius: AITheme.cardRadius, style: .continuous) .strokeBorder(AITheme.line) } } } public struct AITag: View { private let text: String private let tint: Color public init(_ text: String, tint: Color = AITheme.primary) { self.text = text self.tint = tint } public var body: some View { Text(text) .font(.system(size: 12, weight: .semibold)) .foregroundStyle(tint) .lineLimit(1) .truncationMode(.middle) .padding(.horizontal, 10) .frame(minHeight: 30) .background(tint.opacity(0.10)) .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) .overlay { RoundedRectangle(cornerRadius: 10, style: .continuous) .strokeBorder(tint.opacity(0.16)) } } } 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) .accessibilityHidden(true) Text(title) .font(.headline.weight(.semibold)) .kerning(-0.1) .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) } } public struct AIStatCard: View { private let title: String private let value: String private let detail: String? private let tint: Color public init(title: String, value: String, detail: String? = nil, tint: Color) { self.title = title self.value = value self.detail = detail self.tint = tint } public var body: some View { AICard(padding: 14) { VStack(alignment: .leading, spacing: 5) { Text(title) .font(.caption.weight(.semibold)) .foregroundStyle(AITheme.muted) Text(value) .font(.title3.weight(.bold)) .foregroundStyle(value == "0" || value.hasPrefix("0/") ? AITheme.muted : tint) .lineLimit(1) .minimumScaleFactor(0.75) if let detail { Text(detail) .font(.caption) .foregroundStyle(AITheme.muted) .lineLimit(2) } } .frame(maxWidth: .infinity, alignment: .leading) } } } 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: 18, style: .continuous)) .overlay { RoundedRectangle(cornerRadius: 18, 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 public init(systemImage: String, title: String, message: String) { self.systemImage = systemImage self.title = title self.message = message } 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) } .frame(maxWidth: .infinity) .padding(.vertical, 16) .accessibilityElement(children: .combine) } } public struct AIListCard: 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(1) .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) Text(message) .font(.subheadline) .foregroundStyle(AITheme.muted) } .frame(maxWidth: .infinity) .padding(.vertical, 18) .accessibilityElement(children: .combine) } } public struct AIActionRow: 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 public init(_ title: String, value: String, tint: Color = AITheme.ink) { self.title = title self.value = value self.valueTint = tint } public var body: some View { HStack(alignment: .firstTextBaseline, spacing: 12) { Text(title) .font(.subheadline) .foregroundStyle(AITheme.muted) .lineLimit(2) Spacer(minLength: 12) Text(value) .font(.subheadline.weight(.medium)) .foregroundStyle(valueTint) .multilineTextAlignment(.trailing) .lineLimit(2) .truncationMode(.middle) .minimumScaleFactor(0.85) .textSelection(.enabled) } .accessibilityElement(children: .combine) } } public struct AISegmentedControl: View { private let title: String private let options: [(value: Value, title: String)] @Binding private var selection: Value public init( _ title: String, selection: Binding, options: [(value: Value, title: String)] ) { self.title = title self._selection = selection self.options = options } public var body: some View { HStack(spacing: 4) { 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.onPrimary : AITheme.muted) .frame(maxWidth: .infinity, minHeight: AITheme.segmentedControlHeight) .background { if isSelected { RoundedRectangle(cornerRadius: 12, style: .continuous) .fill(AITheme.primary) } } } .buttonStyle(.plain) .accessibilityLabel(option.title) .accessibilityAddTraits(isSelected ? .isSelected : []) } } .padding(4) .background(AITheme.control) .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) .overlay { RoundedRectangle(cornerRadius: 16, style: .continuous) .strokeBorder(AITheme.line) } .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) -> 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)? @FocusState private var isFocused: Bool public init( _ placeholder: String, text: Binding, onSubmit: (() -> Void)? = nil ) { self.placeholder = placeholder self._text = text self.onSubmit = onSubmit } public var body: some View { HStack(spacing: 6) { Image(systemName: "magnifyingglass") .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: AITheme.buttonHeight, 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(@ViewBuilder content: () -> Content) -> some View { VStack(spacing: 0) { self 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() -> some View { self .tint(AITheme.ink) .font(.subheadline.weight(.semibold)) .foregroundStyle(AITheme.ink) .labelsHidden() .frame(maxWidth: .infinity, alignment: .leading) .aiInputSurface() } } 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: .toolbar)) .accessibilityLabel(title) } }