feat(ios): unify list cards and loading states
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
import SwiftUI
|
||||
|
||||
public struct AIButtonStyle: ButtonStyle {
|
||||
public static let pressedScale: CGFloat = 0.97
|
||||
|
||||
public enum Kind: Hashable {
|
||||
case primary
|
||||
case secondary
|
||||
@@ -30,15 +32,25 @@ public struct AIButtonStyle: ButtonStyle {
|
||||
case compact
|
||||
case regular
|
||||
case large
|
||||
case toolbar
|
||||
}
|
||||
|
||||
private let kind: Kind
|
||||
private let size: Size
|
||||
private let fixedWidthRequest: CGFloat?
|
||||
let fillsWidth: Bool
|
||||
@Environment(\.isEnabled) private var isEnabled
|
||||
|
||||
public init(kind: Kind = .secondary, size: Size = .regular) {
|
||||
public init(
|
||||
kind: Kind = .secondary,
|
||||
size: Size = .regular,
|
||||
fixedWidth: CGFloat? = nil,
|
||||
fillsWidth: Bool = false
|
||||
) {
|
||||
self.kind = kind
|
||||
self.size = size
|
||||
self.fixedWidthRequest = fixedWidth
|
||||
self.fillsWidth = fillsWidth
|
||||
}
|
||||
|
||||
public func makeBody(configuration: Configuration) -> some View {
|
||||
@@ -46,20 +58,29 @@ public struct AIButtonStyle: ButtonStyle {
|
||||
case .compact: AITheme.compactButtonHeight
|
||||
case .large: AITheme.largeButtonHeight
|
||||
case .regular: AITheme.buttonHeight
|
||||
case .toolbar: AITheme.toolbarButtonHeight
|
||||
}
|
||||
|
||||
configuration.label
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.labelStyle(.titleAndIcon)
|
||||
.font(.system(size: size == .toolbar ? 15 : 16, weight: .semibold))
|
||||
.lineLimit(1)
|
||||
.foregroundStyle(kind.foregroundColor(isEnabled: isEnabled))
|
||||
.frame(minWidth: minimumWidth, minHeight: height)
|
||||
.padding(.horizontal, kind == .secondary ? 12 : 14)
|
||||
.frame(
|
||||
minWidth: (!fillsWidth && fixedWidth == nil) ? minimumWidth : 0,
|
||||
minHeight: height
|
||||
)
|
||||
.frame(maxWidth: fillsWidth ? .infinity : nil)
|
||||
.padding(.horizontal, fixedWidth == nil ? (kind == .secondary ? 12 : 14) : 0)
|
||||
.frame(width: fixedWidth)
|
||||
.background(kind.backgroundColor(isEnabled: isEnabled))
|
||||
.clipShape(RoundedRectangle(cornerRadius: AITheme.buttonRadius))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: AITheme.buttonRadius)
|
||||
.strokeBorder(kind == .secondary || !isEnabled ? AITheme.line : .clear)
|
||||
}
|
||||
.opacity(configuration.isPressed ? 0.78 : 1)
|
||||
.scaleEffect(configuration.isPressed ? Self.pressedScale : 1)
|
||||
.opacity(configuration.isPressed ? 0.82 : 1)
|
||||
}
|
||||
|
||||
private var minimumWidth: CGFloat {
|
||||
@@ -67,6 +88,11 @@ public struct AIButtonStyle: ButtonStyle {
|
||||
case .compact: return AITheme.compactButtonMinWidth
|
||||
case .regular: return AITheme.regularButtonMinWidth
|
||||
case .large: return 92
|
||||
case .toolbar: return AITheme.toolbarButtonMinWidth
|
||||
}
|
||||
}
|
||||
|
||||
private var fixedWidth: CGFloat? {
|
||||
fixedWidthRequest ?? (size == .toolbar ? AITheme.toolbarButtonWidth : nil)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,16 +2,19 @@ import SwiftUI
|
||||
|
||||
public struct AICard<Content: View>: View {
|
||||
private let content: Content
|
||||
private let padding: CGFloat
|
||||
|
||||
public init(@ViewBuilder content: () -> Content) {
|
||||
public init(padding: CGFloat = 16, @ViewBuilder content: () -> Content) {
|
||||
self.padding = padding
|
||||
self.content = content()
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
content
|
||||
.padding(16)
|
||||
.padding(padding)
|
||||
.background(AITheme.surface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AITheme.cardRadius))
|
||||
.shadow(color: .black.opacity(0.045), radius: 8, x: 0, y: 3)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: AITheme.cardRadius)
|
||||
.strokeBorder(AITheme.line)
|
||||
@@ -32,6 +35,8 @@ public struct AITag: View {
|
||||
Text(text)
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(tint)
|
||||
.lineLimit(1)
|
||||
.fixedSize(horizontal: true, vertical: false)
|
||||
.padding(.horizontal, 9)
|
||||
.frame(minHeight: 26)
|
||||
.background(tint.opacity(0.10))
|
||||
@@ -39,6 +44,257 @@ public struct AITag: View {
|
||||
}
|
||||
}
|
||||
|
||||
public struct AISectionTitle: View {
|
||||
private let title: String
|
||||
private let systemImage: String
|
||||
private let detail: String?
|
||||
|
||||
public init(_ title: String, systemImage: String, detail: String? = nil) {
|
||||
self.title = title
|
||||
self.systemImage = systemImage
|
||||
self.detail = detail
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: systemImage)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(AITheme.primary)
|
||||
Text(title)
|
||||
.font(.headline.weight(.bold))
|
||||
.foregroundStyle(AITheme.ink)
|
||||
.lineLimit(1)
|
||||
Spacer(minLength: 8)
|
||||
if let detail {
|
||||
Text(detail)
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(AITheme.muted)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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(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)
|
||||
Text(message)
|
||||
.font(.subheadline.weight(.medium))
|
||||
.foregroundStyle(tint)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.padding(14)
|
||||
.background(tint.opacity(0.10))
|
||||
.clipShape(RoundedRectangle(cornerRadius: AITheme.cardRadius))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: AITheme.cardRadius)
|
||||
.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: 28))
|
||||
.foregroundStyle(AITheme.muted)
|
||||
Text(title)
|
||||
.font(.headline.weight(.bold))
|
||||
.foregroundStyle(AITheme.ink)
|
||||
Text(message)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(AITheme.muted)
|
||||
.multilineTextAlignment(.center)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 18)
|
||||
.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 {
|
||||
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
|
||||
}
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
}
|
||||
|
||||
public struct AILoadingCard: View {
|
||||
private let message: String
|
||||
|
||||
public init(_ message: String) {
|
||||
self.message = message
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
AICard {
|
||||
VStack(spacing: 10) {
|
||||
ProgressView()
|
||||
.controlSize(.regular)
|
||||
Text(message)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(AITheme.muted)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 18)
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
.minimumScaleFactor(0.85)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
}
|
||||
|
||||
public struct AIFieldStyle: TextFieldStyle {
|
||||
public init() {}
|
||||
|
||||
@@ -76,13 +332,25 @@ public struct AISearchField: View {
|
||||
.foregroundStyle(AITheme.muted)
|
||||
TextField(placeholder, text: $text)
|
||||
.textFieldStyle(.plain)
|
||||
.submitLabel(.search)
|
||||
.onSubmit {
|
||||
onSubmit?()
|
||||
}
|
||||
if !text.isEmpty {
|
||||
Button {
|
||||
text = ""
|
||||
onSubmit?()
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundStyle(AITheme.muted)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("清除搜索")
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.frame(minHeight: AITheme.buttonHeight)
|
||||
.background(AITheme.surface)
|
||||
.background(AITheme.canvas)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AITheme.buttonRadius))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: AITheme.buttonRadius)
|
||||
@@ -107,3 +375,71 @@ public struct AIBrandBar: View {
|
||||
.background(Color(red: 0.075, green: 0.118, blue: 0.188))
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func aiBottomActionBar<Content: View>(@ViewBuilder content: () -> Content) -> some View {
|
||||
safeAreaInset(edge: .bottom) {
|
||||
content()
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 10)
|
||||
.padding(.bottom, 12)
|
||||
.background(AITheme.canvas)
|
||||
.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)
|
||||
.frame(maxWidth: .infinity, minHeight: AITheme.buttonHeight, alignment: .leading)
|
||||
.padding(.horizontal, 12)
|
||||
.background(AITheme.canvas)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AITheme.buttonRadius))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: AITheme.buttonRadius)
|
||||
.strokeBorder(AITheme.line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,9 +15,14 @@ public enum AITheme {
|
||||
public static let buttonHeight: CGFloat = 44
|
||||
public static let compactButtonHeight: CGFloat = 40
|
||||
public static let largeButtonHeight: CGFloat = 50
|
||||
public static let toolbarButtonHeight: CGFloat = 38
|
||||
public static let iconButtonSize: CGFloat = 44
|
||||
public static let buttonRadius: CGFloat = 12
|
||||
public static let cardRadius: CGFloat = 16
|
||||
public static let compactButtonMinWidth: CGFloat = 58
|
||||
public static let regularButtonMinWidth: CGFloat = 76
|
||||
public static let toolbarButtonMinWidth: CGFloat = 88
|
||||
public static let toolbarButtonWidth: CGFloat = 100
|
||||
public static let footerSecondaryButtonWidth: CGFloat = 100
|
||||
public static let listActionButtonWidth: CGFloat = 72
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user