Files
multi-simadmin/apps/ios/Sources/MultiSimAdmin/Theme/AICard.swift
T
chick 0f2866a853 feat(ios): refine native interface and app icon
Unify bottom actions, selection controls, navigation structure, spacing, and theme colors across iOS pages. Replace the app icon with a higher-contrast native-style asset and include its source SVG.
2026-09-12 14:09:02 +08:00

634 lines
16 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(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<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(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<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(.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<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")
.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: .toolbar))
.accessibilityLabel(title)
}
}