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.
This commit is contained in:
@@ -13,10 +13,10 @@ public struct AICard<Content: View>: View {
|
||||
content
|
||||
.padding(padding)
|
||||
.background(AITheme.surface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AITheme.cardRadius))
|
||||
.shadow(color: .black.opacity(0.045), radius: 8, x: 0, y: 3)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AITheme.cardRadius, style: .continuous))
|
||||
.shadow(color: .black.opacity(0.07), radius: 18, x: 0, y: 8)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: AITheme.cardRadius)
|
||||
RoundedRectangle(cornerRadius: AITheme.cardRadius, style: .continuous)
|
||||
.strokeBorder(AITheme.line)
|
||||
}
|
||||
}
|
||||
@@ -37,10 +37,14 @@ public struct AITag: View {
|
||||
.foregroundStyle(tint)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
.padding(.horizontal, 9)
|
||||
.frame(minHeight: 26)
|
||||
.padding(.horizontal, 10)
|
||||
.frame(minHeight: 30)
|
||||
.background(tint.opacity(0.10))
|
||||
.clipShape(Capsule())
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.strokeBorder(tint.opacity(0.16))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,8 +100,10 @@ public struct AISectionTitle: View {
|
||||
Image(systemName: systemImage)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(AITheme.primary)
|
||||
.accessibilityHidden(true)
|
||||
Text(title)
|
||||
.font(.headline.weight(.bold))
|
||||
.font(.headline.weight(.semibold))
|
||||
.kerning(-0.1)
|
||||
.foregroundStyle(AITheme.ink)
|
||||
.lineLimit(1)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
@@ -149,7 +155,7 @@ public struct AIStatCard: View {
|
||||
.foregroundStyle(AITheme.muted)
|
||||
Text(value)
|
||||
.font(.title3.weight(.bold))
|
||||
.foregroundStyle(tint)
|
||||
.foregroundStyle(value == "0" || value.hasPrefix("0/") ? AITheme.muted : tint)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.75)
|
||||
if let detail {
|
||||
@@ -180,16 +186,17 @@ public struct AIStatusBanner: View {
|
||||
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(14)
|
||||
.padding(15)
|
||||
.background(tint.opacity(0.10))
|
||||
.clipShape(RoundedRectangle(cornerRadius: AITheme.cardRadius))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: AITheme.cardRadius)
|
||||
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
||||
.strokeBorder(tint.opacity(0.22))
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
@@ -210,13 +217,14 @@ public struct AIEmptyState: View {
|
||||
public var body: some View {
|
||||
VStack(spacing: 10) {
|
||||
Image(systemName: systemImage)
|
||||
.font(.system(size: 26))
|
||||
.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(.bold))
|
||||
.font(.headline.weight(.semibold))
|
||||
.foregroundStyle(AITheme.ink)
|
||||
Text(message)
|
||||
.font(.subheadline)
|
||||
@@ -225,7 +233,7 @@ public struct AIEmptyState: View {
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 18)
|
||||
.padding(.vertical, 16)
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
}
|
||||
@@ -255,46 +263,47 @@ public struct AIListCard<Footer: View>: View {
|
||||
}
|
||||
|
||||
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)
|
||||
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 let status {
|
||||
AITag(status, tint: statusTint ?? AITheme.muted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if Footer.self != EmptyView.self {
|
||||
footer
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -319,6 +328,77 @@ public struct AILoadingCard: View {
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
@@ -377,11 +457,11 @@ public struct AISegmentedControl<Value: Hashable>: View {
|
||||
.font(.subheadline.weight(isSelected ? .semibold : .medium))
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.75)
|
||||
.foregroundStyle(isSelected ? .white : AITheme.muted)
|
||||
.frame(maxWidth: .infinity, minHeight: 34)
|
||||
.foregroundStyle(isSelected ? AITheme.onPrimary : AITheme.muted)
|
||||
.frame(maxWidth: .infinity, minHeight: AITheme.segmentedControlHeight)
|
||||
.background {
|
||||
if isSelected {
|
||||
RoundedRectangle(cornerRadius: 9)
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.fill(AITheme.primary)
|
||||
}
|
||||
}
|
||||
@@ -393,9 +473,9 @@ public struct AISegmentedControl<Value: Hashable>: View {
|
||||
}
|
||||
.padding(4)
|
||||
.background(AITheme.control)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AITheme.buttonRadius))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: AITheme.buttonRadius)
|
||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
.strokeBorder(AITheme.line)
|
||||
}
|
||||
.animation(.easeOut(duration: 0.16), value: selection)
|
||||
@@ -405,10 +485,14 @@ public struct AISegmentedControl<Value: Hashable>: View {
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -417,6 +501,7 @@ 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,
|
||||
@@ -432,8 +517,10 @@ public struct AISearchField: View {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.foregroundStyle(AITheme.muted)
|
||||
.accessibilityHidden(true)
|
||||
TextField(placeholder, text: $text)
|
||||
.textFieldStyle(.plain)
|
||||
.focused($isFocused)
|
||||
.submitLabel(.search)
|
||||
.onSubmit {
|
||||
onSubmit?()
|
||||
@@ -448,52 +535,48 @@ public struct AISearchField: View {
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.frame(width: 28, height: 28)
|
||||
.frame(width: AITheme.iconButtonSize, height: AITheme.iconButtonSize)
|
||||
.contentShape(Rectangle())
|
||||
.accessibilityLabel("清除搜索")
|
||||
}
|
||||
}
|
||||
.aiInputSurface(minHeight: AITheme.buttonHeight)
|
||||
}
|
||||
}
|
||||
|
||||
public struct AIBrandBar: View {
|
||||
public init() {}
|
||||
|
||||
public var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "antenna.radiowaves.left.and.right")
|
||||
Text(AppMetadata.name)
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
}
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
.background(AITheme.brand)
|
||||
.aiInputSurface(minHeight: AITheme.buttonHeight, isFocused: isFocused)
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func aiInputSurface(minHeight: CGFloat = AITheme.buttonHeight) -> some View {
|
||||
func aiInputSurface(
|
||||
minHeight: CGFloat = AITheme.inputHeight,
|
||||
isFocused: Bool = false
|
||||
) -> some View {
|
||||
self
|
||||
.padding(.horizontal, 12)
|
||||
.frame(minHeight: minHeight, alignment: .leading)
|
||||
.background(AITheme.control)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AITheme.buttonRadius))
|
||||
.background(isFocused ? AITheme.surfaceRaised : AITheme.control)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AITheme.inputRadius, style: .continuous))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: AITheme.buttonRadius)
|
||||
.strokeBorder(AITheme.line)
|
||||
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) {
|
||||
VStack(spacing: 0) {
|
||||
self
|
||||
content()
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 10)
|
||||
.padding(.bottom, 8)
|
||||
.background(AITheme.canvas)
|
||||
.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)
|
||||
|
||||
Reference in New Issue
Block a user