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:
chick
2026-09-12 14:09:02 +08:00
parent 31b2f33d15
commit 0f2866a853
16 changed files with 1293 additions and 1035 deletions
+164 -81
View File
@@ -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)