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
@@ -1,138 +1,140 @@
import SwiftUI
struct MessagesView: View {
@StateObject private var model: MessagesViewModel
@State private var pendingDeleteMessage: SMSMessage?
@StateObject private var model: MessagesViewModel
@State private var pendingDeleteMessage: SMSMessage?
init(model: MessagesViewModel? = nil) {
_model = StateObject(wrappedValue: model ?? DefaultContainer.makeMessagesModel())
init(model: MessagesViewModel? = nil) {
_model = StateObject(wrappedValue: model ?? DefaultContainer.makeMessagesModel())
}
var body: some View {
ScrollView {
LazyVStack(spacing: 12) {
filterBar
if let errorMessage = model.errorMessage {
AIStatusBanner(
errorMessage, tint: AITheme.warning, systemImage: "exclamationmark.triangle")
}
if model.isLoading && model.filteredRows.isEmpty {
AILoadingCard("正在同步消息数据")
}
if model.filteredRows.isEmpty && !model.isLoading {
AICard {
AIEmptyState(
systemImage: "message",
title: "暂无消息",
message: "刷新后从实例同步短信。"
)
}
}
ForEach(model.filteredRows) { message in
messageCard(message)
}
}
.padding(16)
.padding(.bottom, 8)
}
var body: some View {
ZStack {
AITheme.canvas.ignoresSafeArea()
ScrollView {
LazyVStack(spacing: 12) {
filterBar
if let errorMessage = model.errorMessage {
AIStatusBanner(errorMessage, tint: AITheme.warning, systemImage: "exclamationmark.triangle")
}
if model.isLoading && model.filteredRows.isEmpty {
AILoadingCard("正在同步消息数据")
}
if model.filteredRows.isEmpty && !model.isLoading {
AICard {
AIEmptyState(
systemImage: "message",
title: "暂无消息",
message: "刷新后从实例同步短信。"
)
}
}
ForEach(model.filteredRows) { message in
messageCard(message)
}
}
.padding(16)
}
}
.navigationTitle("消息中心")
.aiBottomActionBar {
Button {
model.showingSendForm = true
} label: {
Label("发送消息", systemImage: "paperplane")
}
.buttonStyle(AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true))
}
.confirmationDialog(
"删除这条消息?操作不可恢复。",
isPresented: Binding(
get: { pendingDeleteMessage != nil },
set: { if !$0 { pendingDeleteMessage = nil } }
),
titleVisibility: .visible
) {
Button("删除消息", role: .destructive) {
guard let message = pendingDeleteMessage else { return }
Task { await model.delete(message) }
}
Button("取消", role: .cancel) {}
}
.refreshable {
await model.refresh()
}
.task {
await model.refresh()
}
.sheet(isPresented: $model.showingSendForm) {
SendMessageFormView(model: model)
.aiBottomActionBar {
Button {
model.showingSendForm = true
} label: {
Label("发送消息", systemImage: "paperplane")
}
.buttonStyle(AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true))
}
private var filterBar: some View {
AICard {
VStack(spacing: 10) {
AISegmentedControl(
"消息方向",
selection: $model.direction,
options: MessagesViewModel.DirectionFilter.allCases.map {
(value: $0, title: $0.rawValue)
}
)
Picker("实例", selection: $model.selectedInstanceID) {
Text("全部实例").tag(Optional<UUID>.none)
ForEach(model.instances) { instance in
Text(instance.name).tag(Optional(instance.id))
}
}
.pickerStyle(.menu)
.aiSelectSurface()
AISearchField("搜索号码或内容", text: $model.searchText)
}
}
.background(AITheme.canvas.ignoresSafeArea())
.navigationTitle("消息中心")
.confirmationDialog(
"删除这条消息?操作不可恢复。",
isPresented: Binding(
get: { pendingDeleteMessage != nil },
set: { if !$0 { pendingDeleteMessage = nil } }
),
titleVisibility: .visible
) {
Button("删除消息", role: .destructive) {
guard let message = pendingDeleteMessage else { return }
Task { await model.delete(message) }
}
Button("取消", role: .cancel) {}
}
private func messageCard(_ message: SMSMessage) -> some View {
AICard {
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 8) {
Text(message.phoneNumber.isEmpty ? "未知号码" : message.phoneNumber)
.font(.headline)
.lineLimit(1)
.truncationMode(.middle)
.layoutPriority(1)
Spacer()
AITag(message.direction.label, tint: message.direction == .outgoing ? AITheme.primary : AITheme.success)
}
Text(message.content)
.font(.subheadline)
.foregroundStyle(AITheme.ink)
.fixedSize(horizontal: false, vertical: true)
HStack(alignment: .center, spacing: 10) {
Text(message.instanceName)
.font(.caption)
.foregroundStyle(AITheme.muted)
.lineLimit(1)
.truncationMode(.middle)
.layoutPriority(1)
Spacer(minLength: 8)
Button {
pendingDeleteMessage = message
} label: {
Label("删除", systemImage: "trash")
}
.buttonStyle(
AIButtonStyle(
kind: .danger,
size: .compact,
fixedWidth: AITheme.listActionButtonWidth
)
)
}
}
}
.refreshable {
await model.refresh()
}
.task {
await model.refresh()
}
.sheet(isPresented: $model.showingSendForm) {
SendMessageFormView(model: model)
}
}
private var filterBar: some View {
AICard {
VStack(spacing: 10) {
AISegmentedControl(
"消息方向",
selection: $model.direction,
options: MessagesViewModel.DirectionFilter.allCases.map {
(value: $0, title: $0.rawValue)
}
)
Picker("实例", selection: $model.selectedInstanceID) {
Text("全部实例").tag(Optional<UUID>.none)
ForEach(model.instances) { instance in
Text(instance.name).tag(Optional(instance.id))
}
}
.pickerStyle(.menu)
.aiSelectSurface()
AISearchField("搜索号码或内容", text: $model.searchText)
}
}
}
private func messageCard(_ message: SMSMessage) -> some View {
AICard {
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 8) {
Text(message.phoneNumber.isEmpty ? "未知号码" : message.phoneNumber)
.font(.headline)
.lineLimit(1)
.truncationMode(.middle)
.layoutPriority(1)
Spacer()
AITag(
message.direction.label,
tint: message.direction == .outgoing ? AITheme.primary : AITheme.success)
}
Text(message.content)
.font(.subheadline)
.foregroundStyle(AITheme.ink)
.fixedSize(horizontal: false, vertical: true)
HStack(alignment: .center, spacing: 10) {
Text(message.instanceName)
.font(.caption)
.foregroundStyle(AITheme.muted)
.lineLimit(1)
.truncationMode(.middle)
.layoutPriority(1)
Spacer(minLength: 8)
Button {
pendingDeleteMessage = message
} label: {
Label("删除", systemImage: "trash")
}
.buttonStyle(
AIButtonStyle(
kind: .danger,
size: .compact,
fixedWidth: AITheme.listActionButtonWidth
)
)
}
}
}
}
}
@@ -1,97 +1,124 @@
import SwiftUI
struct SendMessageFormView: View {
@Environment(\.dismiss) private var dismiss
@ObservedObject var model: MessagesViewModel
@State private var phoneNumber = ""
@State private var content = ""
@State private var validationMessage: String?
@Environment(\.dismiss) private var dismiss
@ObservedObject var model: MessagesViewModel
@State private var phoneNumber = ""
@State private var content = ""
@State private var validationMessage: String?
@State private var isSending = false
private var trimmedPhoneNumber: String {
phoneNumber.trimmingCharacters(in: .whitespacesAndNewlines)
}
private var trimmedPhoneNumber: String {
phoneNumber.trimmingCharacters(in: .whitespacesAndNewlines)
}
private var trimmedContent: String {
content.trimmingCharacters(in: .whitespacesAndNewlines)
}
private var trimmedContent: String {
content.trimmingCharacters(in: .whitespacesAndNewlines)
}
private var canSend: Bool {
!trimmedPhoneNumber.isEmpty && !trimmedContent.isEmpty
}
private var canSend: Bool {
!model.instances.isEmpty && !trimmedPhoneNumber.isEmpty && !trimmedContent.isEmpty
}
var body: some View {
NavigationStack {
ZStack {
AITheme.canvas.ignoresSafeArea()
ScrollView {
VStack(spacing: 12) {
AICard {
VStack(alignment: .leading, spacing: 14) {
VStack(alignment: .leading, spacing: 6) {
AIFieldLabel("实例")
Picker("实例", selection: Binding(
get: { model.selectedInstanceID ?? model.instances.first?.id },
set: { model.selectedInstanceID = $0 }
)) {
ForEach(model.instances) { instance in
Text(instance.name).tag(Optional(instance.id))
}
}
.pickerStyle(.menu)
.aiSelectSurface()
}
VStack(alignment: .leading, spacing: 6) {
AIFieldLabel("收件号码")
TextField("收件号码", text: $phoneNumber)
.textFieldStyle(AIFieldStyle())
#if os(iOS)
.keyboardType(.phonePad)
#endif
}
VStack(alignment: .leading, spacing: 6) {
AIFieldLabel("消息内容")
TextField("消息内容", text: $content, axis: .vertical)
.textFieldStyle(AIFieldStyle())
.lineLimit(4...8)
}
}
}
if let validationMessage {
AIStatusBanner(validationMessage, tint: AITheme.danger, systemImage: "exclamationmark.triangle")
}
}
.padding(16)
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 12) {
AICard {
VStack(alignment: .leading, spacing: 14) {
VStack(alignment: .leading, spacing: 6) {
AIFieldLabel("实例")
Picker(
"实例",
selection: Binding(
get: { model.selectedInstanceID ?? model.instances.first?.id },
set: { model.selectedInstanceID = $0 }
)
) {
if model.instances.isEmpty {
Text("暂无实例").tag(Optional<UUID>.none)
}
ForEach(model.instances) { instance in
Text(instance.name).tag(Optional(instance.id))
}
}
}
.navigationTitle("发送消息")
.aiBottomActionBar {
HStack(spacing: 10) {
Button("取消") { dismiss() }
.buttonStyle(AIButtonStyle(kind: .secondary, size: .regular, fillsWidth: true))
.frame(maxWidth: .infinity)
.pickerStyle(.menu)
.aiSelectSurface()
}
Button("发送") { send() }
.buttonStyle(AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true))
.disabled(!canSend)
.frame(maxWidth: .infinity)
}
}
}
}
VStack(alignment: .leading, spacing: 6) {
AIFieldLabel("收件号码")
TextField("收件号码", text: $phoneNumber)
.textFieldStyle(AIFieldStyle())
.accessibilityLabel("收件号码")
.submitLabel(.next)
#if os(iOS)
.keyboardType(.phonePad)
#endif
}
private func send() {
Task {
do {
try await model.send(phoneNumber: trimmedPhoneNumber, content: trimmedContent)
phoneNumber = ""
content = ""
validationMessage = nil
dismiss()
} catch {
validationMessage = "发送失败,请检查号码、内容和实例登录状态。"
VStack(alignment: .leading, spacing: 6) {
AIFieldLabel("消息内容")
TextField("消息内容", text: $content, axis: .vertical)
.textFieldStyle(AIFieldStyle())
.accessibilityLabel("消息内容")
.submitLabel(.send)
.onSubmit {
if canSend { send() }
}
.lineLimit(4...8)
}
}
}
if let validationMessage {
AIStatusBanner(
validationMessage, tint: AITheme.danger, systemImage: "exclamationmark.triangle")
}
}
.padding(16)
.padding(.bottom, 8)
}
.aiBottomActionBar {
AIActionRow {
Button("取消") { dismiss() }
.buttonStyle(AIButtonStyle(kind: .secondary, size: .regular, fillsWidth: true))
Button {
send()
} label: {
HStack(spacing: 8) {
if isSending {
ProgressView()
.controlSize(.small)
}
Text(isSending ? "发送中" : "发送")
}
}
.buttonStyle(AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true))
.disabled(!canSend || isSending)
}
}
.background(AITheme.canvas.ignoresSafeArea())
.navigationTitle("发送消息")
#if os(iOS)
.scrollDismissesKeyboard(.interactively)
#endif
}
}
private func send() {
guard !isSending else { return }
isSending = true
Task {
do {
try await model.send(phoneNumber: trimmedPhoneNumber, content: trimmedContent)
phoneNumber = ""
content = ""
validationMessage = nil
dismiss()
} catch {
validationMessage = "发送失败,请检查号码、内容和实例登录状态。"
}
isSending = false
}
}
}