Refine primary and toolbar actions, align row buttons, normalize status surfaces, and split storage from destructive cleanup. Verified with Swift tests and the simulator build.
672 lines
18 KiB
Swift
672 lines
18 KiB
Swift
import SwiftUI
|
|
|
|
public struct AICard<Content: View>: 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(AICard.backgroundGradient)
|
|
.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)
|
|
}
|
|
}
|
|
}
|
|
|
|
private extension AICard {
|
|
static var backgroundGradient: LinearGradient {
|
|
LinearGradient(
|
|
colors: [AITheme.surfaceRaised, AITheme.surface],
|
|
startPoint: .top,
|
|
endPoint: .bottom
|
|
)
|
|
}
|
|
}
|
|
|
|
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(.caption2.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)
|
|
.frame(width: 22, height: 22)
|
|
.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))
|
|
.lineLimit(1)
|
|
.minimumScaleFactor(0.85)
|
|
.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: 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
|
|
|
|
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<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: .black.opacity(0.07), radius: 18, x: 0, y: 8)
|
|
.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
|
|
|
|
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<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: 4) {
|
|
ForEach(options, id: \.value) { option in
|
|
let isSelected = selection == option.value
|
|
|
|
Button {
|
|
selection = option.value
|
|
} label: {
|
|
Text(option.title)
|
|
.font(.body.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(
|
|
LinearGradient(
|
|
colors: [AITheme.primaryTop, AITheme.primaryBottom],
|
|
startPoint: .top,
|
|
endPoint: .bottom
|
|
)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
.buttonStyle(.plain)
|
|
.contentShape(Rectangle())
|
|
.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<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)?
|
|
@FocusState private var isFocused: Bool
|
|
|
|
public init(
|
|
_ placeholder: String,
|
|
text: Binding<String>,
|
|
onSubmit: (() -> Void)? = nil
|
|
) {
|
|
self.placeholder = placeholder
|
|
self._text = text
|
|
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: 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<Content: View>(@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: .toolbarInline))
|
|
.accessibilityLabel(title)
|
|
}
|
|
}
|