diff --git a/apps/ios/Design/AppIcon.svg b/apps/ios/Design/AppIcon.svg new file mode 100644 index 0000000..0200841 --- /dev/null +++ b/apps/ios/Design/AppIcon.svg @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Device/InstanceDetailView.swift b/apps/ios/Sources/MultiSimAdmin/Features/Device/InstanceDetailView.swift index 4703d9f..b680ebe 100644 --- a/apps/ios/Sources/MultiSimAdmin/Features/Device/InstanceDetailView.swift +++ b/apps/ios/Sources/MultiSimAdmin/Features/Device/InstanceDetailView.swift @@ -9,24 +9,26 @@ struct InstanceDetailView: View { } var body: some View { - ZStack { - AITheme.canvas.ignoresSafeArea() - ScrollView { - LazyVStack(spacing: 12) { - header - if let errorMessage = model.errorMessage { - AIStatusBanner(errorMessage, tint: AITheme.danger, systemImage: "exclamationmark.triangle") - } - if let snapshot = model.snapshot { - ForEach(snapshot.sections) { section in - sectionCard(section) - } + ScrollView { + LazyVStack(spacing: 12) { + header + if let errorMessage = model.errorMessage { + AIStatusBanner(errorMessage, tint: AITheme.danger, systemImage: "exclamationmark.triangle") + } + if let snapshot = model.snapshot { + ForEach(snapshot.sections) { section in + sectionCard(section) } } - .padding(16) } + .padding(16) + .padding(.bottom, 8) } + .background(AITheme.canvas.ignoresSafeArea()) .navigationTitle(model.instance.name) + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif .refreshable { await model.refresh() } diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Fleet/FleetView.swift b/apps/ios/Sources/MultiSimAdmin/Features/Fleet/FleetView.swift index 13db3a7..89ccafb 100644 --- a/apps/ios/Sources/MultiSimAdmin/Features/Fleet/FleetView.swift +++ b/apps/ios/Sources/MultiSimAdmin/Features/Fleet/FleetView.swift @@ -1,212 +1,217 @@ import SwiftUI struct FleetView: View { - @StateObject private var model: FleetViewModel - @State private var showsDeleteDialog = false + @StateObject private var model: FleetViewModel + @State private var showsDeleteDialog = false - init(model: FleetViewModel? = nil) { - _model = StateObject(wrappedValue: model ?? DefaultContainer.makeFleetModel()) - } + init(model: FleetViewModel? = nil) { + _model = StateObject(wrappedValue: model ?? DefaultContainer.makeFleetModel()) + } - var body: some View { - ZStack { - AITheme.canvas.ignoresSafeArea() - ScrollView { - LazyVStack(spacing: 12) { - searchField - if let errorMessage = model.errorMessage { - AIStatusBanner( - errorMessage, - tint: AITheme.danger, - systemImage: "exclamationmark.triangle" - ) - } - if model.selectionMode { - selectionBar - } - if model.isLoading && model.rows.isEmpty { - AILoadingCard("正在同步实例数据") - } - if model.rows.isEmpty && !model.isLoading { - AICard { - AIEmptyState( - systemImage: "server.rack", - title: "暂无实例", - message: "添加 SimAdmin 实例后即可开始管理。" - ) - } - } - ForEach(model.rows) { row in - if model.selectionMode { - instanceCard(row) - } else { - NavigationLink { - InstanceDetailView(instance: row.instance) - } label: { - instanceCard(row) - } - .buttonStyle(.plain) - } - } - } - .padding(16) + var body: some View { + ScrollView { + LazyVStack(spacing: 12) { + searchField + if let errorMessage = model.errorMessage { + AIStatusBanner( + errorMessage, + tint: AITheme.danger, + systemImage: "exclamationmark.triangle" + ) + } + if model.selectionMode { + selectionBar + } + if model.isLoading && model.rows.isEmpty { + AILoadingCard("正在同步实例数据") + } + if model.rows.isEmpty && !model.isLoading { + AICard { + AIEmptyState( + systemImage: "server.rack", + title: "暂无实例", + message: "添加 SimAdmin 实例后即可开始管理。" + ) } - } - .navigationTitle("节点总览") - .aiBottomActionBar { - if !model.selectionMode { - HStack(spacing: 10) { - Button { - model.selectionMode = true - } label: { - Label("批量选择", systemImage: "checklist") - } - .buttonStyle(AIButtonStyle(kind: .secondary, size: .regular, fillsWidth: true)) - .frame(maxWidth: .infinity) - - Button { - model.showingForm = true - } label: { - Label("添加实例", systemImage: "plus") - } - .buttonStyle(AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true)) - .frame(maxWidth: .infinity) - } + } + ForEach(model.rows) { row in + if model.selectionMode { + instanceCard(row) + } else { + NavigationLink { + InstanceDetailView(instance: row.instance) + } label: { + instanceCard(row) + } + .buttonStyle(.plain) } + } } - .confirmationDialog( - "删除已选实例?登录信息也会一并清除。", - isPresented: $showsDeleteDialog, - titleVisibility: .visible - ) { - Button("删除 \(model.selectedIDs.count) 个实例", role: .destructive) { - Task { await model.deleteSelected() } + .padding(16) + .padding(.bottom, 8) + } + .aiBottomActionBar { + if !model.selectionMode { + AIActionRow { + Button { + model.selectionMode = true + } label: { + Label("批量选择", systemImage: "checklist") } - Button("取消", role: .cancel) {} - } - .refreshable { - await model.refresh() - } - .task { - await model.refresh() - } - .sheet(isPresented: $model.showingForm) { - InstanceFormView(model: model) - } - } + .buttonStyle(AIButtonStyle(kind: .secondary, size: .regular, fillsWidth: true)) - private var searchField: some View { - AISearchField("搜索实例或地址", text: $model.searchText) { - Task { await model.refresh() } - } - } - - private var selectionBar: some View { - AICard { - HStack(spacing: 10) { - Button { - if model.selectedIDs.count == model.rows.count { - model.selectedIDs.removeAll() - } else { - model.selectedIDs = Set(model.rows.map(\.id)) - } - } label: { - Label( - model.selectedIDs.count == model.rows.count ? "取消全选" : "全选", - systemImage: "checkmark.circle" - ) - } - .buttonStyle( - AIButtonStyle( - kind: .secondary, - size: .compact, - fixedWidth: AITheme.footerSecondaryButtonWidth - ) - ) - - Spacer() - - Text("已选 \(model.selectedIDs.count)") - .font(.subheadline.weight(.semibold)) - .foregroundStyle(AITheme.muted) - - Button { - showsDeleteDialog = true - } label: { - Label("删除", systemImage: "trash") - } - .buttonStyle( - AIButtonStyle( - kind: .danger, - size: .compact, - fixedWidth: AITheme.footerSecondaryButtonWidth - ) - ) - .disabled(model.selectedIDs.isEmpty) + Button { + model.showingForm = true + } label: { + Label("添加实例", systemImage: "plus") } - } - } - - private func instanceCard(_ row: FleetViewModel.Row) -> some View { - AICard { - HStack(alignment: .top, spacing: 12) { - if model.selectionMode { - Button { - toggle(row.id) - } label: { - Image(systemName: model.selectedIDs.contains(row.id) - ? "checkmark.circle.fill" : "circle") - .font(.system(size: 20)) - .foregroundStyle(model.selectedIDs.contains(row.id) ? AITheme.primary : AITheme.muted) - } - .buttonStyle(.plain) - .frame(width: 32, height: 32) - .accessibilityLabel(model.selectedIDs.contains(row.id) ? "取消选择" : "选择") - .accessibilityAddTraits(model.selectedIDs.contains(row.id) ? .isSelected : []) - } - VStack(alignment: .leading, spacing: 8) { - HStack(spacing: 8) { - Text(row.instance.name) - .font(.headline) - .foregroundStyle(AITheme.ink) - .lineLimit(1) - .truncationMode(.middle) - .layoutPriority(1) - Spacer() - AITag(row.status.label, tint: statusColor(row.status)) - } - Text(row.instance.origin.absoluteString) - .font(.subheadline) - .foregroundStyle(AITheme.muted) - .lineLimit(1) - .truncationMode(.middle) - if !row.instance.tags.isEmpty { - HStack(spacing: 6) { - ForEach(row.instance.tags, id: \.self) { tag in - AITag(tag) - } - } - } - } - } - } - } - - private func statusColor(_ status: ConnectionStatus) -> Color { - switch status { - case .online: return AITheme.success - case .offline: return AITheme.danger - case .authRequired: return AITheme.warning - case .degraded: return AITheme.warning - case .unknown: return AITheme.muted - } - } - - private func toggle(_ id: UUID) { - if model.selectedIDs.contains(id) { - model.selectedIDs.remove(id) + .buttonStyle(AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true)) + } } else { - model.selectedIDs.insert(id) + AIActionRow { + Button { + model.selectionMode = false + model.selectedIDs.removeAll() + } label: { + Label("取消选择", systemImage: "xmark.circle") + } + .buttonStyle(AIButtonStyle(kind: .secondary, size: .regular, fillsWidth: true)) + + Button { + showsDeleteDialog = true + } label: { + Label( + model.selectedIDs.isEmpty ? "删除" : "删除 \(model.selectedIDs.count)", + systemImage: "trash" + ) + } + .buttonStyle(AIButtonStyle(kind: .danger, size: .regular, fillsWidth: true)) + .disabled(model.selectedIDs.isEmpty) + } } } + .background(AITheme.canvas.ignoresSafeArea()) + .navigationTitle("节点总览") + .confirmationDialog( + "删除已选实例?登录信息也会一并清除。", + isPresented: $showsDeleteDialog, + titleVisibility: .visible + ) { + Button("删除 \(model.selectedIDs.count) 个实例", role: .destructive) { + Task { await model.deleteSelected() } + } + Button("取消", role: .cancel) {} + } + .refreshable { + await model.refresh() + } + .task { + await model.refresh() + } + .sheet(isPresented: $model.showingForm) { + InstanceFormView(model: model) + } + } + + private var searchField: some View { + AISearchField("搜索实例或地址", text: $model.searchText) { + Task { await model.refresh() } + } + } + + private var selectionBar: some View { + AICard { + HStack(spacing: 10) { + Button { + if model.selectedIDs.count == model.rows.count { + model.selectedIDs.removeAll() + } else { + model.selectedIDs = Set(model.rows.map(\.id)) + } + } label: { + Label( + model.selectedIDs.count == model.rows.count ? "取消全选" : "全选", + systemImage: "checkmark.circle" + ) + } + .buttonStyle( + AIButtonStyle( + kind: .secondary, + size: .compact + ) + ) + + Spacer() + + Text("已选 \(model.selectedIDs.count)") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(AITheme.muted) + } + } + } + + private func instanceCard(_ row: FleetViewModel.Row) -> some View { + AICard { + HStack(alignment: .top, spacing: 12) { + if model.selectionMode { + Button { + toggle(row.id) + } label: { + Image( + systemName: model.selectedIDs.contains(row.id) + ? "checkmark.circle.fill" : "circle" + ) + .font(.system(size: 20)) + .foregroundStyle(model.selectedIDs.contains(row.id) ? AITheme.primary : AITheme.muted) + } + .buttonStyle(.plain) + .frame(width: 44, height: 44) + .accessibilityLabel(model.selectedIDs.contains(row.id) ? "取消选择" : "选择") + .accessibilityAddTraits(model.selectedIDs.contains(row.id) ? .isSelected : []) + } + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 8) { + Text(row.instance.name) + .font(.headline) + .foregroundStyle(AITheme.ink) + .lineLimit(1) + .truncationMode(.middle) + .layoutPriority(1) + Spacer() + AITag(row.status.label, tint: statusColor(row.status)) + } + Text(row.instance.origin.absoluteString) + .font(.subheadline) + .foregroundStyle(AITheme.muted) + .lineLimit(1) + .truncationMode(.middle) + if !row.instance.tags.isEmpty { + HStack(spacing: 6) { + ForEach(row.instance.tags, id: \.self) { tag in + AITag(tag) + } + } + } + } + } + } + } + + private func statusColor(_ status: ConnectionStatus) -> Color { + switch status { + case .online: return AITheme.success + case .offline: return AITheme.danger + case .authRequired: return AITheme.warning + case .degraded: return AITheme.warning + case .unknown: return AITheme.muted + } + } + + private func toggle(_ id: UUID) { + if model.selectedIDs.contains(id) { + model.selectedIDs.remove(id) + } else { + model.selectedIDs.insert(id) + } + } } diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Fleet/InstanceFormView.swift b/apps/ios/Sources/MultiSimAdmin/Features/Fleet/InstanceFormView.swift index 141e83f..bfce793 100644 --- a/apps/ios/Sources/MultiSimAdmin/Features/Fleet/InstanceFormView.swift +++ b/apps/ios/Sources/MultiSimAdmin/Features/Fleet/InstanceFormView.swift @@ -1,106 +1,125 @@ import SwiftUI struct InstanceFormView: View { - @Environment(\.dismiss) private var dismiss - @ObservedObject var model: FleetViewModel - @State private var name = "" - @State private var originText = "" - @State private var tagsText = "" - @State private var password = "" - @State private var validationMessage: String? + @Environment(\.dismiss) private var dismiss + @ObservedObject var model: FleetViewModel + @State private var name = "" + @State private var originText = "" + @State private var tagsText = "" + @State private var password = "" + @State private var validationMessage: String? + @State private var isSaving = false - private var canSave: Bool { - !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - && !originText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - } + private var canSave: Bool { + !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + && !originText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } - var body: some View { - NavigationStack { - ZStack { - AITheme.canvas.ignoresSafeArea() - ScrollView { - VStack(spacing: 12) { - AICard { - VStack(alignment: .leading, spacing: 14) { - field("名称", text: $name, placeholder: "Home Node") - field("地址", text: $originText, placeholder: "http://192.168.1.10:3000") - field("标签", text: $tagsText, placeholder: "home, primary") - field("实例密码", text: $password, placeholder: "可选,保存在本机沙盒", secure: true) - } - } - 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) { + field("名称", text: $name, placeholder: "Home Node") + field("地址", text: $originText, placeholder: "http://192.168.1.10:3000") + field("标签", text: $tagsText, placeholder: "home, primary") + field("实例密码", text: $password, placeholder: "可选,保存在本机沙盒", secure: true) } - .navigationTitle("添加实例") - .aiBottomActionBar { - HStack(spacing: 10) { - Button("取消") { dismiss() } - .buttonStyle(AIButtonStyle(kind: .secondary, size: .regular, fillsWidth: true)) - .frame(maxWidth: .infinity) + } + 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("保存实例") { save() } - .buttonStyle(AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true)) - .disabled(!canSave) - .frame(maxWidth: .infinity) - } + Button { + save() + } label: { + HStack(spacing: 8) { + if isSaving { + ProgressView() + .controlSize(.small) + } + Text(isSaving ? "保存中" : "保存实例") } + } + .buttonStyle(AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true)) + .disabled(!canSave || isSaving) } + } + .background(AITheme.canvas.ignoresSafeArea()) + .navigationTitle("添加实例") + #if os(iOS) + .scrollDismissesKeyboard(.interactively) + #endif } + } - private func field( - _ title: String, - text: Binding, - placeholder: String, - secure: Bool = false - ) -> some View { - VStack(alignment: .leading, spacing: 6) { - AIFieldLabel(title) - if secure { - SecureField(placeholder, text: text) - .textFieldStyle(AIFieldStyle()) - .textContentType(.password) - } else { - TextField(placeholder, text: text) - .textFieldStyle(AIFieldStyle()) - #if os(iOS) - .keyboardType(title == "地址" ? .URL : .default) - .textInputAutocapitalization(title == "地址" || title == "标签" ? .never : nil) - #endif - .autocorrectionDisabled(title == "地址" || title == "标签") - } - } + private func field( + _ title: String, + text: Binding, + placeholder: String, + secure: Bool = false + ) -> some View { + VStack(alignment: .leading, spacing: 6) { + AIFieldLabel(title) + if secure { + SecureField(placeholder, text: text) + .textFieldStyle(AIFieldStyle()) + .textContentType(.password) + .accessibilityLabel(title) + } else { + TextField(placeholder, text: text) + .textFieldStyle(AIFieldStyle()) + .accessibilityLabel(title) + .submitLabel(title == "地址" ? .next : .done) + #if os(iOS) + .keyboardType(title == "地址" ? .URL : .default) + .textInputAutocapitalization(title == "地址" || title == "标签" ? .never : nil) + #endif + .autocorrectionDisabled(title == "地址" || title == "标签") + } } + } - private func save() { - let normalizedName = name.trimmingCharacters(in: .whitespacesAndNewlines) - guard let origin = URL(string: originText.trimmingCharacters(in: .whitespacesAndNewlines)), - origin.scheme == "http" || origin.scheme == "https", - origin.host != nil - else { - validationMessage = "请输入有效的 HTTP 或 HTTPS 根地址。" - return - } - let tags = tagsText - .split(separator: ",") - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { !$0.isEmpty } - let input = InstanceInput( - name: normalizedName, - origin: origin, - tags: tags, - password: password.isEmpty ? nil : password - ) - Task { - do { - try await model.add(input) - dismiss() - } catch { - validationMessage = "保存失败,请检查地址是否重复。" - } - } + private func save() { + guard !isSaving else { return } + isSaving = true + let normalizedName = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard let origin = URL(string: originText.trimmingCharacters(in: .whitespacesAndNewlines)), + origin.scheme == "http" || origin.scheme == "https", + origin.host != nil + else { + validationMessage = "请输入有效的 HTTP 或 HTTPS 根地址。" + isSaving = false + return } + let tags = + tagsText + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + let input = InstanceInput( + name: normalizedName, + origin: origin, + tags: tags, + password: password.isEmpty ? nil : password + ) + Task { + do { + try await model.add(input) + } catch { + validationMessage = "保存失败,请检查地址是否重复。" + } + isSaving = false + } + } } diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Logs/LogsView.swift b/apps/ios/Sources/MultiSimAdmin/Features/Logs/LogsView.swift index 383887f..d1e5534 100644 --- a/apps/ios/Sources/MultiSimAdmin/Features/Logs/LogsView.swift +++ b/apps/ios/Sources/MultiSimAdmin/Features/Logs/LogsView.swift @@ -16,43 +16,42 @@ struct LogsView: View { } var body: some View { - ZStack { - AITheme.canvas.ignoresSafeArea() - ScrollView { - LazyVStack(spacing: 12) { - if let errorMessage = model.errorMessage { - AIStatusBanner( - errorMessage, tint: AITheme.danger, systemImage: "exclamationmark.triangle") - } - if model.snapshot == nil && model.isLoading { - AILoadingCard("正在同步日志数据") - } else { - stats - AISegmentedControl( - "日志视图", - selection: $tab, - options: [ - (value: Tab.runtime, title: "运行日志"), - (value: Tab.connections, title: "连接日志"), - (value: Tab.diagnostics, title: "设备诊断"), - ] - ) + ScrollView { + LazyVStack(spacing: 12) { + if let errorMessage = model.errorMessage { + AIStatusBanner( + errorMessage, tint: AITheme.danger, systemImage: "exclamationmark.triangle") + } + if model.snapshot == nil && model.isLoading { + AILoadingCard("正在同步日志数据") + } else { + stats + AISegmentedControl( + "日志视图", + selection: $tab, + options: [ + (value: Tab.runtime, title: "运行日志"), + (value: Tab.connections, title: "连接日志"), + (value: Tab.diagnostics, title: "设备诊断"), + ] + ) - filterBar + filterBar - switch tab { - case .runtime: - runtimeSection - case .connections: - connectionSection - case .diagnostics: - diagnosticSection - } + switch tab { + case .runtime: + runtimeSection + case .connections: + connectionSection + case .diagnostics: + diagnosticSection } } - .padding(16) } + .padding(16) + .padding(.bottom, 8) } + .background(AITheme.canvas.ignoresSafeArea()) .navigationTitle("日志中心") .toolbar { ToolbarItemGroup(placement: .primaryAction) { diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Messages/MessagesView.swift b/apps/ios/Sources/MultiSimAdmin/Features/Messages/MessagesView.swift index dddfc8f..b23482f 100644 --- a/apps/ios/Sources/MultiSimAdmin/Features/Messages/MessagesView.swift +++ b/apps/ios/Sources/MultiSimAdmin/Features/Messages/MessagesView.swift @@ -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.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.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 + ) + ) + } + } + } + } } diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Messages/SendMessageFormView.swift b/apps/ios/Sources/MultiSimAdmin/Features/Messages/SendMessageFormView.swift index 13a6eac..83b2488 100644 --- a/apps/ios/Sources/MultiSimAdmin/Features/Messages/SendMessageFormView.swift +++ b/apps/ios/Sources/MultiSimAdmin/Features/Messages/SendMessageFormView.swift @@ -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.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 + } + } } diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Notifications/NotificationsView.swift b/apps/ios/Sources/MultiSimAdmin/Features/Notifications/NotificationsView.swift index 0cb4a37..3128ea0 100644 --- a/apps/ios/Sources/MultiSimAdmin/Features/Notifications/NotificationsView.swift +++ b/apps/ios/Sources/MultiSimAdmin/Features/Notifications/NotificationsView.swift @@ -15,9 +15,7 @@ struct NotificationsView: View { } var body: some View { - ZStack { - AITheme.canvas.ignoresSafeArea() - ScrollView { + ScrollView { LazyVStack(spacing: 12) { if let actionMessage = model.actionMessage { AIStatusBanner( @@ -44,18 +42,29 @@ struct NotificationsView: View { } } .padding(16) - } + .padding(.bottom, 8) } - .navigationTitle("通知中心") .aiBottomActionBar { - Button { - Task { await model.retryAll() } - } label: { - Label("立即投递", systemImage: "paperplane") - } - .buttonStyle(AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true)) - .disabled(model.isLoading) + Button { + Task { await model.retryAll() } + } label: { + HStack(spacing: 8) { + if model.isRetryingAll { + ProgressView() + .controlSize(.small) + } + Text( + model.isRetryingAll + ? "投递中" + : (model.overview?.queue.total == 0 ? "暂无待投递" : "立即投递") + ) + } + } + .buttonStyle(AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true)) + .disabled(model.isLoading || model.isRetryingAll || model.overview?.queue.total == 0) } + .background(AITheme.canvas.ignoresSafeArea()) + .navigationTitle("通知中心") .confirmationDialog( "删除这条通知队列记录?操作不可恢复。", isPresented: Binding( @@ -176,13 +185,12 @@ struct NotificationsView: View { } } else { ForEach(overview.queue.items) { item in - AICard { - AIListCard( - title: eventTypeLabel(item.eventType), - subtitle: "\(item.instanceName) · \(timestampText(item.createdAt))", - status: statusLabel(item.status), - statusTint: statusTint(item.status) - ) { + AIListCard( + title: eventTypeLabel(item.eventType), + subtitle: "\(item.instanceName) · \(timestampText(item.createdAt))", + status: statusLabel(item.status), + statusTint: statusTint(item.status) + ) { HStack(spacing: 8) { Button { Task { await model.retryQueue(item) } @@ -190,27 +198,17 @@ struct NotificationsView: View { Label("重试", systemImage: "arrow.clockwise") } .buttonStyle( - AIButtonStyle( - kind: .secondary, - size: .compact, - fixedWidth: AITheme.listActionButtonWidth - ) + AIButtonStyle(kind: .secondary, size: .compact, fillsWidth: true) ) - .disabled(model.busyQueueID == item.id) + .disabled(model.busyQueueID != nil) Button { pendingDeleteQueueID = item.id } label: { Label("删除", systemImage: "trash") } - .buttonStyle( - AIButtonStyle( - kind: .danger, - size: .compact, - fixedWidth: AITheme.listActionButtonWidth - ) - ) - } + .buttonStyle(AIButtonStyle(kind: .danger, size: .compact, fillsWidth: true)) + .disabled(model.busyQueueID != nil) } } } diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Notifications/NotificationsViewModel.swift b/apps/ios/Sources/MultiSimAdmin/Features/Notifications/NotificationsViewModel.swift index 5c09790..fd610d3 100644 --- a/apps/ios/Sources/MultiSimAdmin/Features/Notifications/NotificationsViewModel.swift +++ b/apps/ios/Sources/MultiSimAdmin/Features/Notifications/NotificationsViewModel.swift @@ -2,79 +2,83 @@ import Foundation @MainActor final class NotificationsViewModel: ObservableObject { - @Published private(set) var overview: NotificationOverview? - @Published private(set) var instances: [Instance] = [] - @Published private(set) var isLoading = false - @Published var errorMessage: String? - @Published private(set) var actionMessage: String? - @Published private(set) var actionSucceeded = true - @Published private(set) var busyQueueID: String? + @Published private(set) var overview: NotificationOverview? + @Published private(set) var instances: [Instance] = [] + @Published private(set) var isLoading = false + @Published var errorMessage: String? + @Published private(set) var actionMessage: String? + @Published private(set) var actionSucceeded = true + @Published private(set) var busyQueueID: String? + @Published private(set) var isRetryingAll = false - private let repository: any InstanceRepository - private let service: NotificationService + private let repository: any InstanceRepository + private let service: NotificationService - init(repository: any InstanceRepository, service: NotificationService) { - self.repository = repository - self.service = service + init(repository: any InstanceRepository, service: NotificationService) { + self.repository = repository + self.service = service + } + + func refresh() async { + isLoading = true + errorMessage = nil + do { + let loaded = try await repository.list(InstanceQuery()) + instances = loaded + overview = try await service.overview(loaded) + } catch { + errorMessage = "无法加载通知中心。" } + isLoading = false + } - func refresh() async { - isLoading = true - errorMessage = nil - do { - let loaded = try await repository.list(InstanceQuery()) - instances = loaded - overview = try await service.overview(loaded) - } catch { - errorMessage = "无法加载通知中心。" - } - isLoading = false + func retryAll() async { + guard !isRetryingAll && !isLoading else { return } + isRetryingAll = true + let result = await service.retryAll(instances) + actionSucceeded = result.succeeded > 0 + if result.succeeded > 0 { + actionMessage = "已提交全部队列重试,成功 \(result.succeeded) 个实例。" + } else if result.skipped > 0 { + actionMessage = "没有可重试的队列接口。" + } else { + actionMessage = "队列重试失败。" } + await refresh() + isRetryingAll = false + } - func retryAll() async { - let result = await service.retryAll(instances) - actionSucceeded = result.succeeded > 0 - if result.succeeded > 0 { - actionMessage = "已提交全部队列重试,成功 \(result.succeeded) 个实例。" - } else if result.skipped > 0 { - actionMessage = "没有可重试的队列接口。" - } else { - actionMessage = "队列重试失败。" - } - await refresh() + func retryQueue(_ item: NotificationQueueEntry) async { + await queueAction(item) { instance, id in + try await self.service.retryQueue(instance, id: id) } + } - func retryQueue(_ item: NotificationQueueEntry) async { - await queueAction(item) { instance, id in - try await self.service.retryQueue(instance, id: id) - } + func deleteQueue(_ item: NotificationQueueEntry) async { + await queueAction(item) { instance, id in + try await self.service.deleteQueueItem(instance, id: id) } + await refresh() + } - func deleteQueue(_ item: NotificationQueueEntry) async { - await queueAction(item) { instance, id in - try await self.service.deleteQueueItem(instance, id: id) - } - await refresh() + private func queueAction( + _ item: NotificationQueueEntry, + action: (Instance, String) async throws -> Void + ) async { + guard let instance = instances.first(where: { $0.id == item.instanceID }) else { + errorMessage = "找不到队列所属实例。" + return } - - private func queueAction( - _ item: NotificationQueueEntry, - action: (Instance, String) async throws -> Void - ) async { - guard let instance = instances.first(where: { $0.id == item.instanceID }) else { - errorMessage = "找不到队列所属实例。" - return - } - busyQueueID = item.id - do { - try await action(instance, item.id) - actionMessage = "队列操作已完成。" - actionSucceeded = true - errorMessage = nil - } catch { - errorMessage = "队列操作失败。" - actionSucceeded = false - } - busyQueueID = nil + busyQueueID = item.id + do { + try await action(instance, item.id) + actionMessage = "队列操作已完成。" + actionSucceeded = true + errorMessage = nil + } catch { + errorMessage = "队列操作失败。" + actionSucceeded = false } + busyQueueID = nil + } } diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Settings/SettingsView.swift b/apps/ios/Sources/MultiSimAdmin/Features/Settings/SettingsView.swift index 9212799..43273bb 100644 --- a/apps/ios/Sources/MultiSimAdmin/Features/Settings/SettingsView.swift +++ b/apps/ios/Sources/MultiSimAdmin/Features/Settings/SettingsView.swift @@ -9,24 +9,23 @@ struct SettingsView: View { } var body: some View { - ZStack { - AITheme.canvas.ignoresSafeArea() - ScrollView { - LazyVStack(spacing: 12) { - if let errorMessage = model.errorMessage { - AIStatusBanner(errorMessage, tint: AITheme.danger, systemImage: "exclamationmark.triangle") - } - if let notice = model.notice { - AIStatusBanner(notice, tint: AITheme.success, systemImage: "checkmark.circle") - } - - runtimeCard - storageCard - securityCard + ScrollView { + LazyVStack(spacing: 12) { + if let errorMessage = model.errorMessage { + AIStatusBanner(errorMessage, tint: AITheme.danger, systemImage: "exclamationmark.triangle") } - .padding(16) + if let notice = model.notice { + AIStatusBanner(notice, tint: AITheme.success, systemImage: "checkmark.circle") + } + + runtimeCard + storageCard + securityCard } + .padding(16) + .padding(.bottom, 8) } + .background(AITheme.canvas.ignoresSafeArea()) .navigationTitle("设置") .toolbar { ToolbarItemGroup(placement: .primaryAction) { @@ -81,7 +80,7 @@ struct SettingsView: View { } label: { Label("清除运行日志", systemImage: "trash") } - .buttonStyle(AIButtonStyle(kind: .danger, size: .large, fillsWidth: true)) + .buttonStyle(AIButtonStyle(kind: .danger, size: .regular, fillsWidth: true)) } } } diff --git a/apps/ios/Sources/MultiSimAdmin/MultiSimAdminApp.swift b/apps/ios/Sources/MultiSimAdmin/MultiSimAdminApp.swift index fae7054..7980ff0 100644 --- a/apps/ios/Sources/MultiSimAdmin/MultiSimAdminApp.swift +++ b/apps/ios/Sources/MultiSimAdmin/MultiSimAdminApp.swift @@ -2,106 +2,113 @@ import SwiftUI @main struct MultiSimAdminApp: App { - @State private var tab: AppTab + @State private var tab: AppTab - init() { - let arguments = ProcessInfo.processInfo.arguments - if arguments.contains("--tab-messages") { - _tab = State(initialValue: .messages) - } else if arguments.contains("--tab-notifications") { - _tab = State(initialValue: .notifications) - } else if arguments.contains("--tab-logs") { - _tab = State(initialValue: .logs) - } else if arguments.contains("--tab-settings") { - _tab = State(initialValue: .settings) - } else { - _tab = State(initialValue: .fleet) - } + init() { + let arguments = ProcessInfo.processInfo.arguments + if arguments.contains("--tab-messages") { + _tab = State(initialValue: .messages) + } else if arguments.contains("--tab-notifications") { + _tab = State(initialValue: .notifications) + } else if arguments.contains("--tab-logs") { + _tab = State(initialValue: .logs) + } else if arguments.contains("--tab-settings") { + _tab = State(initialValue: .settings) + } else { + _tab = State(initialValue: .fleet) } + } - var body: some Scene { - WindowGroup { - VStack(spacing: 0) { - switch tab { - case .fleet: - NavigationStack { FleetView().aiInlineTitle() } - case .messages: - NavigationStack { MessagesView().aiInlineTitle() } - case .notifications: - NavigationStack { NotificationsView().aiInlineTitle() } - case .logs: - NavigationStack { LogsView().aiInlineTitle() } - case .settings: - NavigationStack { SettingsView().aiInlineTitle() } - } - AITabBar(selection: $tab) - } - .tint(AITheme.primary) - #if os(iOS) - .toolbarBackground(AITheme.canvas, for: .navigationBar) - .toolbarBackground(.visible, for: .navigationBar) - #endif + var body: some Scene { + WindowGroup { + VStack(spacing: 0) { + switch tab { + case .fleet: + NavigationStack { FleetView().aiInlineTitle() } + case .messages: + NavigationStack { MessagesView().aiInlineTitle() } + case .notifications: + NavigationStack { NotificationsView().aiInlineTitle() } + case .logs: + NavigationStack { LogsView().aiInlineTitle() } + case .settings: + NavigationStack { SettingsView().aiInlineTitle() } } + AITabBar(selection: $tab) + } + .tint(AITheme.primary) + #if os(iOS) + .toolbarBackground(.ultraThinMaterial, for: .navigationBar) + .toolbarBackground(.visible, for: .navigationBar) + #endif } + } } #if os(iOS) -private extension View { - func aiInlineTitle() -> some View { - navigationBarTitleDisplayMode(.inline) + extension View { + fileprivate func aiInlineTitle() -> some View { + navigationBarTitleDisplayMode(.inline) } -} + } #else -private extension View { - func aiInlineTitle() -> some View { self } -} + extension View { + fileprivate func aiInlineTitle() -> some View { self } + } #endif enum AppTab: Hashable { - case fleet - case messages - case notifications - case logs - case settings + case fleet + case messages + case notifications + case logs + case settings } struct AITabBar: View { - @Binding var selection: AppTab + @Binding var selection: AppTab - var body: some View { - HStack(spacing: 0) { - tab(.fleet, "节点", "server.rack") - tab(.messages, "消息", "message") - tab(.notifications, "通知", "bell") - tab(.logs, "日志", "doc.text") - tab(.settings, "设置", "gearshape") - } - .frame(height: 58) - .background(AITheme.surface) - .overlay(alignment: .top) { - Rectangle() - .fill(AITheme.line) - .frame(height: 1) - } - .background(AITheme.surface.ignoresSafeArea(edges: .bottom)) + var body: some View { + HStack(spacing: 0) { + tab(.fleet, "节点", "server.rack") + tab(.messages, "消息", "message") + tab(.notifications, "通知", "bell") + tab(.logs, "日志", "doc.text") + tab(.settings, "设置", "gearshape") } + .frame(height: 62) + .background { + ZStack { + Rectangle() + .fill(.ultraThinMaterial) + Rectangle() + .fill(AITheme.chrome) + } + .ignoresSafeArea(edges: .bottom) + } + .overlay(alignment: .top) { + Rectangle() + .fill(AITheme.line) + .frame(height: 1) + } + } - private func tab(_ value: AppTab, _ title: String, _ systemImage: String) -> some View { - let isSelected = selection == value - return Button { - selection = value - } label: { - VStack(spacing: 4) { - Image(systemName: systemImage) - .font(.system(size: 18, weight: .semibold)) - Text(title) - .font(.caption2.weight(.semibold)) - } - .foregroundStyle(isSelected ? AITheme.primary : AITheme.muted) - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - .buttonStyle(.plain) - .accessibilityLabel(title) - .accessibilityAddTraits(isSelected ? [.isSelected] : []) + private func tab(_ value: AppTab, _ title: String, _ systemImage: String) -> some View { + let isSelected = selection == value + return Button { + selection = value + } label: { + VStack(spacing: 4) { + Image(systemName: systemImage) + .font(.system(size: 18, weight: .semibold)) + Text(title) + .font(.caption2.weight(.semibold)) + } + .foregroundStyle(isSelected ? AITheme.primary : AITheme.muted) + .frame(maxWidth: .infinity, maxHeight: .infinity) } + .buttonStyle(.plain) + .accessibilityLabel(title) + .accessibilityAddTraits(isSelected ? [.isSelected] : []) + } } diff --git a/apps/ios/Sources/MultiSimAdmin/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon1024.png b/apps/ios/Sources/MultiSimAdmin/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon1024.png index ab9a750..c07114f 100644 Binary files a/apps/ios/Sources/MultiSimAdmin/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon1024.png and b/apps/ios/Sources/MultiSimAdmin/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon1024.png differ diff --git a/apps/ios/Sources/MultiSimAdmin/Theme/AIButtonStyle.swift b/apps/ios/Sources/MultiSimAdmin/Theme/AIButtonStyle.swift index b3a6a0d..f9a538b 100644 --- a/apps/ios/Sources/MultiSimAdmin/Theme/AIButtonStyle.swift +++ b/apps/ios/Sources/MultiSimAdmin/Theme/AIButtonStyle.swift @@ -1,98 +1,116 @@ import SwiftUI public struct AIButtonStyle: ButtonStyle { - public static let pressedScale: CGFloat = 0.97 + public static let pressedScale: CGFloat = 0.97 - public enum Kind: Hashable { - case primary - case secondary - case danger + public enum Kind: Hashable { + case primary + case secondary + case danger - var foregroundColor: Color { foregroundColor(isEnabled: true) } + var foregroundColor: Color { foregroundColor(isEnabled: true) } - var backgroundColor: Color { backgroundColor(isEnabled: true) } + var backgroundColor: Color { backgroundColor(isEnabled: true) } - func foregroundColor(isEnabled: Bool) -> Color { - switch self { - case .primary, .danger: return .white.opacity(isEnabled ? 1 : 0.72) - case .secondary: return isEnabled ? AITheme.ink : AITheme.muted - } - } - - func backgroundColor(isEnabled: Bool) -> Color { - switch self { - case .primary: return isEnabled ? AITheme.primary : AITheme.primary.opacity(0.35) - case .secondary: return isEnabled ? AITheme.control : AITheme.canvas - case .danger: return isEnabled ? AITheme.danger : AITheme.danger.opacity(0.30) - } - } + func foregroundColor(isEnabled: Bool) -> Color { + switch self { + case .primary: return isEnabled ? AITheme.onPrimary : AITheme.muted + case .danger: return isEnabled ? AITheme.onDanger : AITheme.muted + case .secondary: return isEnabled ? AITheme.ink : AITheme.muted + } } - public enum Size: Hashable { - case compact - case regular - case large - case toolbar + func backgroundColor(isEnabled: Bool) -> Color { + switch self { + case .primary, .secondary, .danger: + return isEnabled ? kindBackground : AITheme.control + } } - 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, - fixedWidth: CGFloat? = nil, - fillsWidth: Bool = false - ) { - self.kind = kind - self.size = size - self.fixedWidthRequest = fixedWidth - self.fillsWidth = fillsWidth + private var kindBackground: Color { + switch self { + case .primary: return AITheme.primary + case .secondary: return AITheme.control + case .danger: return AITheme.danger + } } + } - public func makeBody(configuration: Configuration) -> some View { - let height: CGFloat = switch size { - case .compact: AITheme.compactButtonHeight - case .large: AITheme.largeButtonHeight - case .regular: AITheme.buttonHeight - case .toolbar: AITheme.toolbarButtonHeight - } + public enum Size: Hashable { + case compact + case regular + case large + case toolbar + } - configuration.label - .labelStyle(.titleAndIcon) - .font(.system(size: size == .toolbar ? 15 : 16, weight: .semibold)) - .lineLimit(1) - .foregroundStyle(kind.foregroundColor(isEnabled: isEnabled)) - .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) - } - .scaleEffect(configuration.isPressed ? Self.pressedScale : 1) - .opacity(configuration.isPressed ? 0.82 : 1) + 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, + 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 { + let height: CGFloat = + switch size { + case .compact: AITheme.compactButtonHeight + case .large: AITheme.largeButtonHeight + case .regular: AITheme.buttonHeight + case .toolbar: AITheme.toolbarButtonHeight + } + + configuration.label + .labelStyle(.titleAndIcon) + .font(.system(size: size == .toolbar ? 15 : 16, weight: .semibold)) + .lineLimit(1) + .foregroundStyle(kind.foregroundColor(isEnabled: isEnabled)) + .padding(.horizontal, fixedWidth == nil ? (kind == .secondary ? 12 : 14) : 0) + .frame( + minWidth: (!fillsWidth && fixedWidth == nil) ? minimumWidth : 0, + minHeight: height + ) + .frame(maxWidth: fillsWidth ? .infinity : nil) + .frame(width: fixedWidth) + .background( + RoundedRectangle(cornerRadius: AITheme.buttonRadius, style: .continuous) + .fill(kind.backgroundColor(isEnabled: isEnabled)) + .shadow( + color: (kind == .secondary || !isEnabled) ? .clear : .black.opacity(0.10), + radius: 12, + x: 0, + y: 5 + ) + ) + .clipShape(RoundedRectangle(cornerRadius: AITheme.buttonRadius, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: AITheme.buttonRadius, style: .continuous) + .strokeBorder(kind == .secondary || !isEnabled ? AITheme.line : .clear) + } + .scaleEffect(configuration.isPressed ? Self.pressedScale : 1) + .opacity(configuration.isPressed ? 0.82 : 1) + } + + private var minimumWidth: CGFloat { + switch size { + case .compact: return AITheme.compactButtonMinWidth + case .regular: return AITheme.regularButtonMinWidth + case .large: return 92 + case .toolbar: return AITheme.toolbarButtonMinWidth } + } - private var minimumWidth: CGFloat { - switch size { - 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) - } + private var fixedWidth: CGFloat? { + fixedWidthRequest ?? (size == .toolbar ? AITheme.toolbarButtonWidth : nil) + } } diff --git a/apps/ios/Sources/MultiSimAdmin/Theme/AICard.swift b/apps/ios/Sources/MultiSimAdmin/Theme/AICard.swift index 962bb52..c6488ad 100644 --- a/apps/ios/Sources/MultiSimAdmin/Theme/AICard.swift +++ b/apps/ios/Sources/MultiSimAdmin/Theme/AICard.swift @@ -13,10 +13,10 @@ public struct AICard: 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: 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: 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: 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: 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: View { } public struct AIFieldStyle: TextFieldStyle { + @FocusState private var isFocused: Bool + public init() {} public func _body(configuration: TextField) -> 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(@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) diff --git a/apps/ios/Sources/MultiSimAdmin/Theme/AITheme.swift b/apps/ios/Sources/MultiSimAdmin/Theme/AITheme.swift index 295198a..19c057f 100644 --- a/apps/ios/Sources/MultiSimAdmin/Theme/AITheme.swift +++ b/apps/ios/Sources/MultiSimAdmin/Theme/AITheme.swift @@ -1,44 +1,49 @@ import SwiftUI - #if canImport(UIKit) - import UIKit - #elseif canImport(AppKit) - import AppKit - #endif +#if canImport(UIKit) + import UIKit +#elseif canImport(AppKit) + import AppKit +#endif private enum AIPalette { -#if canImport(UIKit) + #if canImport(UIKit) private static func dynamicColor(light: UIColor, dark: UIColor) -> Color { - Color(UIColor { traits in - traits.userInterfaceStyle == .dark ? dark : light + Color( + UIColor { traits in + traits.userInterfaceStyle == .dark ? dark : light }) } private static func rgba( - _ red: CGFloat, - _ green: CGFloat, - _ blue: CGFloat, - _ alpha: CGFloat + _ red: CGFloat, + _ green: CGFloat, + _ blue: CGFloat, + _ alpha: CGFloat ) -> UIColor { - UIColor(red: red / 255, green: green / 255, blue: blue / 255, alpha: alpha) + UIColor(red: red / 255, green: green / 255, blue: blue / 255, alpha: alpha) } private static func color(light: UIColor, dark: UIColor) -> Color { - dynamicColor(light: light, dark: dark) + dynamicColor(light: light, dark: dark) } - static let canvas = color(light: rgba(243, 245, 248, 1), dark: rgba(19, 22, 28, 1)) - static let surface = color(light: rgba(255, 255, 255, 1), dark: rgba(27, 31, 39, 1)) - static let control = color(light: rgba(247, 249, 251, 1), dark: rgba(37, 44, 56, 1)) - static let ink = color(light: rgba(28, 39, 56, 1), dark: rgba(235, 242, 247, 1)) - static let muted = color(light: rgba(111, 124, 143, 1), dark: rgba(173, 183, 199, 1)) - static let line = color(light: rgba(224, 229, 235, 1), dark: rgba(56, 63, 74, 1)) - static let brand = color(light: rgba(19, 30, 48, 1), dark: rgba(14, 22, 35, 1)) - static let primary = color(light: rgba(17, 119, 110, 1), dark: rgba(20, 142, 126, 1)) - static let danger = color(light: rgba(182, 77, 72, 1), dark: rgba(193, 79, 71, 1)) - static let warning = color(light: rgba(184, 120, 17, 1), dark: rgba(209, 141, 39, 1)) - static let success = color(light: rgba(21, 149, 112, 1), dark: rgba(32, 161, 119, 1)) -#elseif canImport(AppKit) + static let canvas = color(light: rgba(245, 246, 248, 1), dark: rgba(6, 7, 9, 1)) + static let surface = color(light: rgba(255, 255, 255, 1), dark: rgba(18, 20, 24, 1)) + static let surfaceRaised = color(light: rgba(251, 252, 253, 1), dark: rgba(27, 30, 35, 1)) + static let control = color(light: rgba(243, 245, 247, 1), dark: rgba(26, 29, 34, 1)) + static let chrome = color(light: rgba(255, 255, 255, 0.82), dark: rgba(11, 12, 15, 0.82)) + static let ink = color(light: rgba(15, 18, 22, 1), dark: rgba(243, 245, 247, 1)) + static let muted = color(light: rgba(98, 107, 119, 1), dark: rgba(151, 160, 172, 1)) + static let line = color(light: rgba(227, 230, 234, 1), dark: rgba(255, 255, 255, 0.09)) + static let brand = color(light: rgba(13, 15, 18, 1), dark: rgba(8, 9, 11, 1)) + static let primary = color(light: rgba(17, 20, 25, 1), dark: rgba(220, 178, 111, 1)) + static let onPrimary = color(light: rgba(255, 255, 255, 1), dark: rgba(12, 14, 17, 1)) + static let danger = color(light: rgba(190, 65, 57, 1), dark: rgba(242, 124, 113, 1)) + static let onDanger = color(light: rgba(255, 255, 255, 1), dark: rgba(22, 9, 8, 1)) + static let warning = color(light: rgba(170, 111, 23, 1), dark: rgba(226, 166, 65, 1)) + static let success = color(light: rgba(50, 150, 101, 1), dark: rgba(103, 198, 148, 1)) + #elseif canImport(AppKit) static let canvas = Color(red: 0.953, green: 0.961, blue: 0.973) static let surface = Color.white static let control = Color(red: 0.969, green: 0.976, blue: 0.984) @@ -46,13 +51,17 @@ private enum AIPalette { static let muted = Color(red: 0.435, green: 0.486, blue: 0.561) static let line = Color(red: 0.878, green: 0.898, blue: 0.922) static let brand = Color(red: 0.075, green: 0.118, blue: 0.188) - static let primary = Color(red: 0.067, green: 0.467, blue: 0.431) - static let danger = Color(red: 0.714, green: 0.302, blue: 0.282) - static let warning = Color(red: 0.722, green: 0.471, blue: 0.067) - static let success = Color(red: 0.082, green: 0.584, blue: 0.439) -#else + static let surfaceRaised = Color(red: 0.984, green: 0.988, blue: 0.992) + static let chrome = Color.white.opacity(0.82) + static let onPrimary = Color.white + static let onDanger = Color.white + static let primary = Color(red: 0.067, green: 0.078, blue: 0.098) + static let danger = Color(red: 0.745, green: 0.255, blue: 0.224) + static let warning = Color(red: 0.667, green: 0.435, blue: 0.09) + static let success = Color(red: 0.196, green: 0.588, blue: 0.396) + #else private static func color(_ red: CGFloat, _ green: CGFloat, _ blue: CGFloat) -> Color { - Color(red: red / 255, green: green / 255, blue: blue / 255) + Color(red: red / 255, green: green / 255, blue: blue / 255) } static let canvas = color(243, 245, 248) @@ -62,37 +71,48 @@ private enum AIPalette { static let muted = color(111, 124, 143) static let line = color(224, 229, 235) static let brand = color(19, 30, 48) - static let primary = color(17, 119, 110) - static let danger = color(182, 77, 72) - static let warning = color(184, 120, 17) - static let success = color(21, 149, 112) -#endif + static let surfaceRaised = color(251, 252, 253) + static let chrome = color(255, 255, 255, 0.82) + static let onPrimary = color(255, 255, 255) + static let onDanger = color(255, 255, 255) + static let primary = color(17, 20, 25) + static let danger = color(190, 65, 57) + static let warning = color(170, 111, 23) + static let success = color(50, 150, 101) + #endif } public enum AITheme { - public static let canvas = AIPalette.canvas - public static let surface = AIPalette.surface - public static let control = AIPalette.control - public static let ink = AIPalette.ink - public static let muted = AIPalette.muted - public static let line = AIPalette.line - public static let brand = AIPalette.brand - public static let primary = AIPalette.primary - public static let danger = AIPalette.danger - public static let warning = AIPalette.warning - public static let success = AIPalette.success + public static let canvas = AIPalette.canvas + public static let surface = AIPalette.surface + public static let surfaceRaised = AIPalette.surfaceRaised + public static let control = AIPalette.control + public static let chrome = AIPalette.chrome + public static let ink = AIPalette.ink + public static let muted = AIPalette.muted + public static let line = AIPalette.line + public static let brand = AIPalette.brand + public static let primary = AIPalette.primary + public static let onPrimary = AIPalette.onPrimary + public static let danger = AIPalette.danger + public static let onDanger = AIPalette.onDanger + public static let warning = AIPalette.warning + public static let success = AIPalette.success - 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 + public static let buttonHeight: CGFloat = 48 + public static let compactButtonHeight: CGFloat = 44 + public static let largeButtonHeight: CGFloat = 56 + public static let toolbarButtonHeight: CGFloat = 44 + public static let iconButtonSize: CGFloat = 44 + public static let buttonRadius: CGFloat = 16 + public static let cardRadius: CGFloat = 22 + public static let inputRadius: CGFloat = 16 + public static let inputHeight: CGFloat = 52 + public static let segmentedControlHeight: CGFloat = 44 + public static let compactButtonMinWidth: CGFloat = 72 + public static let regularButtonMinWidth: CGFloat = 88 + public static let toolbarButtonMinWidth: CGFloat = 96 + public static let toolbarButtonWidth: CGFloat = 104 + public static let footerSecondaryButtonWidth: CGFloat = 104 + public static let listActionButtonWidth: CGFloat = 84 } diff --git a/apps/ios/Tests/MultiSimAdminTests/AIThemeTests.swift b/apps/ios/Tests/MultiSimAdminTests/AIThemeTests.swift index 2a3cc50..51d4a82 100644 --- a/apps/ios/Tests/MultiSimAdminTests/AIThemeTests.swift +++ b/apps/ios/Tests/MultiSimAdminTests/AIThemeTests.swift @@ -4,84 +4,86 @@ import XCTest @testable import MultiSimAdmin final class AIThemeTests: XCTestCase { - func testControlDimensionsMatchIOS() { - XCTAssertEqual(AITheme.buttonHeight, 44, accuracy: 0.1) - XCTAssertEqual(AITheme.compactButtonHeight, 40, accuracy: 0.1) - XCTAssertEqual(AITheme.largeButtonHeight, 50, accuracy: 0.1) - XCTAssertEqual(AITheme.toolbarButtonHeight, 38, accuracy: 0.1) - XCTAssertEqual(AITheme.buttonRadius, 12, accuracy: 0.1) - XCTAssertEqual(AITheme.iconButtonSize, 44, accuracy: 0.1) - XCTAssertEqual(AITheme.toolbarButtonMinWidth, 88, accuracy: 0.1) - XCTAssertEqual(AITheme.toolbarButtonWidth, 100, accuracy: 0.1) - XCTAssertEqual(AITheme.footerSecondaryButtonWidth, 100, accuracy: 0.1) - XCTAssertEqual(AITheme.listActionButtonWidth, 72, accuracy: 0.1) - XCTAssertEqual(AIButtonStyle.pressedScale, 0.97, accuracy: 0.01) + func testControlDimensionsMatchIOS() { + XCTAssertEqual(AITheme.buttonHeight, 48, accuracy: 0.1) + XCTAssertEqual(AITheme.compactButtonHeight, 44, accuracy: 0.1) + XCTAssertEqual(AITheme.largeButtonHeight, 56, accuracy: 0.1) + XCTAssertEqual(AITheme.toolbarButtonHeight, 44, accuracy: 0.1) + XCTAssertEqual(AITheme.segmentedControlHeight, 44, accuracy: 0.1) + XCTAssertEqual(AITheme.buttonRadius, 16, accuracy: 0.1) + XCTAssertEqual(AITheme.iconButtonSize, 44, accuracy: 0.1) + XCTAssertEqual(AITheme.toolbarButtonMinWidth, 96, accuracy: 0.1) + XCTAssertEqual(AITheme.toolbarButtonWidth, 104, accuracy: 0.1) + XCTAssertEqual(AITheme.footerSecondaryButtonWidth, 104, accuracy: 0.1) + XCTAssertEqual(AITheme.listActionButtonWidth, 84, accuracy: 0.1) + XCTAssertEqual(AIButtonStyle.pressedScale, 0.97, accuracy: 0.01) + } + + func testBottomActionsFillEqualWidths() { + let primary = AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true) + let secondary = AIButtonStyle(kind: .secondary, size: .regular, fillsWidth: true) + XCTAssertTrue(primary.fillsWidth) + XCTAssertTrue(secondary.fillsWidth) + } + + func testFixedWidthDrawsInsideTheButtonStyle() { + let button = AIButtonStyle( + kind: .danger, + size: .compact, + fixedWidth: AITheme.listActionButtonWidth + ) + + XCTAssertTrue(button.fillsWidth == false) + XCTAssertEqual(AITheme.listActionButtonWidth, 84, accuracy: 0.1) + } + + func testToolbarControlsUseUnifiedColorAndGeometry() { + let expected: [(Color, Color, CGFloat, CGFloat)] = [ + (AITheme.control, AITheme.ink, AITheme.toolbarButtonWidth, AITheme.toolbarButtonHeight), + (AITheme.primary, AITheme.onPrimary, AITheme.toolbarButtonWidth, AITheme.toolbarButtonHeight), + (AITheme.danger, AITheme.onDanger, AITheme.toolbarButtonWidth, AITheme.toolbarButtonHeight), + ] + + for (background, foreground, width, height) in expected { + XCTAssertTrue(background == background) + XCTAssertTrue(foreground == foreground) + XCTAssertEqual(width, 104, accuracy: 0.1) + XCTAssertEqual(height, 44, accuracy: 0.1) } + } - func testBottomActionsFillEqualWidths() { - let primary = AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true) - let secondary = AIButtonStyle(kind: .secondary, size: .regular, fillsWidth: true) - XCTAssertTrue(primary.fillsWidth) - XCTAssertTrue(secondary.fillsWidth) - } + func testButtonKindsHaveDistinctBaseColors() { + let colors = [ + AIButtonStyle.Kind.primary, + .secondary, + .danger, + ].map(\.backgroundColor) + XCTAssertEqual(Set(colors).count, 3) + } - func testFixedWidthDrawsInsideTheButtonStyle() { - let button = AIButtonStyle( - kind: .danger, - size: .compact, - fixedWidth: AITheme.listActionButtonWidth - ) + func testSearchFieldExposesClearAction() throws { + let mirror = Mirror(reflecting: AISearchField("搜索", text: .constant("待清理"))) + let text = try XCTUnwrap( + mirror.children.first { $0.label == "_text" }?.value as? Binding) + XCTAssertEqual(text.wrappedValue, "待清理") + } - XCTAssertTrue(button.fillsWidth == false) - XCTAssertEqual(AITheme.listActionButtonWidth, 72, accuracy: 0.1) - } + func testDisabledKindsUseReducedButVisibleRendering() { + let disabledKinds = [ + AIButtonStyle.Kind.primary, + .secondary, + .danger, + ] + let disabledColors = disabledKinds.map { $0.backgroundColor(isEnabled: false) } - func testToolbarControlsUseUnifiedColorAndGeometry() { - let expected: [(Color, Color, CGFloat, CGFloat)] = [ - (AITheme.surface, AITheme.ink, AITheme.toolbarButtonWidth, AITheme.toolbarButtonHeight), - (AITheme.primary, .white, AITheme.toolbarButtonWidth, AITheme.toolbarButtonHeight), - (AITheme.danger, .white, AITheme.toolbarButtonWidth, AITheme.toolbarButtonHeight), - ] - - for (background, foreground, width, height) in expected { - XCTAssertTrue(background == background) - XCTAssertTrue(foreground == foreground) - XCTAssertEqual(width, 100, accuracy: 0.1) - XCTAssertEqual(height, 38, accuracy: 0.1) - } - } - - func testButtonKindsHaveDistinctBaseColors() { - let colors = [ - AIButtonStyle.Kind.primary, - .secondary, - .danger - ].map(\.backgroundColor) - XCTAssertEqual(Set(colors).count, 3) - } - - func testSearchFieldExposesClearAction() throws { - let mirror = Mirror(reflecting: AISearchField("搜索", text: .constant("待清理"))) - let text = try XCTUnwrap(mirror.children.first { $0.label == "_text" }?.value as? Binding) - XCTAssertEqual(text.wrappedValue, "待清理") - } - - func testDisabledKindsUseReducedButVisibleRendering() { - let disabledKinds = [ - AIButtonStyle.Kind.primary, - .secondary, - .danger - ] - let disabledColors = disabledKinds.map { $0.backgroundColor(isEnabled: false) } - - XCTAssertEqual(Set(disabledColors).count, disabledKinds.count) - XCTAssertEqual( - disabledKinds.map { $0.foregroundColor(isEnabled: false) }, - [ - .white.opacity(0.72), - AITheme.muted, - .white.opacity(0.72) - ] - ) - } + XCTAssertEqual(Set(disabledColors), [AITheme.control]) + XCTAssertEqual( + disabledKinds.map { $0.foregroundColor(isEnabled: false) }, + [ + AITheme.muted, + AITheme.muted, + AITheme.muted, + ] + ) + } }