From 31b2f33d1531a945ec587bf38c215105861c325c Mon Sep 17 00:00:00 2001 From: chick Date: Thu, 10 Sep 2026 03:19:03 +0800 Subject: [PATCH] feat(ios): refine adaptive theme and unified controls --- .../Features/Device/InstanceDetailView.swift | 22 +- .../Features/Fleet/FleetView.swift | 17 +- .../Features/Fleet/InstanceFormView.swift | 14 +- .../Features/Logs/LogsView.swift | 536 ++++++------ .../Features/Messages/MessagesView.swift | 24 +- .../Messages/SendMessageFormView.swift | 44 +- .../Notifications/NotificationsView.swift | 571 ++++++------ .../Features/Settings/SettingsView.swift | 5 + .../MultiSimAdmin/MultiSimAdminApp.swift | 1 - .../MultiSimAdmin/Theme/AIButtonStyle.swift | 2 +- .../Sources/MultiSimAdmin/Theme/AICard.swift | 819 ++++++++++-------- .../Sources/MultiSimAdmin/Theme/AITheme.swift | 90 +- 12 files changed, 1161 insertions(+), 984 deletions(-) diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Device/InstanceDetailView.swift b/apps/ios/Sources/MultiSimAdmin/Features/Device/InstanceDetailView.swift index 93ec720..4703d9f 100644 --- a/apps/ios/Sources/MultiSimAdmin/Features/Device/InstanceDetailView.swift +++ b/apps/ios/Sources/MultiSimAdmin/Features/Device/InstanceDetailView.swift @@ -38,29 +38,21 @@ struct InstanceDetailView: View { private var header: some View { AICard { VStack(alignment: .leading, spacing: 12) { - AISectionTitle("设备详情", systemImage: "iphone.gen3") - Button { + AISectionTitle( + "设备详情", + systemImage: "iphone.gen3", + actionTitle: revealsSensitive ? "隐藏敏感值" : "显示敏感值", + actionSystemImage: revealsSensitive ? "eye.slash" : "eye" + ) { revealsSensitive.toggle() - } label: { - Label( - revealsSensitive ? "隐藏敏感值" : "显示敏感值", - systemImage: revealsSensitive ? "eye.slash" : "eye" - ) } - .buttonStyle( - AIButtonStyle( - kind: .secondary, - size: .compact, - fixedWidth: AITheme.footerSecondaryButtonWidth - ) - ) Text(model.instance.origin.absoluteString) .font(.subheadline) .foregroundStyle(AITheme.muted) .lineLimit(2) .truncationMode(.middle) if model.isLoading { - Text("正在读取设备能力…") + Text("正在读取设备详情…") .font(.subheadline) .foregroundStyle(AITheme.muted) } diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Fleet/FleetView.swift b/apps/ios/Sources/MultiSimAdmin/Features/Fleet/FleetView.swift index c737858..13db3a7 100644 --- a/apps/ios/Sources/MultiSimAdmin/Features/Fleet/FleetView.swift +++ b/apps/ios/Sources/MultiSimAdmin/Features/Fleet/FleetView.swift @@ -14,14 +14,18 @@ struct FleetView: View { ScrollView { LazyVStack(spacing: 12) { searchField - if let errorMessage = model.errorMessage { - AIStatusBanner(errorMessage, tint: AITheme.danger, systemImage: "exclamationmark.triangle") - } + if let errorMessage = model.errorMessage { + AIStatusBanner( + errorMessage, + tint: AITheme.danger, + systemImage: "exclamationmark.triangle" + ) + } if model.selectionMode { selectionBar } if model.isLoading && model.rows.isEmpty { - AILoadingCard("正在同步实例") + AILoadingCard("正在同步实例数据") } if model.rows.isEmpty && !model.isLoading { AICard { @@ -58,6 +62,7 @@ struct FleetView: View { Label("批量选择", systemImage: "checklist") } .buttonStyle(AIButtonStyle(kind: .secondary, size: .regular, fillsWidth: true)) + .frame(maxWidth: .infinity) Button { model.showingForm = true @@ -65,6 +70,7 @@ struct FleetView: View { Label("添加实例", systemImage: "plus") } .buttonStyle(AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true)) + .frame(maxWidth: .infinity) } } } @@ -163,6 +169,9 @@ struct FleetView: View { Text(row.instance.name) .font(.headline) .foregroundStyle(AITheme.ink) + .lineLimit(1) + .truncationMode(.middle) + .layoutPriority(1) Spacer() AITag(row.status.label, tint: statusColor(row.status)) } diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Fleet/InstanceFormView.swift b/apps/ios/Sources/MultiSimAdmin/Features/Fleet/InstanceFormView.swift index 375ef80..141e83f 100644 --- a/apps/ios/Sources/MultiSimAdmin/Features/Fleet/InstanceFormView.swift +++ b/apps/ios/Sources/MultiSimAdmin/Features/Fleet/InstanceFormView.swift @@ -39,17 +39,13 @@ struct InstanceFormView: View { .aiBottomActionBar { HStack(spacing: 10) { Button("取消") { dismiss() } - .buttonStyle( - AIButtonStyle( - kind: .secondary, - size: .regular, - fixedWidth: AITheme.footerSecondaryButtonWidth - ) - ) + .buttonStyle(AIButtonStyle(kind: .secondary, size: .regular, fillsWidth: true)) + .frame(maxWidth: .infinity) Button("保存实例") { save() } .buttonStyle(AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true)) .disabled(!canSave) + .frame(maxWidth: .infinity) } } } @@ -62,9 +58,7 @@ struct InstanceFormView: View { secure: Bool = false ) -> some View { VStack(alignment: .leading, spacing: 6) { - Text(title) - .font(.subheadline.weight(.semibold)) - .foregroundStyle(AITheme.ink) + AIFieldLabel(title) if secure { SecureField(placeholder, text: text) .textFieldStyle(AIFieldStyle()) diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Logs/LogsView.swift b/apps/ios/Sources/MultiSimAdmin/Features/Logs/LogsView.swift index 04fc388..383887f 100644 --- a/apps/ios/Sources/MultiSimAdmin/Features/Logs/LogsView.swift +++ b/apps/ios/Sources/MultiSimAdmin/Features/Logs/LogsView.swift @@ -1,286 +1,278 @@ import SwiftUI struct LogsView: View { - private enum Tab: Hashable { - case runtime - case connections - case diagnostics - } + private enum Tab: Hashable { + case runtime + case connections + case diagnostics + } - @StateObject private var model: LogsViewModel - @State private var tab: Tab = .runtime - @State private var showsPruneDialog = false + @StateObject private var model: LogsViewModel + @State private var tab: Tab = .runtime + @State private var showsPruneDialog = false - init(model: LogsViewModel? = nil) { - _model = StateObject(wrappedValue: model ?? DefaultContainer.makeLogsModel()) - } + init(model: LogsViewModel? = nil) { + _model = StateObject(wrappedValue: model ?? DefaultContainer.makeLogsModel()) + } - 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 - Picker("视图", selection: $tab) { - Text("运行日志").tag(Tab.runtime) - Text("连接日志").tag(Tab.connections) - Text("设备诊断").tag(Tab.diagnostics) - } - .pickerStyle(.segmented) - - filterBar - - switch tab { - case .runtime: - runtimeSection - case .connections: - connectionSection - case .diagnostics: - diagnosticSection - } - } - } - .padding(16) - } - } - .navigationTitle("日志中心") - .toolbar { - ToolbarItemGroup(placement: .primaryAction) { - AIToolbarButton("刷新", systemImage: "arrow.clockwise") { - Task { await model.refresh() } - } - .disabled(model.isLoading) - } - } - .confirmationDialog( - "清理 7 天前的连接日志?操作不可恢复。", - isPresented: $showsPruneDialog, - titleVisibility: .visible - ) { - Button("清理连接日志", role: .destructive) { - Task { - _ = await model.pruneConnections(olderThanDays: 7) - } - } - Button("取消", role: .cancel) {} - } - .refreshable { - await model.refresh() - } - .task { - await model.refresh() - } - } - - private var stats: some View { - LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], spacing: 12) { - AIStatCard(title: "日志", value: "\(model.snapshot?.runtime.count ?? 0)", tint: AITheme.primary) - AIStatCard(title: "在线", value: "\(onlineCount)", tint: AITheme.success) - AIStatCard(title: "离线", value: "\(model.offlineCount)", tint: AITheme.danger) - } - } - - private var filterBar: some View { - AICard { - VStack(spacing: 10) { - AISearchField("搜索日志、错误码或实例", text: $model.searchText) - - Picker("实例", selection: $model.selectedInstanceID) { - Text("全部实例").tag(Optional.none) - ForEach(model.instances) { instance in - Text(instance.name).tag(Optional(instance.id)) - } - } - .pickerStyle(.menu) - .aiSelectSurface() - - Group { - if tab == .runtime { - HStack(spacing: 10) { - Picker("来源", selection: $model.selectedSource) { - Text("全部来源").tag(Optional.none) - ForEach(RuntimeLogSource.allCases, id: \.self) { source in - Text(source.label).tag(Optional(source)) - } - } - .pickerStyle(.menu) - .aiSelectSurface() - - Picker("级别", selection: $model.selectedLevel) { - Text("全部级别").tag(Optional.none) - ForEach(RuntimeLogLevel.allCases, id: \.self) { level in - Text(level.label).tag(Optional(level)) - } - } - .pickerStyle(.menu) - .aiSelectSurface() - } - .frame(maxWidth: .infinity) - } else if tab == .connections { - Picker("结果", selection: $model.selectedOutcome) { - Text("全部结果").tag(Optional.none) - ForEach(ConnectionOutcome.allCases, id: \.self) { outcome in - Text(outcome.label).tag(Optional(outcome)) - } - } - .pickerStyle(.menu) - .aiSelectSurface() - } - } - .transition(.opacity) - } - .animation(.easeInOut(duration: 0.18), value: tab) - } - } - - private var runtimeSection: some View { - AICard { - VStack(alignment: .leading, spacing: 10) { - AISectionTitle("统一时间线", systemImage: "clock", detail: "\(model.filteredRuntime.count) 条") - if model.filteredRuntime.isEmpty { - AIEmptyState(systemImage: "clock", title: "暂无运行日志", message: "下拉刷新后会汇总实例日志。") - } else { - ForEach(model.filteredRuntime) { entry in - AIListCard( - title: entry.message, - subtitle: "\(entry.instanceName ?? "未知实例") · \(entry.occurredAt.formatted())", - status: entry.level.label, - statusTint: levelTint(entry.level), - details: { - var values = [entry.source.label] - if let duration = entry.durationMs { - values.append("耗时 \(duration) ms") - } - return values - }() - ) - } - } - } - } - } - - private var connectionSection: some View { - AICard { - VStack(alignment: .leading, spacing: 10) { - HStack(alignment: .center, spacing: 10) { - AISectionTitle( - "连接探测记录", - systemImage: "dot.radiowaves.left.and.right", - detail: "\(model.filteredConnections.count) 条" - ) - compactActionButton("清理", systemImage: "trash") { - showsPruneDialog = true - } - } - if model.filteredConnections.isEmpty { - AIEmptyState(systemImage: "dot.radiowaves.left.and.right", title: "暂无连接日志", message: "连接状态探测后会在这里记录。") - } else { - ForEach(model.filteredConnections) { entry in - AIListCard( - title: entry.instanceName, - subtitle: "\(entry.observedAt.formatted()) · \(entry.state)", - status: entry.outcome.label, - statusTint: outcomeTint(entry.outcome), - details: [ - "HTTP \(entry.httpStatus.map(String.init) ?? "--") · \(entry.durationMs) ms", - entry.errorCode ?? "无错误码" - ] - ) - } - } - } - } - } - - private var diagnosticSection: some View { - AICard { - VStack(alignment: .leading, spacing: 10) { - AISectionTitle("设备诊断", systemImage: "iphone.gen3", detail: "\(model.filteredDiagnostics.count) 台") - if model.filteredDiagnostics.isEmpty { - AIEmptyState(systemImage: "iphone.gen3", title: "暂无设备诊断", message: "已配置实例的设备状态会显示在这里。") - } else { - ForEach(model.filteredDiagnostics) { diagnostic in - AIListCard( - title: diagnostic.name, - status: diagnostic.healthStatus ?? "未知", - statusTint: diagnostic.state == .online ? AITheme.success : AITheme.warning, - details: [ - diagnostic.origin, - "版本 \(diagnostic.version ?? "未知") · \(diagnostic.platform ?? "未知平台")" - ] - ) { - LazyVGrid( - columns: [GridItem(.adaptive(minimum: 82), spacing: 6)], - alignment: .leading, - spacing: 6 - ) { - ForEach(diagnostic.featureStatus) { feature in - AITag(feature.name, tint: featureTint(feature.state)) - } - if let connection = diagnostic.connection, connection.total > 1 { - AITag( - "延迟 \(connection.averageDurationMs) ms", - tint: connection.averageDurationMs <= 1500 ? AITheme.success : AITheme.warning - ) - } - } - } - } - } - } - } - } - - private var onlineCount: Int { - (model.snapshot?.diagnostics.filter { $0.state == .online }.count) ?? 0 - } - - private func compactActionButton( - _ title: String, - systemImage: String, - action: @escaping () -> Void - ) -> some View { - Button(action: action) { - Label(title, systemImage: systemImage) - } - .buttonStyle( - AIButtonStyle( - kind: .secondary, - size: .compact, - fixedWidth: AITheme.listActionButtonWidth + 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: "设备诊断"), + ] ) - ) - } - private func levelTint(_ level: RuntimeLogLevel) -> Color { - switch level { - case .info: return AITheme.success - case .warning: return AITheme.warning - case .error: return AITheme.danger - } - } + filterBar - private func outcomeTint(_ outcome: ConnectionOutcome) -> Color { - switch outcome { - case .success: return AITheme.success - case .stale: return AITheme.warning - case .failed: return AITheme.danger - case .unsupported: return AITheme.muted + switch tab { + case .runtime: + runtimeSection + case .connections: + connectionSection + case .diagnostics: + diagnosticSection + } + } } + .padding(16) + } } + .navigationTitle("日志中心") + .toolbar { + ToolbarItemGroup(placement: .primaryAction) { + AIToolbarButton("刷新", systemImage: "arrow.clockwise") { + Task { await model.refresh() } + } + .disabled(model.isLoading) + } + } + .confirmationDialog( + "清理 7 天前的连接日志?操作不可恢复。", + isPresented: $showsPruneDialog, + titleVisibility: .visible + ) { + Button("清理连接日志", role: .destructive) { + Task { + _ = await model.pruneConnections(olderThanDays: 7) + } + } + Button("取消", role: .cancel) {} + } + .refreshable { + await model.refresh() + } + .task { + await model.refresh() + } + } - private func featureTint(_ state: FeatureStatus.State) -> Color { - switch state { - case .ready: return AITheme.success - case .requiresLogin: return AITheme.warning - case .unavailable: return AITheme.muted - } + private var stats: some View { + LazyVGrid( + columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], spacing: 12 + ) { + AIStatCard(title: "日志", value: "\(model.snapshot?.runtime.count ?? 0)", tint: AITheme.primary) + AIStatCard(title: "在线", value: "\(onlineCount)", tint: AITheme.success) + AIStatCard(title: "离线", value: "\(model.offlineCount)", tint: AITheme.danger) } + } + + private var filterBar: some View { + AICard { + VStack(spacing: 10) { + AISearchField("搜索日志、错误码或实例", text: $model.searchText) + + Picker("实例", selection: $model.selectedInstanceID) { + Text("全部实例").tag(Optional.none) + ForEach(model.instances) { instance in + Text(instance.name).tag(Optional(instance.id)) + } + } + .pickerStyle(.menu) + .aiSelectSurface() + + Group { + if tab == .runtime { + HStack(spacing: 10) { + Picker("来源", selection: $model.selectedSource) { + Text("全部来源").tag(Optional.none) + ForEach(RuntimeLogSource.allCases, id: \.self) { source in + Text(source.label).tag(Optional(source)) + } + } + .pickerStyle(.menu) + .aiSelectSurface() + + Picker("级别", selection: $model.selectedLevel) { + Text("全部级别").tag(Optional.none) + ForEach(RuntimeLogLevel.allCases, id: \.self) { level in + Text(level.label).tag(Optional(level)) + } + } + .pickerStyle(.menu) + .aiSelectSurface() + } + .frame(maxWidth: .infinity) + } else if tab == .connections { + Picker("结果", selection: $model.selectedOutcome) { + Text("全部结果").tag(Optional.none) + ForEach(ConnectionOutcome.allCases, id: \.self) { outcome in + Text(outcome.label).tag(Optional(outcome)) + } + } + .pickerStyle(.menu) + .aiSelectSurface() + } + } + .transition(.opacity) + } + .animation(.easeInOut(duration: 0.18), value: tab) + } + } + + private var runtimeSection: some View { + VStack(alignment: .leading, spacing: 10) { + AISectionTitle("统一时间线", systemImage: "clock", detail: "\(model.filteredRuntime.count) 条") + if model.filteredRuntime.isEmpty { + AICard { + AIEmptyState(systemImage: "clock", title: "暂无运行日志", message: "下拉刷新后会同步实例运行记录。") + } + } else { + ForEach(model.filteredRuntime) { entry in + AIListCard( + title: entry.message, + subtitle: "\(entry.instanceName ?? "未知实例") · \(entry.occurredAt.formatted())", + status: entry.level.label, + statusTint: levelTint(entry.level), + details: { + var values = [entry.source.label] + if let duration = entry.durationMs { + values.append("耗时 \(duration) ms") + } + return values + }() + ) + } + } + } + } + + private var connectionSection: some View { + VStack(alignment: .leading, spacing: 10) { + AISectionTitle( + "连接探测记录", + systemImage: "dot.radiowaves.left.and.right", + detail: "\(model.filteredConnections.count) 条", + actionTitle: "清理", + actionSystemImage: "trash", + actionKind: .danger + ) { + showsPruneDialog = true + } + if model.filteredConnections.isEmpty { + AICard { + AIEmptyState( + systemImage: "dot.radiowaves.left.and.right", title: "暂无连接日志", message: "连接状态探测后会在这里记录。" + ) + } + } else { + ForEach(model.filteredConnections) { entry in + AIListCard( + title: entry.instanceName, + subtitle: "\(entry.observedAt.formatted()) · \(entry.state)", + status: entry.outcome.label, + statusTint: outcomeTint(entry.outcome), + details: [ + "HTTP \(entry.httpStatus.map(String.init) ?? "--") · \(entry.durationMs) ms", + entry.errorCode ?? "无错误码", + ] + ) + } + } + } + } + + private var diagnosticSection: some View { + VStack(alignment: .leading, spacing: 10) { + AISectionTitle( + "设备诊断", systemImage: "iphone.gen3", detail: "\(model.filteredDiagnostics.count) 台") + if model.filteredDiagnostics.isEmpty { + AICard { + AIEmptyState(systemImage: "iphone.gen3", title: "暂无设备诊断", message: "已配置实例的设备状态会显示在这里。") + } + } else { + ForEach(model.filteredDiagnostics) { diagnostic in + AIListCard( + title: diagnostic.name, + status: diagnostic.healthStatus ?? "未知", + statusTint: diagnostic.state == .online ? AITheme.success : AITheme.warning, + details: [ + diagnostic.origin, + "版本 \(diagnostic.version ?? "未知") · \(diagnostic.platform ?? "未知平台")", + ] + ) { + LazyVGrid( + columns: [GridItem(.adaptive(minimum: 82), spacing: 6)], + alignment: .leading, + spacing: 6 + ) { + ForEach(diagnostic.featureStatus) { feature in + AITag(feature.name, tint: featureTint(feature.state)) + } + if let connection = diagnostic.connection, connection.total > 1 { + AITag( + "延迟 \(connection.averageDurationMs) ms", + tint: connection.averageDurationMs <= 1500 ? AITheme.success : AITheme.warning + ) + } + } + } + } + } + } + } + + private var onlineCount: Int { + (model.snapshot?.diagnostics.filter { $0.state == .online }.count) ?? 0 + } + + private func levelTint(_ level: RuntimeLogLevel) -> Color { + switch level { + case .info: return AITheme.success + case .warning: return AITheme.warning + case .error: return AITheme.danger + } + } + + private func outcomeTint(_ outcome: ConnectionOutcome) -> Color { + switch outcome { + case .success: return AITheme.success + case .stale: return AITheme.warning + case .failed: return AITheme.danger + case .unsupported: return AITheme.muted + } + } + + private func featureTint(_ state: FeatureStatus.State) -> Color { + switch state { + case .ready: return AITheme.success + case .requiresLogin: return AITheme.warning + case .unavailable: return AITheme.muted + } + } } diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Messages/MessagesView.swift b/apps/ios/Sources/MultiSimAdmin/Features/Messages/MessagesView.swift index 6c1934c..dddfc8f 100644 --- a/apps/ios/Sources/MultiSimAdmin/Features/Messages/MessagesView.swift +++ b/apps/ios/Sources/MultiSimAdmin/Features/Messages/MessagesView.swift @@ -18,7 +18,7 @@ struct MessagesView: View { AIStatusBanner(errorMessage, tint: AITheme.warning, systemImage: "exclamationmark.triangle") } if model.isLoading && model.filteredRows.isEmpty { - AILoadingCard("正在同步消息") + AILoadingCard("正在同步消息数据") } if model.filteredRows.isEmpty && !model.isLoading { AICard { @@ -73,12 +73,13 @@ struct MessagesView: View { private var filterBar: some View { AICard { VStack(spacing: 10) { - Picker("方向", selection: $model.direction) { - ForEach(MessagesViewModel.DirectionFilter.allCases) { item in - Text(item.rawValue).tag(item) + AISegmentedControl( + "消息方向", + selection: $model.direction, + options: MessagesViewModel.DirectionFilter.allCases.map { + (value: $0, title: $0.rawValue) } - } - .pickerStyle(.segmented) + ) Picker("实例", selection: $model.selectedInstanceID) { Text("全部实例").tag(Optional.none) @@ -100,6 +101,9 @@ struct MessagesView: View { 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) } @@ -107,16 +111,18 @@ struct MessagesView: View { .font(.subheadline) .foregroundStyle(AITheme.ink) .fixedSize(horizontal: false, vertical: true) - HStack { + HStack(alignment: .center, spacing: 10) { Text(message.instanceName) .font(.caption) .foregroundStyle(AITheme.muted) .lineLimit(1) .truncationMode(.middle) .layoutPriority(1) - Spacer() - Button("删除") { + Spacer(minLength: 8) + Button { pendingDeleteMessage = message + } label: { + Label("删除", systemImage: "trash") } .buttonStyle( AIButtonStyle( diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Messages/SendMessageFormView.swift b/apps/ios/Sources/MultiSimAdmin/Features/Messages/SendMessageFormView.swift index b35c6dd..13a6eac 100644 --- a/apps/ios/Sources/MultiSimAdmin/Features/Messages/SendMessageFormView.swift +++ b/apps/ios/Sources/MultiSimAdmin/Features/Messages/SendMessageFormView.swift @@ -28,9 +28,7 @@ struct SendMessageFormView: View { AICard { VStack(alignment: .leading, spacing: 14) { VStack(alignment: .leading, spacing: 6) { - Text("实例") - .font(.subheadline.weight(.semibold)) - .foregroundStyle(AITheme.ink) + AIFieldLabel("实例") Picker("实例", selection: Binding( get: { model.selectedInstanceID ?? model.instances.first?.id }, set: { model.selectedInstanceID = $0 } @@ -43,23 +41,21 @@ struct SendMessageFormView: View { .aiSelectSurface() } - TextField("收件号码", text: $phoneNumber) - .textFieldStyle(AIFieldStyle()) - #if os(iOS) - .keyboardType(.phonePad) - #endif + VStack(alignment: .leading, spacing: 6) { + AIFieldLabel("收件号码") + TextField("收件号码", text: $phoneNumber) + .textFieldStyle(AIFieldStyle()) + #if os(iOS) + .keyboardType(.phonePad) + #endif + } - TextField("消息内容", text: $content, axis: .vertical) - .textFieldStyle(.plain) - .padding(.horizontal, 12) - .frame(minHeight: AITheme.buttonHeight) - .background(AITheme.canvas) - .clipShape(RoundedRectangle(cornerRadius: AITheme.buttonRadius)) - .overlay { - RoundedRectangle(cornerRadius: AITheme.buttonRadius) - .strokeBorder(AITheme.line) - } - .lineLimit(4...8) + VStack(alignment: .leading, spacing: 6) { + AIFieldLabel("消息内容") + TextField("消息内容", text: $content, axis: .vertical) + .textFieldStyle(AIFieldStyle()) + .lineLimit(4...8) + } } } if let validationMessage { @@ -73,17 +69,13 @@ struct SendMessageFormView: View { .aiBottomActionBar { HStack(spacing: 10) { Button("取消") { dismiss() } - .buttonStyle( - AIButtonStyle( - kind: .secondary, - size: .regular, - fixedWidth: AITheme.footerSecondaryButtonWidth - ) - ) + .buttonStyle(AIButtonStyle(kind: .secondary, size: .regular, fillsWidth: true)) + .frame(maxWidth: .infinity) Button("发送") { send() } .buttonStyle(AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true)) .disabled(!canSend) + .frame(maxWidth: .infinity) } } } diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Notifications/NotificationsView.swift b/apps/ios/Sources/MultiSimAdmin/Features/Notifications/NotificationsView.swift index 3d58d43..0cb4a37 100644 --- a/apps/ios/Sources/MultiSimAdmin/Features/Notifications/NotificationsView.swift +++ b/apps/ios/Sources/MultiSimAdmin/Features/Notifications/NotificationsView.swift @@ -1,314 +1,327 @@ import SwiftUI struct NotificationsView: View { - @StateObject private var model: NotificationsViewModel - @State private var pendingDeleteQueueID: String? - private static let timestampFormatter = ISO8601DateFormatter() - private static let fractionalTimestampFormatter: ISO8601DateFormatter = { - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - return formatter - }() + @StateObject private var model: NotificationsViewModel + @State private var pendingDeleteQueueID: String? + private static let timestampFormatter = ISO8601DateFormatter() + private static let fractionalTimestampFormatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + }() - init(model: NotificationsViewModel? = nil) { - _model = StateObject(wrappedValue: model ?? DefaultContainer.makeNotificationsModel()) - } + init(model: NotificationsViewModel? = nil) { + _model = StateObject(wrappedValue: model ?? DefaultContainer.makeNotificationsModel()) + } - var body: some View { - ZStack { - AITheme.canvas.ignoresSafeArea() - ScrollView { - LazyVStack(spacing: 12) { - if let actionMessage = model.actionMessage { - AIStatusBanner( - actionMessage, - tint: model.actionSucceeded ? AITheme.success : AITheme.danger, - systemImage: model.actionSucceeded ? "checkmark.circle" : "exclamationmark.triangle" - ) - } - if let errorMessage = model.errorMessage { - AIStatusBanner(errorMessage, tint: AITheme.danger, systemImage: "exclamationmark.triangle") - } - - if let overview = model.overview { - stats(overview) - queue(overview) - channels(overview) - logs(overview) - devices(overview) - } else if model.isLoading { - AILoadingCard("正在加载通知中心") - } else { - empty - } - } - .padding(16) - } - } - .navigationTitle("通知中心") - .aiBottomActionBar { - Button { - Task { await model.retryAll() } - } label: { - Label("立即投递", systemImage: "paperplane") - } - .buttonStyle(AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true)) - .disabled(model.isLoading) - } - .confirmationDialog( - "删除这条通知队列记录?操作不可恢复。", - isPresented: Binding( - get: { pendingDeleteQueueID != nil }, - set: { if !$0 { pendingDeleteQueueID = nil } } - ), - titleVisibility: .visible - ) { - Button("删除队列记录", role: .destructive) { - guard let id = pendingDeleteQueueID else { return } - Task { - if let item = model.overview?.queue.items.first(where: { $0.id == id }) { - await model.deleteQueue(item) - } - } - } - Button("取消", role: .cancel) {} - } - .refreshable { - await model.refresh() - } - .task { - await model.refresh() - } - } - - private var empty: some View { - AICard { - AIEmptyState( - systemImage: "bell", - title: "暂无通知数据", - message: "下拉刷新后从已配置实例同步。" + var body: some View { + ZStack { + AITheme.canvas.ignoresSafeArea() + ScrollView { + LazyVStack(spacing: 12) { + if let actionMessage = model.actionMessage { + AIStatusBanner( + actionMessage, + tint: model.actionSucceeded ? AITheme.success : AITheme.danger, + systemImage: model.actionSucceeded ? "checkmark.circle" : "exclamationmark.triangle" ) - } - } + } + if let errorMessage = model.errorMessage { + AIStatusBanner( + errorMessage, tint: AITheme.danger, systemImage: "exclamationmark.triangle") + } - private func stats(_ overview: NotificationOverview) -> some View { - LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 12) { - AIStatCard( - title: "通道", - value: "\(overview.config.channelEnabled)/\(overview.config.channelCount)", - detail: "已启用 / 总数", - tint: AITheme.primary - ) - AIStatCard( - title: "规则", - value: "\(overview.config.ruleEnabled)/\(overview.config.ruleCount)", - detail: "已启用 / 总数", - tint: AITheme.primary - ) - AIStatCard( - title: "队列", - value: "\(overview.queue.pending + overview.queue.retrying)", - detail: "等待与重试", - tint: AITheme.warning - ) - AIStatCard( - title: "失败", - value: "\(overview.queue.failed + overview.logs.failed)", - detail: "队列与投递", - tint: AITheme.danger - ) + if let overview = model.overview { + stats(overview) + queue(overview) + channels(overview) + logs(overview) + devices(overview) + } else if model.isLoading { + AILoadingCard("正在加载通知中心") + } else { + empty + } } + .padding(16) + } } + .navigationTitle("通知中心") + .aiBottomActionBar { + Button { + Task { await model.retryAll() } + } label: { + Label("立即投递", systemImage: "paperplane") + } + .buttonStyle(AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true)) + .disabled(model.isLoading) + } + .confirmationDialog( + "删除这条通知队列记录?操作不可恢复。", + isPresented: Binding( + get: { pendingDeleteQueueID != nil }, + set: { if !$0 { pendingDeleteQueueID = nil } } + ), + titleVisibility: .visible + ) { + Button("删除队列记录", role: .destructive) { + guard let id = pendingDeleteQueueID else { return } + Task { + if let item = model.overview?.queue.items.first(where: { $0.id == id }) { + await model.deleteQueue(item) + } + } + } + Button("取消", role: .cancel) {} + } + .refreshable { + await model.refresh() + } + .task { + await model.refresh() + } + } - private func devices(_ overview: NotificationOverview) -> some View { + private var empty: some View { + AICard { + AIEmptyState( + systemImage: "bell", + title: "暂无通知数据", + message: "下拉刷新后从已配置实例同步。" + ) + } + } + + private func stats(_ overview: NotificationOverview) -> some View { + LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 12) { + AIStatCard( + title: "通道", + value: "\(overview.config.channelEnabled)/\(overview.config.channelCount)", + detail: "已启用 / 总数", + tint: AITheme.primary + ) + AIStatCard( + title: "规则", + value: "\(overview.config.ruleEnabled)/\(overview.config.ruleCount)", + detail: "已启用 / 总数", + tint: AITheme.primary + ) + AIStatCard( + title: "队列", + value: "\(overview.queue.pending + overview.queue.retrying)", + detail: "等待与重试", + tint: AITheme.warning + ) + AIStatCard( + title: "失败", + value: "\(overview.queue.failed + overview.logs.failed)", + detail: "队列与投递", + tint: AITheme.danger + ) + } + } + + private func devices(_ overview: NotificationOverview) -> some View { + VStack(alignment: .leading, spacing: 10) { + AISectionTitle("实例状态", systemImage: "server.rack", detail: "\(overview.devices.count) 个") + if overview.devices.isEmpty { AICard { - VStack(alignment: .leading, spacing: 10) { - AISectionTitle("实例状态", systemImage: "server.rack", detail: "\(overview.devices.count) 个") - if overview.devices.isEmpty { - AIEmptyState(systemImage: "server.rack", title: "尚未添加实例", message: "先在节点总览中配置 SimAdmin 实例。") - } else { - ForEach(overview.devices) { device in - AIListCard( - title: device.name, - status: deviceState(device.state), - statusTint: deviceTint(device.state) - ) - } - } - } + AIEmptyState( + systemImage: "server.rack", title: "尚未添加实例", message: "先在节点总览中配置 SimAdmin 实例。") } + } else { + ForEach(overview.devices) { device in + AIListCard( + title: device.name, + status: deviceState(device.state), + statusTint: deviceTint(device.state) + ) + } + } } + } - private func channels(_ overview: NotificationOverview) -> some View { + private func channels(_ overview: NotificationOverview) -> some View { + VStack(alignment: .leading, spacing: 10) { + AISectionTitle( + "通道类型", systemImage: "square.stack.3d.up", detail: "\(overview.config.channelTypes.count) 类" + ) + if overview.config.channelTypes.isEmpty { AICard { - VStack(alignment: .leading, spacing: 10) { - AISectionTitle("通道类型", systemImage: "square.stack.3d.up", detail: "\(overview.config.channelTypes.count) 类") - if overview.config.channelTypes.isEmpty { - AIEmptyState(systemImage: "square.stack.3d.up", title: "未配置通知通道", message: "实例侧启用通知通道后会在这里显示。") - } else { - ForEach(overview.config.channelTypes) { channel in - AIListCard( - title: channelTypeLabel(channel.type), - status: "\(channel.enabled)/\(channel.total)", - statusTint: channel.enabled > 0 ? AITheme.success : AITheme.muted - ) - } - } - } + AIEmptyState( + systemImage: "square.stack.3d.up", title: "未配置通知通道", message: "实例侧启用通知通道后会在这里显示。") } + } else { + ForEach(overview.config.channelTypes) { channel in + AIListCard( + title: channelTypeLabel(channel.type), + status: "\(channel.enabled)/\(channel.total)", + statusTint: channel.enabled > 0 ? AITheme.success : AITheme.muted + ) + } + } } + } - private func queue(_ overview: NotificationOverview) -> some View { + private func queue(_ overview: NotificationOverview) -> some View { + VStack(alignment: .leading, spacing: 10) { + AISectionTitle("投递队列", systemImage: "tray.full", detail: "\(overview.queue.total) 项") + if overview.queue.items.isEmpty { AICard { - VStack(alignment: .leading, spacing: 10) { - AISectionTitle("投递队列", systemImage: "tray.full", detail: "\(overview.queue.total) 项") - if overview.queue.items.isEmpty { - Text("队列为空。") - .font(.subheadline) - .foregroundStyle(AITheme.muted) - } else { - ForEach(overview.queue.items) { item in - 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) } - } label: { - Label("重试", systemImage: "arrow.clockwise") - } - .buttonStyle( - AIButtonStyle( - kind: .secondary, - size: .compact, - fixedWidth: AITheme.listActionButtonWidth - ) - ) - .disabled(model.busyQueueID == item.id) - - Button { - pendingDeleteQueueID = item.id - } label: { - Label("删除", systemImage: "trash") - } - .buttonStyle( - AIButtonStyle( - kind: .danger, - size: .compact, - fixedWidth: AITheme.listActionButtonWidth - ) - ) - } - } - } - } - } + AIEmptyState( + systemImage: "tray.full", + title: "投递队列为空", + message: "新通知触发后会在这里等待投递。" + ) } - } + } 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) + ) { + HStack(spacing: 8) { + Button { + Task { await model.retryQueue(item) } + } label: { + Label("重试", systemImage: "arrow.clockwise") + } + .buttonStyle( + AIButtonStyle( + kind: .secondary, + size: .compact, + fixedWidth: AITheme.listActionButtonWidth + ) + ) + .disabled(model.busyQueueID == item.id) - private func logs(_ overview: NotificationOverview) -> some View { + Button { + pendingDeleteQueueID = item.id + } label: { + Label("删除", systemImage: "trash") + } + .buttonStyle( + AIButtonStyle( + kind: .danger, + size: .compact, + fixedWidth: AITheme.listActionButtonWidth + ) + ) + } + } + } + } + } + } + } + + private func logs(_ overview: NotificationOverview) -> some View { + VStack(alignment: .leading, spacing: 10) { + AISectionTitle( + "投递日志", systemImage: "clock.arrow.circlepath", detail: "\(overview.logs.total) 条") + if overview.logs.entries.isEmpty { AICard { - VStack(alignment: .leading, spacing: 10) { - AISectionTitle("投递日志", systemImage: "clock.arrow.circlepath", detail: "\(overview.logs.total) 条") - if overview.logs.entries.isEmpty { - Text("暂无投递日志。") - .font(.subheadline) - .foregroundStyle(AITheme.muted) - } else { - ForEach(overview.logs.entries) { entry in - AIListCard( - title: eventTypeLabel(entry.eventType), - subtitle: "\(entry.channelName ?? "未指定通道") · \(timestampText(entry.createdAt))", - status: statusLabel(entry.status), - statusTint: statusTint(entry.status) - ) - } - } - } + AIEmptyState( + systemImage: "clock.arrow.circlepath", + title: "暂无投递日志", + message: "通知送达或失败后会在这里记录。" + ) } - } - - private func timestampText(_ value: String) -> String { - let date = Self.fractionalTimestampFormatter.date(from: value) - ?? Self.timestampFormatter.date(from: value) - guard let date else { return value } - return date.formatted( - Date.FormatStyle(date: .numeric, time: .shortened) - .locale(Locale(identifier: "zh_CN")) - ) - } - - private func deviceState(_ state: NotificationDeviceState) -> String { - switch state { - case .ready: return "在线" - case .unauthorized: return "需登录" - case .unavailable: return "离线" - case .failed: return "异常" + } else { + ForEach(overview.logs.entries) { entry in + AIListCard( + title: eventTypeLabel(entry.eventType), + subtitle: "\(entry.channelName ?? "未指定通道") · \(timestampText(entry.createdAt))", + status: statusLabel(entry.status), + statusTint: statusTint(entry.status) + ) } + } } + } - private func deviceTint(_ state: NotificationDeviceState) -> Color { - switch state { - case .ready: return AITheme.success - case .unauthorized: return AITheme.warning - case .unavailable: return AITheme.muted - case .failed: return AITheme.danger - } - } + private func timestampText(_ value: String) -> String { + let date = + Self.fractionalTimestampFormatter.date(from: value) + ?? Self.timestampFormatter.date(from: value) + guard let date else { return value } + return date.formatted( + Date.FormatStyle(date: .numeric, time: .shortened) + .locale(Locale(identifier: "zh_CN")) + ) + } - private func channelTypeLabel(_ type: String) -> String { - switch type { - case "webhook": return "Webhook" - case "bark": return "Bark" - case "pushplus": return "PushPlus" - case "wecom_app": return "企业微信应用" - case "wecom_robot": return "企业微信机器人" - case "dingtalk_robot": return "钉钉机器人" - case "dingtalk_app": return "钉钉应用" - case "feishu_robot": return "飞书机器人" - case "telegram": return "Telegram" - case "email": return "邮件" - case "serverchan": return "Server 酱" - default: return type - } + private func deviceState(_ state: NotificationDeviceState) -> String { + switch state { + case .ready: return "在线" + case .unauthorized: return "需登录" + case .unavailable: return "离线" + case .failed: return "异常" } + } - private func eventTypeLabel(_ value: String) -> String { - switch value { - case "sms": return "短信" - case "ddns": return "DDNS" - case "version": return "版本" - case "system": return "系统" - case "device": return "设备" - case "automation": return "自动化" - default: return value - } + private func deviceTint(_ state: NotificationDeviceState) -> Color { + switch state { + case .ready: return AITheme.success + case .unauthorized: return AITheme.warning + case .unavailable: return AITheme.muted + case .failed: return AITheme.danger } + } - private func statusLabel(_ value: String) -> String { - switch value { - case "success", "succeeded": return "成功" - case "failed": return "失败" - case "pending": return "等待" - case "sending": return "发送中" - case "retrying": return "重试中" - case "cancelled": return "已取消" - case "unmatched": return "未匹配" - case "no_available_channel": return "无可用通道" - case "quiet_hours": return "免打扰" - default: return value - } + private func channelTypeLabel(_ type: String) -> String { + switch type { + case "webhook": return "Webhook" + case "bark": return "Bark" + case "pushplus": return "PushPlus" + case "wecom_app": return "企业微信应用" + case "wecom_robot": return "企业微信机器人" + case "dingtalk_robot": return "钉钉机器人" + case "dingtalk_app": return "钉钉应用" + case "feishu_robot": return "飞书机器人" + case "telegram": return "Telegram" + case "email": return "邮件" + case "serverchan": return "Server 酱" + default: return type } + } - private func statusTint(_ value: String) -> Color { - switch value { - case "success", "succeeded": return AITheme.success - case "pending", "sending", "retrying": return AITheme.warning - case "failed": return AITheme.danger - default: return AITheme.muted - } + private func eventTypeLabel(_ value: String) -> String { + switch value { + case "sms": return "短信" + case "ddns": return "DDNS" + case "version": return "版本" + case "system": return "系统" + case "device": return "设备" + case "automation": return "自动化" + default: return value } + } + + private func statusLabel(_ value: String) -> String { + switch value { + case "success", "succeeded": return "成功" + case "failed": return "失败" + case "pending": return "等待" + case "sending": return "发送中" + case "retrying": return "重试中" + case "cancelled": return "已取消" + case "unmatched": return "未匹配" + case "no_available_channel": return "无可用通道" + case "quiet_hours": return "免打扰" + default: return value + } + } + + private func statusTint(_ value: String) -> Color { + switch value { + case "success", "succeeded": return AITheme.success + case "pending", "sending", "retrying": return AITheme.warning + case "failed": return AITheme.danger + default: return AITheme.muted + } + } } diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Settings/SettingsView.swift b/apps/ios/Sources/MultiSimAdmin/Features/Settings/SettingsView.swift index fc683a8..9212799 100644 --- a/apps/ios/Sources/MultiSimAdmin/Features/Settings/SettingsView.swift +++ b/apps/ios/Sources/MultiSimAdmin/Features/Settings/SettingsView.swift @@ -92,6 +92,11 @@ struct SettingsView: View { AISectionTitle("安全与登录", systemImage: "lock.shield") AIKeyValueRow("实例密码", value: "保存在本机应用沙盒") AIKeyValueRow("登录会话", value: "仅保存在内存") + AIStatusBanner( + "实例凭据只保存在本机应用沙盒,登录会话只保留在内存中,应用关闭后自动失效。", + tint: AITheme.primary, + systemImage: "shield.checkered" + ) } } } diff --git a/apps/ios/Sources/MultiSimAdmin/MultiSimAdminApp.swift b/apps/ios/Sources/MultiSimAdmin/MultiSimAdminApp.swift index 72334a4..fae7054 100644 --- a/apps/ios/Sources/MultiSimAdmin/MultiSimAdminApp.swift +++ b/apps/ios/Sources/MultiSimAdmin/MultiSimAdminApp.swift @@ -37,7 +37,6 @@ struct MultiSimAdminApp: App { AITabBar(selection: $tab) } .tint(AITheme.primary) - .preferredColorScheme(.light) #if os(iOS) .toolbarBackground(AITheme.canvas, for: .navigationBar) .toolbarBackground(.visible, for: .navigationBar) diff --git a/apps/ios/Sources/MultiSimAdmin/Theme/AIButtonStyle.swift b/apps/ios/Sources/MultiSimAdmin/Theme/AIButtonStyle.swift index 8851b84..b3a6a0d 100644 --- a/apps/ios/Sources/MultiSimAdmin/Theme/AIButtonStyle.swift +++ b/apps/ios/Sources/MultiSimAdmin/Theme/AIButtonStyle.swift @@ -22,7 +22,7 @@ public struct AIButtonStyle: ButtonStyle { func backgroundColor(isEnabled: Bool) -> Color { switch self { case .primary: return isEnabled ? AITheme.primary : AITheme.primary.opacity(0.35) - case .secondary: return isEnabled ? AITheme.surface : AITheme.canvas + case .secondary: return isEnabled ? AITheme.control : AITheme.canvas case .danger: return isEnabled ? AITheme.danger : AITheme.danger.opacity(0.30) } } diff --git a/apps/ios/Sources/MultiSimAdmin/Theme/AICard.swift b/apps/ios/Sources/MultiSimAdmin/Theme/AICard.swift index 2086f06..962bb52 100644 --- a/apps/ios/Sources/MultiSimAdmin/Theme/AICard.swift +++ b/apps/ios/Sources/MultiSimAdmin/Theme/AICard.swift @@ -1,445 +1,550 @@ import SwiftUI public struct AICard: View { - private let content: Content - private let padding: CGFloat + private let content: Content + private let padding: CGFloat - public init(padding: CGFloat = 16, @ViewBuilder content: () -> Content) { - self.padding = padding - self.content = content() - } + public init(padding: CGFloat = 16, @ViewBuilder content: () -> Content) { + self.padding = padding + self.content = content() + } - public var body: some View { - content - .padding(padding) - .background(AITheme.surface) - .clipShape(RoundedRectangle(cornerRadius: AITheme.cardRadius)) - .shadow(color: .black.opacity(0.045), radius: 8, x: 0, y: 3) - .overlay { - RoundedRectangle(cornerRadius: AITheme.cardRadius) - .strokeBorder(AITheme.line) - } - } + public var body: some View { + content + .padding(padding) + .background(AITheme.surface) + .clipShape(RoundedRectangle(cornerRadius: AITheme.cardRadius)) + .shadow(color: .black.opacity(0.045), radius: 8, x: 0, y: 3) + .overlay { + RoundedRectangle(cornerRadius: AITheme.cardRadius) + .strokeBorder(AITheme.line) + } + } } public struct AITag: View { - private let text: String - private let tint: Color + private let text: String + private let tint: Color - public init(_ text: String, tint: Color = AITheme.primary) { - self.text = text - self.tint = tint - } + public init(_ text: String, tint: Color = AITheme.primary) { + self.text = text + self.tint = tint + } - public var body: some View { - Text(text) - .font(.system(size: 12, weight: .semibold)) - .foregroundStyle(tint) - .lineLimit(1) - .fixedSize(horizontal: true, vertical: false) - .padding(.horizontal, 9) - .frame(minHeight: 26) - .background(tint.opacity(0.10)) - .clipShape(Capsule()) - } + public var body: some View { + Text(text) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(tint) + .lineLimit(1) + .truncationMode(.middle) + .padding(.horizontal, 9) + .frame(minHeight: 26) + .background(tint.opacity(0.10)) + .clipShape(Capsule()) + } } public struct AISectionTitle: View { - private let title: String - private let systemImage: String - private let detail: String? + private let title: String + private let systemImage: String + private let detail: String? + private let footer: Footer + public init( + _ title: String, + systemImage: String, + detail: String? = nil, + actionTitle: String? = nil, + actionSystemImage: String? = nil, + actionKind: AIButtonStyle.Kind = .secondary, + action: (() -> Void)? = nil + ) { + self.title = title + self.systemImage = systemImage + self.detail = detail + self.footer = Footer( + title: actionTitle, + systemImage: actionSystemImage, + kind: actionKind, + action: action + ) + } - public init(_ title: String, systemImage: String, detail: String? = nil) { - self.title = title - self.systemImage = systemImage - self.detail = detail - } + private struct Footer: View { + let title: String? + let systemImage: String? + let kind: AIButtonStyle.Kind + let action: (() -> Void)? - public var body: some View { - HStack(spacing: 8) { - Image(systemName: systemImage) - .font(.subheadline.weight(.semibold)) - .foregroundStyle(AITheme.primary) - Text(title) - .font(.headline.weight(.bold)) - .foregroundStyle(AITheme.ink) - .lineLimit(1) - Spacer(minLength: 8) - if let detail { - Text(detail) - .font(.caption.weight(.medium)) - .foregroundStyle(AITheme.muted) - .lineLimit(1) - } + var body: some View { + if let title, let action { + Button { + action() + } label: { + if let systemImage { + Label(title, systemImage: systemImage) + } else { + Text(title) + } } + .buttonStyle(AIButtonStyle(kind: kind, size: .compact)) + } } + } + + public var body: some View { + HStack(spacing: 8) { + Image(systemName: systemImage) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(AITheme.primary) + Text(title) + .font(.headline.weight(.bold)) + .foregroundStyle(AITheme.ink) + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) + if let detail { + Text(detail) + .font(.caption.weight(.medium)) + .foregroundStyle(AITheme.muted) + .lineLimit(1) + } + if Footer.self != EmptyView.self { + footer + } + } + } +} + +public struct AIFieldLabel: View { + private let title: String + + public init(_ title: String) { + self.title = title + } + + public var body: some View { + Text(title) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(AITheme.ink) + } } public struct AIStatCard: View { - private let title: String - private let value: String - private let detail: String? - private let tint: Color + private let title: String + private let value: String + private let detail: String? + private let tint: Color - public init(title: String, value: String, detail: String? = nil, tint: Color) { - self.title = title - self.value = value - self.detail = detail - self.tint = tint - } + public init(title: String, value: String, detail: String? = nil, tint: Color) { + self.title = title + self.value = value + self.detail = detail + self.tint = tint + } - public var body: some View { - AICard(padding: 14) { - VStack(alignment: .leading, spacing: 5) { - Text(title) - .font(.caption.weight(.semibold)) - .foregroundStyle(AITheme.muted) - Text(value) - .font(.title3.weight(.bold)) - .foregroundStyle(tint) - .lineLimit(1) - .minimumScaleFactor(0.75) - if let detail { - Text(detail) - .font(.caption) - .foregroundStyle(AITheme.muted) - .lineLimit(2) - } - } - .frame(maxWidth: .infinity, alignment: .leading) + public var body: some View { + AICard(padding: 14) { + VStack(alignment: .leading, spacing: 5) { + Text(title) + .font(.caption.weight(.semibold)) + .foregroundStyle(AITheme.muted) + Text(value) + .font(.title3.weight(.bold)) + .foregroundStyle(tint) + .lineLimit(1) + .minimumScaleFactor(0.75) + if let detail { + Text(detail) + .font(.caption) + .foregroundStyle(AITheme.muted) + .lineLimit(2) } + } + .frame(maxWidth: .infinity, alignment: .leading) } + } } public struct AIStatusBanner: View { - private let message: String - private let tint: Color - private let systemImage: String + private let message: String + private let tint: Color + private let systemImage: String - public init(_ message: String, tint: Color, systemImage: String) { - self.message = message - self.tint = tint - self.systemImage = systemImage - } + public init(_ message: String, tint: Color, systemImage: String) { + self.message = message + self.tint = tint + self.systemImage = systemImage + } - public var body: some View { - HStack(alignment: .top, spacing: 10) { - Image(systemName: systemImage) - .font(.subheadline.weight(.semibold)) - .foregroundStyle(tint) - Text(message) - .font(.subheadline.weight(.medium)) - .foregroundStyle(tint) - .frame(maxWidth: .infinity, alignment: .leading) - } - .padding(14) - .background(tint.opacity(0.10)) - .clipShape(RoundedRectangle(cornerRadius: AITheme.cardRadius)) - .overlay { - RoundedRectangle(cornerRadius: AITheme.cardRadius) - .strokeBorder(tint.opacity(0.22)) - } - .accessibilityElement(children: .combine) + public var body: some View { + HStack(alignment: .top, spacing: 10) { + Image(systemName: systemImage) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(tint) + Text(message) + .font(.subheadline.weight(.medium)) + .foregroundStyle(tint) + .frame(maxWidth: .infinity, alignment: .leading) } + .padding(14) + .background(tint.opacity(0.10)) + .clipShape(RoundedRectangle(cornerRadius: AITheme.cardRadius)) + .overlay { + RoundedRectangle(cornerRadius: AITheme.cardRadius) + .strokeBorder(tint.opacity(0.22)) + } + .accessibilityElement(children: .combine) + } } public struct AIEmptyState: View { - private let systemImage: String - private let title: String - private let message: String + private let systemImage: String + private let title: String + private let message: String - public init(systemImage: String, title: String, message: String) { - self.systemImage = systemImage - self.title = title - self.message = message - } + public init(systemImage: String, title: String, message: String) { + self.systemImage = systemImage + self.title = title + self.message = message + } - public var body: some View { - VStack(spacing: 10) { - Image(systemName: systemImage) - .font(.system(size: 28)) - .foregroundStyle(AITheme.muted) - Text(title) - .font(.headline.weight(.bold)) - .foregroundStyle(AITheme.ink) - Text(message) - .font(.subheadline) - .foregroundStyle(AITheme.muted) - .multilineTextAlignment(.center) - .fixedSize(horizontal: false, vertical: true) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 18) - .accessibilityElement(children: .combine) + public var body: some View { + VStack(spacing: 10) { + Image(systemName: systemImage) + .font(.system(size: 26)) + .foregroundStyle(AITheme.primary) + .frame(width: 54, height: 54) + .background(AITheme.primary.opacity(0.10)) + .clipShape(Circle()) + Text(title) + .font(.headline.weight(.bold)) + .foregroundStyle(AITheme.ink) + Text(message) + .font(.subheadline) + .foregroundStyle(AITheme.muted) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) } + .frame(maxWidth: .infinity) + .padding(.vertical, 18) + .accessibilityElement(children: .combine) + } } public struct AIListCard: View { - private let title: String - private let subtitle: String? - private let status: String? - private let statusTint: Color? - private let details: [String] - private let footer: Footer + private let title: String + private let subtitle: String? + private let status: String? + private let statusTint: Color? + private let details: [String] + private let footer: Footer - public init( - title: String, - subtitle: String? = nil, - status: String? = nil, - statusTint: Color? = nil, - details: [String] = [], - @ViewBuilder footer: () -> Footer = { EmptyView() } - ) { - self.title = title - self.subtitle = subtitle - self.status = status - self.statusTint = statusTint - self.details = details - self.footer = footer() - } + public init( + title: String, + subtitle: String? = nil, + status: String? = nil, + statusTint: Color? = nil, + details: [String] = [], + @ViewBuilder footer: () -> Footer = { EmptyView() } + ) { + self.title = title + self.subtitle = subtitle + self.status = status + self.statusTint = statusTint + self.details = details + self.footer = footer() + } - public var body: some View { - VStack(alignment: .leading, spacing: 8) { - HStack(alignment: .firstTextBaseline, spacing: 10) { - Text(title) - .font(.subheadline.weight(.semibold)) - .foregroundStyle(AITheme.ink) - .lineLimit(2) - .truncationMode(.middle) - .frame(maxWidth: .infinity, alignment: .leading) + public var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .firstTextBaseline, spacing: 10) { + Text(title) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(AITheme.ink) + .lineLimit(2) + .truncationMode(.middle) + .frame(maxWidth: .infinity, alignment: .leading) - if let status { - AITag(status, tint: statusTint ?? AITheme.muted) - } - } - - if let subtitle { - Text(subtitle) - .font(.caption) - .foregroundStyle(AITheme.muted) - .lineLimit(1) - .truncationMode(.middle) - .frame(maxWidth: .infinity, alignment: .leading) - } - - if !details.isEmpty { - VStack(alignment: .leading, spacing: 4) { - ForEach(details, id: \.self) { detail in - Text(detail) - .font(.caption) - .foregroundStyle(AITheme.muted) - .lineLimit(2) - .fixedSize(horizontal: false, vertical: true) - } - } - } - - if Footer.self != EmptyView.self { - footer - } + if let status { + AITag(status, tint: statusTint ?? AITheme.muted) } - .accessibilityElement(children: .combine) + } + + if let subtitle { + Text(subtitle) + .font(.caption) + .foregroundStyle(AITheme.muted) + .lineLimit(1) + .truncationMode(.middle) + .frame(maxWidth: .infinity, alignment: .leading) + } + + if !details.isEmpty { + VStack(alignment: .leading, spacing: 4) { + ForEach(details, id: \.self) { detail in + Text(detail) + .font(.caption) + .foregroundStyle(AITheme.muted) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) + } + } + } + + if Footer.self != EmptyView.self { + footer + } } + .accessibilityElement(children: .combine) + } } public struct AILoadingCard: View { - private let message: String + private let message: String - public init(_ message: String) { - self.message = message - } + public init(_ message: String) { + self.message = message + } - public var body: some View { - AICard { - VStack(spacing: 10) { - ProgressView() - .controlSize(.regular) - Text(message) - .font(.subheadline) - .foregroundStyle(AITheme.muted) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 18) - .accessibilityElement(children: .combine) - } + public var body: some View { + VStack(spacing: 10) { + ProgressView() + .controlSize(.regular) + Text(message) + .font(.subheadline) + .foregroundStyle(AITheme.muted) } + .frame(maxWidth: .infinity) + .padding(.vertical, 18) + .accessibilityElement(children: .combine) + } } public struct AIKeyValueRow: View { - private let title: String - private let value: String - private let valueTint: Color + private let title: String + private let value: String + private let valueTint: Color - public init(_ title: String, value: String, tint: Color = AITheme.ink) { - self.title = title - self.value = value - self.valueTint = tint + public init(_ title: String, value: String, tint: Color = AITheme.ink) { + self.title = title + self.value = value + self.valueTint = tint + } + + public var body: some View { + HStack(alignment: .firstTextBaseline, spacing: 12) { + Text(title) + .font(.subheadline) + .foregroundStyle(AITheme.muted) + .lineLimit(2) + Spacer(minLength: 12) + Text(value) + .font(.subheadline.weight(.medium)) + .foregroundStyle(valueTint) + .multilineTextAlignment(.trailing) + .lineLimit(2) + .truncationMode(.middle) + .minimumScaleFactor(0.85) + .textSelection(.enabled) } + .accessibilityElement(children: .combine) + } +} - public var body: some View { - HStack(alignment: .firstTextBaseline, spacing: 12) { - Text(title) - .font(.subheadline) - .foregroundStyle(AITheme.muted) - .lineLimit(2) - Spacer(minLength: 12) - Text(value) - .font(.subheadline.weight(.medium)) - .foregroundStyle(valueTint) - .multilineTextAlignment(.trailing) - .lineLimit(2) - .minimumScaleFactor(0.85) - .textSelection(.enabled) +public struct AISegmentedControl: View { + private let title: String + private let options: [(value: Value, title: String)] + @Binding private var selection: Value + + public init( + _ title: String, + selection: Binding, + options: [(value: Value, title: String)] + ) { + self.title = title + self._selection = selection + self.options = options + } + + public var body: some View { + HStack(spacing: 4) { + ForEach(options, id: \.value) { option in + let isSelected = selection == option.value + + Button { + selection = option.value + } label: { + Text(option.title) + .font(.subheadline.weight(isSelected ? .semibold : .medium)) + .lineLimit(1) + .minimumScaleFactor(0.75) + .foregroundStyle(isSelected ? .white : AITheme.muted) + .frame(maxWidth: .infinity, minHeight: 34) + .background { + if isSelected { + RoundedRectangle(cornerRadius: 9) + .fill(AITheme.primary) + } + } } - .accessibilityElement(children: .combine) + .buttonStyle(.plain) + .accessibilityLabel(option.title) + .accessibilityAddTraits(isSelected ? .isSelected : []) + } } + .padding(4) + .background(AITheme.control) + .clipShape(RoundedRectangle(cornerRadius: AITheme.buttonRadius)) + .overlay { + RoundedRectangle(cornerRadius: AITheme.buttonRadius) + .strokeBorder(AITheme.line) + } + .animation(.easeOut(duration: 0.16), value: selection) + .accessibilityElement(children: .contain) + .accessibilityLabel(title) + } } public struct AIFieldStyle: TextFieldStyle { - public init() {} + public init() {} - public func _body(configuration: TextField) -> some View { - configuration - .padding(.horizontal, 12) - .frame(minHeight: AITheme.buttonHeight) - .background(AITheme.canvas) - .clipShape(RoundedRectangle(cornerRadius: AITheme.buttonRadius)) - .overlay { - RoundedRectangle(cornerRadius: AITheme.buttonRadius) - .strokeBorder(AITheme.line) - } - } + public func _body(configuration: TextField) -> some View { + configuration + .aiInputSurface() + } } public struct AISearchField: View { - private let placeholder: String - @Binding private var text: String - private var onSubmit: (() -> Void)? + private let placeholder: String + @Binding private var text: String + private var onSubmit: (() -> Void)? - public init( - _ placeholder: String, - text: Binding, - onSubmit: (() -> Void)? = nil - ) { - self.placeholder = placeholder - self._text = text - self.onSubmit = onSubmit - } + public init( + _ placeholder: String, + text: Binding, + onSubmit: (() -> Void)? = nil + ) { + self.placeholder = placeholder + self._text = text + self.onSubmit = onSubmit + } - public var body: some View { - HStack(spacing: 8) { - Image(systemName: "magnifyingglass") - .foregroundStyle(AITheme.muted) - TextField(placeholder, text: $text) - .textFieldStyle(.plain) - .submitLabel(.search) - .onSubmit { - onSubmit?() - } - if !text.isEmpty { - Button { - text = "" - onSubmit?() - } label: { - Image(systemName: "xmark.circle.fill") - .foregroundStyle(AITheme.muted) - } - .buttonStyle(.plain) - .accessibilityLabel("清除搜索") - } + public var body: some View { + HStack(spacing: 6) { + Image(systemName: "magnifyingglass") + .foregroundStyle(AITheme.muted) + TextField(placeholder, text: $text) + .textFieldStyle(.plain) + .submitLabel(.search) + .onSubmit { + onSubmit?() } - .padding(.horizontal, 12) - .frame(minHeight: AITheme.buttonHeight) - .background(AITheme.canvas) - .clipShape(RoundedRectangle(cornerRadius: AITheme.buttonRadius)) - .overlay { - RoundedRectangle(cornerRadius: AITheme.buttonRadius) - .strokeBorder(AITheme.line) + if !text.isEmpty { + Button { + text = "" + onSubmit?() + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(AITheme.muted) + .font(.system(size: 16, weight: .medium)) } + .buttonStyle(.plain) + .frame(width: 28, height: 28) + .contentShape(Rectangle()) + .accessibilityLabel("清除搜索") + } } + .aiInputSurface(minHeight: AITheme.buttonHeight) + } } public struct AIBrandBar: View { - public init() {} + 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(Color(red: 0.075, green: 0.118, blue: 0.188)) + 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) + } } extension View { - func aiBottomActionBar(@ViewBuilder content: () -> Content) -> some View { - safeAreaInset(edge: .bottom) { - content() - .padding(.horizontal, 16) - .padding(.top, 10) - .padding(.bottom, 12) - .background(AITheme.canvas) - .overlay(alignment: .top) { - Rectangle() - .fill(AITheme.line) - .frame(height: 1) - } + func aiInputSurface(minHeight: CGFloat = AITheme.buttonHeight) -> some View { + self + .padding(.horizontal, 12) + .frame(minHeight: minHeight, alignment: .leading) + .background(AITheme.control) + .clipShape(RoundedRectangle(cornerRadius: AITheme.buttonRadius)) + .overlay { + RoundedRectangle(cornerRadius: AITheme.buttonRadius) + .strokeBorder(AITheme.line) + } + } + + func aiBottomActionBar(@ViewBuilder content: () -> Content) -> some View { + safeAreaInset(edge: .bottom) { + content() + .padding(.horizontal, 16) + .padding(.top, 10) + .padding(.bottom, 8) + .background(AITheme.canvas) + .overlay(alignment: .top) { + Rectangle() + .fill(AITheme.line) + .frame(height: 1) } } + } - func aiSelectSurface() -> some View { - self - .tint(AITheme.ink) - .font(.subheadline.weight(.semibold)) - .foregroundStyle(AITheme.ink) - .frame(maxWidth: .infinity, minHeight: AITheme.buttonHeight, alignment: .leading) - .padding(.horizontal, 12) - .background(AITheme.canvas) - .clipShape(RoundedRectangle(cornerRadius: AITheme.buttonRadius)) - .overlay { - RoundedRectangle(cornerRadius: AITheme.buttonRadius) - .strokeBorder(AITheme.line) - } - } + func aiSelectSurface() -> some View { + self + .tint(AITheme.ink) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(AITheme.ink) + .labelsHidden() + .frame(maxWidth: .infinity, alignment: .leading) + .aiInputSurface() + } } public struct AIToolbarButton: View { - private let title: String - private let systemImage: String? - private let kind: AIButtonStyle.Kind - private let action: () -> Void + private let title: String + private let systemImage: String? + private let kind: AIButtonStyle.Kind + private let action: () -> Void - public init( - _ title: String, - systemImage: String? = nil, - kind: AIButtonStyle.Kind = .secondary, - action: @escaping () -> Void - ) { - self.title = title - self.systemImage = systemImage - self.kind = kind - self.action = action - } + public init( + _ title: String, + systemImage: String? = nil, + kind: AIButtonStyle.Kind = .secondary, + action: @escaping () -> Void + ) { + self.title = title + self.systemImage = systemImage + self.kind = kind + self.action = action + } - public var body: some View { - Button { - action() - } label: { - if let systemImage { - Label(title, systemImage: systemImage) - .labelStyle(.titleAndIcon) - .font(.system(size: 15, weight: .semibold)) - } else { - Text(title) - .font(.system(size: 15, weight: .semibold)) - } - } - .buttonStyle(AIButtonStyle(kind: kind, size: .toolbar)) - .accessibilityLabel(title) + public var body: some View { + Button { + action() + } label: { + if let systemImage { + Label(title, systemImage: systemImage) + .labelStyle(.titleAndIcon) + .font(.system(size: 15, weight: .semibold)) + } else { + Text(title) + .font(.system(size: 15, weight: .semibold)) + } } + .buttonStyle(AIButtonStyle(kind: kind, size: .toolbar)) + .accessibilityLabel(title) + } } diff --git a/apps/ios/Sources/MultiSimAdmin/Theme/AITheme.swift b/apps/ios/Sources/MultiSimAdmin/Theme/AITheme.swift index 5ab2bde..295198a 100644 --- a/apps/ios/Sources/MultiSimAdmin/Theme/AITheme.swift +++ b/apps/ios/Sources/MultiSimAdmin/Theme/AITheme.swift @@ -1,16 +1,86 @@ import SwiftUI -public enum AITheme { - public static let canvas = Color(red: 0.953, green: 0.961, blue: 0.973) - public static let surface = Color.white - public static let ink = Color(red: 0.110, green: 0.153, blue: 0.220) - public static let muted = Color(red: 0.435, green: 0.486, blue: 0.561) - public static let line = Color(red: 0.878, green: 0.898, blue: 0.922) + #if canImport(UIKit) + import UIKit + #elseif canImport(AppKit) + import AppKit + #endif - public static let primary = Color(red: 0.067, green: 0.467, blue: 0.431) - public static let danger = Color(red: 0.714, green: 0.302, blue: 0.282) - public static let warning = Color(red: 0.722, green: 0.471, blue: 0.067) - public static let success = Color(red: 0.082, green: 0.584, blue: 0.439) +private enum AIPalette { +#if canImport(UIKit) + private static func dynamicColor(light: UIColor, dark: UIColor) -> Color { + Color(UIColor { traits in + traits.userInterfaceStyle == .dark ? dark : light + }) + } + + private static func rgba( + _ red: CGFloat, + _ green: CGFloat, + _ blue: CGFloat, + _ alpha: CGFloat + ) -> UIColor { + 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) + } + + 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(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) + static let ink = Color(red: 0.110, green: 0.153, blue: 0.220) + 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 + private static func color(_ red: CGFloat, _ green: CGFloat, _ blue: CGFloat) -> Color { + Color(red: red / 255, green: green / 255, blue: blue / 255) + } + + static let canvas = color(243, 245, 248) + static let surface = color(255, 255, 255) + static let control = color(247, 249, 251) + static let ink = color(28, 39, 56) + 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 +} + +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 buttonHeight: CGFloat = 44 public static let compactButtonHeight: CGFloat = 40