diff --git a/CHANGELOG.md b/CHANGELOG.md old mode 100644 new mode 100755 index 985b5275..0ea84790 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,123 @@ -# Changelog +# 版本更新管理 + +## v0.5.0 — 2026-07-11 + +全屏引导之后的功能级一次性气泡 tip、LCD 恢复默认,以及联动 / 我的设备「左状态 · 右操作」卡片一致性。 + +### 功能级新手指引 + +- 新增 `FeatureCoachTipStore`:全屏引导完成后,在连接设备、改键写入、Agent 模式、联动、语音空历史、用户中心权限、灵动岛、插件市场等卡点弹出一次性气泡。 +- 气泡含「去点 …」目标控件说明与可跳转主按钮;同屏最多一条;点「知道了」后不再出现。 +- 「重新打开新手引导」同时重置全屏引导与全部气泡已读状态;修复引导结束后气泡不唤醒的问题。 + +### 硬件 LCD + +- LCD 显示配置增加「清空并恢复默认」:恢复当前 Mode 出厂动图、12 FPS 与默认状态文案(仍需「写入键盘」同步)。 + +### 卡片操作一致性 + +- Agent · 联动:「一键启用联动」「安装 Cursor Hook」移到卡片右侧(左状态、右按钮)。 +- 我的设备:列表空态与详情「基本设置」的「连接设备」移到卡片右侧。 + +### 仓库 + +- 界面开发分支仍为 `ui/develop`;版本说明见 `docs/versioning.md`。 + +## v0.4.0 — 2026-07-11 + +Settings 主壳产品化:语音 / Agent 三分段落地,用户中心并入偏好并以弹窗呈现,插件市场升级为独立商店视觉。 + +### 语音输入 + +- 三分段:**历史记录 / 词典 / 通用设置**(`AhaKeyVoice*` 系列页)。 +- 本机语音状态与词典/历史轻量存储(`VoiceInputStore`);系统权限入口收拢到用户中心 · 设置。 +- 文档:`docs/design/voice-input-settings.md`。 + +### Agent + +- 三分段:**助手 / 联动 / 设置**(含 Pet 外观);改键仍只在硬件 Studio。 +- 助手工具与工作台壳层(`AhaKeyAgent*`、`AgentAssistantTools`)。 +- 文档:`docs/design/agent-assistant-settings.md`。 + +### 用户中心 + +- 原「偏好配置」整页并入侧栏用户中心;以独立 **sheet 弹窗**呈现,不替换主详情区。 +- 弹窗左导航:用户中心 / 设置 / 使用数据 / 关于我们;下方帮助中心 / 版本说明。 +- 首页偏社区:身份卡、本机插件/货架摘要、开源市场入口;订阅与额度降为次级。 +- 设置含外观语言、权限、Agent API、设备联动;删除独立 `AhakeyGeneralSettingsPane`。 +- 顶栏功能入口仅保留插件市场(去掉偏好齿轮)。 + +### 插件市场 + +- 静态货架 catalog + 本机已装轻量扫盘;默认「我的插件」,「开源市场」为次级子页。 +- 独立商店主题 `AhakeyPluginMarketTheme`(橱窗画布、大圆角瓷砖、Hero 渐变),与 Settings 工具页色板区分。 +- 版式:Library 大图标列表;开源 Featured Hero + 货架瓷砖;产品页大图标 + 主 CTA(下载/安装仍占位)。 +- 设计规范同步注明商店主题。 + +### 其他 + +- Settings 主题 token 与嵌入式 Studio 外观微调;新手引导可从帮助中心再进入。 +- 界面开发分支仍为 `ui/develop`;版本说明见 `docs/versioning.md`。 + +## v0.3.0 — 2026-07-11 + +硬件设备主页与「我的设备」一轮产品化:Inspector 二级模块、问号帮助、画布对齐,以及设备连接/系统控制从侧栏 Tab 收拢到左下角入口。 + +### 硬件设备 Studio + +- 右侧 Inspector:元件 Tab + 二级模块(`InspectorSection`);说明文进问号(`InspectorHelpButton`),悬停展开。 +- 语音键结构:按键描述 → 按键配置(输入方式)→ 绑定方法 → 更多设置;非语音键绑定在按键配置内。 +- Agent Mode 分段下移到画布下方;其下增加硬件组装占位。 +- 画布:OLED / 灯条 / 磁吸对齐;去掉灯条上方「灯条」文字标签。 +- 顶栏:去掉「AhaKey」字;插件市场(占位)与偏好配置入口。 +- 右侧各 Tab 布局收紧(下拉宽度、OLED 按钮行、磁吸档位菜单等),减少溢出功能区。 +- 文档:`docs/design/hardware-studio-layout.md`;设计规范 Tab 表同步。 + +### 我的设备 + +- 侧栏不再单独放「设备管理」Tab;左下角设备卡上方标注「我的设备」,点击进入详情。 +- 详情复用整理后的 `DeviceInfoView`(基本设置 / 蓝牙 / 设备信息 / 系统·Agent / 更多诊断)。 +- Agent「设备与系统」、硬件页设备信息入口改为跳转「我的设备」。 +- VibeBar Device 导航对齐同一入口。 + +### 仓库 + +- 界面开发分支仍为 `ui/develop`;版本说明见 `docs/versioning.md`。 + +## v0.2.0 — 2026-07-11 + +VibeBar 灵动岛 Settings 与用户中心一轮产品化更新:信息架构分层、低频选项折叠、账户页视觉升级,以及组件库二级设置。 + +### 用户中心 + +- 侧栏用户卡进入整页用户中心(登录 / 资料 / 额度 / 充值 / 兑换),与通用设置解耦。 +- Profile Hero:头像、欢迎/脱敏手机号、已登录状态点。 +- 登录卡:深色输入框、主次按钮层级;账户卡:资料分行、日/周/月三列额度、退出弱化危险样式。 +- 订阅套餐卡片化、支付二维码区统一容器;AhaType 收成附属卡。 +- 文档:`docs/user-center.md`。 + +### 灵动岛 Settings IA + +- 三分段:**常驻岛 | 展开岛 | 通用**;系统级选项迁入通用。 +- 常驻岛:预览内状态切换、布局(左/右槽)、灯条与尺寸。 +- 展开岛:组件库入口、交互、个性化(焕肤入口常开)。 +- **低频选项默认折叠**:常驻岛「更多外观」、展开岛「交互与更多」、通用「更多通用」;焕肤不折叠。 +- 侧栏品牌头去掉 logo,仅保留「AhaKey Studio」文案。 + +### 组件库与模块设置 + +- 组件库管理展开岛模块显隐与顺序。 +- 四键键帽 / OLED Pet 二级设置页(外观、映射、皮肤、动画、状态覆盖等)。 +- 展开岛命令中心按启用模块渲染。 + +### 稳定性 + +- 修复主窗口关闭后 VibeBar「设置」无法再打开主窗:`openWindow` 注册到 `AppDelegate`。 + +### 仓库 + +- 界面开发分支:`ui/develop`(由原 `integrate/keke-vibebar-ui` 改名)。 +- 版本说明:`docs/versioning.md`。 ## v0.1.1-alpha — 2026-05-03 diff --git a/ahakeyconfig-mac/Package.swift b/ahakeyconfig-mac/Package.swift old mode 100644 new mode 100755 diff --git a/ahakeyconfig-mac/Packaging/AhaKeyConfig-EmbeddedInfo-Debug.plist b/ahakeyconfig-mac/Packaging/AhaKeyConfig-EmbeddedInfo-Debug.plist old mode 100644 new mode 100755 diff --git a/ahakeyconfig-mac/Packaging/AhaKeyConfig-EmbeddedInfo.plist b/ahakeyconfig-mac/Packaging/AhaKeyConfig-EmbeddedInfo.plist old mode 100644 new mode 100755 diff --git a/ahakeyconfig-mac/Resources/AppIcon/AhaKeyStudioIcon.png b/ahakeyconfig-mac/Resources/AppIcon/AhaKeyStudioIcon.png old mode 100644 new mode 100755 diff --git a/ahakeyconfig-mac/Resources/DefaultOLED/claude_0.gif b/ahakeyconfig-mac/Resources/DefaultOLED/claude_0.gif old mode 100644 new mode 100755 diff --git a/ahakeyconfig-mac/Resources/DefaultOLED/codex.gif b/ahakeyconfig-mac/Resources/DefaultOLED/codex.gif old mode 100644 new mode 100755 diff --git a/ahakeyconfig-mac/Resources/DefaultOLED/cursor.gif b/ahakeyconfig-mac/Resources/DefaultOLED/cursor.gif old mode 100644 new mode 100755 diff --git a/ahakeyconfig-mac/Resources/Help/AhaKeyStudioHelp.html b/ahakeyconfig-mac/Resources/Help/AhaKeyStudioHelp.html old mode 100644 new mode 100755 diff --git a/ahakeyconfig-mac/Sources/Agent/AhaKeyAgent.swift b/ahakeyconfig-mac/Sources/Agent/AhaKeyAgent.swift old mode 100644 new mode 100755 diff --git a/ahakeyconfig-mac/Sources/Agent/ClaudeHookHandler.swift b/ahakeyconfig-mac/Sources/Agent/ClaudeHookHandler.swift old mode 100644 new mode 100755 diff --git a/ahakeyconfig-mac/Sources/Agent/CodexConfigLeverSync.swift b/ahakeyconfig-mac/Sources/Agent/CodexConfigLeverSync.swift old mode 100644 new mode 100755 diff --git a/ahakeyconfig-mac/Sources/Agent/CodexHookHandler.swift b/ahakeyconfig-mac/Sources/Agent/CodexHookHandler.swift old mode 100644 new mode 100755 diff --git a/ahakeyconfig-mac/Sources/Agent/CursorCliLeverSync.swift b/ahakeyconfig-mac/Sources/Agent/CursorCliLeverSync.swift old mode 100644 new mode 100755 diff --git a/ahakeyconfig-mac/Sources/Agent/CursorHookHandler.swift b/ahakeyconfig-mac/Sources/Agent/CursorHookHandler.swift old mode 100644 new mode 100755 diff --git a/ahakeyconfig-mac/Sources/Agent/CursorPermissionsJsonLeverSync.swift b/ahakeyconfig-mac/Sources/Agent/CursorPermissionsJsonLeverSync.swift old mode 100644 new mode 100755 diff --git a/ahakeyconfig-mac/Sources/Agent/HookClient.swift b/ahakeyconfig-mac/Sources/Agent/HookClient.swift old mode 100644 new mode 100755 diff --git a/ahakeyconfig-mac/Sources/Agent/HookSupport.swift b/ahakeyconfig-mac/Sources/Agent/HookSupport.swift old mode 100644 new mode 100755 diff --git a/ahakeyconfig-mac/Sources/Agent/KimiConfigDiagnostic.swift b/ahakeyconfig-mac/Sources/Agent/KimiConfigDiagnostic.swift old mode 100644 new mode 100755 diff --git a/ahakeyconfig-mac/Sources/Agent/KimiConfigLeverSync.swift b/ahakeyconfig-mac/Sources/Agent/KimiConfigLeverSync.swift old mode 100644 new mode 100755 diff --git a/ahakeyconfig-mac/Sources/Agent/KimiHookDebugLog.swift b/ahakeyconfig-mac/Sources/Agent/KimiHookDebugLog.swift old mode 100644 new mode 100755 diff --git a/ahakeyconfig-mac/Sources/Agent/KimiHookHandler.swift b/ahakeyconfig-mac/Sources/Agent/KimiHookHandler.swift old mode 100644 new mode 100755 diff --git a/ahakeyconfig-mac/Sources/Agent/main.swift b/ahakeyconfig-mac/Sources/Agent/main.swift old mode 100644 new mode 100755 diff --git a/ahakeyconfig-mac/Sources/AhaKeyConfigApp.swift b/ahakeyconfig-mac/Sources/AhaKeyConfigApp.swift old mode 100644 new mode 100755 index 24497b3c..87b27a01 --- a/ahakeyconfig-mac/Sources/AhaKeyConfigApp.swift +++ b/ahakeyconfig-mac/Sources/AhaKeyConfigApp.swift @@ -4,6 +4,7 @@ import AVFoundation import Speech import UserNotifications import VibeBar +import Combine @main struct AhaKeyConfigApp: App { @@ -12,14 +13,26 @@ struct AhaKeyConfigApp: App { @State private var vibeBarBridge = VibeBarBridge() var body: some Scene { - WindowGroup("AhaKey Studio") { - ContentView(bleManager: bleManager) + WindowGroup("AhaKey Studio", id: "main") { + ContentView(bleManager: bleManager, islandState: vibeBarBridge.state) .frame(minWidth: 1180, minHeight: 680) + .background(MainWindowReopenHelper()) .onAppear { - vibeBarBridge.attach(bleManager: bleManager) { [weak appDelegate] in - appDelegate?.reopenMainWindow() + // 用 AppDelegate.shared,避免 weak 捕获 App 属性包装器导致回调失效。 + let reopenMain: () -> Void = { + AppDelegate.shared?.reopenMainWindow() + } + vibeBarBridge.attach(bleManager: bleManager, onOpenMainWindow: reopenMain) + wireVibeBarNavigation(state: vibeBarBridge.state, reopenMain: reopenMain) + let islandState = vibeBarBridge.state + VibeBarController.shared.start(state: islandState) { + AnyView( + VibeBarIslandExpandedView( + bleManager: bleManager, + state: islandState + ) + ) } - VibeBarController.shared.start(state: vibeBarBridge.state) } } .windowStyle(.titleBar) @@ -27,7 +40,7 @@ struct AhaKeyConfigApp: App { if #available(macOS 13.0, *) { MenuBarExtra("AhaKey", systemImage: "keyboard") { Button("打开主窗口") { - appDelegate.reopenMainWindow() + AppDelegate.shared?.reopenMainWindow() } Divider() @@ -35,17 +48,38 @@ struct AhaKeyConfigApp: App { Button("退出 AhaKey Studio") { NSApp.terminate(nil) } + + MainWindowReopenHelper() } } } } +@MainActor final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegate { + private(set) static weak var shared: AppDelegate? + + /// 由 SwiftUI `openWindow` 注册;主窗口关闭后仍可用于重建 WindowGroup。 + private static var openMainWindowHandler: (@MainActor () -> Void)? + + static func registerOpenMainWindow(_ handler: @escaping @MainActor () -> Void) { + openMainWindowHandler = handler + } + + private var voiceHUDPanel: NSPanel? + private var voiceHUDHostingView: NSHostingView? + private var voiceHUDCancellables = Set() + private var lastHUDCommittedText = "" + private var voiceHUDUserFrameOrigin: NSPoint? + private var voiceHUDWasVisible = false + func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { false } func applicationDidFinishLaunching(_ notification: Notification) { + AppDelegate.shared = self + // 单实例:检查是否已有实例在运行 let bundleID = Bundle.main.bundleIdentifier ?? "lab.jawa.ahakeyconfig" let currentBundlePath = Bundle.main.bundlePath @@ -84,6 +118,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent VoiceRelayService.shared.start() NativeSpeechTranscriptionService.shared.start() + AhaKeyIslandAppSettings.applyStoredPreferences() + installVoiceHUDPanel() + observeVoiceHUDVisibility() + VoiceStatusHUDController.shared.suppressRecordingStates = true } func userNotificationCenter( @@ -108,11 +146,228 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent func reopenMainWindow() { NSApp.activate(ignoringOtherApps: true) + + if let window = Self.findStudioMainWindow() { + if window.isMiniaturized { + window.deminiaturize(nil) + } + window.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + return + } + + // 主窗口已关闭:优先走已注册的 openWindow;并广播通知兜底。 + if let handler = Self.openMainWindowHandler { + handler() + } + NotificationCenter.default.post(name: .ahaKeyReopenMainWindow, object: nil) + + // 下一拍再抢一次前台,避免新建窗口仍落在后台。 + DispatchQueue.main.async { + if let window = Self.findStudioMainWindow() { + window.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + } + } + } + + private static func findStudioMainWindow() -> NSWindow? { + let candidates = NSApp.windows.filter { window in + guard !(window is NSPanel) else { return false } + guard window.styleMask.contains(.titled) else { return false } + // 灵动岛 / HUD 等小窗排除;主 Studio 窗口通常 ≥ 800×500。 + let size = window.frame.size + return size.width >= 700 && size.height >= 450 + } + if let visible = candidates.first(where: { $0.isVisible && !$0.isMiniaturized }) { + return visible + } + if let miniaturized = candidates.first(where: \.isMiniaturized) { + return miniaturized + } + return candidates.first + } + + private func installVoiceHUDPanel() { + guard voiceHUDPanel == nil else { return } + + let nativeSpeech = NativeSpeechTranscriptionService.shared + let view = VoiceInputFloatingHUD( + nativeSpeech: nativeSpeech, + onCancel: { + if nativeSpeech.isRecording { + nativeSpeech.stopRecording() + } + }, + onConfirm: { + if nativeSpeech.isRecording { + nativeSpeech.stopRecording() + } + }, + onDragChanged: { [weak self] delta in + self?.moveVoiceHUDPanel(by: delta) + }, + onDragEnded: { [weak self] in + self?.rememberVoiceHUDPanelPosition() + } + ) + let hostingView = NSHostingView(rootView: view) + hostingView.frame = NSRect(x: 0, y: 0, width: 180, height: 58) + + let panel = NSPanel( + contentRect: hostingView.frame, + styleMask: [.nonactivatingPanel, .fullSizeContentView], + backing: .buffered, + defer: false + ) + panel.contentView = hostingView + panel.isOpaque = false + panel.backgroundColor = .clear + panel.hasShadow = false + panel.level = .floating + panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary] + panel.hidesOnDeactivate = false + panel.ignoresMouseEvents = false + panel.isReleasedWhenClosed = false + panel.titleVisibility = .hidden + panel.titlebarAppearsTransparent = true + panel.standardWindowButton(.closeButton)?.isHidden = true + panel.standardWindowButton(.miniaturizeButton)?.isHidden = true + panel.standardWindowButton(.zoomButton)?.isHidden = true + + voiceHUDPanel = panel + voiceHUDHostingView = hostingView + } + + private func observeVoiceHUDVisibility() { + let service = NativeSpeechTranscriptionService.shared + Publishers.Merge3( + service.$isRecording.map { _ in () }.eraseToAnyPublisher(), + service.$statusMessage.map { _ in () }.eraseToAnyPublisher(), + service.$lastCommittedText.map { _ in () }.eraseToAnyPublisher() + ) + .receive(on: RunLoop.main) + .sink { [weak self] in + self?.updateVoiceHUDPanelVisibility() + } + .store(in: &voiceHUDCancellables) + } + + private func updateVoiceHUDPanelVisibility() { + guard let panel = voiceHUDPanel else { return } + let service = NativeSpeechTranscriptionService.shared + let isBusy = service.statusMessage.contains("整理") + let hasNewCommittedText = !service.lastCommittedText.isEmpty && service.lastCommittedText != lastHUDCommittedText + let visible = service.isRecording || isBusy || hasNewCommittedText + + if visible { + if !voiceHUDWasVisible { + positionVoiceHUDPanel(panel) + } + panel.orderFrontRegardless() + voiceHUDWasVisible = true + if hasNewCommittedText { + lastHUDCommittedText = service.lastCommittedText + DispatchQueue.main.asyncAfter(deadline: .now() + 1.35) { [weak self] in + guard let self, !NativeSpeechTranscriptionService.shared.isRecording else { return } + if !NativeSpeechTranscriptionService.shared.statusMessage.contains("整理") { + self.voiceHUDPanel?.orderOut(nil) + self.voiceHUDWasVisible = false + } + } + } + } else { + panel.orderOut(nil) + voiceHUDWasVisible = false + } + } + + private func positionVoiceHUDPanel(_ panel: NSPanel) { + let size = NSSize(width: 180, height: 58) + if let savedOrigin = voiceHUDUserFrameOrigin { + panel.setFrame(NSRect(origin: clampedVoiceHUDOrigin(savedOrigin, size: size), size: size), display: true) + return + } if let mainWindow = NSApp.windows.first(where: { $0.canBecomeMain && !$0.isMiniaturized }) { - mainWindow.makeKeyAndOrderFront(nil) - } else if let mainWindow = NSApp.windows.first(where: { $0.canBecomeMain }) { - mainWindow.deminiaturize(nil) - mainWindow.makeKeyAndOrderFront(nil) + let frame = mainWindow.frame + let origin = NSPoint( + x: frame.midX - size.width / 2, + y: max(frame.minY + 18, (mainWindow.screen?.visibleFrame.minY ?? 0) + 18) + ) + panel.setFrame(NSRect(origin: origin, size: size), display: true) + return } + + let screen = NSScreen.main ?? NSScreen.screens.first + let frame = screen?.visibleFrame ?? NSRect(x: 0, y: 0, width: 1280, height: 800) + let origin = NSPoint( + x: frame.midX - size.width / 2, + y: frame.minY + 18 + ) + panel.setFrame(NSRect(origin: origin, size: size), display: true) + } + + private func moveVoiceHUDPanel(by delta: CGSize) { + guard let panel = voiceHUDPanel else { return } + let current = panel.frame + let nextOrigin = NSPoint( + x: current.origin.x + delta.width, + y: current.origin.y + delta.height + ) + panel.setFrame(NSRect(origin: clampedVoiceHUDOrigin(nextOrigin, size: current.size), size: current.size), display: true) + } + + private func rememberVoiceHUDPanelPosition() { + guard let panel = voiceHUDPanel else { return } + voiceHUDUserFrameOrigin = panel.frame.origin + } + + private func clampedVoiceHUDOrigin(_ origin: NSPoint, size: NSSize) -> NSPoint { + let screenFrame = (NSScreen.screens.first { $0.visibleFrame.intersects(NSRect(origin: origin, size: size)) } ?? NSScreen.main ?? NSScreen.screens.first)?.visibleFrame + ?? NSRect(x: 0, y: 0, width: 1280, height: 800) + return NSPoint( + x: min(max(origin.x, screenFrame.minX + 8), screenFrame.maxX - size.width - 8), + y: min(max(origin.y, screenFrame.minY + 8), screenFrame.maxY - size.height - 8) + ) + } +} + +@MainActor +private func wireVibeBarNavigation(state: VibeBarState, reopenMain: @escaping () -> Void) { + state.onOpenMainWindow = reopenMain + state.onIslandInteractionSound = { + VibeBarIslandSoundSettings.playInteractionIfEnabled() + } + state.onOpenVoiceAgent = { + reopenMain() + StudioNavigationRouter.shared.navigate(to: .voiceAgent) + } + state.onOpenDevice = { + reopenMain() + StudioNavigationRouter.shared.navigate(to: .device) + } + state.onOpenApprove = { + reopenMain() + StudioNavigationRouter.shared.navigate(to: .approve) + } + state.onOpenOLED = { + reopenMain() + StudioNavigationRouter.shared.navigate(to: .oled) + } + state.onOpenVoice = { + reopenMain() + StudioNavigationRouter.shared.navigate(to: .voice) + } + state.onKeyRecord = { + NativeSpeechTranscriptionService.shared.toggleRecordingFromVoiceKey() + } + state.onKeyApprove = { + reopenMain() + StudioNavigationRouter.shared.navigate(to: .approve) + } + state.onKeyReject = { + reopenMain() + StudioNavigationRouter.shared.navigate(to: .approve) } + state.onKeySwitch = nil } diff --git a/ahakeyconfig-mac/Sources/AhaKeyConfigUI/AhaKeyDesignSystem.swift b/ahakeyconfig-mac/Sources/AhaKeyConfigUI/AhaKeyDesignSystem.swift new file mode 100644 index 00000000..b072c37f --- /dev/null +++ b/ahakeyconfig-mac/Sources/AhaKeyConfigUI/AhaKeyDesignSystem.swift @@ -0,0 +1,242 @@ +import SwiftUI + +public enum AhaKeyAppearanceMode: String, CaseIterable, Identifiable { + case system + case light + case dark + + public static let storageKey = "AhaKey.AppearanceMode" + + /// 新安装默认深色;已有用户保留其持久化选择。 + public static let defaultMode: AhaKeyAppearanceMode = .dark + + public var id: String { rawValue } + + public var title: String { + switch self { + case .system: return "跟随系统" + case .light: return "浅色" + case .dark: return "深色" + } + } + + public var systemImage: String { + switch self { + case .system: return "circle.lefthalf.filled" + case .light: return "sun.max" + case .dark: return "moon.fill" + } + } + + /// `nil` 表示跟随系统外观。 + public var colorScheme: ColorScheme? { + switch self { + case .system: return nil + case .light: return .light + case .dark: return .dark + } + } + + public var next: AhaKeyAppearanceMode { + switch self { + case .system: return .light + case .light: return .dark + case .dark: return .system + } + } +} + +public enum AhaKeyUI { + public enum Radius { + public static let small: CGFloat = 8 + public static let medium: CGFloat = 10 + public static let large: CGFloat = 12 + public static let pill: CGFloat = 999 + } + + public enum Spacing { + public static let shell: CGFloat = 5 + public static let page: CGFloat = 24 + public static let panel: CGFloat = 14 + } + + public enum Font { + public static let largeTitle = SwiftUI.Font.system(size: 22, weight: .semibold) + public static let title1 = SwiftUI.Font.system(size: 20, weight: .semibold) + public static let title2 = SwiftUI.Font.system(size: 17, weight: .semibold) + public static let title3 = SwiftUI.Font.system(size: 15, weight: .semibold) + public static let body = SwiftUI.Font.system(size: 15) + public static let callout = SwiftUI.Font.system(size: 14) + public static let subhead = SwiftUI.Font.system(size: 13, weight: .medium) + public static let footnote = SwiftUI.Font.system(size: 12) + public static let caption = SwiftUI.Font.system(size: 11) + } + + public enum ColorToken { + public static var base: Color { Color(nsColor: .windowBackgroundColor) } + public static var card: Color { Color(nsColor: .controlBackgroundColor) } + public static var control: Color { Color(nsColor: .textBackgroundColor).opacity(0.72) } + public static var canvas: Color { Color(nsColor: .underPageBackgroundColor) } + public static var hover: Color { Color.primary.opacity(0.06) } + public static var border: Color { Color.primary.opacity(0.10) } + public static var borderStrong: Color { Color.primary.opacity(0.18) } + public static var primary: Color { Color.accentColor } + public static var success: Color { .green } + public static var warning: Color { .orange } + } +} + +public struct AhaKeySurface: ViewModifier { + private let radius: CGFloat + + public init(radius: CGFloat = AhaKeyUI.Radius.large) { + self.radius = radius + } + + public func body(content: Content) -> some View { + content + .background( + RoundedRectangle(cornerRadius: radius, style: .continuous) + .fill(AhaKeyUI.ColorToken.card.opacity(0.72)) + ) + .overlay( + RoundedRectangle(cornerRadius: radius, style: .continuous) + .strokeBorder(AhaKeyUI.ColorToken.border, lineWidth: 1) + ) + } +} + +public struct AhaKeyPanel: ViewModifier { + private let isRemark: Bool + + public init(isRemark: Bool = false) { + self.isRemark = isRemark + } + + public func body(content: Content) -> some View { + content + .padding(isRemark ? 12 : AhaKeyUI.Spacing.panel) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: AhaKeyUI.Radius.medium, style: .continuous) + .fill(AhaKeyUI.ColorToken.control.opacity(isRemark ? 0.62 : 1)) + ) + .overlay( + RoundedRectangle(cornerRadius: AhaKeyUI.Radius.medium, style: .continuous) + .strokeBorder( + isRemark ? AhaKeyUI.ColorToken.borderStrong : AhaKeyUI.ColorToken.border, + style: StrokeStyle(lineWidth: 1, dash: isRemark ? [5, 4] : []) + ) + ) + } +} + +public struct AhaKeyPill: ViewModifier { + private let accent: Color? + + public init(accent: Color? = nil) { + self.accent = accent + } + + public func body(content: Content) -> some View { + content + .font(AhaKeyUI.Font.subhead) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: AhaKeyUI.Radius.pill, style: .continuous) + .fill((accent ?? AhaKeyUI.ColorToken.control).opacity(accent == nil ? 1 : 0.12)) + ) + .overlay( + RoundedRectangle(cornerRadius: AhaKeyUI.Radius.pill, style: .continuous) + .strokeBorder((accent ?? AhaKeyUI.ColorToken.border).opacity(accent == nil ? 1 : 0.35), lineWidth: 1) + ) + } +} + +public struct AhaKeyPlainPanelGroupBoxStyle: GroupBoxStyle { + public init() {} + + public func makeBody(configuration: Configuration) -> some View { + VStack(alignment: .leading, spacing: 10) { + configuration.label + .font(AhaKeyUI.Font.subhead.weight(.semibold)) + .foregroundStyle(.primary) + configuration.content + } + .modifier(AhaKeyPanel()) + } +} + +public struct AhaKeyPrimaryButtonStyle: ButtonStyle { + public init() {} + + public func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(AhaKeyUI.Font.subhead.weight(.semibold)) + .padding(.horizontal, 15) + .padding(.vertical, 9) + .foregroundStyle(.white) + .background( + Capsule(style: .continuous) + .fill(AhaKeyUI.ColorToken.primary.opacity(configuration.isPressed ? 0.82 : 1)) + ) + } +} + +public struct AhaKeySecondaryButtonStyle: ButtonStyle { + public init() {} + + public func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(AhaKeyUI.Font.subhead.weight(.semibold)) + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background( + Capsule(style: .continuous) + .fill(configuration.isPressed ? AhaKeyUI.ColorToken.hover : AhaKeyUI.ColorToken.control) + ) + .overlay( + Capsule(style: .continuous) + .strokeBorder(AhaKeyUI.ColorToken.border, lineWidth: 1) + ) + } +} + +public struct AhaKeyIconButtonStyle: ButtonStyle { + public init() {} + + public func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(.primary) + .frame(width: 34, height: 34) + .background( + RoundedRectangle(cornerRadius: AhaKeyUI.Radius.small, style: .continuous) + .fill(configuration.isPressed ? AhaKeyUI.ColorToken.hover : AhaKeyUI.ColorToken.control) + ) + .overlay( + RoundedRectangle(cornerRadius: AhaKeyUI.Radius.small, style: .continuous) + .strokeBorder(AhaKeyUI.ColorToken.border, lineWidth: 1) + ) + } +} + +public extension View { + func ahaKeySurface(radius: CGFloat = AhaKeyUI.Radius.large) -> some View { + modifier(AhaKeySurface(radius: radius)) + } + + func ahaKeyPanel() -> some View { + modifier(AhaKeyPanel()) + } + + func ahaKeyRemarkPanel() -> some View { + modifier(AhaKeyPanel(isRemark: true)) + } + + func ahaKeyPill(accent: Color? = nil) -> some View { + modifier(AhaKeyPill(accent: accent)) + } +} + diff --git a/ahakeyconfig-mac/Sources/AhaKeyPlugin/Plugin.swift b/ahakeyconfig-mac/Sources/AhaKeyPlugin/Plugin.swift old mode 100644 new mode 100755 diff --git a/ahakeyconfig-mac/Sources/AhaKeyPluginKit/JSONRPC.swift b/ahakeyconfig-mac/Sources/AhaKeyPluginKit/JSONRPC.swift old mode 100644 new mode 100755 diff --git a/ahakeyconfig-mac/Sources/AhaKeyPluginKit/PluginClient.swift b/ahakeyconfig-mac/Sources/AhaKeyPluginKit/PluginClient.swift old mode 100644 new mode 100755 diff --git a/ahakeyconfig-mac/Sources/AhaKeyPluginKit/PluginHost.swift b/ahakeyconfig-mac/Sources/AhaKeyPluginKit/PluginHost.swift old mode 100644 new mode 100755 diff --git a/ahakeyconfig-mac/Sources/AhaKeyPluginKit/PluginLifecycle.swift b/ahakeyconfig-mac/Sources/AhaKeyPluginKit/PluginLifecycle.swift old mode 100644 new mode 100755 diff --git a/ahakeyconfig-mac/Sources/AhaKeyPluginKit/PluginManager.swift b/ahakeyconfig-mac/Sources/AhaKeyPluginKit/PluginManager.swift old mode 100644 new mode 100755 diff --git a/ahakeyconfig-mac/Sources/AhaKeyPluginKit/PluginManifest.swift b/ahakeyconfig-mac/Sources/AhaKeyPluginKit/PluginManifest.swift old mode 100644 new mode 100755 diff --git a/ahakeyconfig-mac/Sources/AhaKeyPluginShowcase/PluginShowcaseApp.swift b/ahakeyconfig-mac/Sources/AhaKeyPluginShowcase/PluginShowcaseApp.swift old mode 100644 new mode 100755 diff --git a/ahakeyconfig-mac/Sources/BLE/AhaKeyBLEManager.swift b/ahakeyconfig-mac/Sources/BLE/AhaKeyBLEManager.swift old mode 100644 new mode 100755 index 48cc9817..6e15eb54 --- a/ahakeyconfig-mac/Sources/BLE/AhaKeyBLEManager.swift +++ b/ahakeyconfig-mac/Sources/BLE/AhaKeyBLEManager.swift @@ -77,6 +77,8 @@ final class AhaKeyBLEManager: NSObject, ObservableObject { /// 主 App 自占 BLE 后通过 0x83 查询填充;frameCount == 0 表示用户没自定义上传, /// 键盘显示固件出厂动图(与 bundle/DefaultOLED 同源)。 @Published private(set) var keyboardPictureStates: [Int: KeyboardPictureState] = [:] + @Published var magneticModuleState: MagneticModuleState = MagneticModuleStateStore.load() + @Published var oceanLightConfig: OceanLightConfig = OceanLightConfigStore.load() /// 通信日志(最近 200 条) @Published private(set) var commLog: [BLELogEntry] = [] @@ -521,6 +523,21 @@ final class AhaKeyBLEManager: NSObject, ObservableObject { appendLog("虚拟拨杆 sw_state=\(value) 仅作为软件覆盖;最新固件 0x91 用于灯效预览。") } + /// Gen2 电子海洋灯效配置(占位;固件 0x97/0x98 联调后接入真实写入)。 + func applyOceanLightConfig(_ config: OceanLightConfig) { + oceanLightConfig = config + OceanLightConfigStore.save(config) + guard commandChar != nil else { + appendLog("→ 海洋灯效(占位)preset=\(config.selectedPresetId) brightness=\(Int(config.brightness * 100))%") + return + } + writeCommand(AhaKeyCommandV2.setOceanLightPreset(config)) + if !config.marqueeText.isEmpty { + writeCommand(AhaKeyCommandV2.setMarqueeText(config.marqueeText)) + } + appendLog("→ 海洋灯效 preset=\(config.selectedPresetId)(占位协议)") + } + func setLightMapping(mode: UInt8, stateEffects: [UInt8]) { guard commandChar != nil else { return } writeCommand(AhaKeyCommand.setLightMapping(mode: mode, stateEffects: stateEffects)) diff --git a/ahakeyconfig-mac/Sources/BLE/AhaKeyProtocol.swift b/ahakeyconfig-mac/Sources/BLE/AhaKeyProtocol.swift old mode 100644 new mode 100755 diff --git a/ahakeyconfig-mac/Sources/BLE/AhaKeyProtocolV2.swift b/ahakeyconfig-mac/Sources/BLE/AhaKeyProtocolV2.swift new file mode 100644 index 00000000..4fcdfab6 --- /dev/null +++ b/ahakeyconfig-mac/Sources/BLE/AhaKeyProtocolV2.swift @@ -0,0 +1,21 @@ +import Foundation + +/// Gen2 BLE 协议扩展(占位;固件联调后替换 payload 编解码)。 +enum AhaKeyCommandV2 { + static let cmdMagneticModuleNotify: UInt8 = 0x95 + static let cmdIMUData: UInt8 = 0x96 + static let cmdOceanLightPreset: UInt8 = 0x97 + static let cmdOceanLightMarquee: UInt8 = 0x98 + + static func setOceanLightPreset(_ config: OceanLightConfig) -> Data { + let preset = UInt8(clamping: config.selectedPresetId) + let brightness = UInt8(clamping: Int(config.brightness * 255)) + let flags: UInt8 = (config.imuEnabled ? 0x01 : 0x00) | (config.powerSwitchEnabled ? 0x02 : 0x00) + return Data(AhaKeyCommand.header + [cmdOceanLightPreset, preset, brightness, flags] + AhaKeyCommand.trailer) + } + + static func setMarqueeText(_ text: String) -> Data { + let bytes = Array(text.utf8.prefix(48)) + return Data(AhaKeyCommand.header + [cmdOceanLightMarquee] + bytes + AhaKeyCommand.trailer) + } +} diff --git a/ahakeyconfig-mac/Sources/Models/AhaKeyStudioModels.swift b/ahakeyconfig-mac/Sources/Models/AhaKeyStudioModels.swift old mode 100644 new mode 100755 index 1962a5fe..3320e719 --- a/ahakeyconfig-mac/Sources/Models/AhaKeyStudioModels.swift +++ b/ahakeyconfig-mac/Sources/Models/AhaKeyStudioModels.swift @@ -90,6 +90,7 @@ enum AhaKeyStudioPart: String, CaseIterable, Codable, Identifiable { case key3 case key4 case toggleSwitch + case magneticPort var id: String { rawValue } @@ -109,6 +110,8 @@ enum AhaKeyStudioPart: String, CaseIterable, Codable, Identifiable { "Key 4" case .toggleSwitch: "拨杆" + case .magneticPort: + "磁吸底座" } } @@ -128,6 +131,8 @@ enum AhaKeyStudioPart: String, CaseIterable, Codable, Identifiable { "删除键" case .toggleSwitch: "批准方式" + case .magneticPort: + "磁吸扩展模块" } } @@ -139,7 +144,7 @@ enum AhaKeyStudioPart: String, CaseIterable, Codable, Identifiable { case .oledDisplay: "rectangle.inset.filled" case .key1: - "microphone" + "mic" case .key2: "checkmark" case .key3: @@ -148,9 +153,13 @@ enum AhaKeyStudioPart: String, CaseIterable, Codable, Identifiable { "delete.left" case .toggleSwitch: "switch.2" + case .magneticPort: + "plus.rectangle.on.rectangle" } } + var isGen2Only: Bool { self == .magneticPort } + var keyRole: AhaKeyKeyRole? { switch self { case .key1: @@ -167,6 +176,30 @@ enum AhaKeyStudioPart: String, CaseIterable, Codable, Identifiable { } var isKey: Bool { keyRole != nil } + + /// 当前代际在画布 / 右侧 Tab 中可见的部件(与热区一致)。 + static func visibleParts(for generation: DeviceGeneration) -> [AhaKeyStudioPart] { + switch generation { + case .gen2: + return [.lightBar, .oledDisplay, .key1, .key2, .key3, .key4, .magneticPort] + case .x1: + return [.lightBar, .oledDisplay, .key1, .key2, .key3, .key4, .toggleSwitch] + } + } + + /// Tab 上更短的标签(窄 Inspector 用)。 + var tabTitle: String { + switch self { + case .oledDisplay: "LCD" + case .magneticPort: "磁吸" + case .key1: "K1" + case .key2: "K2" + case .key3: "K3" + case .key4: "K4" + case .lightBar: "灯条" + case .toggleSwitch: "拨杆" + } + } } enum AhaKeyKeyRole: Int, CaseIterable, Codable, Identifiable { @@ -206,7 +239,7 @@ enum AhaKeyKeyRole: Int, CaseIterable, Codable, Identifiable { var systemImage: String { switch self { case .voice: - "microphone" + "mic" case .approve: "checkmark" case .reject: @@ -689,6 +722,17 @@ enum MacroAction: UInt8, Codable, CaseIterable, Identifiable { } } + /// 窄 Inspector 宏行用的短标签。 + var shortTitle: String { + switch self { + case .noOp: return "空" + case .downKey: return "按下" + case .upKey: return "松开" + case .delay: return "延时" + case .upAllKeys: return "全松" + } + } + /// 是否需要 HID 键码作为 param。 var takesKeycodeParam: Bool { self == .downKey || self == .upKey diff --git a/ahakeyconfig-mac/Sources/Utilities/AgentAssistantTools.swift b/ahakeyconfig-mac/Sources/Utilities/AgentAssistantTools.swift new file mode 100644 index 00000000..2aa5ecb4 --- /dev/null +++ b/ahakeyconfig-mac/Sources/Utilities/AgentAssistantTools.swift @@ -0,0 +1,382 @@ +import Foundation + +enum AgentAssistantIntent: String, CaseIterable, Identifiable { + case status + case help + case enableLinkage + case installCursorHook + case installClaudeHook + case installCodexHook + case installKimiHook + case giveBluetoothToAgent + case giveBluetoothToStudio + case openMyDevices + case openApproveKey + case openVoiceKey + case openHardwareMode + case openStatusSection + case installAgent + case startAgent + case stopAgent + + var id: String { rawValue } + + var chipTitle: String { + switch self { + case .status: "查联动状态" + case .help: "能做什么" + case .enableLinkage: "一键启用联动" + case .installCursorHook: "装 Cursor Hook" + case .installClaudeHook: "装 Claude Hook" + case .installCodexHook: "装 Codex Hook" + case .installKimiHook: "装 Kimi Hook" + case .giveBluetoothToAgent: "蓝牙交给 Agent" + case .giveBluetoothToStudio: "蓝牙交回 Studio" + case .openMyDevices: "打开我的设备" + case .openApproveKey: "去改批准键" + case .openVoiceKey: "去改语音键" + case .openHardwareMode: "去切 Mode" + case .openStatusSection: "打开联动页" + case .installAgent: "安装守护进程" + case .startAgent: "启动 Agent" + case .stopAgent: "停止 Agent" + } + } + + /// 点芯片时写入对话的自然语言(等同用户口述)。 + var naturalPhrase: String { + switch self { + case .status: "帮我查一下联动状态" + case .help: "你能做什么" + case .enableLinkage: "一键启用联动" + case .installCursorHook: "帮我装一下 Cursor 的 hook" + case .installClaudeHook: "帮我装一下 Claude 的 hook" + case .installCodexHook: "帮我装一下 Codex 的 hook" + case .installKimiHook: "帮我装一下 Kimi 的 hook" + case .giveBluetoothToAgent: "把蓝牙交给 Agent" + case .giveBluetoothToStudio: "把蓝牙交回 Studio" + case .openMyDevices: "打开我的设备" + case .openApproveKey: "打开批准键设置" + case .openVoiceKey: "打开语音键设置" + case .openHardwareMode: "去硬件切换 Mode" + case .openStatusSection: "打开联动页" + case .installAgent: "帮我安装守护进程" + case .startAgent: "启动 Agent" + case .stopAgent: "停止 Agent" + } + } + + static let quickChips: [AgentAssistantIntent] = [ + .status, .enableLinkage, .giveBluetoothToAgent, .openStatusSection + ] +} + +@MainActor +enum AgentAssistantRouter { + /// 解析用户输入为有序意图列表(支持复合句)。 + static func resolveAll(_ raw: String) -> [AgentAssistantIntent] { + let text = normalize(raw) + guard !text.isEmpty else { return [] } + + if matchesHelp(text) { + return [.help] + } + + if matchesEnableLinkage(text) { + return [.enableLinkage] + } + + var intents: [AgentAssistantIntent] = [] + var remaining = text + + // 复合:启动 + 交蓝牙 → 视为一键启用 + if matchesStart(remaining), matchesGiveBluetoothToAgent(remaining) { + return [.enableLinkage] + } + + func take(_ intent: AgentAssistantIntent, when match: (String) -> Bool) { + guard match(remaining) else { return } + if !intents.contains(intent) { + intents.append(intent) + } + } + + take(.installCursorHook, when: matchesInstallCursor) + take(.installClaudeHook, when: matchesInstallClaude) + take(.installCodexHook, when: matchesInstallCodex) + take(.installKimiHook, when: matchesInstallKimi) + take(.giveBluetoothToStudio, when: matchesGiveBluetoothToStudio) + take(.giveBluetoothToAgent, when: matchesGiveBluetoothToAgent) + take(.installAgent, when: matchesInstallAgent) + take(.startAgent, when: matchesStart) + take(.stopAgent, when: matchesStop) + take(.openMyDevices, when: matchesOpenMyDevices) + take(.openApproveKey, when: matchesOpenApprove) + take(.openVoiceKey, when: matchesOpenVoice) + take(.openHardwareMode, when: matchesOpenMode) + take(.openStatusSection, when: matchesOpenStatusSection) + take(.status, when: matchesStatus) + + // 泛化「安装 hook」且未点名 IDE → Cursor + if intents.isEmpty, matchesGenericInstallHook(text) { + intents.append(.installCursorHook) + } + + return intents + } + + static func resolve(_ raw: String) -> AgentAssistantIntent? { + resolveAll(raw).first + } + + static func helpText() -> String { + """ + 我是设备联动管家,可以用自然语言让我做事(本版不接云端大模型),例如: + · 「一键启用联动」(安装/启动守护进程并交蓝牙) + · 「帮我查一下联动状态」 + · 「装一下 Cursor / Claude / Codex / Kimi 的 hook」 + · 「把蓝牙交给 Agent」或「交回 Studio」 + · 「打开联动页」「打开我的设备」「打开批准键 / 语音键」 + 也可以说复合句,比如「启动 Agent 并把蓝牙交给它」。 + """ + } + + // MARK: - Normalize & matchers + + private static func normalize(_ raw: String) -> String { + raw + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + .replacingOccurrences(of: " ", with: " ") + } + + private static func matchesHelp(_ t: String) -> Bool { + t == "help" || t == "?" || t == "?" + || t.contains("能做什么") || t.contains("你会什么") || t.contains("怎么用") + || t.contains("帮助") || t.contains("有哪些") + } + + private static func matchesEnableLinkage(_ t: String) -> Bool { + t.contains("一键启用") + || (t.contains("启用") && t.contains("联动")) + || t.contains("开启联动") + || t.contains("启用 agent") + } + + private static func matchesStatus(_ t: String) -> Bool { + if t.contains("状态与联动") || t.contains("状态页") || t.contains("联动页") { return false } + if matchesEnableLinkage(t) { return false } + return t.contains("状态") || t.contains("status") || t.contains("好了吗") + || t.contains("连上了吗") + || (t.contains("联动") && (t.contains("查") || t.contains("看") || t.contains("怎么样"))) + || t.contains("检查") || t.contains("诊断一下") + } + + private static func matchesInstallCursor(_ t: String) -> Bool { + t.contains("cursor") && (t.contains("hook") || t.contains("钩子") || t.contains("安装") || t.contains("装")) + } + + private static func matchesInstallClaude(_ t: String) -> Bool { + t.contains("claude") && (t.contains("hook") || t.contains("钩子") || t.contains("安装") || t.contains("装")) + } + + private static func matchesInstallCodex(_ t: String) -> Bool { + t.contains("codex") && (t.contains("hook") || t.contains("钩子") || t.contains("安装") || t.contains("装")) + } + + private static func matchesInstallKimi(_ t: String) -> Bool { + (t.contains("kimi") || t.contains("kimicode")) + && (t.contains("hook") || t.contains("钩子") || t.contains("安装") || t.contains("装")) + } + + private static func matchesGenericInstallHook(_ t: String) -> Bool { + (t.contains("hook") || t.contains("钩子")) && (t.contains("安装") || t.contains("装")) + } + + private static func matchesGiveBluetoothToAgent(_ t: String) -> Bool { + guard t.contains("蓝牙") || t.contains("ble") || t.contains("占用") else { return false } + if t.contains("studio") || t.contains("交回") || t.contains("还给") { return false } + return t.contains("agent") || t.contains("交给") || t.contains("给 agent") || t.contains("联动") + } + + private static func matchesGiveBluetoothToStudio(_ t: String) -> Bool { + (t.contains("蓝牙") || t.contains("ble") || t.contains("占用")) + && (t.contains("studio") || t.contains("交回") || t.contains("还给") || t.contains("配置")) + } + + private static func matchesInstallAgent(_ t: String) -> Bool { + (t.contains("安装") || t.contains("装一下")) + && (t.contains("守护") || t.contains("daemon") || (t.contains("agent") && !t.contains("hook"))) + } + + private static func matchesStart(_ t: String) -> Bool { + (t.contains("启动") || t.contains("开启") || t.contains("start") || t.contains("跑起来")) + && (t.contains("agent") || t.contains("守护") || t.contains("联动")) + } + + private static func matchesStop(_ t: String) -> Bool { + (t.contains("停止") || t.contains("关闭") || t.contains("stop") || t.contains("关掉")) + && (t.contains("agent") || t.contains("守护") || t.contains("联动")) + } + + private static func matchesOpenMyDevices(_ t: String) -> Bool { + t.contains("我的设备") || t.contains("设备管理") || t.contains("设备信息") || t.contains("设备详情") + } + + private static func matchesOpenApprove(_ t: String) -> Bool { + t.contains("批准键") || t.contains("approve") + || (t.contains("批准") && (t.contains("键") || t.contains("设置") || t.contains("改") || t.contains("打开"))) + } + + private static func matchesOpenVoice(_ t: String) -> Bool { + t.contains("语音键") || (t.contains("voice") && (t.contains("键") || t.contains("key"))) + || (t.contains("语音") && (t.contains("键") || t.contains("设置") || t.contains("改"))) + } + + private static func matchesOpenMode(_ t: String) -> Bool { + (t.contains("mode") || t.contains("模式")) + && (t.contains("切") || t.contains("换") || t.contains("打开") || t.contains("去") || t.contains("硬件")) + } + + private static func matchesOpenStatusSection(_ t: String) -> Bool { + t.contains("状态与联动") || t.contains("状态页") || t.contains("联动页") + || (t.contains("打开") && t.contains("联动") && !t.contains("启用") && !t.contains("查")) + || (t.contains("打开") && t.contains("状态") && !t.contains("查") && !t.contains("好了")) + } +} + +@MainActor +struct AgentAssistantTools { + let bleManager: AhaKeyBLEManager + private let agent = AgentManager.shared + + func run(_ intent: AgentAssistantIntent) -> String { + agent.refresh() + switch intent { + case .status: + return statusSummary() + case .help: + return AgentAssistantRouter.helpText() + case .enableLinkage: + return enableLinkage() + case .installCursorHook: + agent.installCursorHooksOnly() + return "已尝试安装 Cursor Hooks。可到「联动」页看徽章是否变绿;也可以说「帮我查一下联动状态」。" + case .installClaudeHook: + agent.installClaudeHooksOnly() + return "已尝试安装 Claude Hooks。可到「联动」页确认。" + case .installCodexHook: + agent.installCodexHooksOnly() + return "已尝试安装 Codex Hooks。可到「联动」页确认。" + case .installKimiHook: + agent.installKimiHooksOnly() + return "已尝试安装 Kimi Hooks。可到「联动」页确认。" + case .giveBluetoothToAgent: + if !agent.isInstalled { + return "还没装守护进程。可以说「一键启用联动」,装好后再把蓝牙交给它。" + } + agent.setBluetoothConnectionOwner(.agentDaemon, bleManager: bleManager) + return "已将蓝牙占用切换为 Agent。Studio 会释放连接,由 Agent 接管灯条与拨杆联动。" + case .giveBluetoothToStudio: + agent.setBluetoothConnectionOwner(.ahaKeyStudio, bleManager: bleManager) + return "已将蓝牙交回 AhaKey Studio,可以去「硬件设备」改键或同步配置。" + case .openMyDevices: + StudioNavigationRouter.shared.openDeviceManagement(showDetail: true) + return "已打开「我的设备」详情,可在那里改名、看电量与系统控制。" + case .openApproveKey: + openHardware(part: .key2) + return "已打开硬件设备 · Key 2(批准)。请在右侧 Inspector 修改绑定。" + case .openVoiceKey: + openHardware(part: .key1) + return "已打开硬件设备 · Key 1(语音)。请在右侧 Inspector 修改绑定。" + case .openHardwareMode: + StudioNavigationRouter.shared.selectSettingsTab(.hardware) + return "已打开「硬件设备」。请在画布下方的 Agent 模式分段切换 Claude / Cursor / Codex / custom。" + case .openStatusSection: + StudioNavigationRouter.shared.selectAgentSection(.status) + return "已切换到「联动」页,可在那里一键启用、切蓝牙与管理 Hook。" + case .installAgent: + if agent.isInstalled { + return "守护进程已经安装。可以说「一键启用联动」或「把蓝牙交给 Agent」。" + } + agent.install() + return "已开始安装并启用 Agent(含 Hook)。稍候可以说「查一下联动状态」确认。" + case .startAgent: + if !agent.isInstalled { + agent.install() + return "检测到未安装,已开始安装并启用 Agent(含 Hook)。装好后会尝试启动。" + } + agent.setBluetoothConnectionOwner(.agentDaemon, bleManager: bleManager) + agent.start() + return "已请求启动 Agent,并尽量把蓝牙交给它。可以说「查一下联动状态」确认是否在跑。" + case .stopAgent: + agent.stop() + return "已请求停止 Agent 守护进程。若要改键,可再说「把蓝牙交回 Studio」。" + } + } + + private func enableLinkage() -> String { + if !agent.isInstalled { + agent.install() + return "已开始安装并启用联动(守护进程 + Hook)。装好后会尽量把蓝牙交给 Agent;可以说「查一下联动状态」确认。" + } + agent.setBluetoothConnectionOwner(.agentDaemon, bleManager: bleManager) + if !agent.isRunning { + agent.start() + } + return "已请求启用联动:蓝牙交给 Agent,并确保守护进程在跑。可以说「查一下联动状态」确认。" + } + + /// 按序执行多个意图,合并回复。 + func runAll(_ intents: [AgentAssistantIntent]) -> String { + guard !intents.isEmpty else { + return """ + 还没听懂。可以说「你能做什么」,或试试:「一键启用联动」「帮我查一下联动状态」「把蓝牙交给 Agent」。 + """ + } + if intents.count == 1 { + return run(intents[0]) + } + return intents.enumerated().map { index, intent in + "\(index + 1). \(run(intent))" + }.joined(separator: "\n") + } + + private func statusSummary() -> String { + let installed = agent.isInstalled ? "已安装" : "未安装" + let running = agent.isRunning ? "运行中" : "未运行" + let ble = agent.isAgentBLEConnected + ? "Agent 已连键盘" + : (bleManager.isConnected ? "Studio 正连着键盘" : "键盘未连接") + let owner = agent.bluetoothConnectionOwner.title + let hooks = [ + agent.claudeHooksInstalled ? "Claude" : nil, + agent.cursorHooksInstalled ? "Cursor" : nil, + agent.codexHooksInstalled ? "Codex" : nil, + agent.kimiHooksInstalled ? "Kimi" : nil, + ].compactMap { $0 } + let hookLine = hooks.isEmpty ? "尚未安装任何 Hook" : "Hook:\(hooks.joined(separator: " · "))" + let switchTitle = bleManager.switchState == 0 ? "自动批准" : "手动批准" + + var tip = "" + if !agent.isInstalled || !agent.isRunning || agent.bluetoothConnectionOwner != .agentDaemon { + tip = "\n建议:可以说「一键启用联动」。" + } + + return """ + 守护进程:\(installed),\(running)。 + 蓝牙占用:\(owner);\(ble)。 + \(hookLine)。 + 拨杆语义:\(switchTitle)(只读上报)。\(tip) + """ + } + + private func openHardware(part: AhaKeyStudioPart) { + StudioNavigationRouter.shared.selectSettingsTab(.hardware) + NotificationCenter.default.post( + name: .ahaKeyStudioSelectPart, + object: nil, + userInfo: [StudioNavigationUserInfoKey.part: part.rawValue] + ) + } +} diff --git a/ahakeyconfig-mac/Sources/Utilities/AgentManager.swift b/ahakeyconfig-mac/Sources/Utilities/AgentManager.swift old mode 100644 new mode 100755 diff --git a/ahakeyconfig-mac/Sources/Utilities/AhaKeyIslandAppSettings.swift b/ahakeyconfig-mac/Sources/Utilities/AhaKeyIslandAppSettings.swift new file mode 100644 index 00000000..1301b4b3 --- /dev/null +++ b/ahakeyconfig-mac/Sources/Utilities/AhaKeyIslandAppSettings.swift @@ -0,0 +1,63 @@ +import AppKit +import Foundation +import ServiceManagement + +/// 灵动岛相关的应用级偏好(Dock / 登录项)。 +enum AhaKeyIslandAppSettings { + static let showInDockKey = "ahakey.island.showInDock" + static let openAtLoginKey = "ahakey.island.openAtLogin" + + static var showInDock: Bool { + get { + if UserDefaults.standard.object(forKey: showInDockKey) == nil { + return true + } + return UserDefaults.standard.bool(forKey: showInDockKey) + } + set { + UserDefaults.standard.set(newValue, forKey: showInDockKey) + applyDockVisibility(newValue) + } + } + + static var openAtLogin: Bool { + get { + if #available(macOS 13.0, *) { + return SMAppService.mainApp.status == .enabled + } + return UserDefaults.standard.bool(forKey: openAtLoginKey) + } + set { + UserDefaults.standard.set(newValue, forKey: openAtLoginKey) + applyOpenAtLogin(newValue) + } + } + + static func applyStoredPreferences() { + applyDockVisibility(showInDock) + if UserDefaults.standard.object(forKey: openAtLoginKey) != nil { + applyOpenAtLogin(UserDefaults.standard.bool(forKey: openAtLoginKey)) + } + } + + private static func applyDockVisibility(_ visible: Bool) { + NSApp.setActivationPolicy(visible ? .regular : .accessory) + if visible { + NSApp.activate(ignoringOtherApps: true) + } + } + + private static func applyOpenAtLogin(_ enabled: Bool) { + guard #available(macOS 13.0, *) else { return } + do { + if enabled { + try SMAppService.mainApp.register() + } else { + try SMAppService.mainApp.unregister() + } + } catch { + // 登录项权限可能被系统拒绝;保留 UserDefaults 值供下次重试。 + NSLog("AhaKey openAtLogin failed: \(error.localizedDescription)") + } + } +} diff --git a/ahakeyconfig-mac/Sources/Utilities/AhaTypeTextOptimizer.swift b/ahakeyconfig-mac/Sources/Utilities/AhaTypeTextOptimizer.swift old mode 100644 new mode 100755 diff --git a/ahakeyconfig-mac/Sources/Utilities/AhakeyGeneralSettingsStore.swift b/ahakeyconfig-mac/Sources/Utilities/AhakeyGeneralSettingsStore.swift new file mode 100644 index 00000000..e452811f --- /dev/null +++ b/ahakeyconfig-mac/Sources/Utilities/AhakeyGeneralSettingsStore.swift @@ -0,0 +1,102 @@ +import Foundation + +enum AhakeyGeneralSettingsStore { + static let agentAPIKeyKey = "ahakey.general.agentAPIKey" + static let languageKey = "ahakey.general.appLanguage" + static let regionKey = "ahakey.general.appRegion" + + static var agentAPIKey: String { + UserDefaults.standard.string(forKey: agentAPIKeyKey) ?? "" + } + + static func setAgentAPIKey(_ value: String) { + UserDefaults.standard.set(value.trimmingCharacters(in: .whitespacesAndNewlines), forKey: agentAPIKeyKey) + } + + static var maskedAgentAPIKey: String { + let key = agentAPIKey + guard !key.isEmpty else { return "未配置" } + if key.count <= 8 { return String(repeating: "•", count: key.count) } + return "\(key.prefix(7))••••\(key.suffix(4))" + } + + static var appLanguage: AhaKeyAppLanguage { + get { + let raw = UserDefaults.standard.string(forKey: languageKey) ?? AhaKeyAppLanguage.system.rawValue + return AhaKeyAppLanguage(rawValue: raw) ?? .system + } + set { UserDefaults.standard.set(newValue.rawValue, forKey: languageKey) } + } + + static var appRegion: AhaKeyAppRegion { + get { + let raw = UserDefaults.standard.string(forKey: regionKey) ?? AhaKeyAppRegion.system.rawValue + return AhaKeyAppRegion(rawValue: raw) ?? .system + } + set { UserDefaults.standard.set(newValue.rawValue, forKey: regionKey) } + } + + static var resolvedLocale: Locale { + AhaKeyLocaleResolver.locale(language: appLanguage, region: appRegion) + } +} + +enum AhaKeyAppLanguage: String, CaseIterable, Identifiable { + case system + case zhHans + case english + + var id: String { rawValue } + + var title: String { + switch self { + case .system: "跟随系统" + case .zhHans: "简体中文" + case .english: "English" + } + } +} + +enum AhaKeyAppRegion: String, CaseIterable, Identifiable { + case system + case chinaMainland + case unitedStates + + var id: String { rawValue } + + var title: String { + switch self { + case .system: "跟随系统" + case .chinaMainland: "中国大陆" + case .unitedStates: "美国" + } + } +} + +enum AhaKeyLocaleResolver { + static func locale(language: AhaKeyAppLanguage, region: AhaKeyAppRegion) -> Locale { + switch (language, region) { + case (.system, .system): + return .autoupdatingCurrent + case (.zhHans, .chinaMainland), (.zhHans, .system): + return Locale(identifier: "zh_CN") + case (.zhHans, .unitedStates): + return Locale(identifier: "zh_Hans_US") + case (.english, .unitedStates), (.english, .system): + return Locale(identifier: "en_US") + case (.english, .chinaMainland): + return Locale(identifier: "en_CN") + case (.system, .chinaMainland): + return Locale(identifier: preferredLanguagePrefix() == "en" ? "en_CN" : "zh_CN") + case (.system, .unitedStates): + return Locale(identifier: preferredLanguagePrefix() == "zh" ? "zh_Hans_US" : "en_US") + } + } + + private static func preferredLanguagePrefix() -> String { + let code = Locale.preferredLanguages.first ?? "zh" + if code.hasPrefix("zh") { return "zh" } + if code.hasPrefix("en") { return "en" } + return "zh" + } +} diff --git a/ahakeyconfig-mac/Sources/Utilities/CloudAccountManager.swift b/ahakeyconfig-mac/Sources/Utilities/CloudAccountManager.swift old mode 100644 new mode 100755 index 64df5888..0115ba5e --- a/ahakeyconfig-mac/Sources/Utilities/CloudAccountManager.swift +++ b/ahakeyconfig-mac/Sources/Utilities/CloudAccountManager.swift @@ -199,6 +199,53 @@ final class CloudAccountManager: ObservableObject { ].filter { !$0.isEmpty }.joined(separator: "\n") } + /// 用户中心 Hero / 账户卡副文案。 + var validUntilDisplay: String { + guard let profile else { return isLoggedIn ? "账户正常" : "" } + let validUntil = stringValue(profile["token_valid_until"]) + return validUntil.isEmpty ? "有效期:无" : "有效期至 \(validUntil)" + } + + var phoneDisplay: String { + if let profile { + let phone = stringValue(profile["phone"]) + if !phone.isEmpty { return phone } + } + let masked = maskedPhoneDisplay + return masked.isEmpty ? phone : masked + } + + /// 侧栏用户卡主标题。 + var sidebarTitle: String { + if !isLoggedIn { return "AhaKey 用户" } + let masked = maskedPhoneDisplay + return masked.isEmpty ? "已登录" : masked + } + + /// 侧栏用户卡副标题。 + var sidebarSubtitle: String { + isLoggedIn ? "账户管理" : "点击登录" + } + + /// 头像首字母。 + var avatarInitial: String { + let digits = phone.filter(\.isNumber) + if let last = digits.last { return String(last) } + return "A" + } + + var maskedPhoneDisplay: String { + let raw: String = { + if let profile, let p = profile["phone"] as? String, !p.isEmpty { return p } + return phone + }() + let digits = raw.filter(\.isNumber) + guard digits.count >= 7 else { return raw.isEmpty ? "" : raw } + let prefix = digits.prefix(3) + let suffix = digits.suffix(4) + return "\(prefix)****\(suffix)" + } + func quotaText(_ period: String) -> String { guard let profile else { return "暂无" } let used = intValue(profile["used_\(period)"]) diff --git a/ahakeyconfig-mac/Sources/Utilities/DebugSigningFixer.swift b/ahakeyconfig-mac/Sources/Utilities/DebugSigningFixer.swift old mode 100644 new mode 100755 diff --git a/ahakeyconfig-mac/Sources/Utilities/DefaultOLEDAssets.swift b/ahakeyconfig-mac/Sources/Utilities/DefaultOLEDAssets.swift old mode 100644 new mode 100755 diff --git a/ahakeyconfig-mac/Sources/Utilities/FeatureCoachTipStore.swift b/ahakeyconfig-mac/Sources/Utilities/FeatureCoachTipStore.swift new file mode 100644 index 00000000..4084ef67 --- /dev/null +++ b/ahakeyconfig-mac/Sources/Utilities/FeatureCoachTipStore.swift @@ -0,0 +1,374 @@ +import Foundation +import SwiftUI + +/// 功能级一次性 coach tip(全屏新手引导完成后再出现)。 +enum FeatureCoachTipID: String, CaseIterable, Identifiable { + case deviceConnect = "tip.device.connect" + case hardwareBindWrite = "tip.hardware.bind_write" + case hardwareModeVsAgent = "tip.hardware.mode_vs_agent" + case agentLinkageFirst = "tip.agent.linkage_first" + case voiceEmptyHistory = "tip.voice.empty_history" + case userCenterPermissions = "tip.usercenter.permissions" + case islandFirst = "tip.island.first" + case islandLibrary = "tip.island.library" + case pluginComingSoon = "tip.plugin.coming_soon" + + var id: String { rawValue } + + /// 数字越小优先级越高;同屏只展示一条时取最高优先级。 + var priority: Int { + switch self { + case .deviceConnect: return 0 + case .hardwareBindWrite: return 1 + case .hardwareModeVsAgent: return 2 + case .agentLinkageFirst: return 3 + case .voiceEmptyHistory: return 4 + case .userCenterPermissions: return 5 + case .islandFirst: return 6 + case .islandLibrary: return 7 + case .pluginComingSoon: return 8 + } + } + + var title: String { + switch self { + case .deviceConnect: return "先连接键盘" + case .hardwareBindWrite: return "改完要写入" + case .hardwareModeVsAgent: return "这不是装 Agent" + case .agentLinkageFirst: return "先打开联动" + case .voiceEmptyHistory: return "去试录一句话" + case .userCenterPermissions: return "权限在这里" + case .islandFirst: return "先调这两个分段" + case .islandLibrary: return "只管岛上模块" + case .pluginComingSoon: return "安装尚未开放" + } + } + + /// 指向界面上的具体控件名称,降低「不知道点哪」的成本。 + var targetLabel: String { + switch self { + case .deviceConnect: + return "卡片右侧「连接设备」" + case .hardwareBindWrite: + return "右侧底部蓝色「写入键盘」" + case .hardwareModeVsAgent: + return "画布下方「Agent 模式」分段" + case .agentLinkageFirst: + return "顶部分段「联动」→ 卡片右侧「一键启用联动」" + case .voiceEmptyHistory: + return "顶部分段「通用设置」" + case .userCenterPermissions: + return "本页「权限与隐私」" + case .islandFirst: + return "顶部分段「常驻岛 / 展开岛」" + case .islandLibrary: + return "本页模块开关与排序" + case .pluginComingSoon: + return "产品页「下载 / 安装到本机」(暂不可点)" + } + } + + var message: String { + switch self { + case .deviceConnect: + return "改键前先连上键盘。点左下角「我的设备」,在卡片右侧点「连接设备」(改键用 Studio 占用蓝牙)。" + case .hardwareBindWrite: + return "先点左侧画布上的按键改绑定;改完务必点右侧底部蓝色「写入键盘」,否则真机不会生效。" + case .hardwareModeVsAgent: + return "下面的「Agent 模式」只是键盘 Mode 槽(对应 Claude / Cursor 等)。要装守护、日常控制,请点右侧按钮去「Agent · 联动」。" + case .agentLinkageFirst: + return "当前还不能对话。请点顶栏「联动」,再点「联动就绪」卡片右侧的「一键启用联动」。" + case .voiceEmptyHistory: + return "历史为空是正常的。点顶栏「通用设置」去做试录;若失败,再到左下角用户中心 →「设置」补权限。" + case .userCenterPermissions: + return "麦克风、语音识别、辅助功能等系统权限都在本页「权限与隐私」里,不在「语音输入」Tab。" + case .islandFirst: + return "用顶部「常驻岛」「展开岛」分段调整左右槽与模块。标「即将推出 / 占位」的条目可先忽略。" + case .islandLibrary: + return "这里只控制悬停展开后面板上的模块显隐与顺序。改按键映射请回侧栏「硬件设备」。" + case .pluginComingSoon: + return "现在可以浏览货架和安装路径。产品页上的「下载」「安装到本机」仍是灰色,尚未开放。" + } + } + + /// 主按钮文案;与界面真实入口对齐。nil 表示只需「知道了」。 + var primaryActionTitle: String? { + switch self { + case .deviceConnect: return "打开我的设备" + case .hardwareBindWrite: return nil + case .hardwareModeVsAgent: return "去 Agent · 联动" + case .agentLinkageFirst: return "打开联动页" + case .voiceEmptyHistory: return "打开通用设置" + case .userCenterPermissions: return "展开权限与隐私" + case .islandFirst: return nil + case .islandLibrary: return "回硬件设备" + case .pluginComingSoon: return nil + } + } + + @MainActor + func performPrimaryAction() { + switch self { + case .deviceConnect: + StudioNavigationRouter.shared.openDeviceManagement(showDetail: true) + case .hardwareBindWrite: + break + case .hardwareModeVsAgent, .agentLinkageFirst: + StudioNavigationRouter.shared.selectAgentSection(.status) + case .voiceEmptyHistory: + StudioNavigationRouter.shared.selectVoiceSection(.general) + case .userCenterPermissions: + NotificationCenter.default.post(name: .ahaKeyExpandUserCenterPermissions, object: nil) + case .islandFirst: + break + case .islandLibrary: + StudioNavigationRouter.shared.selectSettingsTab(.hardware) + case .pluginComingSoon: + break + } + } +} + +enum FeatureCoachTipStore { + private static let seenPrefix = "AhaKey.FeatureCoachTip.seen." + + static func isSeen(_ id: FeatureCoachTipID) -> Bool { + UserDefaults.standard.bool(forKey: seenPrefix + id.rawValue) + } + + static func markSeen(_ id: FeatureCoachTipID) { + UserDefaults.standard.set(true, forKey: seenPrefix + id.rawValue) + } + + static var onboardingCompleted: Bool { + UserDefaults.standard.bool(forKey: UnifiedOnboardingStorage.completedKey) + } + + static func canShow(_ id: FeatureCoachTipID) -> Bool { + onboardingCompleted && !isSeen(id) + } + + /// 重新打开新手引导时清空全部「已读」气泡,便于再次出现。 + static func resetAllSeen() { + for id in FeatureCoachTipID.allCases { + UserDefaults.standard.removeObject(forKey: seenPrefix + id.rawValue) + } + } +} + +/// 全局协调:同屏最多一条 tip;离开锚点不记已读,点「知道了」/主操作才记。 +@MainActor +final class FeatureCoachTipController: ObservableObject { + static let shared = FeatureCoachTipController() + + @Published private(set) var presented: FeatureCoachTipID? + + /// 当前仍挂在界面上、希望展示的 tip(即使当时因未完成全屏引导而未能弹出)。 + private var interestedIDs: Set = [] + + private init() {} + + func request(_ id: FeatureCoachTipID) { + interestedIDs.insert(id) + guard FeatureCoachTipStore.canShow(id) else { return } + if let current = presented { + if id.priority < current.priority { + presented = id + } + return + } + presented = id + } + + func dismissCurrent() { + guard let current = presented else { return } + FeatureCoachTipStore.markSeen(current) + interestedIDs.remove(current) + presented = nil + // 关掉一条后,若同屏还有其它未读锚点,立刻补上一条。 + refreshInterested() + } + + func performPrimaryAndDismiss() { + guard let current = presented else { return } + current.performPrimaryAction() + FeatureCoachTipStore.markSeen(current) + interestedIDs.remove(current) + presented = nil + refreshInterested() + } + + /// 离开页面时撤回,不标记已读。 + func withdraw(_ id: FeatureCoachTipID) { + interestedIDs.remove(id) + if presented == id { + presented = nil + refreshInterested() + } + } + + func isShowing(_ id: FeatureCoachTipID) -> Bool { + presented == id + } + + /// 重放引导时清掉当前气泡,避免残留(保留 interested,等全屏结束后再弹)。 + func clearPresented() { + presented = nil + } + + /// 全屏引导刚完成 / 重放结束后:按优先级从仍挂着的锚点里弹出下一条。 + func refreshInterested() { + let next = interestedIDs + .filter { FeatureCoachTipStore.canShow($0) } + .sorted { $0.priority < $1.priority } + .first + presented = next + } +} + +struct FeatureCoachTipBubble: View { + let tip: FeatureCoachTipID + var onDismiss: () -> Void + var onPrimary: (() -> Void)? + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .top, spacing: 10) { + Image(systemName: "hand.point.up.left.fill") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(AhakeySettingsTheme.accentBlue) + .padding(.top, 1) + + VStack(alignment: .leading, spacing: 6) { + Text(tip.title) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(AhakeySettingsTheme.primaryText) + + Text(tip.message) + .font(.system(size: 12)) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + .fixedSize(horizontal: false, vertical: true) + + HStack(spacing: 6) { + Text("去点") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(AhakeySettingsTheme.accentBlue) + Text(tip.targetLabel) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(AhakeySettingsTheme.primaryText) + .lineLimit(2) + } + .padding(.horizontal, 8) + .padding(.vertical, 6) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(AhakeySettingsTheme.accentBlue.opacity(0.10)) + ) + } + + Spacer(minLength: 0) + } + + HStack(spacing: 8) { + Button("知道了", action: onDismiss) + .buttonStyle(.bordered) + .controlSize(.small) + + Spacer(minLength: 0) + + if let actionTitle = tip.primaryActionTitle, let onPrimary { + Button(actionTitle, action: onPrimary) + .buttonStyle(.borderedProminent) + .controlSize(.small) + .tint(AhakeySettingsTheme.accentBlue) + } + } + } + .padding(14) + .frame(maxWidth: 380, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(AhakeySettingsTheme.cardBackground) + .shadow(color: Color.black.opacity(0.18), radius: 12, y: 4) + ) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(AhakeySettingsTheme.accentBlue.opacity(0.35), lineWidth: 1) + ) + } +} + +struct FeatureCoachTipAnchorModifier: ViewModifier { + let id: FeatureCoachTipID + let isActive: Bool + var alignment: Alignment = .top + var enablePrimaryAction: Bool = true + + @ObservedObject private var controller = FeatureCoachTipController.shared + @AppStorage(UnifiedOnboardingStorage.completedKey) private var onboardingCompleted = false + + func body(content: Content) -> some View { + content + .overlay(alignment: alignment) { + if isActive, onboardingCompleted, controller.isShowing(id) { + FeatureCoachTipBubble( + tip: id, + onDismiss: { controller.dismissCurrent() }, + onPrimary: (enablePrimaryAction && id.primaryActionTitle != nil) + ? { controller.performPrimaryAndDismiss() } + : nil + ) + .padding(12) + .transition(.opacity.combined(with: .move(edge: .top))) + .zIndex(50) + } + } + .animation(.easeInOut(duration: 0.2), value: controller.presented) + .onAppear { syncRequest() } + .onChange(of: isActive) { _ in syncRequest() } + .onChange(of: onboardingCompleted) { completed in + // 主壳在全屏引导下层一直挂着:completed 翻转时必须重新登记 / 唤醒气泡。 + if completed { + syncRequest() + controller.refreshInterested() + } else { + controller.clearPresented() + if isActive { + controller.request(id) + } + } + } + .onDisappear { controller.withdraw(id) } + } + + private func syncRequest() { + if isActive { + controller.request(id) + } else { + controller.withdraw(id) + } + } +} + +extension View { + func featureCoachTip( + _ id: FeatureCoachTipID, + isActive: Bool, + alignment: Alignment = .top, + enablePrimaryAction: Bool = true + ) -> some View { + modifier( + FeatureCoachTipAnchorModifier( + id: id, + isActive: isActive, + alignment: alignment, + enablePrimaryAction: enablePrimaryAction + ) + ) + } +} + +extension Notification.Name { + static let ahaKeyExpandUserCenterPermissions = Notification.Name("lab.jawa.ahakeyconfig.expandUserCenterPermissions") +} diff --git a/ahakeyconfig-mac/Sources/Utilities/NativeSpeechTranscriptionService.swift b/ahakeyconfig-mac/Sources/Utilities/NativeSpeechTranscriptionService.swift old mode 100644 new mode 100755 index c7191f81..ec358e33 --- a/ahakeyconfig-mac/Sources/Utilities/NativeSpeechTranscriptionService.swift +++ b/ahakeyconfig-mac/Sources/Utilities/NativeSpeechTranscriptionService.swift @@ -551,6 +551,7 @@ final class NativeSpeechTranscriptionService: ObservableObject { statusMessage = "未识别到有效语音内容。" VoiceStatusHUDController.shared.show(.empty, autoHideAfter: 1.8) appendDiagnostic("finalize empty reason=\(reason)") + VoiceInputStore.shared.appendDictation(text: "转录已被取消。", status: .cancelled) return } @@ -561,21 +562,24 @@ final class NativeSpeechTranscriptionService: ObservableObject { appendDiagnostic("finalize begin reason=\(reason) bypass=\(bypassAhaType) rawText=\(text)") Task { @MainActor in - let output: String + var output: String if willUseAhaType { output = await AhaTypeTextOptimizer.shared.processIfEnabled(text) } else { output = text } + output = VoiceInputStore.shared.applyDictionary(to: output) if self.injectText(output) { self.lastCommittedText = output self.statusMessage = output == text ? "已写入:\(output)" : "AhaType 已整理并写入:\(output)" VoiceStatusHUDController.shared.show(.done, autoHideAfter: 1.4) self.appendDiagnostic("finalize success reason=\(reason) rawText=\(text) outputText=\(output)") + VoiceInputStore.shared.appendDictation(text: output, status: .ok) } else { self.statusMessage = "识别完成,但写入当前光标失败。" VoiceStatusHUDController.shared.show(.failed, autoHideAfter: 2.0) self.appendDiagnostic("finalize inject failed reason=\(reason) text=\(output)") + VoiceInputStore.shared.appendDictation(text: output, status: .failed) } } } diff --git a/ahakeyconfig-mac/Sources/Utilities/OLEDFrameEncoder.swift b/ahakeyconfig-mac/Sources/Utilities/OLEDFrameEncoder.swift old mode 100644 new mode 100755 diff --git a/ahakeyconfig-mac/Sources/Utilities/PermissionSignatureChecker.swift b/ahakeyconfig-mac/Sources/Utilities/PermissionSignatureChecker.swift old mode 100644 new mode 100755 diff --git a/ahakeyconfig-mac/Sources/Utilities/PixelArtProcessor.swift b/ahakeyconfig-mac/Sources/Utilities/PixelArtProcessor.swift new file mode 100644 index 00000000..64718e87 --- /dev/null +++ b/ahakeyconfig-mac/Sources/Utilities/PixelArtProcessor.swift @@ -0,0 +1,98 @@ +import AppKit +import CoreGraphics +import Foundation + +/// GIF / 屏幕预览 / BLE 上传共用的像素风图像处理。 +enum PixelArtProcessor { + /// 画布预览:块更大,近看更有像素感。 + static let canvasBlockSize = 3 + /// 设备编码(160×80):与固件低分辨率 LCD 匹配。 + static let encodeBlockSize = 4 + static let colorLevels = 6 + + static func pixelateNSImage( + _ image: NSImage, + width: Int, + height: Int, + blockSize: Int = canvasBlockSize, + levels: Int = colorLevels + ) -> NSImage { + guard let cg = image.cgImage(forProposedRect: nil, context: nil, hints: nil), + let processed = pixelateCGImage(cg, width: width, height: height, blockSize: blockSize, levels: levels) else { + return image + } + return NSImage(cgImage: processed, size: NSSize(width: width, height: height)) + } + + static func pixelateCGImage( + _ source: CGImage, + width: Int, + height: Int, + blockSize: Int = canvasBlockSize, + levels: Int = colorLevels + ) -> CGImage? { + guard width > 0, height > 0, blockSize > 0 else { return nil } + + let lowW = max(1, width / blockSize) + let lowH = max(1, height / blockSize) + var lowRGBA = [UInt8](repeating: 0, count: lowW * lowH * 4) + + guard let lowCtx = rgbaContext(width: lowW, height: lowH, data: &lowRGBA) else { return nil } + lowCtx.interpolationQuality = .none + lowCtx.setFillColor(CGColor(gray: 0, alpha: 1)) + lowCtx.fill(CGRect(x: 0, y: 0, width: lowW, height: lowH)) + + let scale = min(Double(lowW) / Double(source.width), Double(lowH) / Double(source.height)) + let drawW = Double(source.width) * scale + let drawH = Double(source.height) * scale + let drawRect = CGRect( + x: (Double(lowW) - drawW) / 2, + y: (Double(lowH) - drawH) / 2, + width: drawW, + height: drawH + ) + lowCtx.draw(source, in: drawRect) + quantizeRGBA(&lowRGBA, levels: levels) + + var outRGBA = [UInt8](repeating: 0, count: width * height * 4) + for y in 0 ..< height { + for x in 0 ..< width { + let sx = min(lowW - 1, x / blockSize) + let sy = min(lowH - 1, y / blockSize) + let src = (sy * lowW + sx) * 4 + let dst = (y * width + x) * 4 + outRGBA[dst] = lowRGBA[src] + outRGBA[dst + 1] = lowRGBA[src + 1] + outRGBA[dst + 2] = lowRGBA[src + 2] + outRGBA[dst + 3] = 255 + } + } + + guard let outCtx = rgbaContext(width: width, height: height, data: &outRGBA) else { return nil } + return outCtx.makeImage() + } + + private static func rgbaContext(width: Int, height: Int, data: inout [UInt8]) -> CGContext? { + CGContext( + data: &data, + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: width * 4, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) + } + + private static func quantizeRGBA(_ pixels: inout [UInt8], levels: Int) { + let steps = max(2, levels) + let stepSize = 255.0 / Double(steps - 1) + for i in stride(from: 0, to: pixels.count, by: 4) { + for c in 0 ..< 3 { + let v = Double(pixels[i + c]) + let q = (v / stepSize).rounded() * stepSize + pixels[i + c] = UInt8(min(255, max(0, Int(q)))) + } + } + } +} diff --git a/ahakeyconfig-mac/Sources/Utilities/ShortcutInjection.swift b/ahakeyconfig-mac/Sources/Utilities/ShortcutInjection.swift new file mode 100644 index 00000000..76164fcc --- /dev/null +++ b/ahakeyconfig-mac/Sources/Utilities/ShortcutInjection.swift @@ -0,0 +1,45 @@ +import AppKit +import Foundation + +/// Posts a configured shortcut to the system (used by VibeBar virtual keyboard mapping). +enum ShortcutInjection { + static func postTap(_ shortcut: ShortcutBinding) { + guard shortcut.isConfigured else { return } + let source = CGEventSource(stateID: .combinedSessionState) + var flags = CGEventFlags() + for modifier in shortcut.orderedModifiers { + switch modifier { + case .command: flags.insert(.maskCommand) + case .shift: flags.insert(.maskShift) + case .option: flags.insert(.maskAlternate) + case .control: flags.insert(.maskControl) + } + } + guard let virtualKey = cgKeyCode(forHIDUsage: shortcut.keyCode) else { return } + postKey(virtualKey, keyDown: true, flags: flags, source: source) + postKey(virtualKey, keyDown: false, flags: flags, source: source) + } + + static func postKeyDraft(_ key: AhaKeyKeyDraft) { + if key.usesMacro { return } + postTap(key.shortcut) + } + + private static func postKey(_ virtualKey: CGKeyCode, keyDown: Bool, flags: CGEventFlags, source: CGEventSource?) { + guard let event = CGEvent(keyboardEventSource: source, virtualKey: virtualKey, keyDown: keyDown) else { return } + event.flags = flags + event.post(tap: .cghidEventTap) + } + + private static func cgKeyCode(forHIDUsage hidCode: UInt8) -> CGKeyCode? { + switch hidCode { + case HIDUsage.f18: return 79 + case HIDUsage.f19: return 80 + case HIDUsage.enter: return 36 + case HIDUsage.escape: return 53 + case HIDUsage.backspace: return 51 + case HIDUsage.space: return 49 + default: return nil + } + } +} diff --git a/ahakeyconfig-mac/Sources/Utilities/StudioNavigationRouter.swift b/ahakeyconfig-mac/Sources/Utilities/StudioNavigationRouter.swift new file mode 100644 index 00000000..913244db --- /dev/null +++ b/ahakeyconfig-mac/Sources/Utilities/StudioNavigationRouter.swift @@ -0,0 +1,142 @@ +import Foundation +import SwiftUI + +enum StudioNavigationSection: String { + case voiceAgent + case device + case approve + case oled + case voice +} + +@MainActor +final class StudioNavigationRouter { + static let shared = StudioNavigationRouter() + + private init() {} + + func navigate(to section: StudioNavigationSection) { + navigate(to: section, part: nil) + } + + func navigate(to section: StudioNavigationSection, part: AhaKeyStudioPart?) { + var userInfo: [String: Any] = [StudioNavigationUserInfoKey.section: section.rawValue] + if let part { + userInfo[StudioNavigationUserInfoKey.part] = part.rawValue + } + NotificationCenter.default.post( + name: .ahaKeyStudioNavigate, + object: nil, + userInfo: userInfo + ) + } + + func selectSettingsTab(_ tab: AhaKeySettingsTab) { + NotificationCenter.default.post( + name: .ahaKeySettingsSelectTab, + object: nil, + userInfo: [StudioNavigationUserInfoKey.tab: tab.rawValue] + ) + } + + func selectAgentSection(_ section: AhaKeyAgentConfigSection) { + NotificationCenter.default.post( + name: .ahaKeySettingsSelectTab, + object: nil, + userInfo: [ + StudioNavigationUserInfoKey.tab: AhaKeySettingsTab.agent.rawValue, + StudioNavigationUserInfoKey.agentSection: section.rawValue + ] + ) + } + + func selectVoiceSection(_ section: AhaKeyVoiceInputConfigSection) { + NotificationCenter.default.post( + name: .ahaKeySettingsSelectTab, + object: nil, + userInfo: [ + StudioNavigationUserInfoKey.tab: AhaKeySettingsTab.voiceInput.rawValue, + StudioNavigationUserInfoKey.voiceSection: section.rawValue + ] + ) + } + + /// 打开 Agent · 设置(Pet 外观主入口)。 + func openPetAppearanceSettings() { + selectAgentSection(.general) + } + + /// 兼容旧调用名。 + func openOledPetSettings() { + openPetAppearanceSettings() + } + + /// 打开左下角「我的设备」详情页。 + func openDeviceManagement(showDetail: Bool = true) { + NotificationCenter.default.post( + name: .ahaKeyOpenMyDevices, + object: nil, + userInfo: [StudioNavigationUserInfoKey.openDeviceDetail: showDetail] + ) + } + + func openUserCenter(section: AhaKeyUserCenterSection = .account) { + NotificationCenter.default.post( + name: .ahaKeyOpenUserCenter, + object: nil, + userInfo: [StudioNavigationUserInfoKey.userCenterSection: section.rawValue] + ) + } + + func openPluginMarket(section: AhakeyPluginMarketSection = .mine) { + NotificationCenter.default.post( + name: .ahaKeyOpenPluginMarket, + object: nil, + userInfo: [StudioNavigationUserInfoKey.pluginMarketSection: section.rawValue] + ) + } +} + +enum StudioNavigationUserInfoKey { + static let section = "section" + static let tab = "tab" + static let part = "part" + static let workbenchTab = "workbenchTab" + static let keyRole = "keyRole" + static let openDeviceDetail = "openDeviceDetail" + static let agentSection = "agentSection" + static let voiceSection = "voiceSection" + static let dynamicIslandRoute = "dynamicIslandRoute" + static let userCenterSection = "userCenterSection" + static let pluginMarketSection = "pluginMarketSection" +} + +extension Notification.Name { + static let ahaKeyStudioNavigate = Notification.Name("lab.jawa.ahakeyconfig.studioNavigate") + static let ahaKeySettingsSelectTab = Notification.Name("lab.jawa.ahakeyconfig.settingsSelectTab") + static let ahaKeyStudioSelectPart = Notification.Name("lab.jawa.ahakeyconfig.studioSelectPart") + static let ahaKeyStudioShowDeviceInfo = Notification.Name("lab.jawa.ahakeyconfig.studioShowDeviceInfo") + static let ahaKeyReopenMainWindow = Notification.Name("lab.jawa.ahakeyconfig.reopenMainWindow") + static let ahaKeyOpenUserCenter = Notification.Name("lab.jawa.ahakeyconfig.openUserCenter") + static let ahaKeyOpenMyDevices = Notification.Name("lab.jawa.ahakeyconfig.openMyDevices") + static let ahaKeyOpenPluginMarket = Notification.Name("lab.jawa.ahakeyconfig.openPluginMarket") +} + +/// 监听重新打开主窗口请求;并把 `openWindow` 注册到 AppDelegate,避免主窗口关闭后无人响应。 +struct MainWindowReopenHelper: View { + @Environment(\.openWindow) private var openWindow + + var body: some View { + Color.clear + .frame(width: 0, height: 0) + .accessibilityHidden(true) + .onAppear { + AppDelegate.registerOpenMainWindow { [openWindow] in + openWindow(id: "main") + } + } + .onReceive(NotificationCenter.default.publisher(for: .ahaKeyReopenMainWindow)) { _ in + openWindow(id: "main") + } + } +} diff --git a/ahakeyconfig-mac/Sources/Utilities/VibeBarBridge.swift b/ahakeyconfig-mac/Sources/Utilities/VibeBarBridge.swift old mode 100644 new mode 100755 index 108a0e6a..9c9ac480 --- a/ahakeyconfig-mac/Sources/Utilities/VibeBarBridge.swift +++ b/ahakeyconfig-mac/Sources/Utilities/VibeBarBridge.swift @@ -7,6 +7,7 @@ import VibeBar final class VibeBarBridge { let state = VibeBarState() private var cancellables = Set() + private weak var bleManager: AhaKeyBLEManager? func attach( bleManager: AhaKeyBLEManager, @@ -14,12 +15,15 @@ final class VibeBarBridge { nativeSpeech: NativeSpeechTranscriptionService = .shared, onOpenMainWindow: @escaping () -> Void ) { + self.bleManager = bleManager state.onOpenMainWindow = onOpenMainWindow - // BLE bleManager.$isConnected .receive(on: RunLoop.main) - .sink { [weak self] in self?.state.keyboardConnected = $0 } + .sink { [weak self] connected in + self?.state.keyboardConnected = connected + self?.syncAppearance() + } .store(in: &cancellables) bleManager.$batteryLevel @@ -32,7 +36,6 @@ final class VibeBarBridge { .sink { [weak self] in self?.state.deviceName = $0 } .store(in: &cancellables) - // Lever: switchState == 0 即 auto;优先采用 agent 缓存(agent 占用 BLE 时主 app 自己未连接) Publishers.CombineLatest3( bleManager.$isConnected, bleManager.$switchState, @@ -54,10 +57,13 @@ final class VibeBarBridge { } .store(in: &cancellables) - // 语音 voiceRelay.$isListening .receive(on: RunLoop.main) - .sink { [weak self] in self?.state.voiceListening = $0 } + .sink { [weak self] listening in + self?.state.voiceListening = listening + self?.syncAppearance() + self?.syncAgentStatus() + } .store(in: &cancellables) Publishers.CombineLatest( @@ -67,7 +73,99 @@ final class VibeBarBridge { .receive(on: RunLoop.main) .sink { [weak self] recording, longPress in self?.state.voiceRecording = recording || longPress + self?.syncAppearance() + self?.syncAgentStatus() + } + .store(in: &cancellables) + + Publishers.CombineLatest3( + bleManager.$workMode, + AgentManager.shared.$isRunning, + bleManager.$isConnected + ) + .receive(on: RunLoop.main) + .sink { [weak self] _, running, _ in + self?.state.agentRunning = running + self?.syncAppearance() + self?.syncAgentStatus() } .store(in: &cancellables) + + bleManager.$liveIDEStateValue + .receive(on: RunLoop.main) + .sink { [weak self] value in + self?.state.liveIDEStateValue = value + self?.syncAgentStatus() + } + .store(in: &cancellables) + + NotificationCenter.default.publisher(for: VibeBarIslandAppearanceSettings.appearanceDidChangeNotification) + .merge(with: NotificationCenter.default.publisher(for: .ahaKeyIslandAppearanceApplyRequested)) + .receive(on: RunLoop.main) + .sink { [weak self] _ in self?.syncAppearance() } + .store(in: &cancellables) + + syncAppearance() + syncAgentStatus() + } + + private func syncAppearance() { + guard let bleManager else { return } + let mode = AhaKeyModeSlot(rawValue: bleManager.workMode) ?? .mode0 + VibeBarIslandAppearanceSettings.sync( + to: state, + agentRunning: AgentManager.shared.isRunning, + modePrimary: mode.name, + connected: bleManager.isConnected + ) + } + + private func syncAgentStatus() { + if state.voiceRecording { + state.agentStatus = .listening + state.agentTaskTitle = "Voice input active" + state.agentProgress = 0 + return + } + + if let raw = state.liveIDEStateValue, let ide = IDEState(rawValue: UInt8(clamping: raw)) { + switch ide { + case .permissionRequest: + state.agentStatus = .approval + state.agentTaskTitle = "Waiting for your confirm" + state.agentProgress = 0 + case .preToolUse: + state.agentStatus = .coding + state.agentTaskTitle = "Generating changes" + state.agentProgress = 0.72 + case .postToolUse, .userPromptSubmit, .notification: + state.agentStatus = .thinking + state.agentTaskTitle = "Analyzing context" + state.agentProgress = 0.4 + case .taskCompleted: + state.agentStatus = .completed + state.agentTaskTitle = "Task finished" + state.agentProgress = 1 + case .sessionStart: + state.agentStatus = .searching + state.agentTaskTitle = "Scanning project" + state.agentProgress = 0.5 + case .stop, .sessionEnd: + state.agentStatus = .idle + state.agentTaskTitle = "" + state.agentProgress = 0 + } + return + } + + if state.agentRunning { + state.agentStatus = .thinking + state.agentTaskTitle = "Agent is running" + state.agentProgress = 0.3 + } else { + state.agentStatus = .idle + state.agentTaskTitle = "" + state.agentProgress = 0 + } } } diff --git a/ahakeyconfig-mac/Sources/Utilities/VibeBarIslandSoundSettings.swift b/ahakeyconfig-mac/Sources/Utilities/VibeBarIslandSoundSettings.swift new file mode 100644 index 00000000..a77cc432 --- /dev/null +++ b/ahakeyconfig-mac/Sources/Utilities/VibeBarIslandSoundSettings.swift @@ -0,0 +1,29 @@ +import AppKit +import Foundation + +/// Vibe Island 展开/收起与交互提示音(与主界面 Settings 灵动岛 Tab 共用)。 +enum VibeBarIslandSoundSettings { + static let mutedDefaultsKey = "lab.jawa.vibebar.island.soundMuted" + static let defaultSoundName = "Bottle" + + static var isMuted: Bool { + get { UserDefaults.standard.bool(forKey: mutedDefaultsKey) } + set { UserDefaults.standard.set(newValue, forKey: mutedDefaultsKey) } + } + + @discardableResult + static func toggleMuted() -> Bool { + isMuted.toggle() + return isMuted + } + + static func playInteractionIfEnabled() { + guard !isMuted else { return } + guard let sound = NSSound(named: NSSound.Name(defaultSoundName)) else { + NSSound.beep() + return + } + sound.stop() + sound.play() + } +} diff --git a/ahakeyconfig-mac/Sources/Utilities/VoiceInputStore.swift b/ahakeyconfig-mac/Sources/Utilities/VoiceInputStore.swift new file mode 100644 index 00000000..af630cf7 --- /dev/null +++ b/ahakeyconfig-mac/Sources/Utilities/VoiceInputStore.swift @@ -0,0 +1,244 @@ +import Foundation + +// MARK: - Models + +enum VoiceHistoryKind: String, Codable, CaseIterable, Identifiable { + case dictation + case askAnything + + var id: String { rawValue } + + var title: String { + switch self { + case .dictation: "听写" + case .askAnything: "问任何问题" + } + } +} + +enum VoiceHistoryStatus: String, Codable { + case ok + case cancelled + case failed +} + +struct VoiceHistoryEntry: Identifiable, Codable, Equatable { + var id: UUID + var createdAt: Date + var text: String + var kind: VoiceHistoryKind + var status: VoiceHistoryStatus + + init( + id: UUID = UUID(), + createdAt: Date = Date(), + text: String, + kind: VoiceHistoryKind = .dictation, + status: VoiceHistoryStatus = .ok + ) { + self.id = id + self.createdAt = createdAt + self.text = text + self.kind = kind + self.status = status + } +} + +struct VoiceDictionaryEntry: Identifiable, Codable, Equatable { + var id: UUID + var phrase: String + var replacement: String + var enabled: Bool + + init( + id: UUID = UUID(), + phrase: String, + replacement: String, + enabled: Bool = true + ) { + self.id = id + self.phrase = phrase + self.replacement = replacement + self.enabled = enabled + } +} + +enum VoiceHistoryRetention: String, Codable, CaseIterable, Identifiable { + case forever + case days7 + case days30 + case never + + var id: String { rawValue } + + var title: String { + switch self { + case .forever: "永远" + case .days7: "7 天" + case .days30: "30 天" + case .never: "不保存" + } + } + + var maxAge: TimeInterval? { + switch self { + case .forever: return nil + case .days7: return 7 * 24 * 60 * 60 + case .days30: return 30 * 24 * 60 * 60 + case .never: return 0 + } + } +} + +// MARK: - Store + +@MainActor +final class VoiceInputStore: ObservableObject { + static let shared = VoiceInputStore() + + @Published private(set) var history: [VoiceHistoryEntry] = [] + @Published private(set) var dictionary: [VoiceDictionaryEntry] = [] + @Published var retention: VoiceHistoryRetention { + didSet { + persist() + pruneHistoryIfNeeded() + } + } + + private let fileURL: URL + private let encoder: JSONEncoder = { + let e = JSONEncoder() + e.outputFormatting = [.prettyPrinted, .sortedKeys] + e.dateEncodingStrategy = .iso8601 + return e + }() + private let decoder: JSONDecoder = { + let d = JSONDecoder() + d.dateDecodingStrategy = .iso8601 + return d + }() + + private struct Snapshot: Codable { + var retention: VoiceHistoryRetention + var history: [VoiceHistoryEntry] + var dictionary: [VoiceDictionaryEntry] + } + + private init() { + let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + ?? FileManager.default.temporaryDirectory + let dir = support.appendingPathComponent("AhaKeyStudio", isDirectory: true) + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + fileURL = dir.appendingPathComponent("voice-input-store.json") + + if let data = try? Data(contentsOf: fileURL), + let snap = try? decoder.decode(Snapshot.self, from: data) { + retention = snap.retention + history = snap.history.sorted { $0.createdAt > $1.createdAt } + dictionary = snap.dictionary + } else { + retention = .forever + history = [] + dictionary = [] + } + pruneHistoryIfNeeded() + } + + // MARK: History + + func appendDictation(text: String, status: VoiceHistoryStatus = .ok) { + guard retention != .never else { return } + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty || status != .ok else { return } + let entry = VoiceHistoryEntry( + text: trimmed.isEmpty ? "转录已被取消。" : trimmed, + kind: .dictation, + status: status + ) + history.insert(entry, at: 0) + pruneHistoryIfNeeded() + persist() + } + + func deleteHistory(id: UUID) { + history.removeAll { $0.id == id } + persist() + } + + func clearHistory() { + history = [] + persist() + } + + func filteredHistory(kind: VoiceHistoryKind?) -> [VoiceHistoryEntry] { + guard let kind else { return history } + return history.filter { $0.kind == kind } + } + + func historyGroupedByDay(kind: VoiceHistoryKind?) -> [(day: Date, entries: [VoiceHistoryEntry])] { + let cal = Calendar.current + let items = filteredHistory(kind: kind) + let grouped = Dictionary(grouping: items) { entry in + cal.startOfDay(for: entry.createdAt) + } + return grouped.keys.sorted(by: >).map { day in + (day, grouped[day]!.sorted { $0.createdAt > $1.createdAt }) + } + } + + // MARK: Dictionary + + func addDictionaryEntry(phrase: String, replacement: String) { + let p = phrase.trimmingCharacters(in: .whitespacesAndNewlines) + let r = replacement.trimmingCharacters(in: .whitespacesAndNewlines) + guard !p.isEmpty, !r.isEmpty else { return } + dictionary.insert(VoiceDictionaryEntry(phrase: p, replacement: r), at: 0) + persist() + } + + func updateDictionaryEntry(_ entry: VoiceDictionaryEntry) { + guard let idx = dictionary.firstIndex(where: { $0.id == entry.id }) else { return } + dictionary[idx] = entry + persist() + } + + func deleteDictionaryEntry(id: UUID) { + dictionary.removeAll { $0.id == id } + persist() + } + + func applyDictionary(to text: String) -> String { + let enabled = dictionary.filter(\.enabled).sorted { $0.phrase.count > $1.phrase.count } + guard !enabled.isEmpty else { return text } + var result = text + for entry in enabled { + result = result.replacingOccurrences(of: entry.phrase, with: entry.replacement) + } + return result + } + + // MARK: Private + + private func pruneHistoryIfNeeded() { + guard let maxAge = retention.maxAge else { return } + if maxAge == 0 { + if !history.isEmpty { + history = [] + persist() + } + return + } + let cutoff = Date().addingTimeInterval(-maxAge) + let next = history.filter { $0.createdAt >= cutoff } + if next.count != history.count { + history = next + persist() + } + } + + private func persist() { + let snap = Snapshot(retention: retention, history: history, dictionary: dictionary) + guard let data = try? encoder.encode(snap) else { return } + try? data.write(to: fileURL, options: .atomic) + } +} diff --git a/ahakeyconfig-mac/Sources/Utilities/VoiceRelayService.swift b/ahakeyconfig-mac/Sources/Utilities/VoiceRelayService.swift old mode 100644 new mode 100755 diff --git a/ahakeyconfig-mac/Sources/Utilities/VoiceStatusHUDController.swift b/ahakeyconfig-mac/Sources/Utilities/VoiceStatusHUDController.swift old mode 100644 new mode 100755 index 08290012..facca39f --- a/ahakeyconfig-mac/Sources/Utilities/VoiceStatusHUDController.swift +++ b/ahakeyconfig-mac/Sources/Utilities/VoiceStatusHUDController.swift @@ -9,9 +9,15 @@ final class VoiceStatusHUDController { private var hostingView: NSHostingView? private var hideWorkItem: DispatchWorkItem? + /// 浮动语音输入 HUD 接管录音交互时,跳过录音/识别状态条,避免双 HUD。 + var suppressRecordingStates = false + private init() {} func show(_ state: VoiceStatusHUDState, autoHideAfter delay: TimeInterval? = nil) { + if suppressRecordingStates && (state == .recording || state == .recognizing) { + return + } hideWorkItem?.cancel() let view = VoiceStatusHUDView(state: state) if let hostingView { diff --git a/ahakeyconfig-mac/Sources/Views/AhaKeyKeyConfigPageView.swift b/ahakeyconfig-mac/Sources/Views/AhaKeyKeyConfigPageView.swift new file mode 100644 index 00000000..fb1c1fd0 --- /dev/null +++ b/ahakeyconfig-mac/Sources/Views/AhaKeyKeyConfigPageView.swift @@ -0,0 +1,562 @@ +import SwiftUI + +// MARK: - 按键配置页面 + +/// 原型图「按键配置」页面——卡片式布局,色板跟随 Settings 外观。 +/// 展示单个按键的完整配置:功能类型、语音方案、触发方式、绑定键位、按键名称, +/// 以及权限状态和操作按钮。 +struct AhaKeyKeyConfigPageView: View { + @ObservedObject var bleManager: AhaKeyBLEManager + @StateObject private var voiceRelay = VoiceRelayService.shared + @StateObject private var nativeSpeech = NativeSpeechTranscriptionService.shared + + @Binding var studioDraft: AhaKeyStudioDraft + let selectedMode: AhaKeyModeSlot + let selectedKeyRole: AhaKeyKeyRole + + // MARK: - 主题色板(自适应浅/深) + + private var bgPage: Color { AhakeySettingsTheme.contentBackground } + private var bgCard: Color { AhakeySettingsTheme.cardBackground } + private var bgCardBorder: Color { AhakeySettingsTheme.divider } + private var bgField: Color { AhakeySettingsTheme.controlFill } + private var bgFieldBorder: Color { AhakeySettingsTheme.divider } + private var accentIndigo: Color { AhakeySettingsTheme.accentBlue } + private let accentCyan = Color(red: 0.20, green: 0.70, blue: 0.82) + private let accentPurple = Color(red: 0.48, green: 0.32, blue: 0.86) + private let accentGreen = Color(red: 0.18, green: 0.68, blue: 0.32) + private var textPrimary: Color { AhakeySettingsTheme.primaryText } + private var textSecondary: Color { AhakeySettingsTheme.secondaryText } + private var textTertiary: Color { AhakeySettingsTheme.tertiaryText } + + // MARK: - 数据 + + private var currentModeDraft: AhaKeyModeDraft { + studioDraft.draft(for: selectedMode) + } + + private var keyDraft: AhaKeyKeyDraft { + currentModeDraft.key(for: selectedKeyRole) + } + + // MARK: - Body + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + pageHeader + statusSummaryCard + keyConfigCard + permissionsCard + usageInstructionsCard + } + .padding(28) + } + .background(bgPage) + } + + // MARK: - 页面头部 + + private var pageHeader: some View { + HStack(spacing: 14) { + ZStack { + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(AhakeySettingsTheme.accentBlue.opacity(0.85)) + .frame(width: 52, height: 52) + + Image(systemName: selectedKeyRole.systemImage) + .font(.system(size: 22, weight: .semibold)) + .foregroundStyle(.white) + } + + VStack(alignment: .leading, spacing: 3) { + Text("Key \(selectedKeyRole.rawValue + 1)") + .font(.system(size: 24, weight: .bold)) + .foregroundStyle(textPrimary) + Text(selectedKeyRole.title) + .font(.system(size: 14)) + .foregroundStyle(textSecondary) + } + + Spacer() + } + .padding(.bottom, 4) + } + + // MARK: - 状态概览卡片 + + private var statusSummaryCard: some View { + HStack(spacing: 0) { + // 当前功能 + summaryItem( + icon: "waveform", + iconBg: accentIndigo, + label: "当前功能", + value: functionTitle + ) + + summaryDivider + + // 当前方案 + summaryItem( + icon: "square.3.layers.3d", + iconBg: accentPurple.opacity(0.2), + iconFg: accentPurple, + label: "当前方案", + value: schemeTitle + ) + + summaryDivider + + // 当前状态 + summaryItem( + icon: "checkmark.circle.fill", + iconBg: Color.clear, + iconFg: accentGreen, + label: "当前状态", + value: statusTitle + ) + } + .padding(20) + .background(cardBackground) + } + + private func summaryItem( + icon: String, + iconBg: Color, + iconFg: Color = .white, + label: String, + value: String + ) -> some View { + HStack(spacing: 10) { + ZStack { + if iconBg != .clear { + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(iconBg) + .frame(width: 36, height: 36) + } + Image(systemName: icon) + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(iconFg) + } + .frame(width: 36, height: 36) + + VStack(alignment: .leading, spacing: 2) { + Text(label) + .font(.system(size: 11)) + .foregroundStyle(textTertiary) + Text(value) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(textPrimary) + } + + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity) + } + + private var summaryDivider: some View { + Rectangle() + .fill(Color.white.opacity(0.06)) + .frame(width: 1, height: 36) + .padding(.horizontal, 8) + } + + // MARK: - 按键配置卡片 + + private var keyConfigCard: some View { + VStack(alignment: .leading, spacing: 0) { + // 卡片标题行 + HStack { + Text("按键配置") + .font(.system(size: 18, weight: .bold)) + .foregroundStyle(textPrimary) + Spacer() + Button { + // 同步到设备 + } label: { + Text("同步到设备") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(.white) + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(accentIndigo) + ) + } + .buttonStyle(.plain) + .disabled(!bleManager.isConnected) + } + .padding(.bottom, 20) + + // 表单行 + VStack(spacing: 16) { + formPickerRow( + label: "功能类型", + value: functionTitle, + options: ["语音输入", "确认操作", "拒绝操作", "发送 / 执行"], + onChange: { _ in } + ) + + formPickerRow( + label: "语音方案", + value: schemeTitle, + options: VoicePreset.allCases.filter(\.availableInV1).map(\.title), + onChange: { newTitle in + if let preset = VoicePreset.allCases.first(where: { $0.title == newTitle }) { + updateKey { key in + key.voicePreset = preset + if preset != .custom { + key.shortcut = preset.defaultBinding + } + } + } + } + ) + + formPickerRow( + label: "触发方式", + value: triggerTitle, + options: ["单击开始,再按结束", "按住说话 / 松开发送", "单击触发"], + onChange: { _ in } + ) + + formPickerRow( + label: "绑定键位", + value: boundKeyTitle, + options: ["F18", "F19", "F20"] + HIDUsage.allOptions.prefix(20).map(\.name), + onChange: { newName in + if let option = HIDUsage.allOptions.first(where: { $0.name == newName }) { + updateKey { key in + key.shortcut = ShortcutBinding(keyCode: option.code) + } + } + } + ) + + formTextFieldRow( + label: "按键名称", + text: keyDescriptionBinding + ) + } + } + .padding(24) + .background(cardBackground) + } + + private func formPickerRow( + label: String, + value: String, + options: [String], + onChange: @escaping (String) -> Void + ) -> some View { + HStack(spacing: 16) { + Text(label) + .font(.system(size: 14)) + .foregroundStyle(textSecondary) + .frame(width: 80, alignment: .leading) + + Menu { + ForEach(options, id: \.self) { option in + Button(option) { + onChange(option) + } + } + } label: { + HStack { + Text(value) + .font(.system(size: 14)) + .foregroundStyle(textPrimary) + Spacer() + Image(systemName: "chevron.down") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(textTertiary) + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(bgField) + .overlay( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .stroke(bgFieldBorder, lineWidth: 1) + ) + ) + } + .menuStyle(.borderlessButton) + } + } + + private func formTextFieldRow( + label: String, + text: Binding + ) -> some View { + HStack(spacing: 16) { + Text(label) + .font(.system(size: 14)) + .foregroundStyle(textSecondary) + .frame(width: 80, alignment: .leading) + + TextField("", text: text) + .textFieldStyle(.plain) + .font(.system(size: 14)) + .foregroundStyle(textPrimary) + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(bgField) + .overlay( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .stroke(bgFieldBorder, lineWidth: 1) + ) + ) + } + } + + // MARK: - 状态与权限卡片 + + private var permissionsCard: some View { + VStack(alignment: .leading, spacing: 16) { + Text("状态与权限") + .font(.system(size: 18, weight: .bold)) + .foregroundStyle(textPrimary) + + // 权限状态指示器 + HStack(spacing: 10) { + permissionBadge( + title: "后台监听", + granted: voiceRelay.isListening + ) + permissionBadge( + title: "麦克风", + granted: nativeSpeech.microphoneGranted + ) + permissionBadge( + title: "输入监控", + granted: voiceRelay.inputMonitoringGranted + ) + permissionBadge( + title: "语音转写", + granted: nativeSpeech.speechRecognitionGranted + ) + } + + // 操作按钮 + HStack(spacing: 12) { + actionButton( + icon: "shield.lefthalf.filled", + title: "检查权限" + ) { + voiceRelay.refreshPermissions(requestIfNeeded: true) + nativeSpeech.refreshPermissions(requestIfNeeded: true) + } + + actionButton( + icon: "waveform", + title: "测试按键" + ) { + if selectedKeyRole == .voice { + nativeSpeech.toggleRecordingFromVoiceKey() + } + } + } + } + .padding(24) + .background(cardBackground) + } + + private func permissionBadge(title: String, granted: Bool) -> some View { + HStack(spacing: 8) { + Circle() + .fill(granted ? accentGreen : Color.orange) + .frame(width: 8, height: 8) + VStack(alignment: .leading, spacing: 1) { + Text(title) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(textPrimary) + Text(granted ? "已开启" : "未开启") + .font(.system(size: 10)) + .foregroundStyle(textTertiary) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + .frame(maxWidth: .infinity) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(bgField) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .stroke(bgFieldBorder, lineWidth: 1) + ) + ) + } + + private func actionButton( + icon: String, + title: String, + action: @escaping () -> Void + ) -> some View { + Button(action: action) { + HStack(spacing: 8) { + Image(systemName: icon) + .font(.system(size: 14)) + Text(title) + .font(.system(size: 13, weight: .medium)) + } + .foregroundStyle(textSecondary) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(bgField) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .stroke(bgFieldBorder, lineWidth: 1) + ) + ) + } + .buttonStyle(.plain) + } + + // MARK: - 使用说明(可折叠) + + @State private var showsUsageInstructions = false + + private var usageInstructionsCard: some View { + VStack(alignment: .leading, spacing: 0) { + Button { + withAnimation(.easeInOut(duration: 0.2)) { + showsUsageInstructions.toggle() + } + } label: { + HStack(spacing: 10) { + Image(systemName: "questionmark.circle") + .font(.system(size: 16)) + .foregroundStyle(textTertiary) + Text("使用说明") + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(textPrimary) + Spacer() + Image(systemName: showsUsageInstructions ? "chevron.up" : "chevron.down") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(textTertiary) + } + .padding(16) + } + .buttonStyle(.plain) + + if showsUsageInstructions { + Divider() + .overlay(Color.white.opacity(0.06)) + + VStack(alignment: .leading, spacing: 10) { + instructionStep("1", "在上方「功能类型」中选择这个按键的用途。") + instructionStep("2", "如果是语音键,选择语音方案和触发方式。") + instructionStep("3", "在「绑定键位」中设置底层 HID 键码。") + instructionStep("4", "填写按键名称,切换模式时 OLED 会短暂显示。") + instructionStep("5", "点击「同步到设备」把配置写入键盘。") + } + .padding(.horizontal, 16) + .padding(.vertical, 14) + .transition(.opacity.combined(with: .move(edge: .top))) + } + } + .background(cardBackground) + } + + private func instructionStep(_ number: String, _ text: String) -> some View { + HStack(alignment: .top, spacing: 10) { + Text(number) + .font(.system(size: 12, weight: .bold, design: .rounded)) + .foregroundStyle(accentCyan) + .frame(width: 20, height: 20) + .background( + Circle().fill(accentCyan.opacity(0.12)) + ) + Text(text) + .font(.system(size: 13)) + .foregroundStyle(textSecondary) + } + } + + // MARK: - 通用 + + private var cardBackground: some View { + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(bgCard) + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .stroke(bgCardBorder, lineWidth: 1) + ) + } + + // MARK: - 计算属性 + + private var functionTitle: String { + switch selectedKeyRole { + case .voice: "语音输入" + case .approve: "确认操作" + case .reject: "拒绝操作" + case .submit: "发送 / 执行" + } + } + + private var schemeTitle: String { + if selectedKeyRole == .voice { + return (keyDraft.voicePreset ?? .custom).title + } + return "—" + } + + private var triggerTitle: String { + if selectedKeyRole == .voice { + switch keyDraft.voicePreset ?? .custom { + case .macOSNative, .claudeCode: + return "单击开始,再按结束" + case .typeless, .wechat: + return "按住说话 / 松开发送" + default: + return "单击触发" + } + } + return "单击触发" + } + + private var boundKeyTitle: String { + keyDraft.shortcut.displayLabel + } + + private var statusTitle: String { + if selectedKeyRole == .voice { + let hasPerms = voiceRelay.inputMonitoringGranted && voiceRelay.accessibilityGranted + return hasPerms ? "可用" : "需要权限" + } + return keyDraft.shortcut.isConfigured ? "可用" : "未配置" + } + + // MARK: - 数据绑定 + + private var keyDescriptionBinding: Binding { + Binding( + get: { keyDraft.description }, + set: { newValue in + updateKey { key in + key.description = String(newValue.prefix(20)) + } + } + ) + } + + private func updateKey(_ transform: (inout AhaKeyKeyDraft) -> Void) { + var next = studioDraft + var mode = next.draft(for: selectedMode) + var key = mode.key(for: selectedKeyRole) + transform(&key) + mode.updateKey(key) + next.updateMode(mode) + studioDraft = next + } +} diff --git a/ahakeyconfig-mac/Sources/Views/AhaKeyRootWorkspaceView.swift b/ahakeyconfig-mac/Sources/Views/AhaKeyRootWorkspaceView.swift new file mode 100644 index 00000000..d92a3fa0 --- /dev/null +++ b/ahakeyconfig-mac/Sources/Views/AhaKeyRootWorkspaceView.swift @@ -0,0 +1,97 @@ +import SwiftUI + +enum AhaKeyRootWorkspaceMode: String, CaseIterable, Identifiable { + case classic + case newWorkbench + + static let storageKey = "AhaKey.RootWorkspaceMode" + + var id: String { rawValue } + + var title: String { + switch self { + case .classic: + "IDE工作台" + case .newWorkbench: + "Agent工作台" + } + } +} + +struct AhaKeyRootWorkspaceView: View { + @ObservedObject var bleManager: AhaKeyBLEManager + @AppStorage(AhaKeyRootWorkspaceMode.storageKey) private var modeRawValue = AhaKeyRootWorkspaceMode.classic.rawValue + @AppStorage(AhaKeyAppearanceMode.storageKey) private var appearanceModeRaw = AhaKeyAppearanceMode.defaultMode.rawValue + + private var mode: AhaKeyRootWorkspaceMode { + AhaKeyRootWorkspaceMode(rawValue: modeRawValue) ?? .classic + } + + private var appearanceMode: AhaKeyAppearanceMode { + AhaKeyAppearanceMode(rawValue: appearanceModeRaw) ?? .defaultMode + } + + private var modeBinding: Binding { + Binding( + get: { mode }, + set: { modeRawValue = $0.rawValue } + ) + } + + var body: some View { + VStack(spacing: AhaKeyUI.Spacing.shell) { + workspaceToolbar + Group { + switch mode { + case .classic: + AhaKeyStudioView(bleManager: bleManager) + case .newWorkbench: + AhaKeyWorkbenchView(bleManager: bleManager) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .preferredColorScheme(appearanceMode.colorScheme) + .onReceive(NotificationCenter.default.publisher(for: .ahaKeyStudioNavigate)) { notification in + guard let raw = notification.userInfo?[StudioNavigationUserInfoKey.section] as? String, + let section = StudioNavigationSection(rawValue: raw) else { return } + + switch section { + case .voiceAgent, .approve, .voice: + modeRawValue = AhaKeyRootWorkspaceMode.newWorkbench.rawValue + case .device, .oled: + modeRawValue = AhaKeyRootWorkspaceMode.classic.rawValue + } + } + } + + private var workspaceToolbar: some View { + HStack(spacing: 12) { + Picker("工作台", selection: modeBinding) { + ForEach(AhaKeyRootWorkspaceMode.allCases) { item in + Text(item.title).tag(item) + } + } + .pickerStyle(.segmented) + .frame(maxWidth: 320) + + Spacer(minLength: 0) + + Button { + appearanceModeRaw = appearanceMode.next.rawValue + } label: { + Image(systemName: appearanceMode.systemImage) + } + .buttonStyle(AhaKeyIconButtonStyle()) + .help(appearanceMode.title) + } + .padding(.horizontal, AhaKeyUI.Spacing.page) + .padding(.vertical, 10) + .background(AhaKeyUI.ColorToken.card) + .overlay(alignment: .bottom) { + Rectangle() + .fill(AhaKeyUI.ColorToken.border) + .frame(height: 1) + } + } +} diff --git a/ahakeyconfig-mac/Sources/Views/AhaKeyStudioView.swift b/ahakeyconfig-mac/Sources/Views/AhaKeyStudioView.swift old mode 100644 new mode 100755 index 9ef52068..df1e7ded --- a/ahakeyconfig-mac/Sources/Views/AhaKeyStudioView.swift +++ b/ahakeyconfig-mac/Sources/Views/AhaKeyStudioView.swift @@ -6,11 +6,13 @@ import UniformTypeIdentifiers struct AhaKeyStudioView: View { @ObservedObject var bleManager: AhaKeyBLEManager + let presentation: AhaKeyStudioPresentation @StateObject private var voiceRelay = VoiceRelayService.shared @StateObject private var nativeSpeech = NativeSpeechTranscriptionService.shared @StateObject private var ahaType = AhaTypeTextOptimizer.shared @StateObject private var cloudAccount = CloudAccountManager.shared @StateObject private var agentManager = AgentManager.shared + @ObservedObject private var coachTips = FeatureCoachTipController.shared @State private var studioDraft: AhaKeyStudioDraft @State private var lastSyncedDraft: AhaKeyStudioDraft @@ -28,23 +30,27 @@ struct AhaKeyStudioView: View { @State private var showsCloudAccount = false @State private var showsAhaTypeLoginRequiredToast = false @AppStorage(UnifiedOnboardingStorage.completedKey) private var unifiedOnboardingCompleted = false - @State private var isEditingInspector = false + @State private var isEditingInspector = true @State private var showsDiagnostics = false - @State private var showsKeyHelp = false @State private var selectedTriggerTab: Int = 0 + @State private var showMoreKeyOptions = false + @State private var showMoreLightBarOptions = false /// 每次主 App 自占 BLE 连接成功只跑一次默认 LCD 自动同步。 /// .onChange(of: isConnected) 在断开时重置;下次重连时再触发一次。 @State private var oledAutoSyncDoneForConnection: Bool = false @State private var showsHelpCenter = false - @State private var showsGuidanceDetail = false @State private var editingModeSlot: AhaKeyModeSlot? @State private var editingModeName: String = "" @FocusState private var modeNameFieldFocused: Bool @State private var showsWriteResultAlert = false @State private var writeResultAlertMessage = "" + @AppStorage(DeviceCapabilityStorage.previewGenerationKey) private var previewGenerationRaw = DeviceGeneration.gen2.rawValue + @State private var magneticModuleState: MagneticModuleState = MagneticModuleStateStore.load() + @State private var oceanLightConfig: OceanLightConfig = OceanLightConfigStore.load() - init(bleManager: AhaKeyBLEManager) { + init(bleManager: AhaKeyBLEManager, presentation: AhaKeyStudioPresentation = .standalone) { self.bleManager = bleManager + self.presentation = presentation let initialDraft = AhaKeyStudioStore.load() ?? .default // 注意:不要在这里调用 VoiceRelayService.updateRoutes —— SwiftUI 会因 bleManager // 的 @Published 属性(workMode/电量/连接状态等)频繁重建 view,init 会跟着多次执行。 @@ -62,17 +68,22 @@ struct AhaKeyStudioView: View { var body: some View { VStack(spacing: 0) { topBar - Divider() + Divider().opacity(isEmbedded ? 0.35 : 1) HStack(spacing: 0) { canvasPane - Divider() + Divider().opacity(isEmbedded ? 0.35 : 1) inspectorPane } - Divider() + Divider().opacity(isEmbedded ? 0.35 : 1) statusBar } - .frame(minWidth: 1180, minHeight: 680) - .background(Color(nsColor: .windowBackgroundColor)) + .frame( + minWidth: isEmbedded ? 0 : 1180, + maxWidth: isEmbedded ? .infinity : nil, + minHeight: isEmbedded ? 0 : 680, + maxHeight: isEmbedded ? .infinity : nil + ) + .background(studioBackground) .onAppear { agentManager.applyStoredBluetoothPreferenceOnLaunch(bleManager: bleManager) voiceRelay.start() @@ -81,6 +92,10 @@ struct AhaKeyStudioView: View { applyCursorRejectMacroSelfHealIfNeeded() voiceRelay.updateRoutes(from: studioDraft) SwitchStateNotifier.shared.bind(to: bleManager) + magneticModuleState = MagneticModuleStateStore.load() + oceanLightConfig = OceanLightConfigStore.load() + bleManager.magneticModuleState = magneticModuleState + bleManager.oceanLightConfig = oceanLightConfig NotificationCenter.default.post( name: .ahaKeyKeyboardWorkModeChanged, object: nil, @@ -140,6 +155,32 @@ struct AhaKeyStudioView: View { .onChange(of: nativeSpeech.dictationEnabled) { _ in refreshStartupPermissionOnboarding() } + .onChange(of: magneticModuleState) { newValue in + MagneticModuleStateStore.save(newValue) + bleManager.magneticModuleState = newValue + } + .onChange(of: oceanLightConfig) { newValue in + OceanLightConfigStore.save(newValue) + bleManager.oceanLightConfig = newValue + } + .onChange(of: previewGenerationRaw) { _ in + if previewGeneration == .x1, selectedPart == .magneticPort { + selectedPart = .key1 + } + } + .onReceive(NotificationCenter.default.publisher(for: .ahaKeyStudioSelectPart)) { notification in + if let raw = notification.userInfo?[StudioNavigationUserInfoKey.part] as? String, + let part = AhaKeyStudioPart(rawValue: raw) { + selectedPart = part + isEditingInspector = false + } + } + .onReceive(NotificationCenter.default.publisher(for: .ahaKeyStudioShowDeviceInfo)) { _ in + openDeviceInfo() + } + .onReceive(NotificationCenter.default.publisher(for: .ahaKeyStudioNavigate)) { notification in + handleStudioNavigation(notification) + } .alert("Agent", isPresented: Binding( get: { agentManager.agentUserAlert != nil }, set: { if !$0 { agentManager.agentUserAlert = nil } } @@ -153,7 +194,7 @@ struct AhaKeyStudioView: View { .alert("AhaType 未注册登录", isPresented: $showsAhaTypeLoginRequiredToast) { Button("知道了", role: .cancel) {} Button("注册登录") { - showsCloudAccount = true + openCloudAccountEntry() } } message: { Text("请先注册登录 AhaType 后再开启云端整理。") @@ -169,16 +210,99 @@ struct AhaKeyStudioView: View { .frame(width: 720, height: 720) } .sheet(isPresented: $showsCloudAccount) { - CloudAccountView() - .frame(width: 520, height: 620) + CloudAccountSheet() + .frame(width: 560, height: 680) + } + } + + private var isEmbedded: Bool { presentation == .embeddedClient } + + private func openCloudAccountEntry() { + if isEmbedded { + StudioNavigationRouter.shared.openUserCenter() + } else { + showsCloudAccount = true + } + } + + private func openDeviceInfo() { + if isEmbedded { + StudioNavigationRouter.shared.openDeviceManagement(showDetail: true) + } else { + showsDeviceInfo = true + } + } + + private var previewGeneration: DeviceGeneration { + DeviceGeneration(rawValue: previewGenerationRaw) ?? .gen2 + } + + private var studioBackground: Color { + isEmbedded ? AhaKeyStudioEmbeddedTheme.windowBackground : AhaKeyUI.ColorToken.base + } + + private var islandLightBarPreview: LightBarPreviewState { + switch lightBarPreview { + case .permissionRequest: + return .waitingApproval + case .stop, .sessionEnd: + return .stopped + case .taskCompleted: + return .taskCompleted + default: + return .aiRunning } } + @ViewBuilder private var topBar: some View { + if isEmbedded { + EmptyView() + } else { + standaloneTopBar + } + } + + private var embeddedTopBar: some View { + HStack(alignment: .center, spacing: 10) { + VibeBarIslandDeviceStatusIcons( + keyboardConnected: isEffectivelyConnected, + batteryLevel: bleManager.batteryLevel, + voiceRecording: nativeSpeech.isRecording, + voiceListening: voiceRelay.isListening, + leverIsAuto: bleManager.switchState == 0, + leverKnown: bleManager.isConnected + ) + + Text(selectedMode.name) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(AhaKeyStudioEmbeddedTheme.secondaryText) + .lineLimit(1) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background(Capsule().fill(AhaKeyStudioEmbeddedTheme.controlFill)) + + Spacer(minLength: 0) + + if !bleManager.isConnected, agentManager.bluetoothConnectionOwner == .ahaKeyStudio { + Button(bleManager.isScanning ? "扫描中…" : "连接设备") { + bleManager.userInitiatedConnect() + } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(bleManager.isScanning) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background(chromeBarBackground) + } + + private var standaloneTopBar: some View { HStack(spacing: 14) { VStack(alignment: .leading, spacing: 0) { Text("AhaKey Studio") - .font(.system(size: 22, weight: .semibold, design: .rounded)) + .font(AhaKeyUI.Font.largeTitle) } .layoutPriority(1) @@ -245,11 +369,11 @@ struct AhaKeyStudioView: View { bleManager.userInitiatedConnect() } Button("设备信息 · Agent…") { - showsDeviceInfo = true + openDeviceInfo() } Divider() Button("云端账号 · AhaType…") { - showsCloudAccount = true + openCloudAccountEntry() } Button("刷新 AhaType 状态") { ahaType.refreshFromDisk() @@ -340,36 +464,57 @@ struct AhaKeyStudioView: View { } private var canvasPane: some View { - VStack(alignment: .leading, spacing: 18) { - modeEditorHeader - - VStack(alignment: .leading, spacing: 8) { - AhaKeyKeyboardCanvasView( - modeDraft: currentModeDraft, - selectedPart: selectedPart, - lightBarPreview: lightBarPreview, - switchTitle: currentSwitchTitle, - dirtyParts: dirtyPartsForCurrentMode(), - onSelect: { selectedPart = $0 }, - onModeSwitch: { cycleModeForward() }, - onSwitchToggle: { toggleVirtualSwitch() }, - liveLightMode: liveCanvasLightMode, - liveIDEStateValue: liveCanvasIDEStateValue, - switchState: liveCanvasSwitchState, - keyboardPictureFrameCount: bleManager.keyboardPictureStates[selectedMode.rawValue]?.frameCount + VStack(alignment: .leading, spacing: isEmbedded ? 12 : 16) { + deviceHeader + + AhaKeyIslandKeyboardCanvasView( + modeDraft: currentModeDraft, + selectedPart: selectedPart, + lightBarPreview: islandLightBarPreview, + switchTitle: currentSwitchTitle, + dirtyParts: dirtyPartsForCurrentMode(), + deviceGeneration: previewGeneration, + magneticModuleState: magneticModuleState, + oceanLightConfig: oceanLightConfig, + workMode: liveKeyboardWorkMode ?? bleManager.workMode, + onSelect: { selectedPart = $0 }, + onModeSwitch: { cycleModeForward() }, + onKeySimulate: { role in + if role == .voice { + nativeSpeech.toggleRecordingFromVoiceKey() + } + }, + onSwitchToggle: { toggleVirtualSwitch() }, + liveLightMode: liveCanvasLightMode, + liveIDEStateValue: liveCanvasIDEStateValue, + switchState: liveCanvasSwitchState, + keyboardPictureFrameCount: bleManager.keyboardPictureStates[selectedMode.rawValue]?.frameCount, + appearance: isEmbedded ? .vibeBarEmbedded : .studioLight + ) + .aspectRatio(canvasAspectRatio, contentMode: .fit) + .frame(maxWidth: .infinity, alignment: .top) + + modeSwitchPanel + .featureCoachTip( + .hardwareModeVsAgent, + isActive: isEmbedded, + alignment: .topTrailing ) - .aspectRatio(109.0 / 54.0, contentMode: .fit) - .frame(maxWidth: .infinity) - Text("点按灯条、屏幕、四个按键或拨杆即可进入对应配置。") - .font(.callout) - .foregroundStyle(.secondary) - .padding(.leading, 20) - } - .frame(maxWidth: .infinity) + hardwareAssemblyPlaceholder } - .padding(24) - .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(isEmbedded ? EdgeInsets(top: 16, leading: 16, bottom: 16, trailing: 16) : EdgeInsets(top: 24, leading: 24, bottom: 24, trailing: 24)) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .featureCoachTip( + .deviceConnect, + isActive: isEmbedded && !bleManager.isConnected, + alignment: .topTrailing + ) + } + + private var canvasAspectRatio: CGFloat { + let layout = isEmbedded ? KeyboardCanvasAppearance.vibeBarEmbedded.layout : KeyboardCanvasAppearance.studioLight.layout + return layout.aspectRatio } @ViewBuilder @@ -396,6 +541,7 @@ struct AhaKeyStudioView: View { Text(modeCustomNames[mode.rawValue] ?? mode.defaultName) .font(.callout.weight(isSelected ? .semibold : .regular)) .lineLimit(1) + .minimumScaleFactor(0.75) .frame(maxWidth: .infinity) .padding(.vertical, 6) .padding(.horizontal, 4) @@ -426,12 +572,45 @@ struct AhaKeyStudioView: View { editingModeSlot = nil } - private var modeEditorHeader: some View { - VStack(alignment: .leading, spacing: 8) { - HStack(alignment: .firstTextBaseline, spacing: 16) { - Text("Keyboard Mode") - .font(.system(size: 17, weight: .semibold)) + /// 左栏顶:仅设备名与连接状态(Mode 在画布下方)。 + private var deviceHeader: some View { + HStack(alignment: .center, spacing: 10) { + Text(bleManager.deviceName ?? "AhaKey Mini") + .font(.system(size: isEmbedded ? 22 : 20, weight: .semibold)) + .foregroundStyle(isEmbedded ? AhaKeyStudioEmbeddedTheme.primaryText : .primary) + .lineLimit(1) + + Spacer(minLength: 8) + VibeBarIslandDeviceStatusIcons( + keyboardConnected: isEffectivelyConnected, + batteryLevel: bleManager.batteryLevel, + voiceRecording: nativeSpeech.isRecording, + voiceListening: voiceRelay.isListening, + leverIsAuto: bleManager.switchState == 0, + leverKnown: bleManager.isConnected + ) + } + } + + /// 画布下方:针对不同 Agent 的 Mode 切换 + 指引。 + private var modeSwitchPanel: some View { + VStack(alignment: .leading, spacing: isEmbedded ? 8 : 10) { + Text(isEmbedded ? "Agent 模式" : "Keyboard Mode") + .font(isEmbedded ? .system(size: 12, weight: .semibold) : .system(size: 13, weight: .semibold)) + .foregroundStyle(isEmbedded ? AhaKeyStudioEmbeddedTheme.secondaryText : .secondary) + + if isEmbedded { + Picker("模式", selection: $selectedMode) { + ForEach(AhaKeyModeSlot.allCases) { mode in + Text(modeCustomNames[mode.rawValue] ?? mode.defaultName) + .tag(mode) + } + } + .pickerStyle(.segmented) + .labelsHidden() + .frame(maxWidth: .infinity) + } else { HStack(spacing: 0) { ForEach(AhaKeyModeSlot.allCases) { mode in modeTabItem(mode) @@ -440,145 +619,132 @@ struct AhaKeyStudioView: View { .background(Color(nsColor: .controlBackgroundColor)) .clipShape(RoundedRectangle(cornerRadius: 6)) .overlay(RoundedRectangle(cornerRadius: 6).stroke(Color(nsColor: .separatorColor), lineWidth: 0.5)) - .frame(width: 480) + .frame(maxWidth: .infinity) + } - Spacer(minLength: 0) + modeGuidanceRow + } + .padding(.top, isEmbedded ? 4 : 6) + .overlay(alignment: .top) { + Rectangle() + .fill(isEmbedded ? AhaKeyStudioEmbeddedTheme.secondaryText.opacity(0.12) : Color(nsColor: .separatorColor).opacity(0.6)) + .frame(height: 0.5) + } + } + + private var modeGuidanceRow: some View { + HStack(alignment: .center, spacing: 6) { + Text(selectedMode.guidance) + .font(isEmbedded ? .system(size: 12) : .callout) + .foregroundStyle(isEmbedded ? AhaKeyStudioEmbeddedTheme.secondaryText : .secondary) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + if let detail = selectedMode.guidanceHoverDetail { + InspectorHelpButton( + title: "Mode 说明", + lines: [detail], + isEmbedded: isEmbedded, + width: 320, + arrowEdge: .top + ) } + } + } - HStack(alignment: .firstTextBaseline, spacing: 6) { - Text(selectedMode.guidance) - .font(.callout) - .foregroundStyle(.secondary) - if let detail = selectedMode.guidanceHoverDetail { - Button { - showsGuidanceDetail.toggle() - } label: { - Image(systemName: "questionmark.circle") - .foregroundStyle(.secondary) - .imageScale(.small) - } - .buttonStyle(.borderless) - .help(detail) - .onHover { showsGuidanceDetail = $0 } - .popover(isPresented: $showsGuidanceDetail, arrowEdge: .top) { - Text(detail) - .font(.callout) - .padding(14) - .frame(width: 320) - .fixedSize(horizontal: false, vertical: true) - } + /// Agent 模式下方预留:用户自定义硬件组装设计(后续开发)。 + private var hardwareAssemblyPlaceholder: some View { + VStack(alignment: .leading, spacing: isEmbedded ? 8 : 10) { + Text("硬件组装") + .font(isEmbedded ? .system(size: 12, weight: .semibold) : .system(size: 13, weight: .semibold)) + .foregroundStyle(isEmbedded ? AhaKeyStudioEmbeddedTheme.secondaryText : .secondary) + + HStack(spacing: 10) { + Image(systemName: "square.dashed") + .font(.system(size: 18, weight: .medium)) + .foregroundStyle(isEmbedded ? AhaKeyStudioEmbeddedTheme.tertiaryText : .secondary) + VStack(alignment: .leading, spacing: 4) { + Text("自定义硬件组装") + .font(.callout.weight(.medium)) + .foregroundStyle(isEmbedded ? AhaKeyStudioEmbeddedTheme.primaryText : .primary) + Text("在此设计磁吸模块与扩展件组合,功能开发中。") + .font(.caption) + .foregroundStyle(isEmbedded ? AhaKeyStudioEmbeddedTheme.tertiaryText : .secondary) } + Spacer(minLength: 0) } + .padding(14) + .frame(maxWidth: .infinity, minHeight: 72, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .strokeBorder( + style: StrokeStyle(lineWidth: 1, dash: [5, 4]) + ) + .foregroundStyle(isEmbedded ? AhaKeyStudioEmbeddedTheme.secondaryText.opacity(0.28) : Color(nsColor: .separatorColor)) + ) } + .padding(.top, isEmbedded ? 4 : 6) + .overlay(alignment: .top) { + Rectangle() + .fill(isEmbedded ? AhaKeyStudioEmbeddedTheme.secondaryText.opacity(0.12) : Color(nsColor: .separatorColor).opacity(0.6)) + .frame(height: 0.5) + } + .accessibilityElement(children: .combine) + .accessibilityLabel("硬件组装,功能开发中") } private var inspectorPane: some View { VStack(spacing: 0) { - ScrollView { - VStack(alignment: .leading, spacing: 18) { - if isEditingInspector { - Label(selectedPart.title, systemImage: selectedPart.systemImage) - .font(.system(size: 18, weight: .semibold)) - - Group { - switch selectedPart { - case .key1, .key2, .key3, .key4: keyInspector - case .oledDisplay: oledInspector - case .lightBar: lightBarInspector - case .toggleSwitch: switchInspector - } - } - - } else { - inspectorHeader + HardwarePartTabBar( + parts: AhaKeyStudioPart.visibleParts(for: previewGeneration), + selection: $selectedPart, + dirtyParts: dirtyPartsForCurrentMode(), + isEmbedded: isEmbedded + ) - VStack(alignment: .leading, spacing: 0) { - partSummaryContent - } - .padding(.horizontal, 14) - .padding(.vertical, 4) - .background( - RoundedRectangle(cornerRadius: 12) - .fill(Color(nsColor: .controlBackgroundColor)) - .overlay( - RoundedRectangle(cornerRadius: 12) - .stroke(Color.black.opacity(0.07), lineWidth: 1) - ) - ) + Divider().opacity(isEmbedded ? 0.35 : 1) - HStack { - Spacer() - Button { - enterEditingConfiguration() - withAnimation(.easeInOut(duration: 0.2)) { isEditingInspector = true } - } label: { - Label("修改", systemImage: "pencil") - } - .buttonStyle(.borderedProminent) - .controlSize(.regular) - .keyboardShortcut("e", modifiers: .command) - } - .padding(.top, 6) - } + ScrollView { + VStack(alignment: .leading, spacing: 16) { + inspectorHeader + inspectorPartContent } - .padding(24) + .padding(isEmbedded ? 14 : 20) + .frame(maxWidth: .infinity, alignment: .leading) } + .frame(maxWidth: .infinity) - if isEditingInspector { - Divider() - HStack(spacing: 12) { - Button { - withAnimation(.easeInOut(duration: 0.2)) { - isEditingInspector = false - returnToKeyboardControl() - } - } label: { - Label("返回", systemImage: "chevron.left") - .font(.callout.weight(.medium)) - } - .buttonStyle(.bordered) - .controlSize(.regular) - - Spacer() - - if selectedPart == .lightBar { - Button { - previewLightEffect(for: lightBarPreview) - } label: { - Label("预览到键盘", systemImage: "play.fill") - .font(.callout.weight(.medium)) - } - .buttonStyle(.bordered) - .controlSize(.regular) - .disabled(isSyncing || !bleManager.isConnected || !bleManager.commandCharReady) - } - - Button { - writeToKeyboard() - } label: { - Label(isSyncing ? "写入中…" : "写入键盘", systemImage: isSyncing ? "arrow.trianglehead.2.clockwise" : "square.and.arrow.down") - .font(.callout.weight(.semibold)) - } - .buttonStyle(.borderedProminent) - .controlSize(.regular) - .disabled(isSyncing || !bleManager.isConnected) - } - .padding(.horizontal, 24) - .padding(.vertical, 12) - } + Divider() + inspectorWriteBar } - .frame(width: 390) + .frame(width: isEmbedded ? 340 : 390) .frame(maxHeight: .infinity) + .clipped() .background(Color(nsColor: .controlBackgroundColor).opacity(0.35)) + .featureCoachTip( + .hardwareBindWrite, + isActive: isEmbedded && bleManager.isConnected, + alignment: .bottom + ) .onChange(of: selectedPart) { _ in commitModeNameEdit() - withAnimation(.easeInOut(duration: 0.18)) { isEditingInspector = false } + isEditingInspector = true + enterEditingConfiguration() } .onChange(of: selectedMode) { _ in if editingModeSlot != nil && editingModeSlot != selectedMode { commitModeNameEdit() } } + .onChange(of: previewGenerationRaw) { _ in + let visible = AhaKeyStudioPart.visibleParts(for: previewGeneration) + if !visible.contains(selectedPart) { + selectedPart = visible.first ?? .key1 + } + } + .onAppear { + isEditingInspector = true + enterEditingConfiguration() + } .alert("写入结果", isPresented: $showsWriteResultAlert) { Button("继续编辑", role: .cancel) {} Button("完成编辑") { @@ -592,49 +758,173 @@ struct AhaKeyStudioView: View { } } + @ViewBuilder + private var inspectorPartContent: some View { + switch selectedPart { + case .key1, .key2, .key3, .key4: + keyInspector + case .oledDisplay: + oledInspector + case .lightBar: + if previewGeneration == .gen2 { + OceanLightInspectorView( + config: $oceanLightConfig, + onApply: { + bleManager.applyOceanLightConfig(oceanLightConfig) + syncStatusMessage = "已更新海洋灯效配置(占位,待固件联调)。" + }, + isEmbedded: isEmbedded + ) + } else { + lightBarInspector + } + case .toggleSwitch: + switchInspector + case .magneticPort: + MagneticModuleInspectorView( + moduleState: $magneticModuleState, + onToggleVirtualSwitch: toggleVirtualSwitch, + isEmbedded: isEmbedded + ) + } + } + + private var inspectorWriteBar: some View { + HStack(spacing: 12) { + if selectedPart == .lightBar { + Button { + previewLightEffect(for: lightBarPreview) + } label: { + Label("预览到键盘", systemImage: "play.fill") + .font(.callout.weight(.medium)) + } + .buttonStyle(.bordered) + .controlSize(.regular) + .disabled(isSyncing || !bleManager.isConnected || !bleManager.commandCharReady) + } + + Spacer(minLength: 0) + + Button { + writeToKeyboard() + } label: { + Label(isSyncing ? "写入中…" : "写入键盘", systemImage: isSyncing ? "arrow.trianglehead.2.clockwise" : "square.and.arrow.down") + .font(.callout.weight(.semibold)) + } + .buttonStyle(.borderedProminent) + .controlSize(.regular) + .disabled(isSyncing || !bleManager.isConnected) + .overlay(alignment: .top) { + if isEmbedded, coachTips.isShowing(.hardwareBindWrite) { + Text("点这里写入") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.white) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Capsule(style: .continuous).fill(AhakeySettingsTheme.accentBlue)) + .offset(y: -30) + } + } + } + .padding(.horizontal, isEmbedded ? 16 : 24) + .padding(.vertical, 12) + } + private var inspectorHeader: some View { - VStack(alignment: .leading, spacing: 8) { - HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .center, spacing: 8) { Label(selectedPart.title, systemImage: selectedPart.systemImage) - .font(.system(size: 20, weight: .semibold)) - Spacer() + .font(.system(size: isEmbedded ? 16 : 20, weight: .semibold)) + .foregroundStyle(isEmbedded ? AhaKeyStudioEmbeddedTheme.primaryText : .primary) + Spacer(minLength: 0) if partIsDirty(selectedPart) { Label("未同步", systemImage: "circle.fill") .font(.caption) .foregroundStyle(.orange) } - if selectedPart.isKey { - Button { - showsKeyHelp.toggle() - } label: { - Image(systemName: "questionmark.circle") - .foregroundStyle(.secondary) - .imageScale(.medium) - } - .buttonStyle(.borderless) - .onHover { showsKeyHelp = $0 } - .popover(isPresented: $showsKeyHelp, arrowEdge: .leading) { - VStack(alignment: .leading, spacing: 8) { - Text("如何使用") - .font(.headline) - Divider() - Text("1. 点击虚拟键盘对应按键选中它。") - Text("2. 语音键先选预设;其他键按需选单键或宏。") - Text("3. 配置完成后点「写入键盘」同步到键盘。") - Text("4. 切模式时 LCD 先显示描述,再回到该模式动图。") - } - .font(.callout) - .padding(16) - .frame(width: 270) - } - } + InspectorHelpButton( + title: inspectorHelpTitle, + lines: inspectorHelpLines, + isEmbedded: isEmbedded, + width: selectedPart == .toggleSwitch || selectedPart.isKey ? 300 : 280 + ) } Text(selectedPart.subtitle) .font(.callout) - .foregroundStyle(.secondary) + .foregroundStyle(isEmbedded ? AhaKeyStudioEmbeddedTheme.secondaryText : .secondary) } } + private var inspectorHelpTitle: String { + "\(selectedPart.title) · 说明" + } + + private var inspectorHelpLines: [String] { + switch selectedPart { + case .key1, .key2, .key3, .key4: + keyInspectorHelpLines + case .oledDisplay: + [ + "选择 GIF/图片并设置 FPS,写入后对应当前 Mode 的 LCD 动图。", + "硬性限制:源文件 ≤ 2 MB,FPS 1–30,单模式最多 70 帧;Mode 1/2/3/4 固定写入 slot 10/80/150/220。", + "切换到当前模式时,LCD 会先显示该模式的按键描述,约 1 秒后回到动图。", + "文字状态、token、模型环境等显示能力后续版本提供。", + ] + case .lightBar: + if previewGeneration == .gen2 { + [ + "选择预设并调节亮度后,用底部「写入键盘」同步。", + "IMU 重力联动与文字轮播为进阶选项;轮播经 BLE 0x98 下发(占位联调)。", + "「应用到设备」为占位入口,正式保存请用底部写入。", + ] + } else { + [ + "为各 IDE 状态选择灯效样式,并调节亮度。", + "在「更多灯效」里点状态可在虚拟键盘预览,并通过 0x91 瞬时预览到设备。", + "保存请使用底部「写入键盘」。", + ] + } + case .toggleSwitch: + [ + "拨杆是物理档位:0 档自动批准,1 档手动批准;此处只读上报,不模拟拨动。", + "对 Claude / Cursor / Codex / Kimi 同时生效,与键盘当前 Mode 无关。", + "自动批准:Claude / Codex PermissionRequest,Cursor preToolUse;须 Agent 与 Hook 就绪,且蓝牙由 Agent 占用。", + "Kimi:安装 AhaKey Kimi Hooks 后接管当前会话;刚装完或升级 kimi-cli 后请完全关闭并重开一次 kimi。", + "手动批准:交回用户/终端确认。若仍弹窗,请到设备信息查看工具批准诊断。", + ] + case .magneticPort: + [ + "磁吸底座默认为空槽;从网格选择要吸附的模块。", + "拨杆免费可用;摇杆、旋钮、滚轮、十字键需购买解锁(测试阶段可全部解锁)。", + "已吸附模块可在下方做模拟操作;固件 0x95 通信仍在联调。", + "滚轮模拟为 UI 占位;触控板滚动可改增量,正式固件协议待定。", + ] + } + } + + private var keyInspectorHelpLines: [String] { + let key = currentSelectedKey + var lines: [String] = [ + "1. 点右侧元件 Tab,或点键盘热区选中部件。", + "2. 配置完成后点「写入键盘」同步到键盘。", + "同步后短按实体键切模式时,LCD 会先短暂显示按键描述,再回到该模式动图。", + "设备实际写入描述:\(currentSelectedKeySanitizedDescription.isEmpty ? "空白" : currentSelectedKeySanitizedDescription)(仅稳定支持 ASCII)。", + ] + if selectedMode == .mode0 { + lines.append("Mode 1 默认文案:Record / Accept / Reject / Backspace。") + } + if key.role == .voice { + lines.append("语音输入方式独立于当前 Mode。") + lines.append("选定输入方式后,下方展开对应绑定方法;该方式的注意事项见绑定旁问号。") + lines.append("短按:按一下开始,再按一下结束;长按:按住录音,松手发送。") + lines.append("长按绑定不同快捷键需固件 v2+;当前长按主要配置 AhaType 与触发阈值。") + } else { + lines.append(key.role.manualText) + lines.append("快捷键与描述会一并写入;长按绑定需固件 v2+,当前仅短按写入设备。") + } + return lines + } + // MARK: - 权限诊断弹窗 private var diagnosticsSheet: some View { @@ -842,8 +1132,15 @@ struct AhaKeyStudioView: View { case .key1: voiceKeySummary case .key2, .key3, .key4: actionKeySummary case .oledDisplay: oledSummary - case .lightBar: lightBarSummary + case .lightBar: + if previewGeneration == .gen2 { + OceanLightSummaryView(config: oceanLightConfig) + } else { + lightBarSummary + } case .toggleSwitch: switchSummary + case .magneticPort: + MagneticModuleSummaryView(moduleState: magneticModuleState) } } @@ -905,212 +1202,213 @@ struct AhaKeyStudioView: View { private var keyInspector: some View { let key = currentSelectedKey return VStack(alignment: .leading, spacing: 16) { - GroupBox("按键描述") { - VStack(alignment: .leading, spacing: 8) { + InspectorSection(title: "按键描述", isEmbedded: isEmbedded) { + HStack(alignment: .center, spacing: 12) { + Text("描述") + .font(.callout) + .foregroundStyle(isEmbedded ? AhaKeyStudioEmbeddedTheme.primaryText : .primary) TextField("例如 Record / Accept / Reject / Backspace", text: selectedKeyDescriptionBinding) .textFieldStyle(.roundedBorder) - if currentSelectedKey.description.containsNonASCII { - Text("设备 LCD 只稳定支持 ASCII。中文、emoji 和全角字符会在写入时被自动过滤,避免乱码。") - .font(.caption) - .foregroundStyle(.orange) - } - Text("设备实际写入:\(currentSelectedKeySanitizedDescription.isEmpty ? "空白" : currentSelectedKeySanitizedDescription)") - .font(.caption) - .foregroundStyle(.secondary) - Text("同步到键盘后,短按实体键切换模式时,LCD 会先短暂显示这里的描述,然后回到该模式的动图。") + } + if currentSelectedKey.description.containsNonASCII { + Text("设备 LCD 只稳定支持 ASCII;非 ASCII 写入时会被过滤。") .font(.caption) - .foregroundStyle(.secondary) - if selectedMode == .mode0 { - Text("Mode 1 默认文案:Record / Accept / Reject / Backspace") - .font(.caption) - .foregroundStyle(.secondary) - } + .foregroundStyle(.orange) } - .padding(.top, 4) } - if key.role == .voice { - GroupBox("语音输入方式") { - VStack(alignment: .leading, spacing: 12) { - VoicePresetPicker( - selectedPreset: key.voicePreset ?? .custom, - onSelect: applyVoicePreset - ) - if (key.voicePreset ?? .custom).isMacOSNativeFamily { - Text("只要 AhaKey Studio 在后台运行,Mode 1 出厂语音键发出的 F18 就会被直接接管到苹果原生转写。现在不再依赖系统听写快捷键。") - .font(.caption) - .foregroundStyle(.secondary) - } - Text("语音键的输入方式独立于当前 Mode,在任意 Mode 下都可使用相同的语音输入设置。") - .font(.caption) - .foregroundStyle(.tertiary) - } - .padding(.top, 4) - } - } else { - GroupBox("按键职责") { - VStack(alignment: .leading, spacing: 8) { - Text(key.role.manualText) + InspectorSection(title: "按键配置", isEmbedded: isEmbedded) { + if key.role == .voice { + HStack(alignment: .center, spacing: 8) { + Text("语音输入方式") .font(.callout) - Text("当前会把快捷键和按键描述一起写入键盘。切换模式时,设备会先显示描述,再回到该模式的 LCD 动图。") - .font(.caption) - .foregroundStyle(.secondary) + .foregroundStyle(isEmbedded ? AhaKeyStudioEmbeddedTheme.primaryText : .primary) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) + Spacer(minLength: 4) + Picker("", selection: selectedVoicePresetBinding) { + ForEach(VoicePreset.visibleCases) { preset in + Text(preset.title).tag(preset) + } + } + .pickerStyle(.menu) + .labelsHidden() + .frame(maxWidth: 120, alignment: .trailing) + .layoutPriority(1) } - .frame(maxWidth: .infinity, alignment: .leading) + } else { + keyBindingControls(for: key) } } - // ── 触发方式(短按 / 长按 Tab)────────────────────────────── - GroupBox("触发方式") { - VStack(alignment: .leading, spacing: 12) { - Picker("", selection: $selectedTriggerTab) { - Text("短按").tag(0) - Text("长按").tag(1) - } - .pickerStyle(.segmented) - .labelsHidden() - - Divider() - - if key.role == .voice { - // ── 语音键触发方式 ────────────────────────────── - if selectedTriggerTab == 0 { - VStack(alignment: .leading, spacing: 10) { - Label("按一下开始,再按一下结束", systemImage: "hand.tap.fill") - .font(.callout.weight(.semibold)) - Text("录音结束后根据下方开关决定是否经 AhaType 整理,再写入光标。") - .font(.caption) - .foregroundStyle(.secondary) - Toggle(isOn: $nativeSpeech.shortPressAhaTypeEnabled) { - HStack(spacing: 6) { - Text("使用 AhaType 整理") - .font(.callout) - if !ahaType.isEnabled { - Text("(AhaType 总开关已关闭)") - .font(.caption) - .foregroundStyle(.secondary) - } - } - } - .toggleStyle(.switch) - .disabled(!ahaType.isEnabled) - - Divider() + if key.role == .voice { + InspectorSection(title: "绑定方法", isEmbedded: isEmbedded) { + voiceBindingSection(for: key) + } + } - // 绑定摘要(短按 = 语音键 HID 绑定) - HStack { - Text(key.displaySummary) - .font(.system(.callout, design: .rounded).weight(.semibold)) - .lineLimit(1) - Spacer() - Text(key.usesMacro ? "固件宏" : "底层 HID") - .font(.caption) - .foregroundStyle(.secondary) - } - Picker("", selection: selectedKeyBindingModeBinding) { - Text("单键 / 组合键").tag(KeyBindingMode.shortcut) - Text("宏").tag(KeyBindingMode.macro) - } - .pickerStyle(.segmented) - .labelsHidden() - .disabled((key.voicePreset ?? .custom) != .custom) - if key.usesMacro { - macroEditor(for: key) - } else { - ShortcutBindingEditor(shortcut: selectedKeyShortcutBinding) - } - Text(voicePresetDetail) - .font(.caption) - .foregroundStyle(.secondary) - if (key.voicePreset ?? .custom) != .custom { - Text("语音键预设会固定使用单键绑定;如需录制宏,请先把预设改为自定义快捷键。") + AhaKeyStudioDisclosureSection( + title: "更多设置", + subtitle: key.role == .voice ? "长按与 AhaType" : "长按(固件预留)", + isEmbedded: isEmbedded, + isExpanded: $showMoreKeyOptions + ) { + if key.role == .voice { + VStack(alignment: .leading, spacing: 10) { + Text("短按 · AhaType") + .font(.caption.weight(.semibold)) + .foregroundStyle(isEmbedded ? AhaKeyStudioEmbeddedTheme.secondaryText : .secondary) + Toggle(isOn: $nativeSpeech.shortPressAhaTypeEnabled) { + HStack(spacing: 6) { + Text("使用 AhaType 整理") + .font(.callout) + if !ahaType.isEnabled { + Text("(总开关已关闭)") .font(.caption) - .foregroundStyle(.orange) - } - } - } else { - // 长按 Tab(语音键)— 始终开启,仅配置 AhaType 与阈值 - VStack(alignment: .leading, spacing: 10) { - Label("按住录音,松手即发送", systemImage: "hand.draw.fill") - .font(.callout.weight(.semibold)) - Text("按住键盘录音键不松手开始录音,松手后直接将 ASR 结果写入,响应更快。") - .font(.caption) - .foregroundStyle(.secondary) - Toggle(isOn: $nativeSpeech.longPressAhaTypeEnabled) { - HStack(spacing: 6) { - Text("使用 AhaType 整理") - .font(.callout) - if !ahaType.isEnabled { - Text("(AhaType 总开关已关闭)") - .font(.caption) - .foregroundStyle(.secondary) - } - } - } - .toggleStyle(.switch) - .disabled(!ahaType.isEnabled) - HStack(spacing: 10) { - Text("触发阈值") - .font(.callout) - Slider( - value: Binding( - get: { Double(nativeSpeech.longPressThresholdMs) }, - set: { nativeSpeech.longPressThresholdMs = Int($0) } - ), - in: 200...1000, - step: 50 - ) - Text("\(nativeSpeech.longPressThresholdMs) ms") - .font(.caption.monospacedDigit()) .foregroundStyle(.secondary) - .frame(width: 58, alignment: .trailing) } } } - } else { - // ── 普通键触发方式 ────────────────────────────── - if selectedTriggerTab == 0 { - VStack(alignment: .leading, spacing: 10) { - HStack { - Text(key.displaySummary) - .font(.system(.callout, design: .rounded).weight(.semibold)) - .lineLimit(2) - Spacer() - Text(key.usesMacro ? "固件宏" : "底层 HID") + .toggleStyle(.switch) + .disabled(!ahaType.isEnabled) + } + + VStack(alignment: .leading, spacing: 10) { + Text("长按") + .font(.caption.weight(.semibold)) + .foregroundStyle(isEmbedded ? AhaKeyStudioEmbeddedTheme.secondaryText : .secondary) + Label("按住录音,松手即发送", systemImage: "hand.draw.fill") + .font(.callout.weight(.semibold)) + Toggle(isOn: $nativeSpeech.longPressAhaTypeEnabled) { + HStack(spacing: 6) { + Text("使用 AhaType 整理") + .font(.callout) + if !ahaType.isEnabled { + Text("(总开关已关闭)") .font(.caption) .foregroundStyle(.secondary) } - Picker("", selection: selectedKeyBindingModeBinding) { - Text("单键 / 组合键").tag(KeyBindingMode.shortcut) - Text("宏").tag(KeyBindingMode.macro) - } - .pickerStyle(.segmented) - .labelsHidden() - if key.usesMacro { - macroEditor(for: key) - } else { - ShortcutBindingEditor(shortcut: selectedKeyShortcutBinding) - } - } - } else { - VStack(alignment: .leading, spacing: 8) { - Label("需要固件 v2+ 支持", systemImage: "exclamationmark.triangle") - .font(.callout.weight(.semibold)) - .foregroundStyle(.orange) - Text("长按绑定不同快捷键需固件升级后生效,当前仅短按绑定会写入设备。") - .font(.caption) - .foregroundStyle(.secondary) } } + .toggleStyle(.switch) + .disabled(!ahaType.isEnabled) + HStack(spacing: 10) { + Text("触发阈值") + .font(.callout) + Slider( + value: Binding( + get: { Double(nativeSpeech.longPressThresholdMs) }, + set: { nativeSpeech.longPressThresholdMs = Int($0) } + ), + in: 200...1000, + step: 50 + ) + Text("\(nativeSpeech.longPressThresholdMs) ms") + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + .frame(width: 58, alignment: .trailing) + } } + } else { + Label("长按需固件 v2+ 支持", systemImage: "exclamationmark.triangle") + .font(.callout.weight(.semibold)) + .foregroundStyle(.orange) } - .padding(.top, 4) } - .onChange(of: selectedPart) { _ in selectedTriggerTab = 0 } + } + .onChange(of: selectedPart) { _ in + selectedTriggerTab = 0 + showMoreKeyOptions = false + } + } + + /// 语音键绑定方法(独立于「按键配置」模块,位于其下方)。 + /// 「按一下开始,再按一下结束」等操作说明收纳在问号帮助中,主区只保留绑定控件。 + @ViewBuilder + private func voiceBindingSection(for key: AhaKeyKeyDraft) -> some View { + let preset = displayedVoicePreset(for: key) + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .center, spacing: 8) { + Text("短按绑定") + .font(.callout.weight(.semibold)) + .foregroundStyle(isEmbedded ? AhaKeyStudioEmbeddedTheme.primaryText : .primary) + Spacer(minLength: 4) + InspectorHelpButton( + title: "绑定说明 · \(preset.title)", + lines: voiceBindingHelpLines(for: preset), + isEmbedded: isEmbedded, + width: 280 + ) + } + keyBindingControls(for: key) + + if preset != .custom { + Text("当前预设固定单键绑定;改宏请先将语音输入方式设为「自定义快捷键」。") + .font(.caption) + .foregroundStyle(.orange) + } + } + } + + @ViewBuilder + private func keyBindingControls(for key: AhaKeyKeyDraft) -> some View { + VStack(alignment: .leading, spacing: 10) { + HStack { + Text(key.displaySummary) + .font(.system(.callout, design: .rounded).weight(.semibold)) + .lineLimit(2) + Spacer() + Text(key.usesMacro ? "固件宏" : "底层 HID") + .font(.caption) + .foregroundStyle(.secondary) + } + Picker("", selection: selectedKeyBindingModeBinding) { + Text("快捷键").tag(KeyBindingMode.shortcut) + Text("宏").tag(KeyBindingMode.macro) + } + .pickerStyle(.segmented) + .labelsHidden() + .controlSize(.small) + .disabled(key.role == .voice && displayedVoicePreset(for: key) != .custom) + if key.usesMacro { + macroEditor(for: key) + } else { + ShortcutBindingEditor(shortcut: selectedKeyShortcutBinding) + } } } + private func displayedVoicePreset(for key: AhaKeyKeyDraft) -> VoicePreset { + let preset = key.voicePreset ?? .custom + if preset.isMacOSNativeFamily { return .macOSNative } + if preset == .wechat || preset == .doubao { return .typeless } + return VoicePreset.visibleCases.contains(preset) ? preset : .custom + } + + private func voiceBindingHelpLines(for preset: VoicePreset) -> [String] { + var lines: [String] = [ + "短按:按一下开始,再按一下结束。", + "长按:按住录音,松手即发送(见下方「更多设置」)。", + "当前输入方式:\(preset.title)", + preset.detail, + "绑定方法决定按下语音键时发给系统的 HID / 宏;写入键盘后生效。", + ] + switch preset { + case .macOSNative, .claudeCode, .kimiCode: + lines.append("默认触发键为 F18;请确保麦克风、语音识别、听写等权限已开启。") + lines.append("预设固定使用单键绑定,不可录制宏。") + case .typeless, .wechat, .doubao: + lines.append("默认触发键为 F19(Fn/Globe 路由);请授予输入监控与辅助功能。") + lines.append("预设固定使用单键绑定,不可录制宏。") + case .custom: + lines.append("可自由指定单键 / 组合键,或录制固件宏。") + case .codex: + lines.append("该方式仍在规划中。") + } + return lines + } + // MARK: - 宏编辑器视图 @ViewBuilder @@ -1121,12 +1419,13 @@ struct AhaKeyStudioView: View { VStack(alignment: .leading, spacing: 10) { HStack(alignment: .firstTextBaseline) { - Text("步骤(依次执行)") + Text("步骤") .font(.callout.weight(.semibold)) - Spacer() - Text("\(stepCount) 步 · \(byteCount) / 98 字节") - .font(.caption) + Spacer(minLength: 4) + Text("\(stepCount) · \(byteCount)/98B") + .font(.caption.monospacedDigit()) .foregroundStyle(overLimit ? .red : .secondary) + .lineLimit(1) } if key.macro.isEmpty { @@ -1143,15 +1442,17 @@ struct AhaKeyStudioView: View { ) } } + .frame(maxWidth: .infinity, alignment: .leading) } - HStack(spacing: 10) { + HStack(spacing: 8) { Button { appendMacroStep() } label: { - Label("添加步骤", systemImage: "plus.circle.fill") + Label("添加", systemImage: "plus.circle.fill") } .buttonStyle(.borderedProminent) + .controlSize(.small) .disabled(overLimit) Button(role: .destructive) { @@ -1160,7 +1461,10 @@ struct AhaKeyStudioView: View { Label("清空", systemImage: "trash") } .buttonStyle(.bordered) + .controlSize(.small) .disabled(key.macro.isEmpty) + + Spacer(minLength: 0) } if overLimit { @@ -1183,20 +1487,21 @@ struct AhaKeyStudioView: View { @ViewBuilder private func macroStepRow(index: Int, step: MacroStep, totalCount: Int) -> some View { - HStack(spacing: 8) { + HStack(spacing: 6) { Text("\(index + 1).") .font(.caption.monospacedDigit()) .foregroundStyle(.secondary) - .frame(width: 24, alignment: .trailing) + .frame(width: 18, alignment: .trailing) Picker("", selection: macroStepActionBinding(id: step.id)) { ForEach(MacroAction.allCases) { action in - Text(action.title).tag(action) + Text(action.shortTitle).tag(action) } } .pickerStyle(.menu) .labelsHidden() - .frame(width: 96) + .frame(maxWidth: 64) + .layoutPriority(0) if step.action.takesKeycodeParam { Picker("", selection: macroStepKeycodeBinding(id: step.id)) { @@ -1207,54 +1512,61 @@ struct AhaKeyStudioView: View { } .pickerStyle(.menu) .labelsHidden() - .frame(minWidth: 96) + .frame(maxWidth: 88) + .layoutPriority(1) } else if step.action.takesDelayParam { // 勿对带标题的 Stepper 用 labelsHidden(),否则连「15 ms」一并被藏掉。 - HStack(spacing: 8) { + HStack(spacing: 4) { Text("\(max(1, Int(step.param)) * 3) ms") .font(.caption.monospacedDigit()) .foregroundStyle(.primary) - .frame(minWidth: 44, alignment: .trailing) + .frame(minWidth: 36, alignment: .trailing) Stepper( "", value: macroStepDelayBinding(id: step.id), in: 1...255 ) .labelsHidden() + .controlSize(.small) } - .frame(minWidth: 120) + .frame(maxWidth: 96) } else { - Color.clear.frame(minWidth: 96, maxHeight: 1) + Color.clear.frame(maxWidth: 88, maxHeight: 1) } - Spacer(minLength: 0) + Spacer(minLength: 2) - Button { - moveMacroStep(from: index, by: -1) - } label: { - Image(systemName: "arrow.up") - } - .buttonStyle(.borderless) - .disabled(index == 0) + HStack(spacing: 2) { + Button { + moveMacroStep(from: index, by: -1) + } label: { + Image(systemName: "arrow.up") + } + .buttonStyle(.borderless) + .disabled(index == 0) - Button { - moveMacroStep(from: index, by: 1) - } label: { - Image(systemName: "arrow.down") - } - .buttonStyle(.borderless) - .disabled(index >= totalCount - 1) + Button { + moveMacroStep(from: index, by: 1) + } label: { + Image(systemName: "arrow.down") + } + .buttonStyle(.borderless) + .disabled(index >= totalCount - 1) - Button(role: .destructive) { - removeMacroStep(at: index) - } label: { - Image(systemName: "xmark.circle.fill") - .foregroundStyle(.red) + Button(role: .destructive) { + removeMacroStep(at: index) + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.red) + } + .buttonStyle(.borderless) } - .buttonStyle(.borderless) + .fixedSize() + .layoutPriority(2) } .padding(.vertical, 4) .padding(.horizontal, 6) + .frame(maxWidth: .infinity, alignment: .leading) .background( RoundedRectangle(cornerRadius: 8) .fill(Color(nsColor: .controlBackgroundColor).opacity(0.6)) @@ -1312,118 +1624,112 @@ struct AhaKeyStudioView: View { private var oledInspector: some View { VStack(alignment: .leading, spacing: 16) { - GroupBox("当前模式的 LCD 动图") { - VStack(alignment: .leading, spacing: 14) { - ZStack { - RoundedRectangle(cornerRadius: 12) - .fill(Color.black.opacity(0.9)) - .frame(height: 140) - - if let image = currentOLEDPreviewImage { - Image(nsImage: image) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(height: 112) - .clipShape(RoundedRectangle(cornerRadius: 8)) - } else { - VStack(spacing: 10) { - Image(systemName: "photo.artframe") - .font(.system(size: 28)) - .foregroundStyle(.white.opacity(0.8)) - Text("当前仅支持动图") - .foregroundStyle(.white.opacity(0.85)) - Text("文字、token、模型状态显示开发中") - .font(.caption) - .foregroundStyle(.white.opacity(0.55)) - } + InspectorSection(title: "显示配置", isEmbedded: isEmbedded) { + ZStack { + RoundedRectangle(cornerRadius: 12) + .fill(Color.black.opacity(0.9)) + .frame(height: 140) + + if let image = currentOLEDPreviewImage { + Image(nsImage: image) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(height: 112) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } else { + VStack(spacing: 8) { + Image(systemName: "photo.artframe") + .font(.system(size: 28)) + .foregroundStyle(.white.opacity(0.8)) + Text("当前仅支持动图") + .foregroundStyle(.white.opacity(0.85)) } } + } - HStack(spacing: 10) { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 8) { Button("选择 GIF 或图片") { selectOLEDGIF() } .buttonStyle(.bordered) + .controlSize(.small) Button("预览动图") { showsOLEDPlaybackPreview = true } .buttonStyle(.bordered) + .controlSize(.small) .disabled(currentModeDraft.oled.localAssetPath == nil) Button("清空") { clearCurrentOLED() } .buttonStyle(.bordered) - - Spacer() - - Text("当前目标:\(selectedMode.title)") - .font(.caption) - .foregroundStyle(.secondary) + .controlSize(.small) + .help("仅清除当前自定义动图,不恢复出厂默认") } - Stepper(value: oledFramesPerSecondBinding, in: 1 ... 30) { - Text("播放速度 \(currentModeDraft.oled.framesPerSecond) FPS") + Button("清空并恢复默认") { + restoreDefaultOLED() } + .buttonStyle(.bordered) + .controlSize(.small) + .help("清除自定义动图,并恢复当前 Mode 的出厂默认动图与 12 FPS") - Text("硬性限制:源文件 ≤ 2 MB,FPS 1–30,单模式最多 70 帧;Mode 1/2/3/4 固定写入 slot 10/80/150/220。") - .font(.caption) - .foregroundStyle(.secondary) - - Text(currentModeDraft.oled.statusLine) + Text("当前目标:\(selectedMode.title)") .font(.caption) .foregroundStyle(.secondary) } - .padding(.top, 4) - } - GroupBox("显示逻辑") { - VStack(alignment: .leading, spacing: 8) { - Text("切换到当前模式时,LCD 会先显示该模式的按键描述,约 1 秒后回到该模式动图。") - Text("后续会继续增加文字状态、token 用量、模型环境等信息显示能力。") + Stepper(value: oledFramesPerSecondBinding, in: 1 ... 30) { + Text("播放速度 \(currentModeDraft.oled.framesPerSecond) FPS") } - .font(.callout) - .frame(maxWidth: .infinity, alignment: .leading) + + Text(currentModeDraft.oled.statusLine) + .font(.caption) + .foregroundStyle(.secondary) } } } private var lightBarInspector: some View { VStack(alignment: .leading, spacing: 16) { - GroupBox("状态灯效映射") { - VStack(alignment: .leading, spacing: 10) { - ForEach(IDEState.workflowOrder) { state in - HStack { - Text(state.shortLabel) - .font(.callout.weight(.medium)) - .frame(width: 80, alignment: .leading) - Picker("", selection: lightEffectBinding(for: state)) { - ForEach(LightEffectStyle.allCases) { effect in - Text(effect.title).tag(effect) - } - } - .labelsHidden() - .frame(maxWidth: .infinity) - } - } + InspectorSection(title: "亮度", isEmbedded: isEmbedded) { + HStack { + Slider(value: brightnessBinding, in: 1...100, step: 1) + Text("\(currentModeDraft.lightBar.brightness)%") + .font(.callout.monospacedDigit()) + .frame(width: 44, alignment: .trailing) } - .padding(.top, 4) } - GroupBox("亮度") { - VStack(alignment: .leading, spacing: 8) { - HStack { - Slider(value: brightnessBinding, in: 1...100, step: 1) - Text("\(currentModeDraft.lightBar.brightness)%") - .font(.callout.monospacedDigit()) - .frame(width: 44, alignment: .trailing) + InspectorSection(title: "状态映射", isEmbedded: isEmbedded) { + ForEach(IDEState.workflowOrder) { state in + HStack(spacing: 8) { + Text(state.shortLabel) + .font(.callout.weight(.medium)) + .lineLimit(1) + .frame(maxWidth: 72, alignment: .leading) + Spacer(minLength: 4) + Picker("", selection: lightEffectBinding(for: state)) { + ForEach(LightEffectStyle.allCases) { effect in + Text(effect.title).tag(effect) + } + } + .labelsHidden() + .frame(maxWidth: 120, alignment: .trailing) + .layoutPriority(1) } } - .padding(.top, 4) } - GroupBox("状态预览") { + AhaKeyStudioDisclosureSection( + title: "更多灯效", + subtitle: "状态预览格", + isEmbedded: isEmbedded, + isExpanded: $showMoreLightBarOptions + ) { VStack(alignment: .leading, spacing: 12) { Text(lightBarPreview.shortLabel) .font(.system(.title3, design: .rounded).weight(.semibold)) @@ -1431,11 +1737,8 @@ struct AhaKeyStudioView: View { Text("画布预览:\(currentModeDraft.lightBar.effect(for: lightBarPreview).title)") .font(.callout) .foregroundStyle(.secondary) - Text("点击状态会在虚拟键盘预览,并通过 0x91 临时预览到设备;保存请使用底部通用按钮。") - .font(.caption) - .foregroundStyle(.secondary) - LazyVGrid(columns: [GridItem(.adaptive(minimum: 110), spacing: 8)], spacing: 8) { + LazyVGrid(columns: [GridItem(.adaptive(minimum: 96), spacing: 8)], spacing: 8) { ForEach(IDEState.workflowOrder) { state in Button { lightBarPreview = state @@ -1444,13 +1747,14 @@ struct AhaKeyStudioView: View { VStack(alignment: .leading, spacing: 4) { Text(state.shortLabel) .font(.caption.weight(.semibold)) + .lineLimit(1) Text(currentModeDraft.lightBar.effect(for: state).title) .font(.caption2) .foregroundStyle(.secondary) .lineLimit(1) } .frame(maxWidth: .infinity, alignment: .leading) - .padding(10) + .padding(8) .background( RoundedRectangle(cornerRadius: 10) .fill(state == lightBarPreview ? Color.accentColor.opacity(0.14) : Color(nsColor: .controlBackgroundColor)) @@ -1464,56 +1768,33 @@ struct AhaKeyStudioView: View { } } } - .padding(.top, 4) } } + .onChange(of: selectedPart) { _ in showMoreLightBarOptions = false } } private var switchInspector: some View { VStack(alignment: .leading, spacing: 16) { - GroupBox("实时档位") { - VStack(alignment: .leading, spacing: 12) { - HStack { - Text(currentSwitchTitle) - .font(.system(.title3, design: .rounded).weight(.semibold)) - Spacer() - Circle() - .fill(currentSwitchTitle == "自动批准" ? Color.green : Color.indigo) - .frame(width: 10, height: 10) - } - Text("拨杆是物理档位,不是按下瞬态。0 档显示「自动批准」,1 档显示「手动批准」。这里只读取键盘上报的位置,不模拟物理拨动。") - .font(.callout) - .foregroundStyle(.secondary) - } - .padding(.top, 4) - } - - switchEffectivenessBox - - if bleManager.switchState == 0 { - GroupBox { - VStack(alignment: .leading, spacing: 8) { - Label("自动批准依赖 Agent 与 Hook,且须蓝牙由 Agent 占用", systemImage: "exclamationmark.triangle.fill") - .foregroundStyle(.orange) - .font(.callout.weight(.semibold)) - Text("Claude:PermissionRequest allow。Cursor:preToolUse 等与 cli-config。Codex:PermissionRequest allow。Kimi:安装过 AhaKey Kimi Hooks 后,**拨杆会直接接管当前会话的自动批准**;若刚装完或刚升级 kimi-cli,请**完全关闭并重新打开一次 kimi**。钩子 stdout 只对 **`permissionDecision: deny`** 有特殊拦截语义。Agent 须在跑且蓝牙由其占用。") - .font(.callout) - .foregroundStyle(.secondary) - } + InspectorSection(title: "实时档位", isEmbedded: isEmbedded) { + HStack { + Text(currentSwitchTitle) + .font(.system(.title3, design: .rounded).weight(.semibold)) + Spacer() + Circle() + .fill(currentSwitchTitle == "自动批准" ? Color.green : Color.indigo) + .frame(width: 10, height: 10) } - } - - GroupBox("如何理解这个部件") { - VStack(alignment: .leading, spacing: 8) { - Text("拨杆对 Claude / Cursor / Codex / Kimi **同时生效**,与键盘当前所在 Mode 无关。Agent 后台同时监听所有 IDE 的 Hook,拨杆拨动后四个 IDE 的批准行为立即切换。") - Divider() - Text("自动批准:**Claude / Codex PermissionRequest**,**Cursor preToolUse**(含 cli-config)。**Kimi**:安装过 AhaKey Kimi Hooks 后,拨杆会直接接管**当前会话**的自动批准;刚装完或刚升级 kimi-cli 时,重开一次 kimi 即可。") - Text("手动批准:会交回用户/终端确认。若 Cursor、Codex 或 Kimi 仍弹窗,请看 diagnostics 里的 ide 与 diagnostic 字段。") - Text("若仍出现手动:在「设备信息」里打开「工具批准诊断」查看 permission-request.log(含 ide、hookEvent、diagnostic 等)。") + Text("0 档自动批准 · 1 档手动批准(只读上报)") + .font(.caption) + .foregroundStyle(.secondary) + if bleManager.switchState == 0 { + Label("自动批准需 Agent、Hook,且蓝牙由 Agent 占用", systemImage: "exclamationmark.triangle.fill") + .font(.caption.weight(.semibold)) + .foregroundStyle(.orange) } - .font(.callout) - .frame(maxWidth: .infinity, alignment: .leading) } + + switchEffectivenessBox } } @@ -1521,56 +1802,51 @@ struct AhaKeyStudioView: View { private var switchEffectivenessBox: some View { let agentReady = agentManager.isInstalled && agentManager.isRunning && agentManager.hooksInstalled let hasAnyMissing = !agentManager.isInstalled || !agentManager.isRunning || !agentManager.hooksInstalled - GroupBox(agentReady ? "已生效" : "未生效") { - VStack(alignment: .leading, spacing: 10) { - HStack(spacing: 8) { - Image(systemName: agentReady ? "checkmark.seal.fill" : "exclamationmark.circle.fill") - .foregroundStyle(agentReady ? .green : .orange) - Text(agentReady - ? "Agent 就绪时 Claude/Cursor/Codex 可随拨杆走批准。**Kimi**:安装过 AhaKey Kimi Hooks 后,拨杆会直接接管当前会话;若刚装完或刚升级 kimi-cli,重开一次 kimi 即可。" - : "拨杆在 IDE 中生效需先安装 Agent 与 Hook,并把蓝牙交给 Agent;否则仅为状态显示。") - .font(.callout) - } + InspectorSection(title: "生效状态", isEmbedded: isEmbedded) { + HStack(spacing: 8) { + Image(systemName: agentReady ? "checkmark.seal.fill" : "exclamationmark.circle.fill") + .foregroundStyle(agentReady ? .green : .orange) + Text(agentReady + ? "Agent 就绪:Claude / Cursor / Codex / Kimi 可随拨杆走批准。" + : "需先安装 Agent 与 Hook,并把蓝牙交给 Agent;否则仅为状态显示。") + .font(.callout) + } - if hasAnyMissing { - VStack(alignment: .leading, spacing: 4) { - agentChecklistRow(label: "LaunchAgent 已安装", ok: agentManager.isInstalled) - agentChecklistRow(label: "Agent 已连接蓝牙", ok: agentManager.isRunning) - agentChecklistRow(label: "Claude / Cursor / Codex / Kimi Hook 已配置", ok: agentManager.hooksInstalled) - } - .padding(.leading, 4) + if hasAnyMissing { + VStack(alignment: .leading, spacing: 4) { + agentChecklistRow(label: "LaunchAgent 已安装", ok: agentManager.isInstalled) + agentChecklistRow(label: "Agent 已连接蓝牙", ok: agentManager.isRunning) + agentChecklistRow(label: "Claude / Cursor / Codex / Kimi Hook 已配置", ok: agentManager.hooksInstalled) + } + .padding(.leading, 4) - HStack(spacing: 8) { - if !agentManager.isInstalled { - Button("安装 Agent + Hook") { - agentManager.install() - } - .buttonStyle(.borderedProminent) - .controlSize(.small) - } else if !agentManager.isRunning { - // 与「设备信息 · Agent」相同:在 launchd 中 load + start 守护进程。 - // 若当前由本 App 占用蓝牙,此处也应引导先去设备信息把「蓝牙连接」切给 Agent,否则与主流程二选一相冲突(故与 DeviceInfo 同样禁用直接启动)。 - Button("启动 Agent") { - agentManager.start() - } - .buttonStyle(.borderedProminent) - .controlSize(.small) - .disabled(agentManager.bluetoothConnectionOwner == .ahaKeyStudio) - .help( - agentManager.bluetoothConnectionOwner == .ahaKeyStudio - ? "当前由本 App 占用蓝牙。请打开下方「设备信息…」,在「蓝牙连接」里选「由 Agent 占用」后再启 Agent;与设备信息里「启动」按钮规则一致。" - : "与「设备信息 · Agent」中的启动相同,由 launchd 加载并执行 ahakeyconfig-agent。" - ) + HStack(spacing: 8) { + if !agentManager.isInstalled { + Button("安装 Agent + Hook") { + agentManager.install() } - Button("设备信息(蓝牙 / 启停 Agent)…") { - showsDeviceInfo = true + .buttonStyle(.borderedProminent) + .controlSize(.small) + } else if !agentManager.isRunning { + Button("启动 Agent") { + agentManager.start() } - .buttonStyle(.bordered) + .buttonStyle(.borderedProminent) .controlSize(.small) + .disabled(agentManager.bluetoothConnectionOwner == .ahaKeyStudio) + .help( + agentManager.bluetoothConnectionOwner == .ahaKeyStudio + ? "当前由本 App 占用蓝牙。请打开下方「设备信息…」,在「蓝牙连接」里选「由 Agent 占用」后再启 Agent;与设备信息里「启动」按钮规则一致。" + : "与「设备信息 · Agent」中的启动相同,由 launchd 加载并执行 ahakeyconfig-agent。" + ) + } + Button("设备信息(蓝牙 / 启停 Agent)…") { + openDeviceInfo() } + .buttonStyle(.bordered) + .controlSize(.small) } } - .padding(.top, 4) } } @@ -1587,61 +1863,82 @@ struct AhaKeyStudioView: View { private var statusBar: some View { - HStack(spacing: 16) { + HStack(alignment: .center, spacing: isEmbedded ? 12 : 16) { Label("\(selectedPart.title) · \(selectedMode.title)", systemImage: selectedPart.systemImage) - .font(.callout) - Divider() - .frame(height: 14) - Text("未同步改动 \(dirtyCount)") - .font(.callout) - Divider() - .frame(height: 14) + .font(isEmbedded ? .system(size: 12, weight: .medium) : .callout) + .foregroundStyle(isEmbedded ? AhaKeyStudioEmbeddedTheme.primaryText : .primary) + .lineLimit(1) + .layoutPriority(2) + statusDivider + HStack(spacing: 4) { + Image(systemName: dirtyCount > 0 ? "arrow.triangle.2.circlepath.circle.fill" : "checkmark.circle") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(dirtyCount > 0 ? .orange : (isEmbedded ? AhaKeyStudioEmbeddedTheme.secondaryText : .secondary)) + if dirtyCount > 0 { + Text("\(dirtyCount)") + .font(isEmbedded ? .system(size: 12, weight: .semibold) : .callout) + .foregroundStyle(isEmbedded ? AhaKeyStudioEmbeddedTheme.secondaryText : .primary) + } + } + .accessibilityLabel("未同步改动 \(dirtyCount)") + statusDivider Text(syncStatusMessage) - .font(.callout) - .foregroundStyle(.secondary) + .font(isEmbedded ? .system(size: 12) : .callout) + .foregroundStyle(isEmbedded ? AhaKeyStudioEmbeddedTheme.secondaryText : .secondary) .lineLimit(1) - Spacer() + .truncationMode(.tail) + .frame(maxWidth: .infinity, alignment: .leading) if let lastSyncDate { Text("最近同步 \(Self.timeFormatter.string(from: lastSyncDate))") - .font(.callout) - .foregroundStyle(.secondary) - } - Button("权限诊断") { - showsDiagnostics = true - } - .buttonStyle(.borderless) - .help("查看语音权限状态与诊断日志") - .sheet(isPresented: $showsDiagnostics) { - diagnosticsSheet + .font(isEmbedded ? .system(size: 11) : .callout) + .foregroundStyle(isEmbedded ? AhaKeyStudioEmbeddedTheme.secondaryText : .secondary) + .lineLimit(1) } + if !isEmbedded { + Button("权限诊断") { + showsDiagnostics = true + } + .buttonStyle(.borderless) + .help("查看语音权限状态与诊断日志") + .sheet(isPresented: $showsDiagnostics) { + diagnosticsSheet + } - Button("新手引导") { - voiceRelay.showsPermissionOnboarding = false - unifiedOnboardingCompleted = false - } - .buttonStyle(.borderless) - .help("重新打开 AhaKey Studio 新手引导") + Button("新手引导") { + voiceRelay.showsPermissionOnboarding = false + UnifiedOnboardingStorage.resetForReplay() + unifiedOnboardingCompleted = false + } + .buttonStyle(.borderless) + .help("重新打开全屏新手引导,并重置各功能页一次性气泡提示") - Button("帮助中心") { - showsHelpCenter = true - } - .buttonStyle(.borderless) - .help("打开内嵌的帮助中心") - .sheet(isPresented: $showsHelpCenter) { - HelpCenterSheet( - studioDraft: studioDraft, - selectedMode: selectedMode, - bleManager: bleManager - ) + Button("帮助中心") { + showsHelpCenter = true + } + .buttonStyle(.borderless) + .help("打开内嵌的帮助中心") + .sheet(isPresented: $showsHelpCenter) { + HelpCenterSheet( + studioDraft: studioDraft, + selectedMode: selectedMode, + bleManager: bleManager + ) + } } } - .padding(.horizontal, 24) - .padding(.vertical, 12) + .padding(.horizontal, isEmbedded ? 16 : 24) + .padding(.vertical, isEmbedded ? 10 : 12) .background(chromeBarBackground) } + private var statusDivider: some View { + Divider() + .frame(height: 14) + .opacity(isEmbedded ? 0.35 : 1) + } + private var chromeBarBackground: Color { - Color(nsColor: .controlBackgroundColor) + isEmbedded ? AhaKeyStudioEmbeddedTheme.contentBackground : AhaKeyUI.ColorToken.card } private var currentModeDraft: AhaKeyModeDraft { @@ -1796,9 +2093,16 @@ struct AhaKeyStudioView: View { return "临时由 AhaKey Studio 接管蓝牙,用于改键、LCD、同步和本机灯效测试。" } - private var voicePresetDetail: String { - let preset = currentSelectedKey.voicePreset ?? .custom - return preset.detail + private var selectedVoicePresetBinding: Binding { + Binding( + get: { + let preset = currentSelectedKey.voicePreset ?? .custom + if preset.isMacOSNativeFamily { return .macOSNative } + if preset == .wechat || preset == .doubao { return .typeless } + return VoicePreset.visibleCases.contains(preset) ? preset : .custom + }, + set: { applyVoicePreset($0) } + ) } private func permissionBadge(title: String, granted: Bool) -> some View { @@ -1888,6 +2192,17 @@ struct AhaKeyStudioView: View { mode.oled.localAssetPath = nil mode.oled.statusLine = AhaKeyOLEDDraft.default(for: selectedMode).statusLine } + syncStatusMessage = "\(selectedMode.title) LCD 动图已清空;写入键盘后设备端才会更新。" + } + + /// 清除自定义动图,并恢复当前 Mode 的出厂默认(含 bundle 默认 GIF 与 12 FPS)。 + private func restoreDefaultOLED() { + let restored = AhaKeyOLEDDraft.default(for: selectedMode) + updateCurrentMode { mode in + mode.oled = restored + } + let assetName = restored.localAssetPath.map { URL(fileURLWithPath: $0).lastPathComponent } ?? "固件默认" + syncStatusMessage = "\(selectedMode.title) LCD 已恢复默认(\(assetName),\(restored.framesPerSecond) FPS);请点「写入键盘」同步到设备。" } private func applyVoicePreset(_ preset: VoicePreset) { @@ -2006,6 +2321,8 @@ struct AhaKeyStudioView: View { return current.lightBar != baseline.lightBar case .toggleSwitch: return false + case .magneticPort: + return false } } @@ -2580,6 +2897,31 @@ struct AhaKeyStudioView: View { private func refreshStartupPermissionOnboarding() { voiceRelay.showsPermissionOnboarding = false } + + private func handleStudioNavigation(_ notification: Notification) { + if let partRaw = notification.userInfo?[StudioNavigationUserInfoKey.part] as? String, + let part = AhaKeyStudioPart(rawValue: partRaw) { + selectedPart = part + isEditingInspector = false + return + } + + guard let raw = notification.userInfo?[StudioNavigationUserInfoKey.section] as? String, + let section = StudioNavigationSection(rawValue: raw) else { return } + + switch section { + case .voiceAgent: + selectedPart = .key1 + case .device: + openDeviceInfo() + case .approve: + selectedPart = .toggleSwitch + case .oled: + selectedPart = .oledDisplay + case .voice: + selectedPart = .key1 + } + } } private struct VoicePermissionOnboardingSheet: View { @@ -2777,75 +3119,6 @@ private struct VoicePermissionOnboardingSheet: View { } } -private struct VoicePresetPicker: View { - let selectedPreset: VoicePreset - let onSelect: (VoicePreset) -> Void - - private let visiblePresets = VoicePreset.visibleCases - private let columns = [ - GridItem(.flexible(), spacing: 10), - GridItem(.flexible(), spacing: 10), - ] - - var body: some View { - LazyVGrid(columns: columns, spacing: 10) { - ForEach(visiblePresets) { preset in - Button { - if preset.availableInV1 { - onSelect(preset) - } - } label: { - VStack(alignment: .leading, spacing: 6) { - HStack { - Text(preset.title) - .font(.callout.weight(.semibold)) - Spacer() - if !preset.availableInV1 { - Text("开发中") - .font(.caption2) - .foregroundStyle(.secondary) - } - } - Text(preset.detail) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(3) - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(12) - .background( - RoundedRectangle(cornerRadius: 12) - .fill(cardFill(for: preset)) - ) - .overlay( - RoundedRectangle(cornerRadius: 12) - .stroke(cardStroke(for: preset), lineWidth: preset == selectedPreset ? 1.5 : 1) - ) - } - .buttonStyle(.plain) - .disabled(!preset.availableInV1) - } - } - } - - private func cardFill(for preset: VoicePreset) -> Color { - if preset == selectedPreset { - return Color.accentColor.opacity(0.16) - } - if !preset.availableInV1 { - return Color(nsColor: .controlBackgroundColor).opacity(0.65) - } - return Color(nsColor: .controlBackgroundColor) - } - - private func cardStroke(for preset: VoicePreset) -> Color { - if preset == selectedPreset { - return .accentColor - } - return Color.black.opacity(0.08) - } -} - private struct ShortcutBindingEditor: View { @Binding var shortcut: ShortcutBinding @State private var isRecordingPrimaryKey = false @@ -2856,25 +3129,27 @@ private struct ShortcutBindingEditor: View { Text("修饰键") .font(.caption) .foregroundStyle(.secondary) - HStack(spacing: 8) { + HStack(spacing: 4) { ForEach(ShortcutModifier.allCases) { modifier in Toggle(isOn: modifierBinding(modifier)) { Text(modifier.symbol) - .font(.system(.headline, design: .rounded)) + .font(.system(.subheadline, design: .rounded)) } .toggleStyle(.button) + .controlSize(.small) .help(modifier.title) } if !shortcut.modifiers.isEmpty { - Button("清除修饰键") { + Button("清除") { var next = shortcut next.modifiers = [] shortcut = next } .buttonStyle(.bordered) - .controlSize(.small) + .controlSize(.mini) } } + .frame(maxWidth: .infinity, alignment: .leading) } VStack(alignment: .leading, spacing: 8) { @@ -3206,8 +3481,16 @@ private struct AhaKeyKeyboardCanvasView: View { private func ledBarButton(width: CGFloat, height: CGFloat) -> some View { let part = AhaKeyStudioPart.lightBar - // 略向上、宽度往里收:让选中态阴影(radius 10pt)跟键盘内描边、按键灰底、LCD 都有 ≥ 5 个基线单位的余量 - let rect = frame(13.0, 4.5, 53.5, 8.6, width: width, height: height) + let layout = KeyboardCanvasLayout.standard + // 顶边与 OLED 屏幕顶边对齐(layout.lightBarY == layout.oledY) + let rect = frame( + layout.lightBarX1, + layout.lightBarY, + layout.lightBarWX1, + 8.6, + width: width, + height: height + ) let modeData = modeDraft.mode.rawValue let effect: LightEffectStyle let baseColor: Color @@ -3229,29 +3512,21 @@ private struct AhaKeyKeyboardCanvasView: View { return Button { onSelect(part) } label: { - VStack(spacing: rect.height * 0.12) { - Text("灯条") - .font(.system(size: max(rect.height * 0.18, 10), weight: .semibold)) - .foregroundStyle(Color.black.opacity(0.72)) - .frame(maxWidth: .infinity, alignment: .center) - - TimelineView(.animation(minimumInterval: 1.0 / 30.0)) { context in - let colors = ledColors(effect: effect, time: context.date.timeIntervalSince1970, count: 10, baseColor: baseColor) - ZStack(alignment: .leading) { - RoundedRectangle(cornerRadius: 12, style: .continuous) - .fill(Color.black.opacity(0.12)) - HStack(spacing: rect.width * 0.026) { - ForEach(0..<10, id: \.self) { index in - Capsule() - .fill(colors[index]) - .frame(width: rect.width * 0.072, height: rect.height * 0.26) - .shadow(color: colors[index].opacity(0.65), radius: 2.5) - } + TimelineView(.animation(minimumInterval: 1.0 / 30.0)) { context in + let colors = ledColors(effect: effect, time: context.date.timeIntervalSince1970, count: 10, baseColor: baseColor) + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color.black.opacity(0.12)) + HStack(spacing: rect.width * 0.026) { + ForEach(0..<10, id: \.self) { index in + Capsule() + .fill(colors[index]) + .frame(width: rect.width * 0.072, height: rect.height * 0.42) + .shadow(color: colors[index].opacity(0.65), radius: 2.5) } - .padding(.horizontal, rect.width * 0.04) - .frame(maxWidth: .infinity, alignment: .leading) } - .frame(height: rect.height * 0.48) + .padding(.horizontal, rect.width * 0.04) + .frame(maxWidth: .infinity, alignment: .leading) } } .frame(width: rect.width, height: rect.height) @@ -3263,7 +3538,8 @@ private struct AhaKeyKeyboardCanvasView: View { private func oledButton(width: CGFloat, height: CGFloat) -> some View { let part = AhaKeyStudioPart.oledDisplay - let rect = frame(71.2, 7.7, 24.2, 13.4, width: width, height: height) + let layout = KeyboardCanvasLayout.standard + let rect = frame(layout.oledX, layout.oledY, layout.oledW, 13.4, width: width, height: height) return Button { onSelect(part) } label: { @@ -3410,6 +3686,7 @@ private struct AhaKeyKeyboardCanvasView: View { .shadow(color: .black.opacity(0.12), radius: 10, y: 4) keyIcon(for: role, size: rect.height * 0.28) + .fixedSize() } .frame(width: rect.width * 0.8, height: rect.height * 0.76) @@ -3428,9 +3705,7 @@ private struct AhaKeyKeyboardCanvasView: View { @ViewBuilder private func keyIcon(for role: AhaKeyKeyRole, size: CGFloat) -> some View { - Image(systemName: role.systemImage) - .font(.system(size: size, weight: .regular)) - .foregroundStyle(Color.black.opacity(0.88)) + CanvasKeyRoleIcon(role: role, size: size) } private func modeSwitchKey(width: CGFloat, height: CGFloat) -> some View { @@ -3918,21 +4193,13 @@ private struct DeviceInfoSheetContainer: View { } } -private struct CloudAccountView: View { +private struct CloudAccountSheet: View { @Environment(\.dismiss) private var dismiss - @StateObject private var account = CloudAccountManager.shared - @StateObject private var optimizer = AhaTypeTextOptimizer.shared - @FocusState private var focusedLoginField: LoginField? - - private enum LoginField { - case phone - case password - } var body: some View { VStack(alignment: .leading, spacing: 0) { HStack { - Text("云端账号 · AhaType") + Text("用户中心") .font(.headline) Spacer() Button("关闭") { dismiss() } @@ -3942,262 +4209,11 @@ private struct CloudAccountView: View { Divider() ScrollView { - VStack(alignment: .leading, spacing: 18) { - if account.isLoggedIn { - profileSection - } else { - loginSection - } - - Divider() - - ahaTypeSection - } - .padding(18) - } - } - .alert("云端账号", isPresented: Binding( - get: { account.alertMessage != nil }, - set: { if !$0 { account.alertMessage = nil } } - )) { - Button("好", role: .cancel) { account.alertMessage = nil } - } message: { - Text(account.alertMessage ?? "") - } - .onAppear { - activateAhaKeyWindowForTextInput() - optimizer.refreshFromDisk() - if account.isLoggedIn { - account.refreshProfile() - } else { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { - activateAhaKeyWindowForTextInput() - focusedLoginField = .phone - } - } - } - } - - private var loginSection: some View { - VStack(alignment: .leading, spacing: 12) { - Text("登录后可使用 AhaType 云端大模型整理。") - .font(.callout) - .foregroundStyle(.secondary) - - TextField("手机号", text: $account.phone) - .textFieldStyle(.roundedBorder) - .focused($focusedLoginField, equals: .phone) - .onTapGesture { - activateAhaKeyWindowForTextInput() - focusedLoginField = .phone - } - .onSubmit { focusedLoginField = .password } - - SecureField("密码", text: $account.password) - .textFieldStyle(.roundedBorder) - .focused($focusedLoginField, equals: .password) - .onTapGesture { - activateAhaKeyWindowForTextInput() - focusedLoginField = .password - } - .onSubmit { account.login() } - - Toggle("记住密码", isOn: $account.rememberPassword) - - HStack(spacing: 10) { - Button("登录") { account.login() } - .buttonStyle(.borderedProminent) - .disabled(account.isBusy) - Button("注册") { account.register() } - .buttonStyle(.bordered) - .disabled(account.isBusy) - } - - Text(account.statusMessage) - .font(.caption) - .foregroundStyle(.secondary) - } - } - - private var profileSection: some View { - VStack(alignment: .leading, spacing: 12) { - Text(account.profileSummary) - .font(.callout) - .textSelection(.enabled) - - VStack(alignment: .leading, spacing: 8) { - quotaRow(title: "每日", value: account.quotaText("daily")) - quotaRow(title: "每周", value: account.quotaText("weekly")) - quotaRow(title: "每月", value: account.quotaText("monthly")) - } - .padding(12) - .background( - RoundedRectangle(cornerRadius: 8) - .fill(Color(nsColor: .controlBackgroundColor)) - ) - - HStack(spacing: 10) { - Button("刷新") { account.refreshProfile() } - .buttonStyle(.borderedProminent) - .disabled(account.isBusy) - Button("切换账号") { - account.prepareForRelogin() - focusedLoginField = .phone - } - .buttonStyle(.bordered) - .disabled(account.isBusy) - Button("退出登录") { account.logout() } - .buttonStyle(.bordered) - .disabled(account.isBusy) - } - - rechargeSection - - HStack(spacing: 10) { - TextField("免费券兑换码", text: $account.couponCode) - .textFieldStyle(.roundedBorder) - Button("兑换") { account.redeemCoupon() } - .buttonStyle(.bordered) - .disabled(account.isBusy) - } - - Text(account.statusMessage) - .font(.caption) - .foregroundStyle(.secondary) - } - } - - private var rechargeSection: some View { - VStack(alignment: .leading, spacing: 10) { - Text("充值订阅") - .font(.callout.weight(.semibold)) - - HStack(spacing: 8) { - ForEach(CloudRechargePlan.allCases) { plan in - Button { - account.createWechatOrder(plan: plan) - } label: { - VStack(spacing: 3) { - Text(plan.title) - .font(.caption.weight(.semibold)) - Text(account.priceText(for: plan)) - .font(.caption2) - .foregroundStyle(.secondary) - Text(plan.subtitle) - .font(.caption2) - .foregroundStyle(.tertiary) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 8) - } - .buttonStyle(.bordered) - .disabled(account.isBusy) - } - } - - if let order = account.paymentOrder { - VStack(alignment: .leading, spacing: 10) { - HStack(alignment: .top, spacing: 12) { - if let image = makeQRCodeImage(from: order.paymentURL) { - Image(nsImage: image) - .interpolation(.none) - .resizable() - .frame(width: 132, height: 132) - .background(Color.white) - .clipShape(RoundedRectangle(cornerRadius: 6)) - } - - VStack(alignment: .leading, spacing: 6) { - Text("\(order.plan.title) · \(order.amountText)") - .font(.caption.weight(.semibold)) - Text("微信扫码完成支付,支付成功后会自动刷新额度。") - .font(.caption) - .foregroundStyle(.secondary) - Text("订单:\(order.outTradeNo)") - .font(.caption2) - .foregroundStyle(.tertiary) - .textSelection(.enabled) - Text("状态:\(order.status)") - .font(.caption2) - .foregroundStyle(.tertiary) - } - } - - HStack(spacing: 8) { - Button("复制支付链接") { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(order.paymentURL, forType: .string) - } - .buttonStyle(.bordered) - - Button("刷新到账") { - account.refreshCurrentPaymentOrder() - } - .buttonStyle(.bordered) - .disabled(account.isBusy) - - Button("关闭订单") { - account.clearPaymentOrder() - } - .buttonStyle(.bordered) - } - } - .padding(12) - .background( - RoundedRectangle(cornerRadius: 8) - .fill(Color(nsColor: .controlBackgroundColor)) - ) - } - } - } - - private var ahaTypeSection: some View { - VStack(alignment: .leading, spacing: 10) { - Toggle(isOn: Binding( - get: { optimizer.isEnabled }, - set: { optimizer.setEnabled($0) } - )) { - Text("启用 AhaType 云端整理") - .font(.callout.weight(.semibold)) + AhaKeyUserCenterContent() + .padding(18) } - .toggleStyle(.switch) - - Text("开启后,macOS 原生语音转写完成后会先请求云端整理,再粘贴整理后的文本。未登录、过期或网络失败时会自动回退原始转写。") - .font(.caption) - .foregroundStyle(.secondary) - - Text(optimizer.statusMessage) - .font(.caption) - .foregroundStyle(.secondary) - - Text(optimizer.lastQuotaSummary) - .font(.caption2) - .foregroundStyle(.tertiary) - } - } - - private func quotaRow(title: String, value: String) -> some View { - HStack { - Text(title) - .font(.caption.weight(.semibold)) - Spacer() - Text(value) - .font(.caption) - .foregroundStyle(.secondary) } - } - - private func makeQRCodeImage(from text: String) -> NSImage? { - guard let data = text.data(using: .utf8), - let filter = CIFilter(name: "CIQRCodeGenerator") else { return nil } - filter.setValue(data, forKey: "inputMessage") - filter.setValue("M", forKey: "inputCorrectionLevel") - guard let output = filter.outputImage else { return nil } - let scaled = output.transformed(by: CGAffineTransform(scaleX: 8, y: 8)) - let representation = NSCIImageRep(ciImage: scaled) - let image = NSImage(size: representation.size) - image.addRepresentation(representation) - return image + .background(Color(nsColor: .windowBackgroundColor)) } } diff --git a/ahakeyconfig-mac/Sources/Views/AhaKeyWorkbenchView.swift b/ahakeyconfig-mac/Sources/Views/AhaKeyWorkbenchView.swift new file mode 100644 index 00000000..90b27e30 --- /dev/null +++ b/ahakeyconfig-mac/Sources/Views/AhaKeyWorkbenchView.swift @@ -0,0 +1,1502 @@ +import AppKit +import SwiftUI + +// MARK: - 侧边栏导航项 + +/// 工作台侧边栏的四个主导航页面。 +private enum WorkbenchTab: String, CaseIterable, Identifiable { + case workbench // 工作台 + case keyConfig // 按键配置 + case agentMode // Agent 模式 + case deviceSystem // 设备与系统 + + var id: String { rawValue } + + var title: String { + switch self { + case .workbench: "工作台" + case .keyConfig: "按键配置" + case .agentMode: "Agent 模式" + case .deviceSystem: "设备与系统" + } + } + + var systemImage: String { + switch self { + case .workbench: "house.fill" + case .keyConfig: "keyboard" + case .agentMode: "brain.head.profile" + case .deviceSystem: "gearshape" + } + } +} + +// MARK: - Agent 类型 + +private enum AgentType: String, CaseIterable, Identifiable { + case claude = "Claude" + case cursor = "Cursor" + case codex = "Codex" + + var id: String { rawValue } +} + +// MARK: - 工作台主视图 + +struct AhaKeyWorkbenchView: View { + @ObservedObject var bleManager: AhaKeyBLEManager + var embeddedInSettings: Bool = false + + @State private var selectedTab: WorkbenchTab = .agentMode + @State private var selectedMode: AhaKeyModeSlot + @State private var selectedKeyRole: AhaKeyKeyRole = .voice + @State private var studioDraft: AhaKeyStudioDraft + @State private var selectedAgent: AgentType = .claude + @State private var autoApprove: Bool = false + @State private var apiKeyRevealed: Bool = false + + init(bleManager: AhaKeyBLEManager, embeddedInSettings: Bool = false) { + self.bleManager = bleManager + self.embeddedInSettings = embeddedInSettings + let draft = AhaKeyStudioStore.load() ?? .default + _studioDraft = State(initialValue: draft) + let mode = AhaKeyModeSlot(rawValue: bleManager.workMode) ?? .mode0 + _selectedMode = State(initialValue: mode) + } + + // MARK: - 颜色常量 + + private let bgPrimary = Color(red: 0.05, green: 0.07, blue: 0.09) + private let bgSecondary = Color(red: 0.09, green: 0.11, blue: 0.13) + private let bgTertiary = Color(red: 0.11, green: 0.13, blue: 0.17) + private let bgCard = Color(red: 0.14, green: 0.16, blue: 0.20) + private let accentCyan = Color(red: 0.35, green: 0.65, blue: 1.0) + private let accentGreen = Color(red: 0.25, green: 0.73, blue: 0.31) + private let accentRed = Color(red: 0.97, green: 0.32, blue: 0.29) + private let accentOrange = Color(red: 0.82, green: 0.60, blue: 0.13) + private let accentPurple = Color(red: 0.74, green: 0.55, blue: 1.0) + private let accentTeal = Color(red: 0.22, green: 0.82, blue: 0.75) + private let textPrimary = Color.white + private let textSecondary = Color.white.opacity(0.55) + private let textTertiary = Color.white.opacity(0.35) + + private var currentModeDraft: AhaKeyModeDraft { + studioDraft.draft(for: selectedMode) + } + + private var syncedStudioDraft: Binding { + Binding( + get: { studioDraft }, + set: { newValue in + studioDraft = newValue + AhaKeyStudioStore.save(newValue) + VoiceRelayService.shared.updateRoutes(from: newValue) + } + ) + } + + var body: some View { + Group { + if embeddedInSettings { + embeddedSettingsLayout + } else { + standaloneLayout + } + } + .background(bgPrimary) + .preferredColorScheme(.dark) + .frame( + minWidth: embeddedInSettings ? 0 : 1280, + minHeight: embeddedInSettings ? 0 : 820 + ) + .onChange(of: bleManager.workMode) { newValue in + if let slot = AhaKeyModeSlot(rawValue: newValue), slot != selectedMode { + selectedMode = slot + } + } + .onAppear { + SwitchStateNotifier.shared.bind(to: bleManager) + } + .onReceive(NotificationCenter.default.publisher(for: .ahaKeyStudioNavigate)) { notification in + handleStudioNavigation(notification) + } + } + + private var standaloneLayout: some View { + HStack(spacing: 0) { + sidebar + Divider().overlay(Color.white.opacity(0.06)) + VStack(spacing: 0) { + topStatusBar + Divider().overlay(Color.white.opacity(0.06)) + contentArea + Divider().overlay(Color.white.opacity(0.06)) + bottomStatusBar + } + } + } + + private var embeddedSettingsLayout: some View { + VStack(spacing: 0) { + embeddedSubTabBar + Divider().overlay(Color.white.opacity(0.06)) + contentArea + } + } + + private var embeddedSubTabBar: some View { + HStack(spacing: 8) { + ForEach(WorkbenchTab.allCases) { tab in + Button { + selectedTab = tab + } label: { + HStack(spacing: 6) { + Image(systemName: tab.systemImage) + .font(.system(size: 12)) + Text(tab.title) + .font(.system(size: 12, weight: tab == selectedTab ? .semibold : .regular)) + } + .foregroundStyle(tab == selectedTab ? accentCyan : textSecondary) + .padding(.horizontal, 12) + .padding(.vertical, 7) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(tab == selectedTab ? accentCyan.opacity(0.12) : Color.clear) + ) + } + .buttonStyle(.plain) + } + Spacer(minLength: 0) + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background(bgSecondary) + } + + private func handleStudioNavigation(_ notification: Notification) { + guard let raw = notification.userInfo?[StudioNavigationUserInfoKey.section] as? String, + let section = StudioNavigationSection(rawValue: raw) else { return } + + switch section { + case .voiceAgent: + selectedTab = .agentMode + case .device: + if embeddedInSettings { + StudioNavigationRouter.shared.openDeviceManagement(showDetail: true) + } else { + selectedTab = .deviceSystem + } + case .approve: + selectedTab = .keyConfig + selectedKeyRole = .approve + case .oled: + selectedTab = .workbench + case .voice: + selectedTab = .keyConfig + selectedKeyRole = .voice + } + } + + // MARK: - 业务内容区域 + + @ViewBuilder + private var contentArea: some View { + Group { + switch selectedTab { + case .workbench: + HStack(spacing: 0) { + centerContent + Divider().overlay(Color.white.opacity(0.06)) + rightPanelWorkbench + } + case .keyConfig: + AhaKeyKeyConfigPageView( + bleManager: bleManager, + studioDraft: syncedStudioDraft, + selectedMode: selectedMode, + selectedKeyRole: selectedKeyRole + ) + case .agentMode: + HStack(spacing: 0) { + agentCenterContent + Divider().overlay(Color.white.opacity(0.06)) + agentRightPanel + } + case .deviceSystem: + if embeddedInSettings { + deviceSystemSettingsRedirect + } else { + ScrollView { + DeviceInfoView(bleManager: bleManager) + .padding(16) + } + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .transaction { transaction in + transaction.animation = nil + transaction.disablesAnimations = true + } + } + + private var deviceSystemSettingsRedirect: some View { + VStack(alignment: .leading, spacing: 16) { + Text("设备与系统已迁至「我的设备」") + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(textPrimary) + Text("改名、蓝牙占用、设备信息与 Agent 系统控制请到左侧底部「我的设备」查看。") + .font(.system(size: 13)) + .foregroundStyle(textSecondary) + .fixedSize(horizontal: false, vertical: true) + Button("打开我的设备") { + StudioNavigationRouter.shared.openDeviceManagement(showDetail: true) + } + .buttonStyle(.borderedProminent) + Spacer(minLength: 0) + } + .padding(28) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + private func openDeviceSystem() { + if embeddedInSettings { + StudioNavigationRouter.shared.openDeviceManagement(showDetail: true) + } else { + selectedTab = .deviceSystem + } + } + + // MARK: - 侧边栏 + + private var sidebar: some View { + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 10) { + ZStack { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill( + LinearGradient( + colors: [Color.blue, Color.purple], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .frame(width: 32, height: 32) + Text("A") + .font(.system(size: 18, weight: .bold, design: .rounded)) + .foregroundStyle(.white) + } + Text("AhaKey Studio") + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(textPrimary) + } + .padding(.horizontal, 16) + .padding(.top, 20) + .padding(.bottom, 24) + + VStack(spacing: 4) { + ForEach(WorkbenchTab.allCases) { tab in + Button { + selectedTab = tab + } label: { + HStack(spacing: 10) { + Image(systemName: tab.systemImage) + .font(.system(size: 14)) + .frame(width: 20) + Text(tab.title) + .font(.system(size: 13, weight: tab == selectedTab ? .semibold : .regular)) + Spacer() + } + .foregroundStyle(tab == selectedTab ? accentCyan : textSecondary) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(tab == selectedTab ? accentCyan.opacity(0.12) : Color.clear) + ) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 12) + + Spacer() + + deviceCard + .padding(.horizontal, 12) + .padding(.bottom, 12) + + userCard + .padding(.horizontal, 12) + .padding(.bottom, 16) + } + .frame(width: 220) + .background(bgSecondary) + } + + private var deviceCard: some View { + HStack(spacing: 10) { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(bgTertiary) + .frame(width: 44, height: 44) + .overlay( + Image(systemName: "keyboard") + .font(.system(size: 18)) + .foregroundStyle(textSecondary) + ) + + VStack(alignment: .leading, spacing: 3) { + Text(bleManager.deviceName ?? "AhaKey Mini") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(textPrimary) + HStack(spacing: 6) { + Circle() + .fill(bleManager.isConnected ? accentGreen : accentOrange) + .frame(width: 7, height: 7) + Text(bleManager.isConnected ? "已连接" : "未连接") + .font(.system(size: 11)) + .foregroundStyle(textSecondary) + Image(systemName: "battery.75percent") + .font(.system(size: 11)) + .foregroundStyle(textSecondary) + Text("\(bleManager.batteryLevel)%") + .font(.system(size: 11)) + .foregroundStyle(textSecondary) + } + } + + Spacer(minLength: 0) + + Image(systemName: "chevron.right") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(textTertiary) + } + .padding(10) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(bgTertiary) + ) + } + + private var userCard: some View { + HStack(spacing: 10) { + ZStack { + Circle() + .fill( + LinearGradient( + colors: [accentPurple, accentCyan], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .frame(width: 32, height: 32) + Text("A") + .font(.system(size: 14, weight: .bold)) + .foregroundStyle(.white) + } + Text("AhaKey 用户") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(textPrimary) + Spacer(minLength: 0) + Image(systemName: "chevron.down") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(textTertiary) + } + .padding(8) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(bgTertiary) + ) + } + + // MARK: - 顶部状态栏 + + private var topStatusBar: some View { + HStack(spacing: 16) { + topPill( + icon: "circle.fill", + iconColor: bleManager.isConnected ? accentGreen : accentOrange, + label: bleManager.isConnected ? "已连接" : "未连接", + value: bleManager.deviceName ?? "等待设备" + ) + topPill( + icon: "battery.75percent", + iconColor: accentGreen, + label: "电量", + value: bleManager.isConnected ? "\(bleManager.batteryLevel)%" : "—" + ) + topPill( + icon: "square.stack.3d.up", + iconColor: accentCyan, + label: "当前模式", + value: "Agent Mode / \(selectedMode.title)" + ) + topPill( + icon: "brain.head.profile", + iconColor: accentTeal, + label: "Agent", + value: "已接入" + ) + + Spacer(minLength: 0) + + Button { + let next = (selectedMode.rawValue + 1) % AhaKeyModeSlot.allCases.count + if let slot = AhaKeyModeSlot(rawValue: next) { + selectedMode = slot + } + } label: { + HStack(spacing: 6) { + Image(systemName: "arrow.left.arrow.right") + .font(.system(size: 12)) + Text("切换模式") + .font(.system(size: 12, weight: .medium)) + } + .foregroundStyle(textSecondary) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .stroke(Color.white.opacity(0.15), lineWidth: 1) + ) + } + .buttonStyle(.plain) + + Button { + openDeviceSystem() + } label: { + HStack(spacing: 6) { + Image(systemName: "gearshape") + .font(.system(size: 12)) + Text("设置") + .font(.system(size: 12, weight: .medium)) + } + .foregroundStyle(textSecondary) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .stroke(Color.white.opacity(0.15), lineWidth: 1) + ) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 24) + .padding(.vertical, 14) + .background(bgSecondary) + } + + private func topPill(icon: String, iconColor: Color, label: String, value: String) -> some View { + HStack(spacing: 8) { + Image(systemName: icon) + .font(.system(size: 10)) + .foregroundStyle(iconColor) + VStack(alignment: .leading, spacing: 1) { + Text(label) + .font(.system(size: 10)) + .foregroundStyle(textTertiary) + Text(value) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(textPrimary) + } + } + } + + // MARK: - 工作台中间内容(原有) + + private var centerContent: some View { + ScrollView { + VStack(alignment: .leading, spacing: 24) { + modeHeader + workbenchDeviceVisualization + keyLegendBar + } + .padding(28) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(bgPrimary) + } + + private var modeHeader: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .center, spacing: 12) { + Text(selectedMode.name + " 确认模式") + .font(.system(size: 24, weight: .bold)) + .foregroundStyle(textPrimary) + + Text(selectedMode.title) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(textSecondary) + .padding(.horizontal, 10) + .padding(.vertical, 4) + .background( + Capsule() + .stroke(Color.white.opacity(0.2), lineWidth: 1) + ) + } + + Text("点按设备上的按键或指示灯,即可查看并调整当前配置。") + .font(.system(size: 13)) + .foregroundStyle(textSecondary) + } + } + + private var workbenchDeviceVisualization: some View { + GeometryReader { proxy in + let width = min(proxy.size.width, 680) + let height = width * 0.52 + ZStack { + RoundedRectangle(cornerRadius: 28, style: .continuous) + .fill( + LinearGradient( + colors: [ + Color(red: 0.18, green: 0.18, blue: 0.24), + Color(red: 0.12, green: 0.12, blue: 0.17), + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .overlay( + RoundedRectangle(cornerRadius: 28, style: .continuous) + .stroke(Color.white.opacity(0.08), lineWidth: 1) + ) + .shadow(color: .black.opacity(0.4), radius: 30, y: 16) + + VStack(spacing: height * 0.06) { + HStack(spacing: width * 0.03) { + ledBarView(width: width * 0.52, height: height * 0.18) + oledView(width: width * 0.22, height: height * 0.22) + } + + HStack(spacing: width * 0.02) { + ForEach(AhaKeyKeyRole.allCases) { role in + keyCapView(role: role, size: width * 0.13) + } + + Spacer().frame(width: width * 0.02) + + toggleSwitchView(width: width * 0.06, height: height * 0.32) + } + } + .padding(.horizontal, width * 0.06) + .padding(.vertical, height * 0.08) + } + .frame(width: width, height: height) + .frame(maxWidth: .infinity) + } + .frame(height: 360) + } + + private func ledBarView(width: CGFloat, height: CGFloat) -> some View { + VStack(alignment: .leading, spacing: 4) { + Text("灯条") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(textSecondary) + + ZStack { + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(Color.black.opacity(0.5)) + .frame(height: height * 0.6) + + HStack(spacing: width * 0.06) { + ForEach(0..<4, id: \.self) { i in + Capsule() + .fill(ledSegmentColor(index: i)) + .frame(width: width * 0.1, height: height * 0.3) + } + } + } + .frame(width: width, height: height * 0.6) + } + } + + private func ledSegmentColor(index: Int) -> Color { + let colors: [Color] = [ + accentCyan, + accentCyan.opacity(0.7), + Color.blue.opacity(0.8), + accentCyan.opacity(0.6), + ] + return colors[index % colors.count] + } + + private func oledView(width: CGFloat, height: CGFloat) -> some View { + ZStack { + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(Color.black.opacity(0.85)) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .stroke(Color.white.opacity(0.08), lineWidth: 1) + ) + + VStack(spacing: 4) { + HStack(spacing: 4) { + Circle() + .fill(Color.orange) + .frame(width: 6, height: 6) + Text(selectedMode.title) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.white.opacity(0.85)) + } + Text("默认动画") + .font(.system(size: 10)) + .foregroundStyle(.white.opacity(0.5)) + } + } + .frame(width: width, height: height) + } + + private func keyCapView(role: AhaKeyKeyRole, size: CGFloat) -> some View { + let isSelected = selectedKeyRole == role + let keyDraft = currentModeDraft.key(for: role) + + return Button { + selectedKeyRole = role + } label: { + VStack(spacing: size * 0.06) { + ZStack { + RoundedRectangle(cornerRadius: size * 0.18, style: .continuous) + .fill( + LinearGradient( + colors: [ + Color(red: 0.22, green: 0.22, blue: 0.28), + Color(red: 0.16, green: 0.16, blue: 0.22), + ], + startPoint: .top, + endPoint: .bottom + ) + ) + .overlay( + RoundedRectangle(cornerRadius: size * 0.18, style: .continuous) + .stroke( + isSelected ? accentCyan : Color.white.opacity(0.08), + lineWidth: isSelected ? 2.5 : 1 + ) + ) + .shadow( + color: isSelected ? accentCyan.opacity(0.3) : .clear, + radius: isSelected ? 12 : 0 + ) + + CanvasKeyRoleIcon(role: role, color: .white.opacity(0.85), size: size * 0.22) + .fixedSize() + } + .frame(width: size, height: size) + + Text(keyDraft.description.isEmpty ? role.defaultDescription : keyDraft.description) + .font(.system(size: max(size * 0.1, 10), weight: .medium)) + .foregroundStyle(keyLabelColor(for: role)) + .lineLimit(1) + } + } + .buttonStyle(.plain) + } + + private func keyLabelColor(for role: AhaKeyKeyRole) -> Color { + switch role { + case .voice: accentCyan + case .approve: accentGreen + case .reject: accentRed + case .submit: accentPurple + } + } + + private func toggleSwitchView(width: CGFloat, height: CGFloat) -> some View { + VStack(spacing: 6) { + ZStack(alignment: bleManager.switchState == 0 ? .top : .bottom) { + RoundedRectangle(cornerRadius: width * 0.3, style: .continuous) + .fill(Color.white.opacity(0.08)) + .overlay( + RoundedRectangle(cornerRadius: width * 0.3, style: .continuous) + .stroke(Color.white.opacity(0.1), lineWidth: 1) + ) + + Circle() + .fill(Color.white.opacity(0.9)) + .frame(width: width * 0.6, height: width * 0.6) + .padding(4) + } + .frame(width: width, height: height) + + Text("自动切换") + .font(.system(size: 9, weight: .medium)) + .foregroundStyle(textTertiary) + } + } + + private var keyLegendBar: some View { + HStack(spacing: 24) { + ForEach(AhaKeyKeyRole.allCases) { role in + let keyDraft = currentModeDraft.key(for: role) + HStack(spacing: 8) { + Text(keyDraft.description.isEmpty ? role.defaultDescription : keyDraft.description) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(keyLabelColor(for: role)) + Text(legendDescription(for: role)) + .font(.system(size: 12)) + .foregroundStyle(textSecondary) + } + } + Spacer() + } + .padding(.horizontal, 4) + } + + private func legendDescription(for role: AhaKeyKeyRole) -> String { + switch role { + case .voice: "语音输入" + case .approve: "确认" + case .reject: "拒绝" + case .submit: "发送 / 执行" + } + } + + // MARK: - Agent 模式中间内容 + + private var agentCenterContent: some View { + ScrollView { + VStack(alignment: .leading, spacing: 24) { + agentModeHeader + agentDeviceVisualization + agentStatusCards + } + .padding(28) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(bgPrimary) + } + + private var agentModeHeader: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .center, spacing: 12) { + Text("Claude Agent 执行模式") + .font(.system(size: 24, weight: .bold)) + .foregroundStyle(textPrimary) + + Text(selectedMode.title) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(accentGreen) + .padding(.horizontal, 10) + .padding(.vertical, 4) + .background( + Capsule() + .fill(accentGreen.opacity(0.15)) + ) + } + + Text("通过按键、拨杆与状态灯控制 Agent 的执行与授权。") + .font(.system(size: 13)) + .foregroundStyle(textSecondary) + } + } + + // MARK: - Agent 设备可视化 + + private var agentDeviceVisualization: some View { + GeometryReader { proxy in + let width = min(proxy.size.width, 680) + let height = width * 0.52 + ZStack { + RoundedRectangle(cornerRadius: 28, style: .continuous) + .fill( + LinearGradient( + colors: [ + Color(red: 0.18, green: 0.18, blue: 0.24), + Color(red: 0.12, green: 0.12, blue: 0.17), + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .overlay( + RoundedRectangle(cornerRadius: 28, style: .continuous) + .stroke(Color.white.opacity(0.08), lineWidth: 1) + ) + .shadow(color: .black.opacity(0.4), radius: 30, y: 16) + + VStack(spacing: height * 0.06) { + HStack(spacing: width * 0.03) { + agentLedBarView(width: width * 0.52, height: height * 0.18) + agentOledView(width: width * 0.22, height: height * 0.22) + } + + HStack(spacing: width * 0.02) { + agentKeyCapView( + icon: "mic.fill", label: "唤醒 / 语音输入", + color: accentCyan, size: width * 0.13 + ) + agentKeyCapView( + icon: "checkmark", label: "批准 / 继续", + color: accentGreen, size: width * 0.13 + ) + agentKeyCapView( + icon: "xmark", label: "拒绝 / 停止", + color: accentRed, size: width * 0.13 + ) + agentKeyCapView( + icon: "return", label: "发送 / 执行", + color: accentPurple, size: width * 0.13 + ) + + Spacer().frame(width: width * 0.02) + + agentToggleSwitchView(width: width * 0.06, height: height * 0.32) + } + } + .padding(.horizontal, width * 0.06) + .padding(.vertical, height * 0.08) + } + .frame(width: width, height: height) + .frame(maxWidth: .infinity) + } + .frame(height: 360) + } + + private func agentLedBarView(width: CGFloat, height: CGFloat) -> some View { + VStack(alignment: .leading, spacing: 4) { + Text("状态灯") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(textSecondary) + + ZStack { + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(Color.black.opacity(0.5)) + .frame(height: height * 0.6) + + HStack(spacing: width * 0.04) { + ForEach(0..<5, id: \.self) { i in + let isCenter = i == 2 + Capsule() + .fill(isCenter ? accentTeal : accentCyan.opacity(0.7)) + .frame( + width: isCenter ? width * 0.14 : width * 0.08, + height: height * 0.3 + ) + } + } + } + .frame(width: width, height: height * 0.6) + } + } + + private func agentOledView(width: CGFloat, height: CGFloat) -> some View { + ZStack { + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(Color.black.opacity(0.85)) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .stroke(Color.white.opacity(0.08), lineWidth: 1) + ) + + VStack(spacing: 4) { + HStack(spacing: 4) { + Circle() + .fill(accentGreen) + .frame(width: 6, height: 6) + Text("Claude Agent") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.white.opacity(0.85)) + } + Text("在线") + .font(.system(size: 10)) + .foregroundStyle(accentGreen) + } + } + .frame(width: width, height: height) + } + + private func agentKeyCapView(icon: String, label: String, color: Color, size: CGFloat) -> some View { + VStack(spacing: size * 0.06) { + ZStack { + RoundedRectangle(cornerRadius: size * 0.18, style: .continuous) + .fill( + LinearGradient( + colors: [ + Color(red: 0.22, green: 0.22, blue: 0.28), + Color(red: 0.16, green: 0.16, blue: 0.22), + ], + startPoint: .top, + endPoint: .bottom + ) + ) + .overlay( + RoundedRectangle(cornerRadius: size * 0.18, style: .continuous) + .stroke(Color.white.opacity(0.08), lineWidth: 1) + ) + + Image(systemName: icon) + .font(.system(size: size * 0.22, weight: .medium)) + .foregroundStyle(color) + } + .frame(width: size, height: size) + + Text(label) + .font(.system(size: max(size * 0.09, 9), weight: .medium)) + .foregroundStyle(color) + .lineLimit(1) + .minimumScaleFactor(0.7) + } + } + + private func agentToggleSwitchView(width: CGFloat, height: CGFloat) -> some View { + VStack(spacing: 8) { + ZStack(alignment: autoApprove ? .top : .bottom) { + RoundedRectangle(cornerRadius: width * 0.3, style: .continuous) + .fill(Color.white.opacity(0.08)) + .overlay( + RoundedRectangle(cornerRadius: width * 0.3, style: .continuous) + .stroke(Color.white.opacity(0.1), lineWidth: 1) + ) + + Circle() + .fill(Color.white.opacity(0.9)) + .frame(width: width * 0.6, height: width * 0.6) + .padding(4) + } + .frame(width: width, height: height) + .onTapGesture { autoApprove.toggle() } + + VStack(alignment: .leading, spacing: 3) { + HStack(spacing: 4) { + Circle() + .fill(autoApprove ? accentGreen : textTertiary) + .frame(width: 5, height: 5) + Text("自动批准") + .font(.system(size: 9, weight: .medium)) + .foregroundStyle(autoApprove ? textPrimary : textTertiary) + } + HStack(spacing: 4) { + Circle() + .fill(!autoApprove ? accentCyan : textTertiary) + .frame(width: 5, height: 5) + Text("手动确认") + .font(.system(size: 9, weight: .medium)) + .foregroundStyle(!autoApprove ? textPrimary : textTertiary) + } + } + } + } + + // MARK: - Agent 状态卡片行 + + private var agentStatusCards: some View { + HStack(spacing: 16) { + agentStatusCard( + icon: "brain.head.profile", iconColor: accentCyan, + label: "当前 Agent", value: "\(selectedAgent.rawValue) Agent" + ) + agentStatusCard( + icon: "shield.checkered", iconColor: accentGreen, + label: "授权策略", value: autoApprove ? "自动批准" : "手动确认" + ) + agentStatusCard( + icon: "bubble.left.fill", iconColor: accentTeal, + label: "会话状态", value: "待命", valueColor: accentGreen + ) + agentStatusCard( + icon: "doc.text.fill", iconColor: textSecondary, + label: "任务进度", value: "0 个任务" + ) + } + } + + private func agentStatusCard( + icon: String, iconColor: Color, + label: String, value: String, + valueColor: Color? = nil + ) -> some View { + HStack(spacing: 10) { + ZStack { + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(iconColor.opacity(0.15)) + .frame(width: 36, height: 36) + Image(systemName: icon) + .font(.system(size: 15)) + .foregroundStyle(iconColor) + } + VStack(alignment: .leading, spacing: 2) { + Text(label) + .font(.system(size: 11)) + .foregroundStyle(textTertiary) + Text(value) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(valueColor ?? textPrimary) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(14) + .background( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(bgCard) + .overlay( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .stroke(Color.white.opacity(0.06), lineWidth: 1) + ) + ) + } + + // MARK: - Agent 右侧面板 + + private var agentRightPanel: some View { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + // Agent 接入 + agentConnectionSection + + // Agent 状态 + agentStatusSection + + // Agent 切换 + agentSwitchSection + + // 状态灯图例 + agentLedLegendSection + + // 操作按钮 + agentActionButtons + } + .padding(24) + } + .frame(width: 320) + .frame(maxHeight: .infinity) + .background(bgSecondary) + } + + private var agentConnectionSection: some View { + VStack(alignment: .leading, spacing: 14) { + Text("Agent 接入") + .font(.system(size: 16, weight: .bold)) + .foregroundStyle(textPrimary) + + // API Key + HStack(spacing: 10) { + Text("API Key") + .font(.system(size: 13)) + .foregroundStyle(textSecondary) + .frame(width: 56, alignment: .leading) + + HStack(spacing: 6) { + Text(apiKeyRevealed ? "sk-proj-abc123...xyz" : "sk-••••••••••••••••••••••") + .font(.system(size: 13, design: .monospaced)) + .foregroundStyle(textPrimary) + .lineLimit(1) + + Button { + apiKeyRevealed.toggle() + } label: { + Image(systemName: apiKeyRevealed ? "eye.slash" : "eye") + .font(.system(size: 12)) + .foregroundStyle(textTertiary) + } + .buttonStyle(.plain) + } + + Spacer(minLength: 0) + + Button("修改") { openDeviceSystem() } + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(textSecondary) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .stroke(Color.white.opacity(0.15), lineWidth: 1) + ) + .buttonStyle(.plain) + } + .padding(12) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(bgTertiary) + ) + + // 状态 + HStack(spacing: 10) { + Text("状态") + .font(.system(size: 13)) + .foregroundStyle(textSecondary) + .frame(width: 56, alignment: .leading) + + HStack(spacing: 6) { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 14)) + .foregroundStyle(accentGreen) + Text("已验证") + .font(.system(size: 13)) + .foregroundStyle(accentGreen) + } + + Spacer(minLength: 0) + + Button("重新验证") { openDeviceSystem() } + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(textSecondary) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .stroke(Color.white.opacity(0.15), lineWidth: 1) + ) + .buttonStyle(.plain) + } + .padding(12) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(bgTertiary) + ) + } + } + + private var agentStatusSection: some View { + VStack(alignment: .leading, spacing: 14) { + Text("Agent 状态") + .font(.system(size: 16, weight: .bold)) + .foregroundStyle(textPrimary) + + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 6) { + Circle() + .fill(accentGreen) + .frame(width: 8, height: 8) + Text("在线") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(accentGreen) + } + + agentInfoRow(label: "当前", value: "\(selectedAgent.rawValue) Agent") + agentInfoRow(label: "最近心跳", value: "2s 前") + agentInfoRow(label: "当前状态", value: "待命", valueColor: accentGreen) + } + .padding(14) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(bgTertiary) + ) + } + } + + private func agentInfoRow(label: String, value: String, valueColor: Color? = nil) -> some View { + HStack { + Text(label) + .font(.system(size: 13)) + .foregroundStyle(textSecondary) + Spacer() + Text(value) + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(valueColor ?? textPrimary) + } + } + + private var agentSwitchSection: some View { + VStack(alignment: .leading, spacing: 14) { + Text("Agent 切换") + .font(.system(size: 16, weight: .bold)) + .foregroundStyle(textPrimary) + + HStack(spacing: 8) { + ForEach(AgentType.allCases) { agent in + Button { + selectedAgent = agent + } label: { + Text(agent.rawValue) + .font(.system(size: 13, weight: .medium)) + .foregroundStyle( + selectedAgent == agent ? .white : textSecondary + ) + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill( + selectedAgent == agent + ? accentTeal + : Color.clear + ) + ) + .overlay( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .stroke( + selectedAgent == agent + ? Color.clear + : Color.white.opacity(0.12), + lineWidth: 1 + ) + ) + } + .buttonStyle(.plain) + } + } + } + } + + private var agentLedLegendSection: some View { + VStack(alignment: .leading, spacing: 14) { + Text("状态灯") + .font(.system(size: 16, weight: .bold)) + .foregroundStyle(textPrimary) + + VStack(spacing: 10) { + ledLegendRow(color: accentCyan, label: "蓝色常亮", meaning: "待命") + ledLegendRow(color: accentTeal, label: "青色流水", meaning: "执行中") + ledLegendRow(color: accentOrange, label: "橙色呼吸", meaning: "等待授权") + ledLegendRow(color: accentGreen, label: "绿色闪烁", meaning: "任务完成") + ledLegendRow(color: accentRed, label: "红色闪烁", meaning: "错误 / 断开") + } + .padding(14) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(bgTertiary) + ) + } + } + + private func ledLegendRow(color: Color, label: String, meaning: String) -> some View { + HStack(spacing: 10) { + Capsule() + .fill(color) + .frame(width: 40, height: 8) + Text(label) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(textPrimary) + Spacer() + Text(meaning) + .font(.system(size: 12)) + .foregroundStyle(textSecondary) + } + } + + private var agentActionButtons: some View { + HStack(spacing: 12) { + Button { + openDeviceSystem() + } label: { + HStack(spacing: 6) { + Image(systemName: "doc.text") + .font(.system(size: 13)) + Text("查看日志") + .font(.system(size: 13, weight: .medium)) + } + .foregroundStyle(textSecondary) + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(bgTertiary) + ) + } + .buttonStyle(.plain) + + Button { + StudioNavigationRouter.shared.selectSettingsTab(.hardware) + } label: { + HStack(spacing: 6) { + Image(systemName: "arrow.down.to.line") + .font(.system(size: 13)) + Text("同步到键盘") + .font(.system(size: 13, weight: .medium)) + } + .foregroundStyle(.white) + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(accentRed) + ) + } + .buttonStyle(.plain) + } + } + + // MARK: - 工作台右侧面板(原有) + + private var rightPanelWorkbench: some View { + let keyDraft = currentModeDraft.key(for: selectedKeyRole) + + return ScrollView { + VStack(alignment: .leading, spacing: 20) { + Text("\(keyDraft.description.isEmpty ? selectedKeyRole.defaultDescription : keyDraft.description) 键配置") + .font(.system(size: 18, weight: .bold)) + .foregroundStyle(textPrimary) + + configRow( + icon: "mic.fill", + iconColor: accentCyan, + sectionTitle: "当前功能", + title: functionTitle(for: selectedKeyRole), + hasChevron: true + ) + + configRow( + icon: "waveform", + iconColor: accentCyan, + sectionTitle: "触发方式", + title: triggerTitle(for: selectedKeyRole), + hasChevron: true + ) + + VStack(alignment: .leading, spacing: 4) { + Text("快捷动作") + .font(.system(size: 11)) + .foregroundStyle(textTertiary) + .padding(.leading, 4) + + configActionRow(icon: "pencil", title: "修改映射", hasChevron: true) + configActionRow(icon: "play.fill", title: "测试按键", hasChevron: true) + } + } + .padding(24) + } + .frame(width: 320) + .frame(maxHeight: .infinity) + .background(bgSecondary) + } + + private func functionTitle(for role: AhaKeyKeyRole) -> String { + switch role { + case .voice: "语音输入" + case .approve: "确认操作" + case .reject: "拒绝操作" + case .submit: "发送 / 执行" + } + } + + private func triggerTitle(for role: AhaKeyKeyRole) -> String { + switch role { + case .voice: "按住说话 / 松开发送" + case .approve: "单击触发" + case .reject: "单击触发" + case .submit: "单击触发" + } + } + + private func configRow( + icon: String, iconColor: Color, + sectionTitle: String, title: String, hasChevron: Bool + ) -> some View { + VStack(alignment: .leading, spacing: 4) { + Text(sectionTitle) + .font(.system(size: 11)) + .foregroundStyle(textTertiary) + .padding(.leading, 4) + + HStack(spacing: 12) { + ZStack { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(iconColor.opacity(0.15)) + .frame(width: 36, height: 36) + Image(systemName: icon) + .font(.system(size: 15)) + .foregroundStyle(iconColor) + } + + Text(title) + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(textPrimary) + + Spacer() + + if hasChevron { + Image(systemName: "chevron.right") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(textTertiary) + } + } + .padding(12) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(bgTertiary) + ) + } + } + + private func configActionRow(icon: String, title: String, hasChevron: Bool) -> some View { + HStack(spacing: 12) { + ZStack { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(Color.white.opacity(0.06)) + .frame(width: 36, height: 36) + Image(systemName: icon) + .font(.system(size: 14)) + .foregroundStyle(textSecondary) + } + + Text(title) + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(textPrimary) + + Spacer() + + if hasChevron { + Image(systemName: "chevron.right") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(textTertiary) + } + } + .padding(12) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(bgTertiary) + ) + } + + // MARK: - 底部状态栏 + + private var bottomStatusBar: some View { + HStack(spacing: 20) { + statusItem(icon: "waveform", iconColor: accentCyan, label: "语音服务", value: "运行中") + statusDivider + statusItem(icon: "antenna.radiowaves.left.and.right", iconColor: accentCyan, label: "蓝牙延迟", value: bleManager.isConnected ? "28ms" : "—") + statusDivider + statusItem(icon: "doc.text", iconColor: textSecondary, label: "固件", value: bleManager.firmwareRevision) + statusDivider + statusItem(icon: "brain.head.profile", iconColor: accentGreen, label: "Agent", value: "在线") + Spacer() + } + .padding(.horizontal, 24) + .padding(.vertical, 10) + .background(bgSecondary) + } + + private var statusDivider: some View { + Rectangle() + .fill(Color.white.opacity(0.08)) + .frame(width: 1, height: 14) + } + + private func statusItem(icon: String, iconColor: Color, label: String, value: String) -> some View { + HStack(spacing: 6) { + Image(systemName: icon) + .font(.system(size: 11)) + .foregroundStyle(iconColor) + Text(label) + .font(.system(size: 11)) + .foregroundStyle(textSecondary) + if !value.isEmpty { + Text(value) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(textPrimary) + } + } + } + + // MARK: - 占位页面 + + private func placeholderPage(title: String, subtitle: String) -> some View { + VStack(spacing: 12) { + Spacer() + Image(systemName: "hammer.fill") + .font(.system(size: 36)) + .foregroundStyle(textTertiary) + Text(title) + .font(.system(size: 20, weight: .bold)) + .foregroundStyle(textPrimary) + Text(subtitle) + .font(.system(size: 14)) + .foregroundStyle(textSecondary) + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(bgPrimary) + } +} diff --git a/ahakeyconfig-mac/Sources/Views/CanvasKeyIcons.swift b/ahakeyconfig-mac/Sources/Views/CanvasKeyIcons.swift new file mode 100644 index 00000000..7f15d684 --- /dev/null +++ b/ahakeyconfig-mac/Sources/Views/CanvasKeyIcons.swift @@ -0,0 +1,98 @@ +import SwiftUI + +/// 语音 / Record 键帽图标:实心圆点(与 Gen2 硬件丝印一致)。 +struct RecordKeyIcon: View { + var color: Color = Color.black.opacity(0.88) + var diameter: CGFloat + + var body: some View { + Circle() + .fill(color) + .frame(width: diameter, height: diameter) + } +} + +/// 画布键帽图标:submit / voice 使用硬件丝印,其余走 SF Symbol。 +struct CanvasKeyRoleIcon: View { + let role: AhaKeyKeyRole + var color: Color = Color.black.opacity(0.88) + var size: CGFloat + + var body: some View { + Group { + switch role { + case .submit: + SubmitKeyIcon(color: color) + .frame(width: size * 1.35, height: size * 1.15) + case .voice: + RecordKeyIcon(color: color, diameter: size * 0.62) + default: + Image(systemName: role.systemImage) + .font(.system(size: size, weight: .regular)) + .foregroundStyle(color) + } + } + .fixedSize() + } +} + +/// 回车键帽图标:左侧箭头 + 圆角外框 + 居中 X(与 Gen2 硬件丝印一致)。 +struct SubmitKeyIcon: View { + var color: Color = Color.black.opacity(0.88) + + var body: some View { + GeometryReader { geo in + let stroke = min(geo.size.width, geo.size.height) * 0.11 + ZStack { + SubmitKeyCapShape() + .stroke(color, style: StrokeStyle(lineWidth: stroke, lineCap: .round, lineJoin: .round)) + SubmitKeyXShape() + .stroke(color, style: StrokeStyle(lineWidth: stroke * 0.9, lineCap: .round)) + } + } + .aspectRatio(1.18, contentMode: .fit) + .fixedSize() + } +} + +private struct SubmitKeyCapShape: Shape { + func path(in rect: CGRect) -> Path { + var path = Path() + let w = rect.width + let h = rect.height + let insetX = w * 0.04 + let insetY = h * 0.06 + let tipX = insetX + let bodyLeft = w * 0.34 + let right = w - insetX + let top = insetY + let bottom = h - insetY + let midY = h * 0.5 + + path.move(to: CGPoint(x: right, y: top)) + path.addLine(to: CGPoint(x: bodyLeft, y: top)) + path.addLine(to: CGPoint(x: tipX, y: midY)) + path.addLine(to: CGPoint(x: bodyLeft, y: bottom)) + path.addLine(to: CGPoint(x: right, y: bottom)) + path.closeSubpath() + return path + } +} + +private struct SubmitKeyXShape: Shape { + func path(in rect: CGRect) -> Path { + var path = Path() + let w = rect.width + let h = rect.height + let left = w * 0.46 + let right = w * 0.78 + let top = h * 0.30 + let bottom = h * 0.70 + + path.move(to: CGPoint(x: left, y: top)) + path.addLine(to: CGPoint(x: right, y: bottom)) + path.move(to: CGPoint(x: right, y: top)) + path.addLine(to: CGPoint(x: left, y: bottom)) + return path + } +} diff --git a/ahakeyconfig-mac/Sources/Views/CompatLabeledContent.swift b/ahakeyconfig-mac/Sources/Views/CompatLabeledContent.swift old mode 100644 new mode 100755 diff --git a/ahakeyconfig-mac/Sources/Views/ContentView.swift b/ahakeyconfig-mac/Sources/Views/ContentView.swift old mode 100644 new mode 100755 index f8c0c3c6..1c162b19 --- a/ahakeyconfig-mac/Sources/Views/ContentView.swift +++ b/ahakeyconfig-mac/Sources/Views/ContentView.swift @@ -1,8 +1,10 @@ import AppKit import SwiftUI +import VibeBar struct ContentView: View { @ObservedObject var bleManager: AhaKeyBLEManager + @ObservedObject var islandState: VibeBarState @StateObject private var voiceRelay = VoiceRelayService.shared @StateObject private var nativeSpeech = NativeSpeechTranscriptionService.shared @AppStorage(UnifiedOnboardingStorage.completedKey) private var unifiedOnboardingCompleted = false @@ -20,6 +22,7 @@ struct ContentView: View { ) { _, _ in unifiedOnboardingCompleted = true voiceRelay.suppressPermissionOnboarding(for: 60) + FeatureCoachTipController.shared.refreshInterested() } .transition(.opacity) .zIndex(20) @@ -39,19 +42,26 @@ struct ContentView: View { if allGranted { unifiedOnboardingCompleted = true voiceRelay.showsPermissionOnboarding = false + FeatureCoachTipController.shared.refreshInterested() } else if shouldShowUnifiedOnboarding { voiceRelay.suppressPermissionOnboarding(for: 60) } } + .onChange(of: unifiedOnboardingCompleted) { completed in + if completed { + // 全屏引导结束(含重放后再次完成):主壳早已挂载,需主动唤醒气泡 tip。 + FeatureCoachTipController.shared.refreshInterested() + } + } } @ViewBuilder private var mainWorkspace: some View { if #available(macOS 14.0, *) { - AhaKeyStudioView(bleManager: bleManager) + AhaKeySettingsRootView(bleManager: bleManager, islandState: islandState) .focusEffectDisabled() } else { - AhaKeyStudioView(bleManager: bleManager) + AhaKeySettingsRootView(bleManager: bleManager, islandState: islandState) } } diff --git a/ahakeyconfig-mac/Sources/Views/DeviceInfoView.swift b/ahakeyconfig-mac/Sources/Views/DeviceInfoView.swift old mode 100644 new mode 100755 index 930314d5..3b25b842 --- a/ahakeyconfig-mac/Sources/Views/DeviceInfoView.swift +++ b/ahakeyconfig-mac/Sources/Views/DeviceInfoView.swift @@ -1,7 +1,15 @@ import SwiftUI +enum DeviceInfoChrome { + case systemForm + case settingsCards +} + +/// 设备信息与系统控制(Agent / 我的设备共用)。 struct DeviceInfoView: View { @ObservedObject var bleManager: AhaKeyBLEManager + var chrome: DeviceInfoChrome = .systemForm + @StateObject private var agentManager = AgentManager.shared @State private var isEditingName = false @State private var editableName = "" @@ -9,8 +17,38 @@ struct DeviceInfoView: View { @State private var agentLogPanel = 0 @State private var logPanelContentTick = 0 @State private var showAgentRequiredForAgentBLE = false + @State private var showDiagnosticsExpanded = false var body: some View { + Group { + switch chrome { + case .systemForm: + formBody + case .settingsCards: + settingsCardsBody + } + } + .alert("需要先安装 Agent", isPresented: $showAgentRequiredForAgentBLE) { + Button("好", role: .cancel) {} + } message: { + Text("将蓝牙交给 `ahakeyconfig-agent` 前,请先在下方完成「安装并启用」,生成 LaunchAgent。") + } + .alert("Agent", isPresented: Binding( + get: { agentManager.agentUserAlert != nil }, + set: { if !$0 { agentManager.agentUserAlert = nil } } + )) { + Button("好", role: .cancel) { + agentManager.agentUserAlert = nil + } + } message: { + Text(agentManager.agentUserAlert ?? "") + } + .sheet(isPresented: $showAgentLog) { + agentLogSheet + } + } + + private var formBody: some View { Form { // MARK: - 设备信息 Section { @@ -86,12 +124,6 @@ struct DeviceInfoView: View { } header: { Text("蓝牙连接") } - .alert("需要先安装 Agent", isPresented: $showAgentRequiredForAgentBLE) { - Button("好", role: .cancel) {} - } message: { - Text("将蓝牙交给 `ahakeyconfig-agent` 前,请先在下方完成「安装并启用」,生成 LaunchAgent。") - } - // MARK: - 拨杆状态 Section { HStack { @@ -225,52 +257,6 @@ struct DeviceInfoView: View { .foregroundStyle(.secondary) } } - .sheet(isPresented: $showAgentLog) { - VStack(alignment: .leading, spacing: 8) { - HStack { - Text("诊断日志") - .font(.headline) - Spacer() - Button("关闭") { showAgentLog = false } - } - Picker("内容", selection: $agentLogPanel) { - Text("ahakeyconfig-agent 主日志").tag(0) - Text("工具批准(permission-request.log)").tag(1) - Text("~/.cursor/hooks.json").tag(2) - Text("~/.cursor/cli-config.json").tag(3) - Text("~/.codex/config.toml").tag(4) - Text("Codex Hook(codex-hook.log)").tag(5) - Text("~/.kimi/config.toml").tag(6) - } - .labelsHidden() - .pickerStyle(.menu) - HStack { - Button("刷新本页") { - logPanelContentTick += 1 - agentManager.refresh() - } - if agentLogPanel == 3 { - Button("合并 CLI + IDE 终端白名单") { - let a = agentManager.mergeUserCursorCliConfigForShellAutoApprove() - let b = agentManager.mergeUserCursorPermissionsJsonForAgentTUI() - agentManager.agentUserAlert = a + "\n\n——\n\n" + b - } - .help("写 cli-config(CLI)与 permissions.json 的 terminalAllowlist(Agent TUI「Not in allowlist」层);分见官方文档。均先备份为 .ahakey.bak。") - } - Spacer() - } - .font(.caption) - ScrollView { - logPanelContent - .id(logPanelContentTick) - .font(.system(.caption2, design: .monospaced)) - .textSelection(.enabled) - .frame(maxWidth: .infinity, alignment: .leading) - } - } - .padding() - .frame(width: 540, height: 380) - } // MARK: - LED 测试 if bleManager.isConnected { @@ -442,18 +428,392 @@ struct DeviceInfoView: View { Text("通信日志") } } - // 「设备信息」在 sheet 中展示时,父视图的 `.alert` 往往不会置顶显示,导致 Hooks 安装/报错像「无反应」。在此重复绑定以确保可见。 - .alert("Agent", isPresented: Binding( - get: { agentManager.agentUserAlert != nil }, - set: { if !$0 { agentManager.agentUserAlert = nil } } - )) { - Button("好", role: .cancel) { - agentManager.agentUserAlert = nil + } + + // MARK: - Settings Cards + + private var settingsCardsBody: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + deviceCard(title: "基本设置") { + basicSettingsCardContent + } + deviceCard(title: "蓝牙连接") { + bluetoothOwnerCardContent + } + deviceCard(title: "设备信息") { + deviceInfoCardContent + } + deviceCard(title: "系统 · Agent") { + agentSystemCardContent + } + DisclosureGroup(isExpanded: $showDiagnosticsExpanded) { + VStack(alignment: .leading, spacing: 14) { + if bleManager.isConnected { + Text("LED 测试") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + ledTestGrid + } + Text("通信日志") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + communicationLogBlock + } + .padding(.top, 8) + } label: { + Text("更多诊断") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(AhakeySettingsTheme.primaryText) + } + .padding(14) + .background(cardBackground) + } + .padding(20) + .frame(maxWidth: 720, alignment: .leading) + .frame(maxWidth: .infinity, alignment: .leading) + } + .background(AhakeySettingsTheme.contentBackground.ignoresSafeArea()) + } + + private func deviceCard(title: String, @ViewBuilder content: () -> Content) -> some View { + VStack(alignment: .leading, spacing: 12) { + Text(title) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(AhakeySettingsTheme.primaryText) + content() + } + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background(cardBackground) + } + + private var cardBackground: some View { + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(AhakeySettingsTheme.cardBackground) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(AhakeySettingsTheme.divider, lineWidth: 1) + ) + } + + @ViewBuilder + private var basicSettingsCardContent: some View { + if isEditingName { + HStack(spacing: 8) { + TextField("最长 15 字节", text: $editableName) + .textFieldStyle(.roundedBorder) + .frame(maxWidth: 220) + .onSubmit { submitNameChange() } + Button("保存") { submitNameChange() } + .buttonStyle(.borderedProminent) + .controlSize(.small) + Button("取消") { isEditingName = false } + .buttonStyle(.bordered) + .controlSize(.small) + } + } else { + HStack { + Label(bleManager.deviceName ?? "AhaKey", systemImage: "keyboard") + .foregroundStyle(AhakeySettingsTheme.primaryText) + Spacer() + if bleManager.isConnected { + Button { + editableName = bleManager.deviceName ?? "" + isEditingName = true + } label: { + Image(systemName: "pencil") + } + .buttonStyle(.borderless) + .help("修改设备名称") + } } - } message: { - Text(agentManager.agentUserAlert ?? "") } + HStack(alignment: .center, spacing: 12) { + Circle() + .fill(bleManager.isConnected ? Color.green : Color.orange) + .frame(width: 8, height: 8) + Text(bleManager.bleConnectionStatus) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + .lineLimit(2) + + Spacer(minLength: 12) + + if !bleManager.isConnected { + Button(bleManager.isScanning ? "扫描中…" : "连接设备") { + bleManager.userInitiatedConnect() + } + .buttonStyle(.borderedProminent) + .controlSize(.regular) + .disabled(bleManager.isScanning || agentManager.bluetoothConnectionOwner == .agentDaemon) + .help(agentManager.bluetoothConnectionOwner == .agentDaemon + ? "当前由 Agent 占用蓝牙。请先在「蓝牙连接」切到 AhaKey Studio。" + : "本 App 主动连接键盘。") + } else { + Button("查询状态") { bleManager.queryDeviceStatus() } + .buttonStyle(.bordered) + .controlSize(.regular) + Button("断开", role: .destructive) { bleManager.disconnect() } + .buttonStyle(.bordered) + .controlSize(.regular) + } + } + } + + @ViewBuilder + private var bluetoothOwnerCardContent: some View { + Text("同一时间只能由本 App 或 Agent 其中之一连接键盘。") + .font(.system(size: 12)) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + HStack(spacing: 10) { + ForEach(BluetoothConnectionOwner.allCases) { owner in + let selected = agentManager.bluetoothConnectionOwner == owner + let disableAgent = owner == .agentDaemon && !agentManager.isInstalled + Button { + if owner == .agentDaemon && !agentManager.isInstalled { + showAgentRequiredForAgentBLE = true + } else { + agentManager.setBluetoothConnectionOwner(owner, bleManager: bleManager) + } + } label: { + VStack(alignment: .leading, spacing: 4) { + Text(owner.title) + .fontWeight(selected ? .semibold : .regular) + .foregroundStyle(AhakeySettingsTheme.primaryText) + Text(owner == .ahaKeyStudio + ? "改键、LCD、同步、本机灯效测试" + : "Hook、灯条状态、拨杆查询") + .font(.caption2) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + .multilineTextAlignment(.leading) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(10) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(selected ? AhakeySettingsTheme.accentBlue.opacity(0.18) : AhakeySettingsTheme.controlFill) + ) + } + .buttonStyle(.plain) + .disabled(disableAgent) + } + } + Text("\(bleManager.isConnected ? "本 App 已连接" : "本 App 未连接") · \(agentBluetoothStatusText())") + .font(.system(size: 12)) + .foregroundStyle(AhakeySettingsTheme.tertiaryText) + } + + @ViewBuilder + private var deviceInfoCardContent: some View { + settingsInfoRow("电量", "\(bleManager.batteryLevel)%") + settingsInfoRow("固件", "v\(bleManager.firmwareMainVersion).\(bleManager.firmwareSubVersion)") + settingsInfoRow("工作模式", workModeName(bleManager.workMode)) + settingsInfoRow("灯光模式", lightModeName(bleManager.lightMode)) + settingsInfoRow("信号强度", "\(bleManager.signalStrength) dBm") + HStack { + Text("拨杆档位") + .foregroundStyle(AhakeySettingsTheme.secondaryText) + Spacer() + HStack(spacing: 6) { + Circle() + .fill(bleManager.switchState == 0 ? Color.green : Color.indigo) + .frame(width: 8, height: 8) + Text(switchStateLabel(bleManager.switchState)) + .foregroundStyle(AhakeySettingsTheme.primaryText) + } + } + settingsInfoRow("设备 UUID", bleManager.bleDeviceUUID) + HStack { + Text("特征就绪") + .foregroundStyle(AhakeySettingsTheme.secondaryText) + Spacer() + HStack(spacing: 8) { + charBadge("DATA", ready: bleManager.dataCharReady) + charBadge("CMD", ready: bleManager.commandCharReady) + charBadge("NOTIFY", ready: bleManager.notifyCharReady) + } + } + } + + @ViewBuilder + private var agentSystemCardContent: some View { + HStack { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 6) { + Circle() + .fill(agentManager.isRunning ? Color.green : Color.gray.opacity(0.4)) + .frame(width: 8, height: 8) + Text("LED 跟随 IDE 状态") + .foregroundStyle(AhakeySettingsTheme.primaryText) + Text(agentBluetoothShortLabel()) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + } + HStack(spacing: 10) { + hookBadge("Claude", installed: agentManager.claudeHooksInstalled) + hookBadge("Cursor", installed: agentManager.cursorHooksInstalled) + hookBadge("Codex", installed: agentManager.codexHooksInstalled) + hookBadge("Kimi", installed: agentManager.kimiHooksInstalled) + } + .font(.caption) + } + Spacer() + if agentManager.isInstalled { + Button(agentManager.isRunning ? "停止" : "启动") { + if agentManager.isRunning { agentManager.stop() } else { agentManager.start() } + } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(agentManager.bluetoothConnectionOwner == .ahaKeyStudio) + Button("卸载", role: .destructive) { + agentManager.uninstall(bleManager: bleManager) + } + .buttonStyle(.bordered) + .controlSize(.small) + } else { + Button("安装并启用") { agentManager.install() } + .buttonStyle(.borderedProminent) + .controlSize(.small) + .disabled(agentManager.isAgentOperationInProgress) + } + } + + if agentManager.isInstalled { + HStack { + Button("查看诊断日志") { showAgentLog = true } + .buttonStyle(.borderless) + .font(.caption) + Spacer() + } + } + + if !agentManager.isAgentBinaryPresentInBundle { + Text("发版包内未包含 ahakeyconfig-agent,无法使用守护进程。") + .font(.caption) + .foregroundStyle(.orange) + } + } + + private var ledTestGrid: some View { + LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 3), spacing: 8) { + ForEach(Array(IDEState.allCases.enumerated()), id: \.offset) { _, state in + Button { + bleManager.updateIDEState(state) + } label: { + Text(state.label) + .font(.caption) + .frame(maxWidth: .infinity) + .padding(.vertical, 6) + } + .buttonStyle(.bordered) + } + } + } + + private var communicationLogBlock: some View { + VStack(alignment: .leading, spacing: 0) { + ScrollViewReader { proxy in + ScrollView { + LazyVStack(alignment: .leading, spacing: 2) { + ForEach(bleManager.commLog) { entry in + HStack(alignment: .top, spacing: 8) { + Text(entry.formattedTime) + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(AhakeySettingsTheme.tertiaryText) + .frame(width: 80, alignment: .leading) + Text(entry.message) + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(entry.isError ? .red : AhakeySettingsTheme.secondaryText) + .textSelection(.enabled) + } + .id(entry.id) + } + } + .padding(8) + } + .frame(height: 150) + .background(AhakeySettingsTheme.controlFill) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .onChange(of: bleManager.commLog.count) { _ in + if let last = bleManager.commLog.last { + proxy.scrollTo(last.id, anchor: .bottom) + } + } + } + HStack { + Spacer() + Button("复制全部") { + let text = bleManager.commLog.map { "[\($0.formattedTime)] \($0.message)" }.joined(separator: "\n") + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(text, forType: .string) + } + .buttonStyle(.borderless) + .font(.caption) + Button("清空") { bleManager.clearLog() } + .buttonStyle(.borderless) + .font(.caption) + } + .padding(.top, 4) + } + } + + private func settingsInfoRow(_ label: String, _ value: String) -> some View { + HStack { + Text(label) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + Spacer() + Text(value) + .foregroundStyle(AhakeySettingsTheme.primaryText) + .font(.system(.body, design: .monospaced)) + .textSelection(.enabled) + } + } + + private var agentLogSheet: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("诊断日志") + .font(.headline) + Spacer() + Button("关闭") { showAgentLog = false } + } + Picker("内容", selection: $agentLogPanel) { + Text("ahakeyconfig-agent 主日志").tag(0) + Text("工具批准(permission-request.log)").tag(1) + Text("~/.cursor/hooks.json").tag(2) + Text("~/.cursor/cli-config.json").tag(3) + Text("~/.codex/config.toml").tag(4) + Text("Codex Hook(codex-hook.log)").tag(5) + Text("~/.kimi/config.toml").tag(6) + } + .labelsHidden() + .pickerStyle(.menu) + HStack { + Button("刷新本页") { + logPanelContentTick += 1 + agentManager.refresh() + } + if agentLogPanel == 3 { + Button("合并 CLI + IDE 终端白名单") { + let a = agentManager.mergeUserCursorCliConfigForShellAutoApprove() + let b = agentManager.mergeUserCursorPermissionsJsonForAgentTUI() + agentManager.agentUserAlert = a + "\n\n——\n\n" + b + } + .help("写 cli-config(CLI)与 permissions.json 的 terminalAllowlist(Agent TUI「Not in allowlist」层);分见官方文档。均先备份为 .ahakey.bak。") + } + Spacer() + } + .font(.caption) + ScrollView { + logPanelContent + .id(logPanelContentTick) + .font(.system(.caption2, design: .monospaced)) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .padding() + .frame(width: 540, height: 380) } // MARK: - Components diff --git a/ahakeyconfig-mac/Sources/Views/IslandKeyboard/AhaKeyIslandKeyboardCanvasView.swift b/ahakeyconfig-mac/Sources/Views/IslandKeyboard/AhaKeyIslandKeyboardCanvasView.swift new file mode 100644 index 00000000..efe0c62a --- /dev/null +++ b/ahakeyconfig-mac/Sources/Views/IslandKeyboard/AhaKeyIslandKeyboardCanvasView.swift @@ -0,0 +1,689 @@ +import SwiftUI + +private struct CanvasKeyButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .scaleEffect(configuration.isPressed ? 0.88 : 1.0) + .animation(.spring(response: 0.12, dampingFraction: 0.65), value: configuration.isPressed) + } +} + +struct AhaKeyIslandKeyboardCanvasView: View { + let modeDraft: AhaKeyModeDraft + let selectedPart: AhaKeyStudioPart + let lightBarPreview: LightBarPreviewState + let switchTitle: String + let dirtyParts: Set + var deviceGeneration: DeviceGeneration = .x1 + var magneticModuleState: MagneticModuleState = .empty + var oceanLightConfig: OceanLightConfig = .default + var workMode: Int = 0 + let onSelect: (AhaKeyStudioPart) -> Void + let onModeSwitch: () -> Void + var onKeySimulate: ((AhaKeyKeyRole) -> Void)? = nil + var onSwitchToggle: (() -> Void)? = nil + var liveLightMode: Int? = nil + var liveIDEStateValue: Int? = nil + var switchState: Int = 1 // 0=auto, 1=manual; firmware uses for color/effect overrides + /// 0x83 查询出的当前 mode flash 帧数:nil=尚未查询/未连接;0=用户没上传;>0=已上传 N 帧 + var keyboardPictureFrameCount: Int? = nil + var appearance: KeyboardCanvasAppearance = .studioLight + + @State private var modeSwitchPressed = false + @State private var leverPressed = false + + private var theme: KeyboardCanvasTheme { appearance.theme } + private var layout: KeyboardCanvasLayout { appearance.layout } + + private var isGen2: Bool { deviceGeneration == .gen2 } + + var body: some View { + GeometryReader { proxy in + let drawingWidth = min(proxy.size.width, proxy.size.height * layout.aspectRatio) + let drawingHeight = drawingWidth / layout.aspectRatio + + ZStack { + keyboardFrame(width: drawingWidth, height: drawingHeight) + } + .frame(width: drawingWidth, height: drawingHeight) + .position(x: proxy.size.width / 2, y: proxy.size.height / 2) + } + } + + @ViewBuilder + private func keyboardFrame(width: CGFloat, height: CGFloat) -> some View { + ZStack(alignment: .topLeading) { + RoundedRectangle(cornerRadius: 24, style: .continuous) + .fill(theme.chassisFill) + .overlay( + RoundedRectangle(cornerRadius: 24, style: .continuous) + .stroke(theme.chassisStroke, lineWidth: 1.2) + ) + .shadow(color: theme.chassisShadowColor, radius: theme.chassisShadowRadius, y: theme.chassisShadowY) + + RoundedRectangle(cornerRadius: 18, style: .continuous) + .stroke(theme.innerBezelStroke, lineWidth: 1) + .padding(12) + + // 螺丝挪到真正的"边角内侧" + 缩小直径 4.8 → 3.6: + // 旧位置 (8,8)/(8,46) 会被按键灰底矩形和灯条/Key1 边线擦边或交叠。 + // 新位置每颗距离灯条/按键灰底/Key 边都留出 ≥ 3 个基线单位。 + ForEach(Array(layout.cornerScrewPoints.enumerated()), id: \.offset) { _, point in + Circle() + .stroke(theme.screwStroke, lineWidth: 1.2) + .background(Circle().fill(theme.screwFill)) + .frame(width: scaled(3.6, in: width), height: scaled(3.6, in: width)) + .position(position(point.x, point.y, width: width, height: height)) + } + + // 左侧挂绳孔(Gen2 无此开孔) + if !isGen2 { + RoundedRectangle(cornerRadius: 10, style: .continuous) + .stroke(theme.chassisStroke, lineWidth: 1) + .frame(width: scaled(4.2, in: width), height: scaled(12, in: width)) + .position(position(3.8, 28, width: width, height: height)) + } + + // 按键灰底:略收一点尺寸,使它显著低于灯条选中态阴影的影响范围(≥ 5 个基线单位) + Group { + if theme.usesNeumorphicKeyCaps { + RoundedRectangle(cornerRadius: 16, style: .continuous) + .neumorphicRecessed( + cornerRadius: 16, + fill: theme.keyTrackFill, + stroke: theme.keyTrackStroke + ) + } else { + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(theme.keyTrackFill) + } + } + .frame( + width: scaled(isGen2 ? layout.keyTrackWidthGen2 : layout.keyTrackWidthX1, in: width), + height: scaled(21, in: width) + ) + .position( + position( + isGen2 ? layout.keyTrackCenterGen2 : layout.keyTrackCenterX1, + 38.5, + width: width, + height: height + ) + ) + + ledBarButton(width: width, height: height) + oledButton(width: width, height: height) + keyButton(for: .voice, width: width, height: height) + keyButton(for: .approve, width: width, height: height) + keyButton(for: .reject, width: width, height: height) + keyButton(for: .submit, width: width, height: height) + + if isGen2 { + magneticBaseButton(width: width, height: height) + } else { + modeSwitchKey(width: width, height: height) + switchButton(width: width, height: height) + } + } + } + + // 固件 ws2812_mode_e (psk_ws2812.h) → Swift 灯效样式 + private func lightModeToEffect(_ mode: Int) -> LightEffectStyle { + switch mode { + case 1: return .singleMove + case 2: return .rainbowMove + case 3: return .rainbowWave + case 4: return .rainbowWaveSlow + case 5: return .breathing + case 6: return .middleLight + default: return .off + } + } + + private static let firmwareRed = Color(red: 240 / 255, green: 32 / 255, blue: 41 / 255) + private static let firmwareBlue = Color(red: 32 / 255, green: 80 / 255, blue: 255 / 255) + + /// 1:1 还原固件 update_claude_ws2812() 的灯效/颜色决策(CH582m main.c:458-500)。 + /// 仅 mode_data==0 时固件会按 claude_state 切灯效;其他 mode 固件提前 return, + /// 灯条停在上一次设定的状态,所以我们这里返回 OFF 作为"没有运行时灯效"的真实表达。 + private func firmwareLEDState(ideState: IDEState?, modeData: Int, switchState: Int) -> (LightEffectStyle, Color) { + guard modeData == 0, let s = ideState else { + return (.off, Self.firmwareRed) + } + var effect: LightEffectStyle + var color: Color = Self.firmwareRed + switch s { + case .sessionStart, .stop: + effect = .middleLight + case .postToolUse, .userPromptSubmit: + effect = .singleMove + case .permissionRequest: + effect = .breathing + case .preToolUse: + effect = .singleMove + color = Self.firmwareBlue + case .sessionEnd: + effect = .off + case .notification, .taskCompleted: + // 固件 switch 未处理这两个 state,灯条保持上一次状态(这里以 OFF 表示无新效果) + return (.off, Self.firmwareRed) + } + if switchState == 0 { // auto: 固件覆盖部分 state 为彩虹效果 + switch s { + case .postToolUse, .userPromptSubmit: + effect = .rainbowMove + case .permissionRequest, .preToolUse: + effect = .rainbowWave + default: + break + } + } + return (effect, color) + } + + @ViewBuilder + private func ledBarButton(width: CGFloat, height: CGFloat) -> some View { + let part = AhaKeyStudioPart.lightBar + let rect = frame( + isGen2 ? layout.lightBarXGen2 : layout.lightBarX1, + layout.lightBarY, + isGen2 ? layout.lightBarWGen2 : layout.lightBarWX1, + isGen2 ? 10.8 : 8.6, + width: width, + height: height + ) + + if isGen2 { + Button { + onSelect(part) + } label: { + OceanLightPreviewView(config: oceanLightConfig, rowCount: 3, ledsPerRow: 14) + .frame(width: rect.width, height: rect.height) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(theme.ledSlotFill) + ) + .modifier(hotspotChrome(part: part)) + } + .buttonStyle(.plain) + .position(x: rect.midX, y: rect.midY) + } else { + x1LedBarButton(part: part, rect: rect, width: width, height: height) + } + } + + private func x1LedBarButton(part: AhaKeyStudioPart, rect: CGRect, width: CGFloat, height: CGFloat) -> some View { + let modeData = modeDraft.mode.rawValue + let effect: LightEffectStyle + let baseColor: Color + if let live = liveLightMode { + // BLE 连接且 mode tab 与物理 workMode 一致:直接信任固件回报的 ws2812_mode + claude_state + effect = lightModeToEffect(live) + let liveIDE: IDEState? = liveIDEStateValue.flatMap { IDEState(rawValue: UInt8($0)) } + // 颜色:仅 preToolUse + manual 是蓝,其他均红(与固件 ws2812_single_color 设定一致) + if let s = liveIDE, s == .preToolUse, switchState != 0 { + baseColor = Self.firmwareBlue + } else { + baseColor = Self.firmwareRed + } + } else { + // 离线/查看非物理档位:按固件逻辑模拟 update_claude_ws2812() + let previewIDE = lightBarPreview.ideState + (effect, baseColor) = firmwareLEDState(ideState: previewIDE, modeData: modeData, switchState: switchState) + } + return Button { + onSelect(part) + } label: { + TimelineView(.animation(minimumInterval: 1.0 / 30.0)) { context in + let colors = ledColors(effect: effect, time: context.date.timeIntervalSince1970, count: 10, baseColor: baseColor) + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(theme.ledSlotFill) + HStack(spacing: rect.width * 0.026) { + ForEach(0..<10, id: \.self) { index in + Capsule() + .fill(colors[index]) + .frame(width: rect.width * 0.072, height: rect.height * 0.42) + .shadow(color: colors[index].opacity(0.65), radius: 2.5) + } + } + .padding(.horizontal, rect.width * 0.04) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .frame(width: rect.width, height: rect.height) + .modifier(hotspotChrome(part: part)) + } + .buttonStyle(.plain) + .position(x: rect.midX, y: rect.midY) + } + + private func oledButton(width: CGFloat, height: CGFloat) -> some View { + let part = AhaKeyStudioPart.oledDisplay + let rect = frame(layout.oledX, layout.oledY, layout.oledW, 13.4, width: width, height: height) + return Button { + onSelect(part) + } label: { + ZStack(alignment: .topTrailing) { + ZStack { + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(Color.black.opacity(0.92)) + oledInnerContent(rect: rect) + } + // 右上角徽章:反映键盘 flash 真实状态 + pictureStateBadge(rect: rect) + } + .frame(width: rect.width, height: rect.height) + .modifier(hotspotChrome(part: part)) + } + .buttonStyle(.plain) + .position(x: rect.midX, y: rect.midY) + } + + @ViewBuilder + private func pictureStateBadge(rect: CGRect) -> some View { + if let count = keyboardPictureFrameCount { + let isUploaded = count > 0 + let label = isUploaded ? "✓ 已上传 \(count) 帧" : "未上传" + Text(label) + .font(.system(size: max(rect.height * 0.11, 8), weight: .medium)) + .foregroundStyle(.white) + .padding(.horizontal, rect.width * 0.04) + .padding(.vertical, rect.height * 0.02) + .background( + Capsule() + .fill(isUploaded ? Color.green.opacity(0.85) : Color.gray.opacity(0.85)) + ) + .padding(rect.width * 0.025) + } + } + + /// 真实 OLED 是 160×80(2:1)。在 slot 中央用一个 2:1 的"屏幕区"渲染内容, + /// 周围留键盘黑壳作为外框;图片 / 占位都在屏幕区内 .fit,不会撑出范围、不会被裁切。 + private func screenInnerSize(for rect: CGRect) -> CGSize { + let screenAspect: CGFloat = 2.0 + if rect.width / rect.height >= screenAspect { + let h = rect.height * 0.86 + return CGSize(width: h * screenAspect, height: h) + } else { + let w = rect.width * 0.86 + return CGSize(width: w, height: w / screenAspect) + } + } + + private func oledInnerContent(rect: CGRect) -> some View { + let size = screenInnerSize(for: rect) + return ZStack { + Color.clear + screenBody(screenWidth: size.width, screenHeight: size.height) + .frame(width: size.width, height: size.height) + .clipShape(RoundedRectangle(cornerRadius: 4, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 4, style: .continuous) + .stroke(Color.white.opacity(0.08), lineWidth: 0.5) + ) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + @ViewBuilder + private func screenBody(screenWidth: CGFloat, screenHeight: CGFloat) -> some View { + if let gifPath = modeDraft.oled.localAssetPath { + PixelArtAnimatedGIFView( + path: gifPath, + fps: modeDraft.oled.framesPerSecond, + targetWidth: 160, + targetHeight: 80, + blockSize: isGen2 ? 3 : PixelArtProcessor.canvasBlockSize + ) + .id(gifPath) + } else { + PixelArtStatusScreenView( + mode: modeDraft.mode, + screenWidth: screenWidth, + screenHeight: screenHeight + ) + } + } + + private func hotspotChrome(part: AhaKeyStudioPart, cornerRadius: CGFloat = 14) -> IslandHotspotChrome { + IslandHotspotChrome( + part: part, + selectedPart: selectedPart, + dirtyParts: dirtyParts, + theme: theme, + cornerRadius: cornerRadius + ) + } + + @ViewBuilder + private func keyCapSurface(cornerRadius: CGFloat) -> some View { + if theme.usesNeumorphicKeyCaps { + Color.clear + .neumorphicRecessed( + cornerRadius: cornerRadius, + fill: theme.keyCapBaseColor, + stroke: theme.keyTrackStroke + ) + } else { + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + .fill(theme.keyCapFill) + .shadow(color: theme.keyCapShadowColor, radius: theme.keyCapShadowRadius, y: theme.keyCapShadowY) + } + } + + private func keyButton(for role: AhaKeyKeyRole, width: CGFloat, height: CGFloat) -> some View { + let part = role.part + let keyDraft = modeDraft.key(for: role) + let specs: (x: CGFloat, y: CGFloat, w: CGFloat, h: CGFloat) + switch role { + case .voice: + specs = isGen2 ? (9.5, 29.2, 15.0, 16.8) : (10.2, 29.2, 16.2, 16.8) + case .approve: + specs = isGen2 ? (25.5, 29.2, 15.0, 16.8) : (27.2, 29.2, 16.2, 16.8) + case .reject: + specs = isGen2 ? (41.5, 29.2, 15.0, 16.8) : (44.2, 29.2, 16.2, 16.8) + case .submit: + specs = isGen2 ? (57.5, 29.2, 15.0, 16.8) : (61.2, 29.2, 16.2, 16.8) + } + let rect = frame(specs.x, specs.y, specs.w, specs.h, width: width, height: height) + return Button { + onSelect(part) + onKeySimulate?(role) + } label: { + VStack(spacing: rect.height * 0.07) { + ZStack { + keyCapSurface(cornerRadius: rect.width * 0.18) + keyCapIcon(for: role, size: rect.height * 0.24) + .fixedSize() + } + .frame(width: rect.width * 0.8, height: rect.height * 0.76) + + Text(keyDraft.description.isEmpty ? keyDraft.displaySummary : keyDraft.description) + .font(.system(size: rect.height * 0.11, weight: .medium)) + .foregroundStyle(theme.keyLabelColor) + .lineLimit(1) + .frame(maxWidth: .infinity) + } + .frame(width: rect.width, height: rect.height) + .modifier(hotspotChrome(part: part)) + } + .buttonStyle(CanvasKeyButtonStyle()) + .position(x: rect.midX, y: rect.midY) + } + + @ViewBuilder + private func keyCapIcon(for role: AhaKeyKeyRole, size: CGFloat) -> some View { + CanvasKeyRoleIcon(role: role, color: theme.keyIconColor, size: size) + } + + private func modeSwitchKey(width: CGFloat, height: CGFloat) -> some View { + let rect = frame(78.9, 40.9, 8.0, 10.2, width: width, height: height) + return Button { + onModeSwitch() + } label: { + VStack(spacing: rect.height * 0.08) { + ZStack { + keyCapSurface(cornerRadius: rect.width * 0.2) + + Image(systemName: "arrow.triangle.2.circlepath") + .font(.system(size: rect.height * 0.18, weight: .semibold)) + .foregroundStyle(theme.accentColor.opacity(0.85)) + } + .frame(width: rect.width * 0.78, height: rect.height * 0.5) + + Text("Mode") + .font(.system(size: rect.height * 0.1, weight: .medium)) + .foregroundStyle(theme.secondaryLabelColor) + .lineLimit(1) + } + .frame(width: rect.width, height: rect.height) + } + .buttonStyle(CanvasKeyButtonStyle()) + .position(x: rect.midX, y: rect.midY) + .help("点击切换 Mode(模拟实体键)") + } + + private func magneticBaseButton(width: CGFloat, height: CGFloat) -> some View { + let part = AhaKeyStudioPart.magneticPort + // 圆角正方形磁吸槽,吸附模块后与单键同高、控件更大 + let side = layout.magneticSide + let rect = frame(layout.magneticPortX, 27.8, side, side, width: width, height: height) + let baseSize = min(rect.width, rect.height) + + return Button { + onSelect(part) + } label: { + VStack(spacing: rect.height * 0.05) { + MagneticBaseView(moduleState: magneticModuleState) + .frame(width: baseSize, height: baseSize) + .modifier( + hotspotChrome(part: part, cornerRadius: baseSize * 0.2) + ) + + Text(magneticModuleState.isConnected ? magneticModuleState.attachedType.title : "磁吸底座") + .font(.system(size: max(rect.height * 0.11, 9), weight: .semibold)) + .foregroundStyle( + magneticModuleState.isConnected + ? theme.keyLabelColor + : theme.secondaryLabelColor + ) + .lineLimit(1) + } + .frame(width: rect.width, height: rect.height) + } + .buttonStyle(CanvasKeyButtonStyle()) + .position(x: rect.midX, y: rect.midY) + .zIndex(2) + } + + private func switchButton(width: CGFloat, height: CGFloat) -> some View { + let part = AhaKeyStudioPart.toggleSwitch + let rect = frame(87.8, 35.6, 6.8, 10.6, width: width, height: height) + return Button { + onSelect(part) + // 物理拨杆损坏的用户靠这个:点击即翻转 auto/manual。 + // - 已 patch 固件:agent 通过 0x91 BLE 命令真改键盘 sw_state,灯效也会跟着变 + // - 老固件:只在 agent 软覆盖层生效(hook 自动批准走新值),键盘灯效不会变 + onSwitchToggle?() + } label: { + VStack(spacing: 6) { + ZStack(alignment: .top) { + RoundedRectangle(cornerRadius: rect.width * 0.18, style: .continuous) + .fill(Color.black.opacity(0.12)) + .overlay( + RoundedRectangle(cornerRadius: rect.width * 0.18, style: .continuous) + .stroke(Color.black.opacity(0.12), lineWidth: 1) + ) + Capsule() + .fill(Color.white) + .frame(width: rect.width * 0.36, height: rect.height * 0.65) + .overlay(Circle().fill(Color.gray.opacity(0.24)).frame(width: rect.width * 0.28, height: rect.width * 0.28)) + .offset(y: switchTitle == "自动批准" ? -rect.height * 0.08 : rect.height * 0.12) + } + .frame(width: rect.width * 0.58, height: rect.height * 0.78) + + Text(switchTitle) + .font(.system(size: rect.height * 0.12, weight: .medium)) + .foregroundStyle(theme.secondaryLabelColor) + .lineLimit(1) + } + .frame(width: rect.width, height: rect.height) + .modifier(hotspotChrome(part: part)) + } + .buttonStyle(CanvasKeyButtonStyle()) + .position(x: rect.midX, y: rect.midY) + } + + private func frame(_ x: CGFloat, _ y: CGFloat, _ w: CGFloat, _ h: CGFloat, width: CGFloat, height: CGFloat) -> CGRect { + CGRect( + x: x / layout.baseWidth * width, + y: y / layout.baseHeight * height, + width: w / layout.baseWidth * width, + height: h / layout.baseHeight * height + ) + } + + private func position(_ x: CGFloat, _ y: CGFloat, width: CGFloat, height: CGFloat) -> CGPoint { + CGPoint(x: x / layout.baseWidth * width, y: y / layout.baseHeight * height) + } + + private func scaled(_ value: CGFloat, in width: CGFloat) -> CGFloat { + value / layout.baseWidth * width + } + + private func ledColors(effect: LightEffectStyle, time: TimeInterval, count: Int, + baseColor: Color = Self.firmwareRed) -> [Color] { + switch effect { + case .off: + return Array(repeating: Color.gray.opacity(0.15), count: count) + case .middleLight: + let center = Double(count - 1) / 2.0 + return (0..= 0 ? 0.0 : max(0, 1.0 + dist * 0.25) + let head = dist >= 0 && dist < 1.5 ? max(0, 1.0 - dist * 0.65) : 0.0 + return baseColor.opacity(0.08 + max(tail, head) * 0.88) + } + case .scanBar: + let period = 2.0 + let t = time.truncatingRemainder(dividingBy: period) / period + let pos = t < 0.5 ? t * 2.0 * Double(count - 1) : (1.0 - (t - 0.5) * 2.0) * Double(count - 1) + return (0.. 0 ? 0.9 : 0.1 + let orange = Color(red: 1.0, green: 0.6, blue: 0.0) + return Array(repeating: orange.opacity(blink), count: count) + case .successSweep: + let green = Color(red: 0.1, green: 0.85, blue: 0.3) + let progress = time.truncatingRemainder(dividingBy: 2.0) / 2.0 + let fillPos = progress * Double(count + 2) - 1 + return (0.. 0 ? 1.0 : 0.4 + return (0.. + var theme: KeyboardCanvasTheme = .studioLight + var cornerRadius: CGFloat = 14 + + func body(content: Content) -> some View { + let isSelected = selectedPart == part + content + .overlay( + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + .strokeBorder( + isSelected ? theme.hotspotSelectedStroke : theme.hotspotIdleStroke, + lineWidth: isSelected ? 2 : 1 + ) + ) + .overlay(alignment: .topTrailing) { + if dirtyParts.contains(part) { + Circle() + .fill(Color.orange) + .frame(width: 8, height: 8) + .padding(8) + } + } + // 选中态阴影从 10 收到 6,减少向邻近元件溢出的发光半径 + .shadow(color: isSelected ? theme.hotspotSelectedGlow : .clear, radius: 6) + } +} \ No newline at end of file diff --git a/ahakeyconfig-mac/Sources/Views/IslandKeyboard/AhaKeyStudioDisclosureSection.swift b/ahakeyconfig-mac/Sources/Views/IslandKeyboard/AhaKeyStudioDisclosureSection.swift new file mode 100644 index 00000000..bfb6a481 --- /dev/null +++ b/ahakeyconfig-mac/Sources/Views/IslandKeyboard/AhaKeyStudioDisclosureSection.swift @@ -0,0 +1,64 @@ +import SwiftUI + +/// Studio Inspector 折叠区:与灵动岛 `AhaKeySettingsDisclosureSection` 同构,色随嵌入/独立主题。 +struct AhaKeyStudioDisclosureSection: View { + let title: String + var subtitle: String? = nil + var isEmbedded: Bool = true + @Binding var isExpanded: Bool + @ViewBuilder var content: () -> Content + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Button { + withAnimation(.easeInOut(duration: 0.18)) { + isExpanded.toggle() + } + } label: { + HStack(spacing: 10) { + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(primaryText) + if let subtitle, !subtitle.isEmpty { + Text(subtitle) + .font(.system(size: 11)) + .foregroundStyle(tertiaryText) + } + } + Spacer(minLength: 0) + Image(systemName: "chevron.right") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(tertiaryText) + .rotationEffect(.degrees(isExpanded ? 90 : 0)) + } + .padding(.horizontal, 12) + .padding(.vertical, 11) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(controlFill) + ) + } + .buttonStyle(.plain) + + if isExpanded { + VStack(alignment: .leading, spacing: 14) { + content() + } + .transition(.opacity.combined(with: .move(edge: .top))) + } + } + } + + private var primaryText: Color { + isEmbedded ? AhaKeyStudioEmbeddedTheme.primaryText : Color.primary + } + + private var tertiaryText: Color { + isEmbedded ? AhaKeyStudioEmbeddedTheme.tertiaryText : Color.secondary + } + + private var controlFill: Color { + isEmbedded ? AhaKeyStudioEmbeddedTheme.controlFill : Color(nsColor: .controlBackgroundColor) + } +} diff --git a/ahakeyconfig-mac/Sources/Views/IslandKeyboard/DeviceCapability.swift b/ahakeyconfig-mac/Sources/Views/IslandKeyboard/DeviceCapability.swift new file mode 100644 index 00000000..6928d454 --- /dev/null +++ b/ahakeyconfig-mac/Sources/Views/IslandKeyboard/DeviceCapability.swift @@ -0,0 +1,27 @@ +import Foundation + +/// 设备代际;开发阶段通过 Dev Toggle 切换,后续改为读取 Model Number / 固件版本。 +enum DeviceGeneration: String, CaseIterable, Identifiable, Codable { + case x1 + case gen2 + + var id: String { rawValue } + + var displayName: String { + switch self { + case .x1: return "X1" + case .gen2: return "Gen2" + } + } + + var supportsMagneticPort: Bool { self == .gen2 } + var supportsOceanLight: Bool { self == .gen2 } + var modeSlotCount: Int { self == .gen2 ? 4 : 3 } +} + +enum DeviceCapabilityStorage { + static let previewGenerationKey = "AhaKey.Dev.PreviewGeneration" + static let magneticModuleKey = "AhaKey.Gen2.MagneticModule" + static let magneticModuleUnlockKey = "AhaKey.Gen2.MagneticModuleUnlocks" + static let oceanLightConfigKey = "AhaKey.Gen2.OceanLightConfig" +} diff --git a/ahakeyconfig-mac/Sources/Views/IslandKeyboard/HardwarePartTabBar.swift b/ahakeyconfig-mac/Sources/Views/IslandKeyboard/HardwarePartTabBar.swift new file mode 100644 index 00000000..c710373a --- /dev/null +++ b/ahakeyconfig-mac/Sources/Views/IslandKeyboard/HardwarePartTabBar.swift @@ -0,0 +1,69 @@ +import SwiftUI + +/// 硬件 Inspector 顶部元件分段:与画布 `selectedPart` 双向同步。 +struct HardwarePartTabBar: View { + let parts: [AhaKeyStudioPart] + @Binding var selection: AhaKeyStudioPart + var dirtyParts: Set = [] + var isEmbedded: Bool = true + + var body: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 4) { + ForEach(parts) { part in + partButton(part) + } + } + .padding(.horizontal, 10) + .padding(.vertical, 8) + } + .background(isEmbedded ? AhaKeyStudioEmbeddedTheme.cardBackground : Color(nsColor: .controlBackgroundColor).opacity(0.55)) + } + + private func partButton(_ part: AhaKeyStudioPart) -> some View { + let isSelected = selection == part + let isDirty = dirtyParts.contains(part) + return Button { + selection = part + } label: { + HStack(spacing: 4) { + Image(systemName: part.systemImage) + .font(.system(size: 10, weight: .semibold)) + Text(part.tabTitle) + .font(.system(size: 11, weight: isSelected ? .semibold : .medium)) + .lineLimit(1) + if isDirty { + Circle() + .fill(Color.orange) + .frame(width: 5, height: 5) + } + } + .foregroundStyle( + isSelected + ? (isEmbedded ? AhaKeyStudioEmbeddedTheme.primaryText : Color.primary) + : (isEmbedded ? AhaKeyStudioEmbeddedTheme.secondaryText : Color.secondary) + ) + .padding(.horizontal, 7) + .padding(.vertical, 6) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill( + isSelected + ? (isEmbedded ? AhaKeyStudioEmbeddedTheme.controlFill : Color.accentColor.opacity(0.14)) + : Color.clear + ) + ) + .overlay( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .stroke( + isSelected + ? (isEmbedded ? AhaKeyStudioEmbeddedTheme.accentBlue.opacity(0.55) : Color.accentColor.opacity(0.35)) + : Color.clear, + lineWidth: 1 + ) + ) + } + .buttonStyle(.plain) + .help("\(part.title) · \(part.subtitle)") + } +} diff --git a/ahakeyconfig-mac/Sources/Views/IslandKeyboard/InspectorHelpButton.swift b/ahakeyconfig-mac/Sources/Views/IslandKeyboard/InspectorHelpButton.swift new file mode 100644 index 00000000..36298ca9 --- /dev/null +++ b/ahakeyconfig-mac/Sources/Views/IslandKeyboard/InspectorHelpButton.swift @@ -0,0 +1,64 @@ +import SwiftUI + +/// 硬件 Inspector 问号帮助:悬停即展开,移开即收起。 +struct InspectorHelpButton: View { + var title: String = "如何使用" + var lines: [String] + var isEmbedded: Bool = true + var width: CGFloat = 280 + var arrowEdge: Edge = .leading + + @State private var isPresented = false + @State private var dismissTask: Task? + + var body: some View { + Image(systemName: "questionmark.circle") + .foregroundStyle(isEmbedded ? AhaKeyStudioEmbeddedTheme.secondaryText : Color.secondary) + .imageScale(.medium) + .contentShape(Rectangle()) + .onHover { hovering in + dismissTask?.cancel() + dismissTask = nil + if hovering { + isPresented = true + } else { + // 极短延迟,避免移向 popover 瞬间误关造成闪烁 + dismissTask = Task { @MainActor in + try? await Task.sleep(nanoseconds: 60_000_000) + guard !Task.isCancelled else { return } + isPresented = false + } + } + } + .popover(isPresented: $isPresented, arrowEdge: arrowEdge) { + VStack(alignment: .leading, spacing: 8) { + Text(title) + .font(.headline) + Divider() + ForEach(Array(lines.enumerated()), id: \.offset) { _, line in + Text(line) + } + } + .font(.callout) + .padding(16) + .frame(width: width) + .fixedSize(horizontal: false, vertical: true) + .onHover { hovering in + dismissTask?.cancel() + dismissTask = nil + if hovering { + isPresented = true + } else { + dismissTask = Task { @MainActor in + try? await Task.sleep(nanoseconds: 60_000_000) + guard !Task.isCancelled else { return } + isPresented = false + } + } + } + } + .accessibilityLabel(title) + .accessibilityAddTraits(.isButton) + .accessibilityHint("将指针悬停以查看说明") + } +} diff --git a/ahakeyconfig-mac/Sources/Views/IslandKeyboard/InspectorSection.swift b/ahakeyconfig-mac/Sources/Views/IslandKeyboard/InspectorSection.swift new file mode 100644 index 00000000..7950cceb --- /dev/null +++ b/ahakeyconfig-mac/Sources/Views/IslandKeyboard/InspectorSection.swift @@ -0,0 +1,28 @@ +import SwiftUI + +/// Inspector 二级模块:标题 + 内容区(1 级为元件名 `inspectorHeader`)。 +struct InspectorSection: View { + let title: String + var isEmbedded: Bool = true + @ViewBuilder var content: () -> Content + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + Text(title) + .font(.system(size: isEmbedded ? 13 : 14, weight: .semibold)) + .foregroundStyle(isEmbedded ? AhaKeyStudioEmbeddedTheme.primaryText : Color.primary) + + VStack(alignment: .leading, spacing: 12) { + content() + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) + .clipped() + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(isEmbedded ? AhaKeyStudioEmbeddedTheme.controlFill : Color(nsColor: .controlBackgroundColor)) + ) + } + } +} diff --git a/ahakeyconfig-mac/Sources/Views/IslandKeyboard/KeyboardCanvasAppearance.swift b/ahakeyconfig-mac/Sources/Views/IslandKeyboard/KeyboardCanvasAppearance.swift new file mode 100644 index 00000000..b962c40a --- /dev/null +++ b/ahakeyconfig-mac/Sources/Views/IslandKeyboard/KeyboardCanvasAppearance.swift @@ -0,0 +1,247 @@ +import SwiftUI + +/// Visual preset for `AhaKeyKeyboardCanvasView`. +enum KeyboardCanvasAppearance { + case studioLight + case vibeBarEmbedded + + var theme: KeyboardCanvasTheme { + switch self { + case .studioLight: + return .studioLight + case .vibeBarEmbedded: + return .vibeBarEmbedded + } + } + + var layout: KeyboardCanvasLayout { + switch self { + case .studioLight: + return .standard + case .vibeBarEmbedded: + return .vibeBarWide + } + } +} + +/// Coordinate system for the hardware keyboard canvas (109×54 design units by default). +struct KeyboardCanvasLayout { + let baseWidth: CGFloat + let baseHeight: CGFloat + let screwInset: CGFloat + let keyTrackCenterGen2: CGFloat + let keyTrackWidthGen2: CGFloat + let keyTrackCenterX1: CGFloat + let keyTrackWidthX1: CGFloat + let magneticPortX: CGFloat + let magneticSide: CGFloat + let oledX: CGFloat + let oledY: CGFloat + let oledW: CGFloat + let lightBarXGen2: CGFloat + let lightBarY: CGFloat + let lightBarWGen2: CGFloat + let lightBarX1: CGFloat + let lightBarWX1: CGFloat + + var aspectRatio: CGFloat { baseWidth / baseHeight } + + var cornerScrewPoints: [CGPoint] { + let right = baseWidth - screwInset + let bottom = baseHeight - screwInset + return [ + CGPoint(x: screwInset, y: screwInset), + CGPoint(x: right, y: screwInset), + CGPoint(x: screwInset, y: bottom), + CGPoint(x: right, y: bottom), + ] + } + + static let standard = KeyboardCanvasLayout( + baseWidth: 109, + baseHeight: 54, + screwInset: 5.5, + keyTrackCenterGen2: 44.5, + keyTrackWidthGen2: 72, + keyTrackCenterX1: 43.8, + keyTrackWidthX1: 67, + magneticPortX: 75.5, + magneticSide: 20, + oledX: 71.3, + oledY: 7.7, + oledW: 24.2, + lightBarXGen2: 9.5, + lightBarY: 7.7, + lightBarWGen2: 56.5, + lightBarX1: 10.2, + lightBarWX1: 56.3 + ) + + /// Extra horizontal span so Key4 and the magnetic port do not overlap in the expanded island. + static let vibeBarWide = KeyboardCanvasLayout( + baseWidth: 122, + baseHeight: 54, + screwInset: 5.5, + keyTrackCenterGen2: 42.5, + keyTrackWidthGen2: 66, + keyTrackCenterX1: 43.8, + keyTrackWidthX1: 67, + magneticPortX: 88.0, + magneticSide: 20, + oledX: 83.8, + oledY: 7.7, + oledW: 24.2, + lightBarXGen2: 9.5, + lightBarY: 7.7, + lightBarWGen2: 68.0, + lightBarX1: 10.2, + lightBarWX1: 56.3 + ) +} + +struct KeyboardCanvasTheme { + let chassisFill: AnyShapeStyle + let chassisStroke: Color + let chassisShadowColor: Color + let chassisShadowRadius: CGFloat + let chassisShadowY: CGFloat + let innerBezelStroke: Color + let screwStroke: Color + let screwFill: Color + let keyTrackFill: Color + let keyTrackStroke: Color + let keyCapFill: AnyShapeStyle + let keyCapBaseColor: Color + let keyCapShadowColor: Color + let keyCapShadowRadius: CGFloat + let keyCapShadowY: CGFloat + let keyIconColor: Color + let keyLabelColor: Color + let partLabelColor: Color + let secondaryLabelColor: Color + let ledSlotFill: Color + let accentColor: Color + let hotspotIdleStroke: Color + let hotspotSelectedStroke: Color + let hotspotSelectedGlow: Color + let usesNeumorphicKeyCaps: Bool + + static let studioLight = KeyboardCanvasTheme( + chassisFill: AnyShapeStyle( + LinearGradient( + colors: [Color.white.opacity(0.95), Color(red: 0.92, green: 0.95, blue: 0.98)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ), + chassisStroke: Color.black.opacity(0.1), + chassisShadowColor: Color.black.opacity(0.08), + chassisShadowRadius: 18, + chassisShadowY: 14, + innerBezelStroke: Color.black.opacity(0.06), + screwStroke: Color.black.opacity(0.14), + screwFill: Color.white.opacity(0.4), + keyTrackFill: Color.black.opacity(0.035), + keyTrackStroke: Color.clear, + keyCapFill: AnyShapeStyle( + LinearGradient( + colors: [Color.white, Color(red: 0.95, green: 0.96, blue: 0.98)], + startPoint: .top, + endPoint: .bottom + ) + ), + keyCapBaseColor: Color.white, + keyCapShadowColor: Color.black.opacity(0.12), + keyCapShadowRadius: 10, + keyCapShadowY: 4, + keyIconColor: Color.black.opacity(0.88), + keyLabelColor: Color.secondary, + partLabelColor: Color.black.opacity(0.72), + secondaryLabelColor: Color.secondary, + ledSlotFill: Color.black.opacity(0.12), + accentColor: Color.accentColor, + hotspotIdleStroke: Color.black.opacity(0.015), + hotspotSelectedStroke: Color.accentColor, + hotspotSelectedGlow: Color.accentColor.opacity(0.18), + usesNeumorphicKeyCaps: false + ) + + /// Matches `MacOSVibeBarDesignSystem` tokens (duplicated here so AhaKeyCore stays self-contained). + static let vibeBarEmbedded = KeyboardCanvasTheme( + chassisFill: AnyShapeStyle(Color(red: 0.067, green: 0.086, blue: 0.106)), + chassisStroke: Color.white.opacity(0.08), + chassisShadowColor: Color.black.opacity(0.25), + chassisShadowRadius: 8, + chassisShadowY: 4, + innerBezelStroke: Color.white.opacity(0.06), + screwStroke: Color.white.opacity(0.12), + screwFill: Color(red: 0.047, green: 0.063, blue: 0.078), + keyTrackFill: Color(red: 0.047, green: 0.063, blue: 0.078), + keyTrackStroke: Color.white.opacity(0.07), + keyCapFill: AnyShapeStyle(Color(red: 0.047, green: 0.063, blue: 0.078)), + keyCapBaseColor: Color(red: 0.047, green: 0.063, blue: 0.078), + keyCapShadowColor: Color.clear, + keyCapShadowRadius: 0, + keyCapShadowY: 0, + keyIconColor: Color(red: 0.953, green: 0.965, blue: 0.980), + keyLabelColor: Color(red: 0.651, green: 0.686, blue: 0.729), + partLabelColor: Color(red: 0.651, green: 0.686, blue: 0.729), + secondaryLabelColor: Color(red: 0.435, green: 0.471, blue: 0.514), + ledSlotFill: Color.black.opacity(0.35), + accentColor: Color(red: 0.337, green: 0.761, blue: 1.000), + hotspotIdleStroke: Color.white.opacity(0.06), + hotspotSelectedStroke: Color(red: 0.337, green: 0.761, blue: 1.000), + hotspotSelectedGlow: Color(red: 0.337, green: 0.761, blue: 1.000).opacity(0.22), + usesNeumorphicKeyCaps: true + ) +} + +struct NeumorphicRecessedSurface: ViewModifier { + let cornerRadius: CGFloat + let fill: Color + let stroke: Color + + func body(content: Content) -> some View { + content + .background( + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + .fill(fill) + .overlay( + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + .stroke(stroke, lineWidth: 1) + ) + .overlay(alignment: .topLeading) { + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + .strokeBorder( + LinearGradient( + colors: [Color.black.opacity(0.45), Color.clear], + startPoint: .topLeading, + endPoint: .center + ), + lineWidth: 1 + ) + } + .overlay(alignment: .bottomTrailing) { + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + .strokeBorder( + LinearGradient( + colors: [Color.clear, Color.white.opacity(0.08)], + startPoint: .center, + endPoint: .bottomTrailing + ), + lineWidth: 1 + ) + } + ) + } +} + +extension View { + func neumorphicRecessed( + cornerRadius: CGFloat, + fill: Color, + stroke: Color = Color.white.opacity(0.07) + ) -> some View { + modifier(NeumorphicRecessedSurface(cornerRadius: cornerRadius, fill: fill, stroke: stroke)) + } +} diff --git a/ahakeyconfig-mac/Sources/Views/IslandKeyboard/MagneticBaseView.swift b/ahakeyconfig-mac/Sources/Views/IslandKeyboard/MagneticBaseView.swift new file mode 100644 index 00000000..63387902 --- /dev/null +++ b/ahakeyconfig-mac/Sources/Views/IslandKeyboard/MagneticBaseView.swift @@ -0,0 +1,91 @@ +import SwiftUI + +/// Gen2 磁吸底座:空槽显示触点底座;吸附模块后由虚拟硬件完全覆盖。 +struct MagneticBaseView: View { + let moduleState: MagneticModuleState + + private let baseFill = Color(red: 0.18, green: 0.19, blue: 0.21) + private let magnetFill = Color(red: 0.82, green: 0.84, blue: 0.88) + private let pinFill = Color(red: 0.82, green: 0.68, blue: 0.28) + + var body: some View { + GeometryReader { proxy in + let size = min(proxy.size.width, proxy.size.height) + let corner = size * 0.2 + + Group { + if moduleState.isConnected { + MagneticModuleHardwareView(type: moduleState.attachedType) + .frame(width: size, height: size) + } else { + emptyBase(size: size, corner: corner) + } + } + .overlay { + if !moduleState.isConnected { + RoundedRectangle(cornerRadius: corner, style: .continuous) + .strokeBorder(style: StrokeStyle(lineWidth: 1.2, dash: [4, 3])) + .foregroundStyle(Color.white.opacity(0.22)) + } + } + } + .aspectRatio(1, contentMode: .fit) + } + + @ViewBuilder + private func emptyBase(size: CGFloat, corner: CGFloat) -> some View { + ZStack { + RoundedRectangle(cornerRadius: corner, style: .continuous) + .fill( + LinearGradient( + colors: [baseFill, baseFill.opacity(0.88)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .overlay( + RoundedRectangle(cornerRadius: corner, style: .continuous) + .stroke(Color.white.opacity(0.12), lineWidth: 1) + ) + .shadow(color: .black.opacity(0.28), radius: 2, y: 1) + + ForEach(Array(cornerOffsets.enumerated()), id: \.offset) { _, point in + Circle() + .fill( + RadialGradient( + colors: [Color.white.opacity(0.98), magnetFill, Color.gray.opacity(0.55)], + center: .topLeading, + startRadius: 0, + endRadius: size * 0.09 + ) + ) + .overlay(Circle().stroke(Color.white.opacity(0.35), lineWidth: 0.5)) + .frame(width: size * 0.18, height: size * 0.18) + .offset(x: point.x * size * 0.36, y: point.y * size * 0.36) + } + + pinGrid(size: size) + } + } + + private var cornerOffsets: [CGPoint] { + [CGPoint(x: -1, y: -1), CGPoint(x: 1, y: -1), CGPoint(x: -1, y: 1), CGPoint(x: 1, y: 1)] + } + + @ViewBuilder + private func pinGrid(size: CGFloat) -> some View { + let spacing = size * 0.1 + VStack(spacing: spacing) { + ForEach(0..<3, id: \.self) { _ in + HStack(spacing: spacing) { + ForEach(0..<3, id: \.self) { _ in + Circle() + .fill(pinFill) + .frame(width: size * 0.075, height: size * 0.075) + .shadow(color: pinFill.opacity(0.6), radius: 1) + } + } + } + } + } +} diff --git a/ahakeyconfig-mac/Sources/Views/IslandKeyboard/MagneticModuleHardwareView.swift b/ahakeyconfig-mac/Sources/Views/IslandKeyboard/MagneticModuleHardwareView.swift new file mode 100644 index 00000000..6a744764 --- /dev/null +++ b/ahakeyconfig-mac/Sources/Views/IslandKeyboard/MagneticModuleHardwareView.swift @@ -0,0 +1,272 @@ +import SwiftUI + +private enum HardwareChevronDirection { case up, down, left, right } + +/// 磁吸模块虚拟硬件外观,用于键盘映射画布;设置页 Inspector 仍使用 SF Symbol。 +struct MagneticModuleHardwareView: View { + let type: MagneticModuleType + + private let bodyFill = Color(red: 0.95, green: 0.95, blue: 0.94) + private let recessFill = Color(red: 0.90, green: 0.90, blue: 0.89) + private let metalLight = Color(red: 0.94, green: 0.95, blue: 0.98) + private let metalMid = Color(red: 0.74, green: 0.76, blue: 0.80) + private let metalDark = Color(red: 0.50, green: 0.52, blue: 0.56) + private let controlBlack = Color(red: 0.10, green: 0.10, blue: 0.11) + + var body: some View { + GeometryReader { proxy in + let size = min(proxy.size.width, proxy.size.height) + let corner = size * 0.2 + let recessCorner = corner * 0.55 + + ZStack { + RoundedRectangle(cornerRadius: corner, style: .continuous) + .fill( + LinearGradient( + colors: [bodyFill, bodyFill.opacity(0.94)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .overlay( + RoundedRectangle(cornerRadius: corner, style: .continuous) + .stroke(Color.black.opacity(0.08), lineWidth: max(size * 0.012, 0.6)) + ) + .shadow(color: .black.opacity(0.14), radius: size * 0.035, y: size * 0.02) + + RoundedRectangle(cornerRadius: recessCorner, style: .continuous) + .fill(recessFill) + .frame(width: size * 0.78, height: size * 0.78) + .overlay( + RoundedRectangle(cornerRadius: recessCorner, style: .continuous) + .stroke(Color.black.opacity(0.05), lineWidth: size * 0.006) + ) + + moduleControl(size: size) + + Circle() + .fill(Color.black.opacity(0.22)) + .frame(width: size * 0.045, height: size * 0.045) + .offset(y: size * 0.36) + } + } + .aspectRatio(1, contentMode: .fit) + } + + @ViewBuilder + private func moduleControl(size: CGFloat) -> some View { + switch type { + case .none: + EmptyView() + case .toggle: + toggleControl(size: size) + case .joystick: + joystickControl(size: size) + case .knob: + knobControl(size: size) + case .scrollWheel: + scrollWheelControl(size: size) + case .dpad: + dpadControl(size: size) + } + } + + private func toggleControl(size: CGFloat) -> some View { + ZStack { + hardwareChevron(size: size * 0.08, direction: .up, onRecess: true) + .offset(y: -size * 0.19) + hardwareChevron(size: size * 0.08, direction: .down, onRecess: true) + .offset(y: size * 0.19) + + RoundedRectangle(cornerRadius: size * 0.028, style: .continuous) + .fill( + LinearGradient( + colors: [metalLight, metalMid, metalDark], + startPoint: .leading, + endPoint: .trailing + ) + ) + .frame(width: size * 0.13, height: size * 0.42) + .overlay( + RoundedRectangle(cornerRadius: size * 0.028, style: .continuous) + .stroke(Color.white.opacity(0.4), lineWidth: size * 0.006) + ) + .shadow(color: .black.opacity(0.28), radius: size * 0.025, y: size * 0.012) + } + } + + private func joystickControl(size: CGFloat) -> some View { + ZStack { + Circle() + .fill(controlBlack.opacity(0.94)) + .frame(width: size * 0.44, height: size * 0.44) + Circle() + .stroke(Color.white.opacity(0.07), lineWidth: size * 0.012) + .frame(width: size * 0.44, height: size * 0.44) + + Circle() + .fill( + RadialGradient( + colors: [Color(red: 0.30, green: 0.30, blue: 0.32), controlBlack], + center: .topLeading, + startRadius: 0, + endRadius: size * 0.12 + ) + ) + .frame(width: size * 0.26, height: size * 0.26) + .overlay( + Circle() + .stroke(Color.white.opacity(0.06), lineWidth: size * 0.004) + ) + .shadow(color: .black.opacity(0.35), radius: size * 0.03, y: size * 0.018) + } + } + + private func knobControl(size: CGFloat) -> some View { + let knobDiameter = size * 0.50 + return ZStack { + Text("−") + .font(.system(size: size * 0.10, weight: .medium)) + .foregroundStyle(Color.black.opacity(0.24)) + .offset(x: -size * 0.22, y: size * 0.14) + Text("+") + .font(.system(size: size * 0.10, weight: .medium)) + .foregroundStyle(Color.black.opacity(0.24)) + .offset(x: size * 0.22, y: size * 0.14) + + ZStack { + Circle() + .fill( + RadialGradient( + colors: [metalLight, metalMid, metalDark], + center: .topLeading, + startRadius: knobDiameter * 0.05, + endRadius: knobDiameter * 0.55 + ) + ) + + KnurledRing() + .stroke(metalDark.opacity(0.55), lineWidth: knobDiameter * 0.07) + .padding(knobDiameter * 0.02) + + Circle() + .stroke(Color.white.opacity(0.38), lineWidth: size * 0.006) + } + .frame(width: knobDiameter, height: knobDiameter) + .shadow(color: .black.opacity(0.24), radius: size * 0.03, y: size * 0.016) + + Capsule() + .fill(Color.white.opacity(0.82)) + .frame(width: size * 0.016, height: size * 0.075) + .offset(y: -knobDiameter * 0.34) + } + } + + private func scrollWheelControl(size: CGFloat) -> some View { + ZStack { + RoundedRectangle(cornerRadius: size * 0.035, style: .continuous) + .fill(Color.black.opacity(0.07)) + .frame(width: size * 0.58, height: size * 0.18) + + Capsule() + .fill( + LinearGradient( + colors: [metalLight, metalMid, metalDark], + startPoint: .top, + endPoint: .bottom + ) + ) + .frame(width: size * 0.50, height: size * 0.13) + .overlay( + HStack(spacing: size * 0.028) { + ForEach(0..<6, id: \.self) { _ in + Capsule() + .fill(Color.black.opacity(0.13)) + .frame(width: size * 0.007, height: size * 0.085) + } + } + ) + .shadow(color: .black.opacity(0.22), radius: size * 0.022, y: size * 0.01) + } + } + + private func dpadControl(size: CGFloat) -> some View { + let arm = size * 0.16 + let span = size * 0.50 + return ZStack { + RoundedRectangle(cornerRadius: arm * 0.32, style: .continuous) + .fill(controlBlack) + .frame(width: arm, height: span) + RoundedRectangle(cornerRadius: arm * 0.32, style: .continuous) + .fill(controlBlack) + .frame(width: span, height: arm) + + hardwareChevron(size: size * 0.07, direction: .up).offset(y: -span * 0.34) + hardwareChevron(size: size * 0.07, direction: .down).offset(y: span * 0.34) + hardwareChevron(size: size * 0.07, direction: .left).offset(x: -span * 0.34) + hardwareChevron(size: size * 0.07, direction: .right).offset(x: span * 0.34) + } + .shadow(color: .black.opacity(0.22), radius: size * 0.022, y: size * 0.01) + } + + private func hardwareChevron(size: CGFloat, direction: HardwareChevronDirection, onRecess: Bool = false) -> some View { + HardwareChevronShape(direction: direction) + .fill(onRecess ? Color.black.opacity(0.22) : Color.white.opacity(0.42)) + .frame(width: size, height: size) + } +} + +private struct KnurledRing: Shape { + func path(in rect: CGRect) -> Path { + var path = Path() + let center = CGPoint(x: rect.midX, y: rect.midY) + let radius = min(rect.width, rect.height) / 2 + let teeth = 48 + for index in 0.. Path { + var path = Path() + let w = rect.width + let h = rect.height + switch direction { + case .up: + path.move(to: CGPoint(x: w * 0.5, y: 0)) + path.addLine(to: CGPoint(x: w, y: h)) + path.addLine(to: CGPoint(x: 0, y: h)) + case .down: + path.move(to: CGPoint(x: w * 0.5, y: h)) + path.addLine(to: CGPoint(x: w, y: 0)) + path.addLine(to: CGPoint(x: 0, y: 0)) + case .left: + path.move(to: CGPoint(x: 0, y: h * 0.5)) + path.addLine(to: CGPoint(x: w, y: h)) + path.addLine(to: CGPoint(x: w, y: 0)) + case .right: + path.move(to: CGPoint(x: w, y: h * 0.5)) + path.addLine(to: CGPoint(x: 0, y: h)) + path.addLine(to: CGPoint(x: 0, y: 0)) + } + path.closeSubpath() + return path + } +} diff --git a/ahakeyconfig-mac/Sources/Views/IslandKeyboard/MagneticModuleInspectorView.swift b/ahakeyconfig-mac/Sources/Views/IslandKeyboard/MagneticModuleInspectorView.swift new file mode 100644 index 00000000..3974dd97 --- /dev/null +++ b/ahakeyconfig-mac/Sources/Views/IslandKeyboard/MagneticModuleInspectorView.swift @@ -0,0 +1,477 @@ +import SwiftUI + +struct MagneticModuleInspectorView: View { + @Binding var moduleState: MagneticModuleState + var onToggleVirtualSwitch: (() -> Void)? + var isEmbedded: Bool = true + + @State private var lockedModuleHint: MagneticModuleType? + + private let columns = [GridItem(.adaptive(minimum: 72), spacing: 8)] + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + InspectorSection(title: "磁吸模块", isEmbedded: isEmbedded) { + if !moduleState.isConnected { + HStack(spacing: 8) { + Image(systemName: "square.dashed") + .foregroundStyle(.secondary) + Text("当前为空槽,请从下方选择要吸附的模块。") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(Color.primary.opacity(0.04)) + ) + } + + LazyVGrid(columns: columns, spacing: 8) { + ForEach(MagneticModuleType.attachableCases) { module in + moduleCard(module) + } + } + + Button { + moduleState.attachedType = .none + } label: { + Label("清空磁吸槽", systemImage: "xmark.circle") + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .disabled(!moduleState.isConnected) + } + + if moduleState.isConnected { + moduleControls + } + + if !MagneticModuleUnlockStore.previewAssumeAllPurchased, + !MagneticModuleUnlockStore.lockedPurchasableModules().isEmpty { + InspectorSection(title: "未解锁套件", isEmbedded: isEmbedded) { + ForEach(MagneticModuleUnlockStore.lockedPurchasableModules()) { module in + HStack(spacing: 8) { + Image(systemName: "lock.fill") + .font(.caption) + .foregroundStyle(.orange) + Text(module.title) + .font(.caption) + Spacer() + Text("需购买") + .font(.caption2) + .foregroundStyle(.secondary) + } + } + } + } + } + .alert(item: $lockedModuleHint) { module in + Alert( + title: Text("\(module.title) 未解锁"), + message: Text("该磁吸模块需单独购买套件。购买并绑定账号后将自动解锁。"), + dismissButton: .default(Text("知道了")) + ) + } + } + + @ViewBuilder + private func moduleCard(_ module: MagneticModuleType) -> some View { + let selected = moduleState.attachedType == module + let unlocked = MagneticModuleUnlockStore.isUnlocked(module) + let showsLock = MagneticModuleUnlockStore.showsLockedInUI(module) + Button { + if unlocked { + moduleState.attachedType = module + } else { + lockedModuleHint = module + } + } label: { + VStack(spacing: 6) { + ZStack(alignment: .topTrailing) { + Image(systemName: module.systemImage) + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(unlocked ? Color.primary : Color.secondary.opacity(0.45)) + if showsLock { + Image(systemName: "lock.fill") + .font(.system(size: 9, weight: .bold)) + .foregroundStyle(.orange) + .offset(x: 8, y: -6) + } else if module.isIncludedByDefault { + Text("免费") + .font(.system(size: 8, weight: .bold)) + .padding(.horizontal, 4) + .padding(.vertical, 2) + .background(Capsule().fill(Color.green.opacity(0.18))) + .foregroundStyle(.green) + .offset(x: 10, y: -8) + } else if module.requiresPurchase, MagneticModuleUnlockStore.previewAssumeAllPurchased { + Text("测试") + .font(.system(size: 8, weight: .bold)) + .padding(.horizontal, 4) + .padding(.vertical, 2) + .background(Capsule().fill(Color.blue.opacity(0.14))) + .foregroundStyle(.blue) + .offset(x: 10, y: -8) + } + } + Text(module.title) + .font(.caption.weight(.semibold)) + .foregroundStyle(unlocked ? Color.primary : Color.secondary) + Text(MagneticModuleUnlockStore.moduleCardSubtitle(for: module)) + .font(.system(size: 9)) + .foregroundStyle(showsLock ? Color.orange.opacity(0.85) : Color.secondary) + .lineLimit(2) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + .padding(.horizontal, 6) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(selected ? Color.accentColor.opacity(0.14) : Color(nsColor: .controlBackgroundColor)) + .opacity(unlocked ? 1 : 0.72) + ) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(selected ? Color.accentColor : Color.black.opacity(0.08), lineWidth: 1) + ) + } + .buttonStyle(.plain) + .disabled(!unlocked && !module.requiresPurchase) + } + + @ViewBuilder + private var moduleControls: some View { + InspectorSection(title: "\(moduleState.attachedType.title) 模拟", isEmbedded: isEmbedded) { + switch moduleState.attachedType { + case .none: + EmptyView() + case .toggle: + toggleControls + case .joystick: + joystickControls + case .knob: + knobControls + case .scrollWheel: + scrollControls + case .dpad: + dpadControls + } + } + } + + private var toggleControls: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 8) { + Text("档位") + .font(.callout) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) + Spacer(minLength: 4) + Picker("", selection: $moduleState.togglePosition) { + Text("自动批准").tag(0) + Text("手动批准").tag(1) + Text("中间档").tag(2) + } + .pickerStyle(.menu) + .labelsHidden() + .frame(maxWidth: 120, alignment: .trailing) + } + Text("当前:\(moduleState.toggleTitle)") + .font(.caption) + .foregroundStyle(.secondary) + if let onToggleVirtualSwitch { + Button("同步虚拟拨杆(0x91 占位)") { + onToggleVirtualSwitch() + } + .buttonStyle(.bordered) + .controlSize(.small) + } + } + .padding(.top, 4) + } + + private var joystickControls: some View { + VStack(alignment: .leading, spacing: 10) { + JoystickPad(x: $moduleState.joystickX, y: $moduleState.joystickY) + .frame(height: 140) + Text("X: \(moduleState.joystickX) Y: \(moduleState.joystickY)") + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + } + .padding(.top, 4) + } + + private var knobControls: some View { + VStack(alignment: .leading, spacing: 8) { + Stepper("累计增量:\(moduleState.knobDelta)", value: $moduleState.knobDelta, in: -999...999) + Button("归零") { moduleState.knobDelta = 0 } + .buttonStyle(.borderless) + } + .padding(.top, 4) + } + + private var scrollControls: some View { + VStack(alignment: .leading, spacing: 8) { + Stepper("滚动增量:\(moduleState.scrollDelta)", value: $moduleState.scrollDelta, in: -999...999) + Button("归零") { moduleState.scrollDelta = 0 } + .buttonStyle(.borderless) + } + .padding(.top, 4) + } + + private var dpadControls: some View { + VStack(spacing: 8) { + HStack(spacing: 8) { + dpadButton(.up) + } + HStack(spacing: 8) { + dpadButton(.left) + dpadButton(.center) + dpadButton(.right) + } + HStack(spacing: 8) { + dpadButton(.down) + } + if let dir = moduleState.dpadDirection { + Text("当前方向:\(dir.title)") + .font(.caption) + .foregroundStyle(.secondary) + } + } + .frame(maxWidth: .infinity) + .padding(.top, 4) + } + + private func dpadButton(_ direction: DPadDirection) -> some View { + let selected = moduleState.dpadDirection == direction + return Button { + moduleState.dpadDirection = direction + } label: { + Text(direction.title) + .font(.callout.weight(.semibold)) + .frame(width: 44, height: 36) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(selected ? Color.accentColor.opacity(0.2) : Color(nsColor: .controlBackgroundColor)) + ) + } + .buttonStyle(.plain) + } +} + +private struct JoystickPad: View { + @Binding var x: Int + @Binding var y: Int + + var body: some View { + GeometryReader { proxy in + let size = min(proxy.size.width, proxy.size.height) + let center = CGPoint(x: proxy.size.width / 2, y: proxy.size.height / 2) + let knobX = center.x + CGFloat(x) / 100 * (size / 2 - 16) + let knobY = center.y - CGFloat(y) / 100 * (size / 2 - 16) + + ZStack { + RoundedRectangle(cornerRadius: 12) + .fill(Color.black.opacity(0.06)) + Circle() + .stroke(Color.black.opacity(0.12), lineWidth: 1) + .frame(width: size - 24, height: size - 24) + Circle() + .fill(Color.accentColor) + .frame(width: 28, height: 28) + .shadow(radius: 3, y: 1) + .position(x: knobX, y: knobY) + .gesture( + DragGesture(minimumDistance: 0) + .onChanged { value in + let dx = value.location.x - center.x + let dy = center.y - value.location.y + let maxR = size / 2 - 16 + x = Int(max(-100, min(100, dx / maxR * 100))) + y = Int(max(-100, min(100, dy / maxR * 100))) + } + ) + } + } + } +} + +struct MagneticModuleSummaryView: View { + let moduleState: MagneticModuleState + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + summaryRow("吸附模块", value: moduleState.isConnected ? moduleState.attachedType.title : "空槽", + dot: moduleState.isConnected ? .green : .orange) + if moduleState.isConnected { + summaryRow("模块来源", value: MagneticModuleUnlockStore.moduleSourceLabel(for: moduleState.attachedType), + dot: MagneticModuleUnlockStore.isUnlocked(moduleState.attachedType) ? .green : .orange) + } + if moduleState.attachedType == .toggle { + summaryRow("拨杆档位", value: moduleState.toggleTitle) + } + if moduleState.attachedType == .joystick { + summaryRow("摇杆", value: "X \(moduleState.joystickX) · Y \(moduleState.joystickY)") + } + if moduleState.attachedType == .knob { + summaryRow("旋钮增量", value: "\(moduleState.knobDelta)") + } + if moduleState.attachedType == .scrollWheel { + summaryRow("滚轮增量", value: "\(moduleState.scrollDelta)") + } + if moduleState.attachedType == .dpad, let dir = moduleState.dpadDirection { + summaryRow("方向键", value: dir.title) + } + } + } + + private func summaryRow(_ label: String, value: String, dot: Color? = nil) -> some View { + HStack(alignment: .firstTextBaseline, spacing: 10) { + Text(label) + .font(.callout) + .foregroundStyle(.secondary) + .frame(width: 76, alignment: .leading) + HStack(spacing: 6) { + if let dot { + Circle().fill(dot).frame(width: 7, height: 7) + } + Text(value) + .font(.callout) + .lineLimit(2) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(.vertical, 8) + .overlay(alignment: .bottom) { + Rectangle().fill(Color.primary.opacity(0.06)).frame(height: 0.5) + } + } +} + +struct OceanLightInspectorView: View { + @Binding var config: OceanLightConfig + var onApply: (() -> Void)? + var isEmbedded: Bool = true + + @State private var showMoreOcean = false + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + InspectorSection(title: "灯效配置", isEmbedded: isEmbedded) { + Toggle("灯效总开关", isOn: $config.powerSwitchEnabled) + HStack { + Text("亮度") + Slider(value: $config.brightness, in: 0.1...1) + Text("\(Int(config.brightness * 100))%") + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + .frame(width: 36, alignment: .trailing) + } + + Text("预设(10 种)") + .font(.caption.weight(.semibold)) + .foregroundStyle(isEmbedded ? AhaKeyStudioEmbeddedTheme.secondaryText : .secondary) + + LazyVGrid(columns: [GridItem(.adaptive(minimum: 96), spacing: 8)], spacing: 8) { + ForEach(OceanLightPreset.all) { preset in + presetCard(preset) + } + } + } + + AhaKeyStudioDisclosureSection( + title: "更多海洋灯", + subtitle: "IMU、文字轮播与占位下发", + isEmbedded: isEmbedded, + isExpanded: $showMoreOcean + ) { + Toggle("IMU 重力联动", isOn: $config.imuEnabled) + + TextField("输入轮播文字(最多 48 字符)", text: $config.marqueeText) + .textFieldStyle(.roundedBorder) + + if let onApply { + Button("应用到设备(占位)") { onApply() } + .buttonStyle(.borderedProminent) + .disabled(!config.powerSwitchEnabled) + } + } + } + } + + private func presetCard(_ preset: OceanLightPreset) -> some View { + let selected = config.selectedPresetId == preset.id + return Button { + config.selectedPresetId = preset.id + } label: { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 4) { + ForEach(Array(preset.previewColors.enumerated()), id: \.offset) { _, color in + RoundedRectangle(cornerRadius: 3) + .fill(color) + .frame(height: 8) + } + } + Text(preset.name) + .font(.callout.weight(.semibold)) + Text("#\(preset.id)") + .font(.caption2) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(8) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(selected ? Color.accentColor.opacity(0.14) : Color(nsColor: .controlBackgroundColor)) + ) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(selected ? Color.accentColor : Color.black.opacity(0.08), lineWidth: 1) + ) + } + .buttonStyle(.plain) + } +} + +struct OceanLightSummaryView: View { + let config: OceanLightConfig + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + summaryRow("预设", value: config.selectedPreset.name) + summaryRow("亮度", value: "\(Int(config.brightness * 100))%") + summaryRow("IMU 联动", value: config.imuEnabled ? "开启" : "关闭", + dot: config.imuEnabled ? .green : .gray) + summaryRow("总开关", value: config.powerSwitchEnabled ? "开启" : "关闭", + dot: config.powerSwitchEnabled ? .green : .orange) + if !config.marqueeText.isEmpty { + summaryRow("轮播文字", value: String(config.marqueeText.prefix(24))) + } + } + } + + private func summaryRow(_ label: String, value: String, dot: Color? = nil) -> some View { + HStack(alignment: .firstTextBaseline, spacing: 10) { + Text(label) + .font(.callout) + .foregroundStyle(.secondary) + .frame(width: 76, alignment: .leading) + HStack(spacing: 6) { + if let dot { + Circle().fill(dot).frame(width: 7, height: 7) + } + Text(value) + .font(.callout) + .lineLimit(2) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(.vertical, 8) + .overlay(alignment: .bottom) { + Rectangle().fill(Color.primary.opacity(0.06)).frame(height: 0.5) + } + } +} diff --git a/ahakeyconfig-mac/Sources/Views/IslandKeyboard/MagneticModuleState.swift b/ahakeyconfig-mac/Sources/Views/IslandKeyboard/MagneticModuleState.swift new file mode 100644 index 00000000..c64bd796 --- /dev/null +++ b/ahakeyconfig-mac/Sources/Views/IslandKeyboard/MagneticModuleState.swift @@ -0,0 +1,206 @@ +import Foundation + +enum MagneticModuleType: Int, CaseIterable, Codable, Identifiable { + case none = 0 + case toggle + case joystick + case knob + case scrollWheel + case dpad + + var id: Int { rawValue } + + var title: String { + switch self { + case .none: return "无模块" + case .toggle: return "拨杆" + case .joystick: return "摇杆" + case .knob: return "旋钮" + case .scrollWheel: return "滚轮" + case .dpad: return "十字键" + } + } + + var systemImage: String { + switch self { + case .none: return "square.dashed" + case .toggle: return "switch.2" + case .joystick: return "plus.circle" + case .knob: + if #available(macOS 13, *) { return "dial.medium" } + return "circle.circle" + case .scrollWheel: + if #available(macOS 13, *) { return "scroll" } + return "arrow.up.arrow.down" + case .dpad: + if #available(macOS 14, *) { return "dpad" } + return "arrow.up.and.down.and.arrow.left.and.right" + } + } + + var subtitle: String { + switch self { + case .none: return "空槽占位" + case .toggle: return "免费 · 自动/手动批准" + case .joystick: return "二维方向输入" + case .knob: return "旋转增量" + case .scrollWheel: return "滚轮滚动" + case .dpad: return "五向导航" + } + } + + /// 出厂自带,无需购买。 + var isIncludedByDefault: Bool { self == .toggle } + + /// 磁吸扩展套件,需单独购买解锁。 + var requiresPurchase: Bool { + switch self { + case .joystick, .knob, .scrollWheel, .dpad: return true + default: return false + } + } + + static var attachableCases: [MagneticModuleType] { + allCases.filter { $0 != .none } + } + + static var purchasableCases: [MagneticModuleType] { + attachableCases.filter(\.requiresPurchase) + } +} + +enum DPadDirection: String, CaseIterable, Codable, Identifiable { + case up, down, left, right, center + + var id: String { rawValue } + + var title: String { + switch self { + case .up: return "↑" + case .down: return "↓" + case .left: return "←" + case .right: return "→" + case .center: return "●" + } + } +} + +struct MagneticModuleState: Equatable, Codable { + var attachedType: MagneticModuleType + var togglePosition: Int + var joystickX: Int + var joystickY: Int + var knobDelta: Int + var scrollDelta: Int + var dpadDirection: DPadDirection? + + static let empty = MagneticModuleState( + attachedType: .none, + togglePosition: 1, + joystickX: 0, + joystickY: 0, + knobDelta: 0, + scrollDelta: 0, + dpadDirection: nil + ) + + /// 出厂默认:磁吸底座为空槽,用户在设置中选择模块后吸附。 + static let `default` = empty + + var isConnected: Bool { attachedType != .none } + + var toggleTitle: String { + switch togglePosition { + case 0: return "自动批准" + case 2: return "中间档" + default: return "手动批准" + } + } +} + +enum MagneticModuleStateStore { + static func load() -> MagneticModuleState { + guard let data = UserDefaults.standard.data(forKey: DeviceCapabilityStorage.magneticModuleKey), + var state = try? JSONDecoder().decode(MagneticModuleState.self, from: data) else { + return .default + } + state = sanitized(state) + // 打开页面时始终从空槽开始;吸附类型仅在本会话内有效。 + state.attachedType = .none + return state + } + + static func save(_ state: MagneticModuleState) { + var clean = sanitized(state) + // 不持久化吸附模块,避免下次打开仍显示已安装控件。 + clean.attachedType = .none + guard let data = try? JSONEncoder().encode(clean) else { return } + UserDefaults.standard.set(data, forKey: DeviceCapabilityStorage.magneticModuleKey) + } + + /// 若吸附了未解锁的付费模块,回退到空槽。 + static func sanitized(_ state: MagneticModuleState) -> MagneticModuleState { + var next = state + if next.attachedType.requiresPurchase, !MagneticModuleUnlockStore.isUnlocked(next.attachedType) { + next.attachedType = .none + } + return next + } +} + +/// 磁吸扩展模块解锁状态(拨杆免费可用;其余需购买)。 +enum MagneticModuleUnlockStore { + private static let key = DeviceCapabilityStorage.magneticModuleUnlockKey + + /// 测试阶段:付费套件在 UI 上全部显示为已解锁。 + static let previewAssumeAllPurchased = true + + static func isUnlocked(_ type: MagneticModuleType) -> Bool { + if type == .none { return false } + if type.isIncludedByDefault { return true } + if previewAssumeAllPurchased, type.requiresPurchase { return true } + guard type.requiresPurchase else { return false } + return loadUnlockedRawValues().contains(type.rawValue) + } + + /// 是否应在 UI 上显示锁定态(测试阶段付费模块不显示锁)。 + static func showsLockedInUI(_ type: MagneticModuleType) -> Bool { + type.requiresPurchase && !isUnlocked(type) + } + + static func moduleSourceLabel(for type: MagneticModuleType) -> String { + if type.isIncludedByDefault { return "免费可用" } + if type.requiresPurchase { + if previewAssumeAllPurchased { return "需购买 · 测试已解锁" } + return isUnlocked(type) ? "已购买解锁" : "未解锁" + } + return "—" + } + + static func moduleCardSubtitle(for module: MagneticModuleType) -> String { + if module.isIncludedByDefault { return module.subtitle } + if showsLockedInUI(module) { return "需购买解锁" } + if previewAssumeAllPurchased, module.requiresPurchase { return "需购买 · 测试已解锁" } + return module.subtitle + } + + static func unlock(_ type: MagneticModuleType) { + guard type.requiresPurchase else { return } + var raw = loadUnlockedRawValues() + raw.insert(type.rawValue) + UserDefaults.standard.set(Array(raw).sorted(), forKey: key) + } + + static func lockedPurchasableModules() -> [MagneticModuleType] { + MagneticModuleType.purchasableCases.filter { !isUnlocked($0) } + } + + static func unlockedPurchasableModules() -> [MagneticModuleType] { + MagneticModuleType.purchasableCases.filter { isUnlocked($0) } + } + + private static func loadUnlockedRawValues() -> Set { + let list = UserDefaults.standard.array(forKey: key) as? [Int] ?? [] + return Set(list) + } +} diff --git a/ahakeyconfig-mac/Sources/Views/IslandKeyboard/OceanLightPreset.swift b/ahakeyconfig-mac/Sources/Views/IslandKeyboard/OceanLightPreset.swift new file mode 100644 index 00000000..5b395b20 --- /dev/null +++ b/ahakeyconfig-mac/Sources/Views/IslandKeyboard/OceanLightPreset.swift @@ -0,0 +1,67 @@ +import SwiftUI + +struct OceanLightPreset: Identifiable, Equatable { + let id: Int + let name: String + let previewColors: [Color] + let animationKind: OceanLightAnimationKind + + static let all: [OceanLightPreset] = [ + OceanLightPreset(id: 1, name: "深海静流", previewColors: [.cyan, .blue, .teal], animationKind: .flow), + OceanLightPreset(id: 2, name: "珊瑚脉冲", previewColors: [.orange, .pink, .red], animationKind: .pulse), + OceanLightPreset(id: 3, name: "极光带", previewColors: [.green, .purple, .blue], animationKind: .wave), + OceanLightPreset(id: 4, name: "生物荧光", previewColors: [.mint, .cyan, .yellow], animationKind: .sparkle), + OceanLightPreset(id: 5, name: "日落潮汐", previewColors: [.orange, .red, .pink], animationKind: .flow), + OceanLightPreset(id: 6, name: "冰洋", previewColors: [.white, .cyan, .blue], animationKind: .wave), + OceanLightPreset(id: 7, name: "熔岩海", previewColors: [.red, .orange, .yellow], animationKind: .flow), + OceanLightPreset(id: 8, name: "星云", previewColors: [.purple, .pink, .indigo], animationKind: .sparkle), + OceanLightPreset(id: 9, name: "翡翠池", previewColors: [.green, .mint, .teal], animationKind: .pulse), + OceanLightPreset(id: 10, name: "自定义", previewColors: [.gray, .white, .gray], animationKind: .wave), + ] + + static func preset(id: Int) -> OceanLightPreset { + all.first { $0.id == id } ?? all[0] + } +} + +enum OceanLightAnimationKind: String, Codable { + case flow + case pulse + case wave + case sparkle +} + +struct OceanLightConfig: Equatable, Codable { + var selectedPresetId: Int + var brightness: Double + var marqueeText: String + var imuEnabled: Bool + var powerSwitchEnabled: Bool + + static let `default` = OceanLightConfig( + selectedPresetId: 1, + brightness: 0.85, + marqueeText: "", + imuEnabled: true, + powerSwitchEnabled: true + ) + + var selectedPreset: OceanLightPreset { + OceanLightPreset.preset(id: selectedPresetId) + } +} + +enum OceanLightConfigStore { + static func load() -> OceanLightConfig { + guard let data = UserDefaults.standard.data(forKey: DeviceCapabilityStorage.oceanLightConfigKey), + let config = try? JSONDecoder().decode(OceanLightConfig.self, from: data) else { + return .default + } + return config + } + + static func save(_ config: OceanLightConfig) { + guard let data = try? JSONEncoder().encode(config) else { return } + UserDefaults.standard.set(data, forKey: DeviceCapabilityStorage.oceanLightConfigKey) + } +} diff --git a/ahakeyconfig-mac/Sources/Views/IslandKeyboard/OceanLightPreviewView.swift b/ahakeyconfig-mac/Sources/Views/IslandKeyboard/OceanLightPreviewView.swift new file mode 100644 index 00000000..46afc261 --- /dev/null +++ b/ahakeyconfig-mac/Sources/Views/IslandKeyboard/OceanLightPreviewView.swift @@ -0,0 +1,115 @@ +import SwiftUI + +/// Gen2 电子海洋灯效画布预览(多行 LED 矩阵,SwiftUI 模拟)。 +struct OceanLightPreviewView: View { + let config: OceanLightConfig + var rowCount: Int = 3 + var ledsPerRow: Int = 14 + + var body: some View { + TimelineView(.animation(minimumInterval: 1.0 / 30.0)) { timeline in + let time = timeline.date.timeIntervalSince1970 + GeometryReader { proxy in + let preset = config.selectedPreset + let totalCount = rowCount * ledsPerRow + let colors = oceanColors( + preset: preset, + time: time, + count: totalCount, + brightness: config.powerSwitchEnabled ? config.brightness : 0.08, + imuTilt: config.imuEnabled ? sin(time * 0.7) * 0.35 : 0 + ) + + ZStack { + RoundedRectangle(cornerRadius: min(proxy.size.height / 2, 14), style: .continuous) + .fill(Color.black.opacity(0.14)) + + if config.powerSwitchEnabled { + Canvas { context, size in + let horizontalPadding: CGFloat = 8 + let verticalPadding: CGFloat = 5 + let usableWidth = size.width - horizontalPadding * 2 + let usableHeight = size.height - verticalPadding * 2 + let colSpacing = usableWidth / CGFloat(max(ledsPerRow - 1, 1)) + let rowSpacing = usableHeight / CGFloat(max(rowCount - 1, 1)) + let dotSize = min(colSpacing * 0.55, rowSpacing * 0.62, 7) + + for row in 0.. [Color] { + guard brightness > 0.05 else { + return Array(repeating: Color.gray.opacity(0.15), count: count) + } + + let palette = preset.previewColors + return (0.. Double { + switch kind { + case .flow: return 1.0 + case .pulse: return 0.85 + case .wave: return 0.75 + case .sparkle: return 1.25 + } + } +} diff --git a/ahakeyconfig-mac/Sources/Views/IslandKeyboard/VibeBarCommandCenterViews.swift b/ahakeyconfig-mac/Sources/Views/IslandKeyboard/VibeBarCommandCenterViews.swift new file mode 100644 index 00000000..35b05fd4 --- /dev/null +++ b/ahakeyconfig-mac/Sources/Views/IslandKeyboard/VibeBarCommandCenterViews.swift @@ -0,0 +1,417 @@ +import SwiftUI +import VibeBar + +// MARK: - Chassis tokens + +enum VibeBarChassisTheme { + static let oledCorner: CGFloat = 10 + static let keyCorner: CGFloat = 8 + /// 内容相对灵动岛黑壳的内边距(岛缘=键盘缘,只留键帽呼吸边)。 + static let contentInset: CGFloat = 12 + + static let keyFace = Color(red: 0.16, green: 0.17, blue: 0.19) + static let keyFacePressed = Color(red: 0.11, green: 0.12, blue: 0.14) +} + +// MARK: - Edge-to-edge keyboard body (island shell IS the chassis) + +/// 灵动岛黑壳即机身外缘:按组件库启用顺序渲染模块。 +struct VibeBarHardwareChassis: View { + let status: VibeBarAgentStatus + let state: VibeBarState + let taskTitle: String + let progress: Double + let keys: [VibeBarCommandKey] + @Binding var isSoundMuted: Bool + var onOpenMainWindow: () -> Void + var onQuit: () -> Void + var onPetTap: () -> Void + var petAppearance: VibeBarPetAppearance = VibeBarPetAppearanceSettings.appearance + @ObservedObject private var focusTimer = VibeBarFocusTimerStore.shared + + private var modules: [VibeBarExpandedModule] { + state.enabledExpandedModules.isEmpty + ? VibeBarExpandedModule.defaultEnabledOrder + : state.enabledExpandedModules + } + + var body: some View { + VStack(spacing: 0) { + chromeRow + .padding(.horizontal, VibeBarChassisTheme.contentInset) + .padding(.top, 4) + .padding(.bottom, modules.contains(.lightStrip) || modules.contains(.focusChip) ? 6 : 10) + + ForEach(modules) { module in + moduleView(module) + } + } + .background(Color.clear) + } + + @ViewBuilder + private func moduleView(_ module: VibeBarExpandedModule) -> some View { + switch module { + case .lightStrip: + VibeBarAgentLightStrip( + status: status, + ledCount: 40, + height: 16, + showsRecess: false + ) + .padding(.horizontal, VibeBarChassisTheme.contentInset) + .padding(.bottom, 10) + case .focusChip: + // 专注芯片已在 chromeRow;若单独启用且未在顶栏展示则补一条 + EmptyView() + case .oledPet: + VibeBarAIPetOLED( + status: status, + taskTitle: taskTitle, + progress: progress, + appearance: petAppearance, + onTap: onPetTap + ) + .padding(.horizontal, VibeBarChassisTheme.contentInset) + .padding(.bottom, 12) + case .keyPad: + VibeBarCommandKeyPad(keys: keys, highlightStatus: status) + .padding(.horizontal, VibeBarChassisTheme.contentInset) + .padding(.bottom, 10) + case .magneticBase: + VibeBarMagneticBaseStatus(state: state, focusTimer: focusTimer) + .padding(.horizontal, VibeBarChassisTheme.contentInset) + .padding(.bottom, 8) + } + } + + private var chromeRow: some View { + HStack(spacing: 8) { + Text("VibeBar") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.white.opacity(0.55)) + .tracking(0.4) + + if modules.contains(.focusChip) { + focusChip + } + + Spacer(minLength: 0) + + VibeBarIslandMenuBar( + isSoundMuted: $isSoundMuted, + onOpenMainWindow: onOpenMainWindow, + onQuit: onQuit + ) + .scaleEffect(0.92, anchor: .trailing) + } + } + + private var focusChip: some View { + Button { + focusTimer.toggle() + } label: { + HStack(spacing: 4) { + Image(systemName: focusTimer.isRunning ? "pause.fill" : "play.fill") + .font(.system(size: 8, weight: .bold)) + Text(focusTimer.displayText) + .font(.system(size: 10, weight: .bold, design: .rounded)) + .monospacedDigit() + } + .foregroundStyle(focusTimer.isRunning ? Color(red: 1.0, green: 0.78, blue: 0.28) : .white.opacity(0.7)) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background( + Capsule(style: .continuous) + .fill(Color.white.opacity(focusTimer.isRunning ? 0.12 : 0.06)) + ) + } + .buttonStyle(.plain) + .help(focusTimer.isRunning ? "暂停专注" : "开始专注") + } +} + +// MARK: - OLED AI Pet + +struct VibeBarAIPetOLED: View { + let status: VibeBarAgentStatus + var taskTitle: String + var progress: Double + var appearance: VibeBarPetAppearance = VibeBarPetAppearanceSettings.appearance + var onTap: (() -> Void)? + + var body: some View { + Button { + onTap?() + } label: { + ZStack { + RoundedRectangle(cornerRadius: VibeBarChassisTheme.oledCorner + 2, style: .continuous) + .fill(Color.white.opacity(0.04)) + + RoundedRectangle(cornerRadius: VibeBarChassisTheme.oledCorner, style: .continuous) + .fill( + LinearGradient( + colors: [ + Color(red: 0.05, green: 0.055, blue: 0.07), + Color(red: 0.015, green: 0.015, blue: 0.02), + ], + startPoint: .top, + endPoint: .bottom + ) + ) + .overlay( + RoundedRectangle(cornerRadius: VibeBarChassisTheme.oledCorner, style: .continuous) + .stroke(status.lightPrimary.opacity(0.4), lineWidth: 1) + ) + .padding(2) + + HStack(spacing: 14) { + petGlyph + .frame(width: appearance.petSize.glyphFontSize + 18) + + VStack(alignment: .leading, spacing: 4) { + if appearance.showsStatusLabel { + Text(status.title) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(.white.opacity(0.95)) + } + + if appearance.showsTaskTitle { + Text(taskTitle) + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(.white.opacity(0.5)) + .lineLimit(1) + } + + if appearance.showsProgress, + progress > 0, + status == .coding || status == .searching || status == .thinking { + progressBar + .padding(.top, 2) + } + } + Spacer(minLength: 0) + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + } + .frame(maxWidth: .infinity) + .frame(height: appearance.petSize.panelHeight) + } + .buttonStyle(.plain) + .frame(maxWidth: .infinity) + .accessibilityLabel("AI Pet:\(status.title)") + } + + @ViewBuilder + private var petGlyph: some View { + if let url = appearance.resolvedCustomAssetURL { + PixelArtAnimatedGIFView(path: url.path, fps: 12) + .frame(width: appearance.petSize.glyphFontSize + 8, height: appearance.petSize.glyphFontSize + 8) + .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous)) + .modifier(VibeBarPetAnimationModifier( + style: appearance.animationStyle(for: status), + speed: appearance.animationSpeed + )) + } else { + Text(appearance.glyph(for: status)) + .font(.system(size: appearance.petSize.glyphFontSize)) + .foregroundStyle(status.lightPrimary) + .modifier(VibeBarPetAnimationModifier( + style: appearance.animationStyle(for: status), + speed: appearance.animationSpeed + )) + } + } + + private var progressBar: some View { + GeometryReader { proxy in + ZStack(alignment: .leading) { + Capsule().fill(Color.white.opacity(0.08)) + Capsule() + .fill(status.lightPrimary.opacity(0.9)) + .frame(width: max(8, proxy.size.width * progress)) + } + } + .frame(height: 3) + } +} + +private struct VibeBarPetAnimationModifier: ViewModifier { + let style: VibeBarPetAnimationStyle + let speed: VibeBarPetAnimationSpeed + + func body(content: Content) -> some View { + switch style { + case .none: + content + case .breathe: + TimelineView(.animation(minimumInterval: 1.0 / 30.0)) { context in + let t = context.date.timeIntervalSinceReferenceDate + let phase = (sin(t * (.pi * 2) / speed.period) + 1) / 2 + content + .scaleEffect(0.92 + 0.08 * phase) + .opacity(0.75 + 0.25 * phase) + } + case .bounce: + TimelineView(.animation(minimumInterval: 1.0 / 30.0)) { context in + let t = context.date.timeIntervalSinceReferenceDate + let phase = abs(sin(t * (.pi * 2) / speed.period)) + content + .offset(y: -4 * phase) + } + case .blink: + TimelineView(.animation(minimumInterval: 1.0 / 30.0)) { context in + let t = context.date.timeIntervalSinceReferenceDate + let on = Int(t / (speed.period / 2)) % 2 == 0 + content.opacity(on ? 1 : 0.25) + } + } + } +} + +// MARK: - Four keycaps + +struct VibeBarCommandKey: Identifiable { + let id: String + let title: String + let subtitle: String + let systemName: String + let tint: Color + let action: () -> Void +} + +struct VibeBarCommandKeyPad: View { + let keys: [VibeBarCommandKey] + var highlightStatus: VibeBarAgentStatus = .idle + + var body: some View { + HStack(spacing: 10) { + ForEach(keys) { key in + keycap(key) + } + } + } + + private func keycap(_ key: VibeBarCommandKey) -> some View { + let emphasized = shouldEmphasize(key) + return Button(action: key.action) { + VStack(spacing: 5) { + Image(systemName: key.systemName) + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(key.tint) + Text(key.title) + .font(.system(size: 10, weight: .bold)) + .foregroundStyle(.white.opacity(0.9)) + Text(key.subtitle) + .font(.system(size: 8, weight: .medium)) + .foregroundStyle(.white.opacity(0.4)) + .lineLimit(1) + } + .frame(maxWidth: .infinity) + .frame(height: 64) + .background( + RoundedRectangle(cornerRadius: VibeBarChassisTheme.keyCorner, style: .continuous) + .fill( + LinearGradient( + colors: [ + VibeBarChassisTheme.keyFace.opacity(emphasized ? 1 : 0.95), + VibeBarChassisTheme.keyFacePressed, + ], + startPoint: .top, + endPoint: .bottom + ) + ) + ) + .overlay( + RoundedRectangle(cornerRadius: VibeBarChassisTheme.keyCorner, style: .continuous) + .stroke( + emphasized ? key.tint.opacity(0.55) : Color.white.opacity(0.1), + lineWidth: emphasized ? 1.2 : 1 + ) + ) + .overlay(alignment: .top) { + RoundedRectangle(cornerRadius: 2, style: .continuous) + .fill(Color.white.opacity(0.14)) + .frame(height: 1.5) + .padding(.horizontal, 10) + .padding(.top, 5) + } + .shadow(color: .black.opacity(0.45), radius: 1.5, y: 1) + } + .buttonStyle(VibeBarKeycapButtonStyle()) + } + + private func shouldEmphasize(_ key: VibeBarCommandKey) -> Bool { + switch highlightStatus { + case .listening: return key.id == VibeBarKeyPadRole.voice.rawValue || key.id == "record" + case .approval: + return key.id == VibeBarKeyPadRole.approve.rawValue + || key.id == VibeBarKeyPadRole.reject.rawValue + || key.id == "approve" + || key.id == "reject" + case .coding, .thinking, .searching: + return key.id == VibeBarKeyPadRole.submit.rawValue || key.id == "switch" + default: return false + } + } +} + +private struct VibeBarKeycapButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .scaleEffect(configuration.isPressed ? 0.96 : 1) + .offset(y: configuration.isPressed ? 1 : 0) + .animation(.easeOut(duration: 0.12), value: configuration.isPressed) + } +} + +// MARK: - Magnetic base + +struct VibeBarMagneticBaseStatus: View { + let state: VibeBarState + @ObservedObject var focusTimer: VibeBarFocusTimerStore + + var body: some View { + HStack(spacing: 12) { + Circle() + .fill(state.keyboardConnected ? Color(red: 0.26, green: 0.91, blue: 0.42) : Color.white.opacity(0.2)) + .frame(width: 6, height: 6) + + Text(state.keyboardConnected ? "Connected" : "Offline") + .foregroundStyle(.white.opacity(0.55)) + + Spacer(minLength: 0) + + Button { + focusTimer.toggle() + } label: { + Label(focusTimer.isRunning ? "专注中" : "专注", systemImage: "timer") + .labelStyle(.titleAndIcon) + .foregroundStyle(focusTimer.isRunning ? Color(red: 1.0, green: 0.78, blue: 0.28) : .white.opacity(0.45)) + } + .buttonStyle(.plain) + + if state.keyboardConnected { + Label("\(state.batteryLevel)%", systemImage: "battery.75percent") + .labelStyle(.titleAndIcon) + .foregroundStyle(.white.opacity(0.45)) + } + + if state.leverKnown { + Text(state.leverIsAuto ? "Auto" : "Ask") + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background(Capsule().fill(Color.white.opacity(0.08))) + .foregroundStyle(.white.opacity(0.55)) + } + } + .font(.system(size: 10, weight: .medium)) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(Color.white.opacity(0.05)) + ) + } +} diff --git a/ahakeyconfig-mac/Sources/Views/IslandKeyboard/VibeBarIslandDeviceStatusIcons.swift b/ahakeyconfig-mac/Sources/Views/IslandKeyboard/VibeBarIslandDeviceStatusIcons.swift new file mode 100644 index 00000000..06bdeb2d --- /dev/null +++ b/ahakeyconfig-mac/Sources/Views/IslandKeyboard/VibeBarIslandDeviceStatusIcons.swift @@ -0,0 +1,123 @@ +import SwiftUI +import VibeBar + +/// 灵动岛 / 硬件 Tab 顶栏设备状态(仅图标,无文字)。 +struct VibeBarIslandDeviceStatusIcons: View { + let keyboardConnected: Bool + let batteryLevel: Int + let voiceRecording: Bool + let voiceListening: Bool + let leverIsAuto: Bool + let leverKnown: Bool + + init(state: VibeBarState) { + keyboardConnected = state.keyboardConnected + batteryLevel = state.batteryLevel + voiceRecording = state.voiceRecording + voiceListening = state.voiceListening + leverIsAuto = state.leverIsAuto + leverKnown = state.leverKnown + } + + init( + keyboardConnected: Bool, + batteryLevel: Int, + voiceRecording: Bool = false, + voiceListening: Bool = false, + leverIsAuto: Bool = false, + leverKnown: Bool = false + ) { + self.keyboardConnected = keyboardConnected + self.batteryLevel = batteryLevel + self.voiceRecording = voiceRecording + self.voiceListening = voiceListening + self.leverIsAuto = leverIsAuto + self.leverKnown = leverKnown + } + + private let iconFont = Font.system(size: 11, weight: .semibold) + private let accentCyan = Color(red: 0.337, green: 0.761, blue: 1.0) + + var body: some View { + HStack(spacing: 6) { + statusIcon( + systemName: keyboardConnected ? "keyboard.fill" : "keyboard", + tint: keyboardConnected ? accentCyan : .white.opacity(0.38), + accessibilityLabel: keyboardConnected ? "键盘已连接" : "等待设备" + ) + + if keyboardConnected { + statusIcon( + systemName: batterySymbol, + tint: batteryTint, + accessibilityLabel: "电量 \(batteryLevel)%" + ) + } else { + statusIcon( + systemName: "antenna.radiowaves.left.and.right", + tint: .white.opacity(0.38), + accessibilityLabel: "等待设备连接" + ) + } + + statusIcon( + systemName: voiceIconName, + tint: voiceTint, + accessibilityLabel: voiceAccessibilityLabel + ) + + if leverKnown { + statusIcon( + systemName: leverIsAuto ? "checkmark.circle.fill" : "hand.raised.fill", + tint: leverIsAuto ? Color(red: 0.26, green: 0.91, blue: 0.42) : .orange.opacity(0.9), + accessibilityLabel: leverIsAuto ? "自动放行" : "手动确认" + ) + } + } + } + + private var batterySymbol: String { + switch batteryLevel { + case 0...10: return "battery.0percent" + case 11...35: return "battery.25percent" + case 36...65: return "battery.50percent" + case 66...90: return "battery.75percent" + default: return "battery.100percent" + } + } + + private var batteryTint: Color { + if batteryLevel <= 15 { return .orange.opacity(0.92) } + return .white.opacity(0.62) + } + + private var voiceIconName: String { + if voiceRecording { return "mic.fill" } + if voiceListening { return "mic" } + return "mic.slash" + } + + private var voiceTint: Color { + if voiceRecording { return Color(red: 1.0, green: 0.35, blue: 0.35) } + if voiceListening { return Color(red: 0.26, green: 0.91, blue: 0.42) } + return .white.opacity(0.38) + } + + private var voiceAccessibilityLabel: String { + if voiceRecording { return "语音录制中" } + if voiceListening { return "语音监听中" } + return "语音未激活" + } + + private func statusIcon( + systemName: String, + tint: Color, + accessibilityLabel: String + ) -> some View { + Image(systemName: systemName) + .font(iconFont) + .foregroundStyle(tint) + .frame(width: 22, height: 22) + .accessibilityLabel(accessibilityLabel) + } +} diff --git a/ahakeyconfig-mac/Sources/Views/IslandKeyboard/VibeBarIslandExpandedView.swift b/ahakeyconfig-mac/Sources/Views/IslandKeyboard/VibeBarIslandExpandedView.swift new file mode 100644 index 00000000..565c8bc0 --- /dev/null +++ b/ahakeyconfig-mac/Sources/Views/IslandKeyboard/VibeBarIslandExpandedView.swift @@ -0,0 +1,207 @@ +import AppKit +import SwiftUI +import VibeBar + +/// VibeBar Agent Command Center:单机身剪影(灯带 × OLED × 四键 × 底座)。 +struct VibeBarIslandExpandedView: View { + @ObservedObject var bleManager: AhaKeyBLEManager + @ObservedObject var state: VibeBarState + + @StateObject private var voiceRelay = VoiceRelayService.shared + @StateObject private var nativeSpeech = NativeSpeechTranscriptionService.shared + @AppStorage(VibeBarIslandSoundSettings.mutedDefaultsKey) private var isIslandSoundMuted = false + @State private var showingQuitConfirmation = false + @State private var previewStatusIndex = 0 + @State private var usingPreviewOverride = false + @State private var keyPadSlots = VibeBarKeyPadSettings.slots + @State private var petAppearance = VibeBarPetAppearanceSettings.appearance + + private let notchLaneHeaderOffset: CGFloat = -10 + + private var displayStatus: VibeBarAgentStatus { + if let live = liveDerivedStatus { + return live + } + if usingPreviewOverride { + return VibeBarAgentStatus.allCases[previewStatusIndex % VibeBarAgentStatus.allCases.count] + } + return state.agentStatus + } + + private var liveDerivedStatus: VibeBarAgentStatus? { + if state.voiceRecording { return .listening } + if let raw = state.liveIDEStateValue, let ide = IDEState(rawValue: UInt8(raw)) { + return mapIDEState(ide) + } + if state.agentRunning { return .thinking } + return nil + } + + var body: some View { + VibeBarHardwareChassis( + status: displayStatus, + state: state, + taskTitle: taskTitle(for: displayStatus), + progress: progress(for: displayStatus), + keys: commandKeys, + isSoundMuted: $isIslandSoundMuted, + onOpenMainWindow: openMainWindow, + onQuit: { showingQuitConfirmation = true }, + onPetTap: cyclePreviewStatus, + petAppearance: petAppearance + ) + .frame(width: state.islandWidth) + .padding(.top, max(0, -notchLaneHeaderOffset)) + .offset(y: notchLaneHeaderOffset) + .contentShape(Rectangle()) + .onHover { state.onIslandHoverChanged?($0) } + .alert("退出 AhaKey Studio?", isPresented: $showingQuitConfirmation) { + Button("退出", role: .destructive) { + NSApplication.shared.terminate(nil) + } + Button("取消", role: .cancel) {} + } message: { + Text("将关闭主窗口与灵动岛。") + } + .onAppear { + state.onIslandAppear?() + voiceRelay.updateRoutes(from: AhaKeyStudioStore.load() ?? .default) + usingPreviewOverride = liveDerivedStatus == nil + keyPadSlots = VibeBarKeyPadSettings.slots + petAppearance = VibeBarPetAppearanceSettings.appearance + } + .onChange(of: state.voiceRecording) { _ in refreshPreviewGate() } + .onChange(of: state.agentRunning) { _ in refreshPreviewGate() } + .onChange(of: state.liveIDEStateValue) { _ in refreshPreviewGate() } + .onReceive(NotificationCenter.default.publisher(for: VibeBarKeyPadSettings.didChangeNotification)) { _ in + keyPadSlots = VibeBarKeyPadSettings.slots + } + .onReceive(NotificationCenter.default.publisher(for: VibeBarPetAppearanceSettings.didChangeNotification)) { _ in + petAppearance = VibeBarPetAppearanceSettings.appearance + } + } + + private var commandKeys: [VibeBarCommandKey] { + let visible = keyPadSlots.filter(\.isEnabled) + let slots = visible.isEmpty ? Array(keyPadSlots.prefix(1)) : visible + return slots.map { makeCommandKey(from: $0) } + } + + private func makeCommandKey(from slot: VibeBarKeyPadSlot) -> VibeBarCommandKey { + let systemName: String = { + if slot.role == .voice { + return state.voiceRecording ? "mic.fill" : slot.systemImage + } + return slot.systemImage + }() + let tint: Color = { + if slot.role == .voice, state.voiceRecording { + return Color(red: 1.0, green: 0.35, blue: 0.35) + } + return slot.tintColor + }() + let subtitle: String = { + if slot.role == .approve, state.leverKnown { + return state.leverIsAuto ? "Auto" : "Ask" + } + return slot.id.uppercased() + }() + + return VibeBarCommandKey( + id: slot.role.rawValue, + title: slot.title, + subtitle: subtitle, + systemName: systemName, + tint: tint + ) { + performRole(slot.role) + } + } + + private func performRole(_ role: VibeBarKeyPadRole) { + switch role { + case .voice: + if let onKeyRecord = state.onKeyRecord { + onKeyRecord() + } else { + nativeSpeech.toggleRecordingFromVoiceKey() + } + case .approve: + if let onKeyApprove = state.onKeyApprove { + onKeyApprove() + } else { + state.onOpenApprove?() + } + case .reject: + if let onKeyReject = state.onKeyReject { + onKeyReject() + } else { + openMainWindow() + StudioNavigationRouter.shared.navigate(to: .approve) + } + case .submit: + if let onKeySwitch = state.onKeySwitch { + onKeySwitch() + } else { + cyclePreviewStatus() + } + } + } + + private func mapIDEState(_ ide: IDEState) -> VibeBarAgentStatus { + switch ide { + case .permissionRequest: return .approval + case .preToolUse: return .coding + case .postToolUse, .userPromptSubmit: return .thinking + case .taskCompleted: return .completed + case .sessionStart: return .searching + case .notification: return .thinking + case .stop, .sessionEnd: return .idle + } + } + + private func taskTitle(for status: VibeBarAgentStatus) -> String { + if !state.agentTaskTitle.isEmpty { return state.agentTaskTitle } + switch status { + case .idle: return "Ready for next task" + case .listening: return "Voice input active" + case .thinking: return "Analyzing context" + case .searching: return "Scanning project" + case .coding: return "Generating changes" + case .approval: return "Waiting for your confirm" + case .completed: return "Task finished" + case .error: return "Something went wrong" + } + } + + private func progress(for status: VibeBarAgentStatus) -> Double { + if state.agentProgress > 0 { return min(1, max(0, state.agentProgress)) } + switch status { + case .thinking: return 0.35 + case .searching: return 0.55 + case .coding: return 0.72 + default: return 0 + } + } + + private func cyclePreviewStatus() { + usingPreviewOverride = true + previewStatusIndex = (previewStatusIndex + 1) % VibeBarAgentStatus.allCases.count + VibeBarIslandSoundSettings.playInteractionIfEnabled() + } + + private func refreshPreviewGate() { + if liveDerivedStatus != nil { + usingPreviewOverride = false + } + } + + private func openMainWindow() { + state.onOpenMainWindow?() + state.onIslandCompact?() + // 等主窗口前台后再切 Tab,避免窗口尚未重建时通知丢失。 + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + StudioNavigationRouter.shared.selectSettingsTab(.hardware) + } + } +} diff --git a/ahakeyconfig-mac/Sources/Views/IslandKeyboard/VibeBarIslandMenuBar.swift b/ahakeyconfig-mac/Sources/Views/IslandKeyboard/VibeBarIslandMenuBar.swift new file mode 100644 index 00000000..7df6b8c7 --- /dev/null +++ b/ahakeyconfig-mac/Sources/Views/IslandKeyboard/VibeBarIslandMenuBar.swift @@ -0,0 +1,59 @@ +import SwiftUI + +/// Vibe Island 顶栏菜单按钮(对齐 VibeBar.app `openedHeaderButtons`)。 +struct VibeBarIslandMenuBar: View { + @Binding var isSoundMuted: Bool + var onOpenMainWindow: () -> Void + var onQuit: () -> Void + + private let buttonSize: CGFloat = 22 + private let spacing: CGFloat = 8 + + var body: some View { + HStack(spacing: spacing) { + menuIconButton( + systemName: isSoundMuted ? "speaker.slash.fill" : "speaker.wave.2.fill", + tint: isSoundMuted ? .orange.opacity(0.92) : .white.opacity(0.62), + accessibilityLabel: isSoundMuted ? "开启提示音" : "关闭提示音" + ) { + isSoundMuted.toggle() + if !isSoundMuted { + VibeBarIslandSoundSettings.playInteractionIfEnabled() + } + } + + menuIconButton( + systemName: "gearshape.fill", + tint: .white.opacity(0.62), + accessibilityLabel: "打开客户端主界面" + ) { + onOpenMainWindow() + } + + menuIconButton( + systemName: "power", + tint: .white.opacity(0.62), + accessibilityLabel: "退出应用" + ) { + onQuit() + } + } + } + + private func menuIconButton( + systemName: String, + tint: Color, + accessibilityLabel: String, + action: @escaping () -> Void + ) -> some View { + Button(action: action) { + Image(systemName: systemName) + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(tint) + .frame(width: buttonSize, height: buttonSize) + .background(.white.opacity(0.08), in: Circle()) + } + .buttonStyle(.plain) + .accessibilityLabel(accessibilityLabel) + } +} diff --git a/ahakeyconfig-mac/Sources/Views/KeyMappingView.swift b/ahakeyconfig-mac/Sources/Views/KeyMappingView.swift old mode 100644 new mode 100755 diff --git a/ahakeyconfig-mac/Sources/Views/OLEDManagerView.swift b/ahakeyconfig-mac/Sources/Views/OLEDManagerView.swift old mode 100644 new mode 100755 diff --git a/ahakeyconfig-mac/Sources/Views/PixelArtAnimatedGIFView.swift b/ahakeyconfig-mac/Sources/Views/PixelArtAnimatedGIFView.swift new file mode 100644 index 00000000..9cfcb26f --- /dev/null +++ b/ahakeyconfig-mac/Sources/Views/PixelArtAnimatedGIFView.swift @@ -0,0 +1,172 @@ +import AppKit +import ImageIO +import SwiftUI + +/// 像素风 GIF 动画视图(画布 / Inspector / 预览弹窗共用)。 +struct PixelArtAnimatedGIFView: View { + let path: String + let fps: Int + var targetWidth: Int = 160 + var targetHeight: Int = 80 + var blockSize: Int = PixelArtProcessor.canvasBlockSize + + @State private var frames: [NSImage] = [] + @State private var currentFrame = 0 + @State private var gifTimer: Timer? + + var body: some View { + Group { + if !frames.isEmpty, currentFrame >= 0, currentFrame < frames.count { + Image(nsImage: frames[currentFrame]) + .interpolation(.none) + .resizable() + .aspectRatio(contentMode: .fit) + } else { + Color.black + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.black) + .onAppear { loadFrames() } + .onDisappear { + gifTimer?.invalidate() + gifTimer = nil + } + } + + private func loadFrames() { + gifTimer?.invalidate() + gifTimer = nil + + let url = URL(fileURLWithPath: path) + guard let src = CGImageSourceCreateWithURL(url as CFURL, nil) else { return } + let count = CGImageSourceGetCount(src) + guard count > 0 else { return } + + var images: [NSImage] = [] + images.reserveCapacity(count) + for i in 0 ..< count { + guard let cgImage = CGImageSourceCreateImageAtIndex(src, i, nil) else { continue } + let pixel = PixelArtProcessor.pixelateNSImage( + NSImage(cgImage: cgImage, size: .zero), + width: targetWidth, + height: targetHeight, + blockSize: blockSize + ) + images.append(pixel) + } + + frames = images + currentFrame = 0 + guard images.count > 1 else { return } + let interval = 1.0 / Double(max(fps, 1)) + gifTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { _ in + currentFrame = (currentFrame + 1) % max(1, frames.count) + } + } +} + +/// 无自定义 GIF 时的像素风默认屏(Gen2 参考渲染图:状态栏 + 波形)。 +struct PixelArtStatusScreenView: View { + var mode: AhaKeyModeSlot + var screenWidth: CGFloat + var screenHeight: CGFloat + + var body: some View { + TimelineView(.animation(minimumInterval: 1.0 / 12.0)) { timeline in + let t = timeline.date.timeIntervalSince1970 + Canvas { context, size in + context.fill(Path(CGRect(origin: .zero, size: size)), with: .color(.black)) + + let px = max(1, size.width / 40) + let py = max(1, size.height / 20) + + drawPixelText(context: &context, text: "AhaKey", x: px * 2, y: py * 1.5, px: px, py: py, color: .white) + drawPixelDot(context: &context, x: px * 14, y: py * 2, px: px, color: .green) + + drawPixelText(context: &context, text: "Listening", x: px * 2, y: py * 7, px: px, py: py, color: .white.opacity(0.9)) + drawWaveform(context: &context, t: t, x: px * 2, y: py * 10, width: size.width - px * 4, height: py * 5, px: px, py: py) + + drawPixelText(context: &context, text: mode.shortTitle, x: px * 2, y: size.height - py * 3, px: px, py: py, color: .cyan) + drawPixelText(context: &context, text: "AI", x: px * 10, y: size.height - py * 3, px: px, py: py, color: .white.opacity(0.55)) + } + } + } + + private func drawPixelDot(context: inout GraphicsContext, x: CGFloat, y: CGFloat, px: CGFloat, color: Color) { + let rect = CGRect(x: x, y: y, width: px * 1.2, height: px * 1.2) + context.fill(Path(rect), with: .color(color)) + } + + private func drawPixelText(context: inout GraphicsContext, text: String, x: CGFloat, y: CGFloat, px: CGFloat, py: CGFloat, color: Color) { + var cursor = x + for char in text { + drawChar(context: &context, char: char, x: cursor, y: y, px: px, py: py, color: color) + cursor += px * 4.5 + } + } + + private func drawChar(context: inout GraphicsContext, char: Character, x: CGFloat, y: CGFloat, px: CGFloat, py: CGFloat, color: Color) { + let pattern = PixelFont.pattern(for: char) + for (row, bits) in pattern.enumerated() { + for col in 0 ..< 3 { + if (bits >> (2 - col)) & 1 == 1 { + let rect = CGRect(x: x + CGFloat(col) * px, y: y + CGFloat(row) * py, width: px * 0.9, height: py * 0.9) + context.fill(Path(rect), with: .color(color)) + } + } + } + } + + private func drawWaveform(context: inout GraphicsContext, t: TimeInterval, x: CGFloat, y: CGFloat, width: CGFloat, height: CGFloat, px: CGFloat, py: CGFloat) { + let bars = Int(width / (px * 1.4)) + guard bars > 0 else { return } + for i in 0 ..< bars { + let phase = sin(t * 3.5 + Double(i) * 0.55) + let level = (phase + 1) / 2 + let barH = max(py, height * CGFloat(level)) + let rect = CGRect( + x: x + CGFloat(i) * px * 1.4, + y: y + height - barH, + width: px, + height: barH + ) + context.fill(Path(rect), with: .color(.blue.opacity(0.85))) + } + } +} + +/// 3×5 极简像素字体(大写字母 + 数字 + 常用符号)。 +private enum PixelFont { + static func pattern(for char: Character) -> [UInt8] { + switch String(char).uppercased().first { + case "A": return [0b010, 0b101, 0b111, 0b101, 0b101] + case "B": return [0b110, 0b101, 0b110, 0b101, 0b110] + case "C": return [0b011, 0b100, 0b100, 0b100, 0b011] + case "D": return [0b110, 0b101, 0b101, 0b101, 0b110] + case "E": return [0b111, 0b100, 0b110, 0b100, 0b111] + case "G": return [0b011, 0b100, 0b101, 0b101, 0b011] + case "H": return [0b101, 0b101, 0b111, 0b101, 0b101] + case "I": return [0b111, 0b010, 0b010, 0b010, 0b111] + case "K": return [0b101, 0b110, 0b100, 0b110, 0b101] + case "L": return [0b100, 0b100, 0b100, 0b100, 0b111] + case "M": return [0b101, 0b111, 0b111, 0b101, 0b101] + case "N": return [0b101, 0b111, 0b111, 0b111, 0b101] + case "O": return [0b010, 0b101, 0b101, 0b101, 0b010] + case "R": return [0b110, 0b101, 0b110, 0b101, 0b101] + case "S": return [0b011, 0b100, 0b010, 0b001, 0b110] + case "T": return [0b111, 0b010, 0b010, 0b010, 0b010] + case "U": return [0b101, 0b101, 0b101, 0b101, 0b111] + case "V": return [0b101, 0b101, 0b101, 0b101, 0b010] + case "W": return [0b101, 0b101, 0b111, 0b111, 0b101] + case "Y": return [0b101, 0b101, 0b010, 0b010, 0b010] + case "0": return [0b010, 0b101, 0b101, 0b101, 0b010] + case "1": return [0b010, 0b110, 0b010, 0b010, 0b111] + case "2": return [0b110, 0b001, 0b110, 0b100, 0b111] + case "3": return [0b110, 0b001, 0b010, 0b001, 0b110] + case ".": return [0b000, 0b000, 0b000, 0b000, 0b010] + case "-": return [0b000, 0b000, 0b111, 0b000, 0b000] + default: return [0b000, 0b000, 0b000, 0b000, 0b000] + } + } +} diff --git a/ahakeyconfig-mac/Sources/Views/Settings/AhaKeyAgentAssistantPane.swift b/ahakeyconfig-mac/Sources/Views/Settings/AhaKeyAgentAssistantPane.swift new file mode 100644 index 00000000..2174c1a0 --- /dev/null +++ b/ahakeyconfig-mac/Sources/Views/Settings/AhaKeyAgentAssistantPane.swift @@ -0,0 +1,643 @@ +import SwiftUI +import VibeBar + +private struct AgentChatMessage: Identifiable, Equatable { + let id: UUID + let isUser: Bool + var text: String + var isPending: Bool + var followUps: [String] + + init( + id: UUID = UUID(), + isUser: Bool, + text: String, + isPending: Bool = false, + followUps: [String] = [] + ) { + self.id = id + self.isUser = isUser + self.text = text + self.isPending = isPending + self.followUps = followUps + } +} + +struct AhaKeyAgentAssistantPane: View { + @ObservedObject var bleManager: AhaKeyBLEManager + @StateObject private var agentManager = AgentManager.shared + @State private var petAppearance = VibeBarPetAppearanceSettings.appearance + @State private var draft = "" + @State private var isTyping = false + @State private var messages: [AgentChatMessage] = [ + AgentChatMessage( + isUser: false, + text: "我是设备管家。先启用联动,再说「查状态」或点下面的建议。", + followUps: ["一键启用联动", "帮我查一下联动状态", "打开联动页"] + ) + ] + + private var tools: AgentAssistantTools { AgentAssistantTools(bleManager: bleManager) } + + private var isLinkageReady: Bool { + agentManager.isInstalled + && agentManager.isRunning + && agentManager.bluetoothConnectionOwner == .agentDaemon + } + + private var headerStatus: VibeBarAgentStatus { + isTyping ? .thinking : .idle + } + + private var statusLine: String { + if isTyping { return "正在处理…" } + if isLinkageReady { return "联动就绪 · 可以说自然语言下达任务" } + return "联动未就绪 · 可说「一键启用」" + } + + private var statusDotColor: Color { + if isTyping { return Color.orange } + if isLinkageReady { return Color.green.opacity(0.85) } + return Color.orange.opacity(0.9) + } + + var body: some View { + VStack(spacing: 0) { + identityHeader + .padding(.bottom, 16) + + messageList + .frame(maxWidth: .infinity, maxHeight: .infinity) + + Divider() + .background(AhakeySettingsTheme.divider) + .padding(.top, 4) + + bottomComposer + .padding(.top, 12) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .featureCoachTip(.agentLinkageFirst, isActive: !isLinkageReady, alignment: .topTrailing) + .onAppear { agentManager.refresh() } + .onReceive(NotificationCenter.default.publisher(for: VibeBarPetAppearanceSettings.didChangeNotification)) { _ in + petAppearance = VibeBarPetAppearanceSettings.appearance + } + } + + // MARK: - Header + + private var identityHeader: some View { + HStack(alignment: .center, spacing: 14) { + AgentPetLiveMark( + glyph: petAppearance.glyph(for: headerStatus), + tint: headerStatus.lightPrimary, + style: isTyping + ? .busy + : AgentPetLiveStyle(from: petAppearance.animationStyle(for: headerStatus)), + period: isTyping ? 0.9 : petAppearance.animationSpeed.period, + size: 56, + glyphSize: 28, + cornerRadius: 16, + onTap: { /* 轻点也有反馈,增强互动感 */ } + ) + + VStack(alignment: .leading, spacing: 5) { + Text("设备管家") + .font(.system(size: 22, weight: .bold, design: .rounded)) + .foregroundStyle(AhakeySettingsTheme.primaryText) + + HStack(spacing: 6) { + Circle() + .fill(statusDotColor) + .frame(width: 6, height: 6) + .modifier(AgentStatusDotPulse(active: isTyping || !isLinkageReady)) + Text(statusLine) + .font(.system(size: 12)) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + .lineLimit(1) + } + } + + Spacer(minLength: 8) + + Button { + StudioNavigationRouter.shared.openPetAppearanceSettings() + } label: { + Image(systemName: "paintbrush.pointed") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + .frame(width: 32, height: 32) + .background( + Circle() + .fill(AhakeySettingsTheme.controlFill) + ) + } + .buttonStyle(.plain) + .help("修改 Pet 外观(Agent · 设置)") + } + } + + // MARK: - Messages + + private var messageList: some View { + ScrollViewReader { proxy in + ScrollView(.vertical, showsIndicators: false) { + LazyVStack(alignment: .leading, spacing: 16) { + ForEach(messages) { message in + chatRow(message) + .id(message.id) + .transition(.asymmetric( + insertion: .opacity.combined(with: .move(edge: message.isUser ? .trailing : .leading)), + removal: .opacity + )) + } + } + .padding(.vertical, 4) + .frame(maxWidth: .infinity, alignment: .topLeading) + } + .onChange(of: messages) { _ in + scrollToBottom(proxy: proxy) + } + .onAppear { + scrollToBottom(proxy: proxy) + } + } + } + + @ViewBuilder + private func chatRow(_ message: AgentChatMessage) -> some View { + if message.isUser { + userBubble(message) + } else { + assistantBubble(message) + } + } + + private func userBubble(_ message: AgentChatMessage) -> some View { + HStack(alignment: .bottom, spacing: 0) { + Spacer(minLength: 72) + Text(message.text) + .font(.system(size: 13.5)) + .foregroundStyle(Color.white.opacity(0.96)) + .padding(.horizontal, 13) + .padding(.vertical, 10) + .background( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(AhakeySettingsTheme.accentBlue) + ) + } + } + + private func assistantBubble(_ message: AgentChatMessage) -> some View { + HStack(alignment: .top, spacing: 10) { + petAvatar(thinking: message.isPending) + + VStack(alignment: .leading, spacing: 8) { + Group { + if message.isPending { + typingIndicator + } else { + Text(message.text) + .font(.system(size: 13.5)) + .foregroundStyle(AhakeySettingsTheme.primaryText) + .textSelection(.enabled) + } + } + .padding(.horizontal, 13) + .padding(.vertical, 10) + .background( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(AhakeySettingsTheme.cardBackground) + ) + .overlay( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .stroke(AhakeySettingsTheme.divider, lineWidth: 1) + ) + + if !message.isPending, !message.followUps.isEmpty { + followUpLinks(message.followUps) + } + } + .frame(maxWidth: 460, alignment: .leading) + + Spacer(minLength: 48) + } + } + + private func petAvatar(thinking: Bool) -> some View { + let status: VibeBarAgentStatus = thinking ? .thinking : .idle + return AgentPetLiveMark( + glyph: petAppearance.glyph(for: status), + tint: status.lightPrimary, + style: thinking + ? .busy + : AgentPetLiveStyle(from: petAppearance.animationStyle(for: status)), + period: thinking ? 0.85 : max(1.1, petAppearance.animationSpeed.period), + size: 28, + glyphSize: 15, + cornerRadius: 14, + isCircle: true + ) + } + + private var typingIndicator: some View { + HStack(spacing: 5) { + ForEach(0..<3, id: \.self) { index in + Circle() + .fill(AhakeySettingsTheme.secondaryText) + .frame(width: 5, height: 5) + .opacity(0.4) + .modifier(AgentTypingDotBounce(index: index)) + } + Text("…") + .font(.system(size: 12)) + .foregroundStyle(AhakeySettingsTheme.tertiaryText) + } + .padding(.vertical, 2) + } + + private func followUpLinks(_ phrases: [String]) -> some View { + VStack(alignment: .leading, spacing: 5) { + ForEach(Array(phrases.enumerated()), id: \.offset) { _, phrase in + Button { + sendNatural(phrase) + } label: { + HStack(spacing: 4) { + Text("→") + .font(.system(size: 11, weight: .medium)) + Text(phrase) + .font(.system(size: 12, weight: .medium)) + .underline(true, color: AhakeySettingsTheme.accentBlue.opacity(0.35)) + } + .foregroundStyle(AhakeySettingsTheme.accentBlue) + } + .buttonStyle(.plain) + } + } + .padding(.leading, 2) + } + + // MARK: - Composer + + private var bottomComposer: some View { + VStack(alignment: .leading, spacing: 10) { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 6) { + ForEach(AgentAssistantIntent.quickChips) { intent in + Button { + sendNatural(intent.naturalPhrase) + } label: { + Text(intent.chipTitle) + .font(.system(size: 11.5, weight: .medium)) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(AhakeySettingsTheme.controlFill) + ) + } + .buttonStyle(.plain) + } + } + } + + HStack(spacing: 0) { + TextField("跟管家说点什么…", text: $draft) + .textFieldStyle(.plain) + .font(.system(size: 13.5)) + .padding(.leading, 14) + .padding(.vertical, 12) + .onSubmit(submitDraft) + + Button { + submitDraft() + } label: { + Image(systemName: "arrow.up") + .font(.system(size: 12, weight: .bold)) + .foregroundStyle( + canSend + ? Color.white + : AhakeySettingsTheme.tertiaryText + ) + .frame(width: 28, height: 28) + .background( + Circle() + .fill( + canSend + ? AhakeySettingsTheme.accentBlue + : AhakeySettingsTheme.controlFill + ) + ) + } + .buttonStyle(.plain) + .disabled(!canSend) + .padding(.trailing, 10) + .accessibilityLabel("发送") + } + .background( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(AhakeySettingsTheme.cardBackground) + ) + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .stroke(AhakeySettingsTheme.divider, lineWidth: 1) + ) + } + } + + private var canSend: Bool { + !draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + // MARK: - Actions + + private func scrollToBottom(proxy: ScrollViewProxy) { + guard let last = messages.last else { return } + DispatchQueue.main.async { + withAnimation(.easeOut(duration: 0.22)) { + proxy.scrollTo(last.id, anchor: .bottom) + } + } + } + + private func submitDraft() { + let text = draft.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { return } + draft = "" + sendNatural(text) + } + + private func sendNatural(_ text: String) { + withAnimation(.spring(response: 0.35, dampingFraction: 0.86)) { + messages.append(AgentChatMessage(isUser: true, text: text)) + } + + let pendingID = UUID() + isTyping = true + withAnimation(.easeOut(duration: 0.2)) { + messages.append(AgentChatMessage(id: pendingID, isUser: false, text: "", isPending: true)) + } + + let intents = AgentAssistantRouter.resolveAll(text) + let rawReply = tools.runAll(intents) + let bubbled = chatifyReply(rawReply, intents: intents) + let followUps = suggestedFollowUps(for: intents) + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.28) { + withAnimation(.spring(response: 0.38, dampingFraction: 0.84)) { + if let index = messages.firstIndex(where: { $0.id == pendingID }) { + messages[index] = AgentChatMessage( + id: pendingID, + isUser: false, + text: bubbled, + isPending: false, + followUps: followUps + ) + } else { + messages.append(AgentChatMessage(isUser: false, text: bubbled, followUps: followUps)) + } + isTyping = false + } + } + } + + private func chatifyReply(_ raw: String, intents: [AgentAssistantIntent]) -> String { + if intents.isEmpty { + return "这句话我还没学会。可以点下面的建议,或者说「你能做什么」。" + } + let openers = [ + "好的:", + "收到:", + "结果:", + ] + let opener = openers[abs(raw.hashValue) % openers.count] + if intents == [.help] { + return raw + } + if intents.count == 1, intents[0] == .status { + return "当前联动:\n\n\(raw)" + } + return "\(opener)\n\n\(raw)" + } + + private func suggestedFollowUps(for intents: [AgentAssistantIntent]) -> [String] { + if intents.isEmpty { + return ["你能做什么", "一键启用联动", "打开联动页"] + } + if intents.contains(.help) { + return ["一键启用联动", "帮我查一下联动状态", "把蓝牙交给 Agent"] + } + if intents.contains(.enableLinkage) || intents.contains(.startAgent) { + return ["帮我查一下联动状态", "装一下 Cursor 的 hook", "打开联动页"] + } + if intents.contains(.status) { + var tips = ["打开联动页"] + if !isLinkageReady { + tips.insert("一键启用联动", at: 0) + } else if agentManager.bluetoothConnectionOwner != .agentDaemon { + tips.insert("把蓝牙交给 Agent", at: 0) + } else { + tips.insert("打开我的设备", at: 0) + } + return Array(tips.prefix(3)) + } + if intents.contains(.giveBluetoothToAgent) { + return ["帮我查一下联动状态", "打开联动页", "打开我的设备"] + } + if intents.contains(.installCursorHook) || intents.contains(.installClaudeHook) { + return ["帮我查一下联动状态", "把蓝牙交给 Agent"] + } + if intents.contains(.openApproveKey) || intents.contains(.openVoiceKey) || intents.contains(.openHardwareMode) { + return ["帮我查一下联动状态", "把蓝牙交回 Studio"] + } + return ["帮我查一下联动状态", "打开联动页"] + } +} + +private struct AgentTypingDotBounce: ViewModifier { + let index: Int + @State private var up = false + + func body(content: Content) -> some View { + content + .offset(y: up ? -3 : 2) + .animation( + .easeInOut(duration: 0.38) + .repeatForever(autoreverses: true) + .delay(Double(index) * 0.12), + value: up + ) + .onAppear { up = true } + } +} + +/// 助手页 Pet 持续动画:空闲跟外观设置,忙碌时更活泼;可点按触发轻弹。 +private enum AgentPetLiveStyle { + case idleBreathe + case bounce + case blink + case busy + + init(from style: VibeBarPetAnimationStyle) { + switch style { + case .none, .breathe: self = .idleBreathe + case .bounce: self = .bounce + case .blink: self = .blink + } + } +} + +private struct AgentPetLiveMark: View { + let glyph: String + let tint: Color + let style: AgentPetLiveStyle + let period: TimeInterval + var size: CGFloat = 56 + var glyphSize: CGFloat = 28 + var cornerRadius: CGFloat = 16 + var isCircle: Bool = false + var onTap: (() -> Void)? = nil + + @State private var tapScale: CGFloat = 1 + + var body: some View { + Button { + withAnimation(.spring(response: 0.28, dampingFraction: 0.45)) { + tapScale = 1.14 + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.12) { + withAnimation(.spring(response: 0.42, dampingFraction: 0.62)) { + tapScale = 1 + } + } + onTap?() + } label: { + TimelineView(.animation(minimumInterval: 1.0 / 30.0)) { context in + let t = context.date.timeIntervalSinceReferenceDate + let p = max(0.45, period) + let phase = (sin(t * (.pi * 2) / p) + 1) / 2 + let busyBoost = style == .busy ? 0.08 : 0.0 + + ZStack { + // 底:主题色 + 状态 tint 的柔和渐变 + petFillShape + .fill( + LinearGradient( + colors: [ + tint.opacity(0.22 + 0.10 * phase + busyBoost), + AhakeySettingsTheme.cardBackground.opacity(0.92), + tint.opacity(0.10 + 0.06 * (1 - phase)), + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + + // 高光斑:随呼吸轻微漂移 + Ellipse() + .fill( + RadialGradient( + colors: [ + Color.white.opacity(0.18 + 0.08 * phase), + tint.opacity(0.06), + Color.clear, + ], + center: .center, + startRadius: 0, + endRadius: size * 0.42 + ) + ) + .frame(width: size * 0.55, height: size * 0.38) + .offset(x: -size * 0.12, y: -size * 0.14 - CGFloat(phase) * 1.5) + .allowsHitTesting(false) + + // 外圈:状态色描边,忙碌时略亮 + petStrokeShape + .stroke( + tint.opacity(0.28 + 0.22 * phase + busyBoost), + lineWidth: style == .busy ? 1.4 : 1 + ) + + Text(glyph) + .font(.system(size: glyphSize)) + .foregroundStyle(tint) + .shadow(color: tint.opacity(0.35 + 0.2 * phase), radius: 4 + 2 * phase, y: 1) + .modifier(AgentPetMotion(style: style, phase: phase)) + } + .frame(width: size, height: size) + .clipShape(petClipShape) + } + .scaleEffect(tapScale) + } + .buttonStyle(.plain) + .accessibilityLabel("设备管家 Pet") + .help("点一下打个招呼") + } + + private var petFillShape: AnyShape { + isCircle ? AnyShape(Circle()) : AnyShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) + } + + private var petStrokeShape: AnyShape { + petFillShape + } + + private var petClipShape: AnyShape { + petFillShape + } +} + +/// 统一圆形 / 圆角矩形,避免 `@ViewBuilder some Shape` 分支问题。 +private struct AnyShape: Shape { + private let pathBuilder: (CGRect) -> Path + + init(_ shape: S) { + pathBuilder = { rect in shape.path(in: rect) } + } + + func path(in rect: CGRect) -> Path { + pathBuilder(rect) + } +} + +private struct AgentPetMotion: ViewModifier { + let style: AgentPetLiveStyle + let phase: Double + + func body(content: Content) -> some View { + switch style { + case .idleBreathe: + content + .scaleEffect(0.94 + 0.08 * phase) + .opacity(0.78 + 0.22 * phase) + .offset(y: -1.2 * phase) + case .bounce: + content + .offset(y: -5 * abs(sin(phase * .pi))) + case .blink: + content + .opacity(phase > 0.82 ? 0.28 : 1.0) + case .busy: + content + .scaleEffect(0.9 + 0.14 * phase) + .rotationEffect(.degrees((-6 + 12 * phase) * 0.55)) + .offset(y: -2.5 * phase) + .opacity(0.82 + 0.18 * phase) + } + } +} + +private struct AgentStatusDotPulse: ViewModifier { + let active: Bool + + func body(content: Content) -> some View { + TimelineView(.animation(minimumInterval: 1.0 / 20.0)) { context in + let t = context.date.timeIntervalSinceReferenceDate + let phase = active ? (sin(t * 3.2) + 1) / 2 : 1.0 + content + .opacity(0.55 + 0.45 * phase) + .scaleEffect(0.85 + 0.2 * phase) + } + } +} diff --git a/ahakeyconfig-mac/Sources/Views/Settings/AhaKeyAgentGeneralPane.swift b/ahakeyconfig-mac/Sources/Views/Settings/AhaKeyAgentGeneralPane.swift new file mode 100644 index 00000000..5a142f5a --- /dev/null +++ b/ahakeyconfig-mac/Sources/Views/Settings/AhaKeyAgentGeneralPane.swift @@ -0,0 +1,110 @@ +import SwiftUI +import VibeBar + +/// Agent · 设置:Pet 外观主入口 + 边界与跳转说明。 +struct AhaKeyAgentGeneralPane: View { + @ObservedObject var islandState: VibeBarState + @State private var showMore = false + + var body: some View { + VStack(alignment: .leading, spacing: 20) { + VStack(alignment: .leading, spacing: 6) { + Text("设置") + .font(AhakeySettingsTheme.pageTitleFont) + .foregroundStyle(AhakeySettingsTheme.primaryText) + Text("Pet 外观(皮肤 / 图标)在此配置;动画、尺寸与信息层请到灵动岛 · 组件库。日常对话用「助手」,启用守护与蓝牙用「联动」。") + .font(AhakeySettingsTheme.rowSubtitleFont) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + } + + VStack(alignment: .leading, spacing: 10) { + Text("Pet 外观") + .font(AhakeySettingsTheme.sectionTitleFont) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + .padding(.leading, 4) + + AhaKeyPetAppearanceEditor(agentTaskTitle: islandState.agentTaskTitle) + } + + AhakeySettingsCard(sectionTitle: "怎么用") { + Text("先到「联动」一键启用;再用「助手」查状态或切蓝牙。键盘 Mode、批准键与语音键请到「硬件设备」。") + .font(AhakeySettingsTheme.rowSubtitleFont) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + .fixedSize(horizontal: false, vertical: true) + .padding(.horizontal, AhakeySettingsTheme.rowPaddingH) + .padding(.vertical, 14) + } + + AhaKeySettingsDisclosureSection( + title: "跳转与说明", + subtitle: "Mode、改键入口与安全边界", + isExpanded: $showMore + ) { + AhakeySettingsCard(sectionTitle: "面向的 AI 工具") { + VStack(alignment: .leading, spacing: 8) { + Text("键盘 Mode 对应 Claude / Cursor / Codex / custom。切换请到硬件画布下方的 Agent 模式分段。") + .font(AhakeySettingsTheme.rowSubtitleFont) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + .fixedSize(horizontal: false, vertical: true) + Button { + StudioNavigationRouter.shared.selectSettingsTab(.hardware) + } label: { + navRow("打开硬件设备 · 切换 Mode") + } + .buttonStyle(.plain) + } + .padding(.horizontal, AhakeySettingsTheme.rowPaddingH) + .padding(.vertical, 14) + } + + AhakeySettingsCard(sectionTitle: "快捷改键入口") { + Button { + StudioNavigationRouter.shared.selectSettingsTab(.hardware) + NotificationCenter.default.post( + name: .ahaKeyStudioSelectPart, + object: nil, + userInfo: [StudioNavigationUserInfoKey.part: AhaKeyStudioPart.key2.rawValue] + ) + } label: { + navRow("配置批准键(Key 2)") + } + .buttonStyle(.plain) + AhakeySettingsCardDivider() + Button { + StudioNavigationRouter.shared.selectSettingsTab(.hardware) + NotificationCenter.default.post( + name: .ahaKeyStudioSelectPart, + object: nil, + userInfo: [StudioNavigationUserInfoKey.part: AhaKeyStudioPart.key1.rawValue] + ) + } label: { + navRow("配置语音键(Key 1)") + } + .buttonStyle(.plain) + } + + AhakeySettingsCard(sectionTitle: "安全与边界") { + Text("Hook 会写入本机用户配置(如 ~/.cursor/hooks.json)。AhaKey 助手只管理设备与工作流联动,不会替代 Claude/Cursor 写代码。") + .font(AhakeySettingsTheme.rowSubtitleFont) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + .padding(.horizontal, AhakeySettingsTheme.rowPaddingH) + .padding(.vertical, 14) + } + } + } + } + + private func navRow(_ title: String) -> some View { + HStack { + Text(title) + .font(AhakeySettingsTheme.rowTitleFont) + .foregroundStyle(AhakeySettingsTheme.primaryText) + Spacer() + Image(systemName: "chevron.right") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(AhakeySettingsTheme.tertiaryText) + } + .padding(.horizontal, AhakeySettingsTheme.rowPaddingH) + .padding(.vertical, 12) + } +} diff --git a/ahakeyconfig-mac/Sources/Views/Settings/AhaKeyAgentSectionPicker.swift b/ahakeyconfig-mac/Sources/Views/Settings/AhaKeyAgentSectionPicker.swift new file mode 100644 index 00000000..75395d6b --- /dev/null +++ b/ahakeyconfig-mac/Sources/Views/Settings/AhaKeyAgentSectionPicker.swift @@ -0,0 +1,37 @@ +import SwiftUI + +/// Agent 顶部分段:助手 / 联动 / 设置。 +struct AhaKeyAgentSectionPicker: View { + @Binding var selection: AhaKeyAgentConfigSection + + var body: some View { + HStack(spacing: 0) { + ForEach(AhaKeyAgentConfigSection.allCases) { section in + Button { + selection = section + } label: { + VStack(spacing: 3) { + Text(section.title) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(selection == section ? AhakeySettingsTheme.primaryText : AhakeySettingsTheme.secondaryText) + Text(section.subtitle) + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(selection == section ? AhakeySettingsTheme.accentBlue : AhakeySettingsTheme.tertiaryText) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(selection == section ? AhakeySettingsTheme.controlFill : Color.clear) + ) + } + .buttonStyle(.plain) + } + } + .padding(4) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(AhakeySettingsTheme.cardBackground) + ) + } +} diff --git a/ahakeyconfig-mac/Sources/Views/Settings/AhaKeyAgentStatusPane.swift b/ahakeyconfig-mac/Sources/Views/Settings/AhaKeyAgentStatusPane.swift new file mode 100644 index 00000000..bf153017 --- /dev/null +++ b/ahakeyconfig-mac/Sources/Views/Settings/AhaKeyAgentStatusPane.swift @@ -0,0 +1,326 @@ +import SwiftUI + +struct AhaKeyAgentStatusPane: View { + @ObservedObject var bleManager: AhaKeyBLEManager + @StateObject private var agentManager = AgentManager.shared + @State private var showAdvanced = false + + private var isLinkageReady: Bool { + agentManager.isInstalled + && agentManager.isRunning + && agentManager.bluetoothConnectionOwner == .agentDaemon + } + + private var hasAnyHook: Bool { + agentManager.claudeHooksInstalled + || agentManager.cursorHooksInstalled + || agentManager.codexHooksInstalled + || agentManager.kimiHooksInstalled + } + + private var readinessDetail: String { + if isLinkageReady { + if hasAnyHook { + return "守护进程运行中,蓝牙由 Agent 占用,Hook 已就绪。" + } + return "守护与蓝牙已就绪。建议安装 Cursor Hook,IDE 才能驱动灯条。" + } + var missing: [String] = [] + if !agentManager.isInstalled { missing.append("未安装守护进程") } + else if !agentManager.isRunning { missing.append("守护进程未运行") } + if agentManager.bluetoothConnectionOwner != .agentDaemon { + missing.append("蓝牙未交给 Agent") + } + return missing.joined(separator: " · ") + } + + var body: some View { + VStack(alignment: .leading, spacing: 20) { + VStack(alignment: .leading, spacing: 6) { + Text("联动") + .font(AhakeySettingsTheme.pageTitleFont) + .foregroundStyle(AhakeySettingsTheme.primaryText) + Text("先启用联动,再日常用助手对话;改键请到硬件设备。") + .font(AhakeySettingsTheme.rowSubtitleFont) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + } + + readinessCard + bluetoothCard + hooksOverviewCard + + AhaKeySettingsDisclosureSection( + title: "高级设置", + subtitle: "守护进程细节、其它 Hook、Mode 与诊断", + isExpanded: $showAdvanced + ) { + daemonDetailCard + otherHooksCard + modeSummaryCard + diagnosticsCard + } + } + .onAppear { agentManager.refresh() } + } + + private var readinessCard: some View { + AhakeySettingsCard(sectionTitle: "联动就绪") { + HStack(alignment: .center, spacing: 16) { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 10) { + Circle() + .fill(isLinkageReady ? Color.green : Color.orange) + .frame(width: 10, height: 10) + Text(isLinkageReady ? "已就绪" : "未就绪") + .font(AhakeySettingsTheme.rowTitleFont) + .foregroundStyle(AhakeySettingsTheme.primaryText) + } + + Text(readinessDetail) + .font(AhakeySettingsTheme.rowSubtitleFont) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + .fixedSize(horizontal: false, vertical: true) + } + + Spacer(minLength: 12) + + if !isLinkageReady { + Button { + enableLinkage() + } label: { + HStack(spacing: 6) { + Text("一键启用联动") + if agentManager.isAgentOperationInProgress { + ProgressView().controlSize(.small) + } + } + } + .buttonStyle(.borderedProminent) + .controlSize(.regular) + .disabled(agentManager.isAgentOperationInProgress || !agentManager.isAgentBinaryPresentInBundle) + } + } + .padding(.horizontal, AhakeySettingsTheme.rowPaddingH) + .padding(.vertical, 14) + } + } + + private var bluetoothCard: some View { + AhakeySettingsCard(sectionTitle: "蓝牙占用") { + VStack(alignment: .leading, spacing: 10) { + Text("日常写代码选 Agent;改键前切回 Studio。") + .font(AhakeySettingsTheme.rowSubtitleFont) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + Picker("", selection: Binding( + get: { agentManager.bluetoothConnectionOwner }, + set: { agentManager.setBluetoothConnectionOwner($0, bleManager: bleManager) } + )) { + ForEach(BluetoothConnectionOwner.allCases) { owner in + Text(owner.title).tag(owner) + } + } + .pickerStyle(.segmented) + .labelsHidden() + } + .padding(.horizontal, AhakeySettingsTheme.rowPaddingH) + .padding(.vertical, 14) + } + } + + private var hooksOverviewCard: some View { + AhakeySettingsCard(sectionTitle: "IDE Hooks") { + HStack(alignment: .center, spacing: 16) { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 10) { + hookBadge("Claude", installed: agentManager.claudeHooksInstalled) + hookBadge("Cursor", installed: agentManager.cursorHooksInstalled) + hookBadge("Codex", installed: agentManager.codexHooksInstalled) + hookBadge("Kimi", installed: agentManager.kimiHooksInstalled) + } + Text("最常用 Cursor;其它 IDE 见高级设置") + .font(.system(size: 11)) + .foregroundStyle(AhakeySettingsTheme.tertiaryText) + } + + Spacer(minLength: 12) + + Button(agentManager.cursorHooksInstalled ? "重装 Cursor Hook" : "安装 Cursor Hook") { + agentManager.installCursorHooksOnly() + } + .buttonStyle(.borderedProminent) + .controlSize(.regular) + .disabled(agentManager.isAgentOperationInProgress) + } + .padding(.horizontal, AhakeySettingsTheme.rowPaddingH) + .padding(.vertical, 14) + } + } + + private var daemonDetailCard: some View { + AhakeySettingsCard(sectionTitle: "守护进程") { + statusRow("安装", value: agentManager.isInstalled ? "已安装" : "未安装", ok: agentManager.isInstalled) + AhakeySettingsCardDivider() + statusRow("运行", value: agentManager.isRunning ? "运行中" : "未运行", ok: agentManager.isRunning) + AhakeySettingsCardDivider() + statusRow("Agent 蓝牙", value: agentManager.isAgentBLEConnected ? "已连接键盘" : "未连接", ok: agentManager.isAgentBLEConnected) + + HStack(spacing: 10) { + if agentManager.isInstalled { + Button(agentManager.isRunning ? "停止" : "启动") { + if agentManager.isRunning { + agentManager.stop() + } else { + agentManager.start() + } + } + .buttonStyle(.bordered) + .disabled(agentManager.isAgentOperationInProgress) + Button("卸载", role: .destructive) { + agentManager.uninstall(bleManager: bleManager) + } + .buttonStyle(.bordered) + .disabled(agentManager.isAgentOperationInProgress) + } else { + Button("安装并启用") { + agentManager.install() + } + .buttonStyle(.borderedProminent) + .disabled(agentManager.isAgentOperationInProgress || !agentManager.isAgentBinaryPresentInBundle) + } + if agentManager.isAgentOperationInProgress { + ProgressView().controlSize(.small) + } + Spacer(minLength: 0) + } + .padding(.horizontal, AhakeySettingsTheme.rowPaddingH) + .padding(.vertical, 12) + } + } + + private var otherHooksCard: some View { + AhakeySettingsCard(sectionTitle: "其它 IDE Hooks") { + HStack(spacing: 8) { + Button(agentManager.claudeHooksInstalled ? "重装 Claude" : "安装 Claude") { + agentManager.installClaudeHooksOnly() + } + .buttonStyle(.bordered) + .controlSize(.small) + Button(agentManager.codexHooksInstalled ? "重装 Codex" : "安装 Codex") { + agentManager.installCodexHooksOnly() + } + .buttonStyle(.bordered) + .controlSize(.small) + Button(agentManager.kimiHooksInstalled ? "重装 Kimi" : "安装 Kimi") { + agentManager.installKimiHooksOnly() + } + .buttonStyle(.bordered) + .controlSize(.small) + Spacer(minLength: 0) + } + .padding(.horizontal, AhakeySettingsTheme.rowPaddingH) + .padding(.vertical, 12) + .disabled(agentManager.isAgentOperationInProgress) + } + } + + private var modeSummaryCard: some View { + AhakeySettingsCard(sectionTitle: "Mode · 批准") { + statusRow("工作模式", value: "Mode \(bleManager.workMode)", ok: true) + AhakeySettingsCardDivider() + statusRow( + "拨杆", + value: bleManager.switchState == 0 ? "自动批准" : "手动批准", + ok: bleManager.switchState == 0 + ) + Button { + StudioNavigationRouter.shared.selectSettingsTab(.hardware) + NotificationCenter.default.post( + name: .ahaKeyStudioSelectPart, + object: nil, + userInfo: [StudioNavigationUserInfoKey.part: AhaKeyStudioPart.toggleSwitch.rawValue] + ) + } label: { + rowLabel("在硬件中查看拨杆", detail: "只读上报;物理拨动才改变档位") + } + .buttonStyle(.plain) + } + } + + private var diagnosticsCard: some View { + AhakeySettingsCard(sectionTitle: "诊断入口") { + Button { + StudioNavigationRouter.shared.openDeviceManagement(showDetail: true) + } label: { + rowLabel("打开我的设备", detail: "改名、电量、系统控制与完整诊断") + } + .buttonStyle(.plain) + AhakeySettingsCardDivider() + Button { + agentManager.refresh() + _ = agentManager.readLog() + StudioNavigationRouter.shared.openDeviceManagement(showDetail: true) + } label: { + rowLabel("刷新并打开设备详情", detail: "在设备页「系统 · Agent」查看诊断日志") + } + .buttonStyle(.plain) + } + } + + private func enableLinkage() { + agentManager.refresh() + if !agentManager.isInstalled { + agentManager.install() + return + } + agentManager.setBluetoothConnectionOwner(.agentDaemon, bleManager: bleManager) + if !agentManager.isRunning { + agentManager.start() + } + } + + private func statusRow(_ title: String, value: String, ok: Bool) -> some View { + HStack { + Circle() + .fill(ok ? Color.green : Color.orange) + .frame(width: 8, height: 8) + Text(title) + .font(AhakeySettingsTheme.rowTitleFont) + .foregroundStyle(AhakeySettingsTheme.primaryText) + Spacer() + Text(value) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + } + .padding(.horizontal, AhakeySettingsTheme.rowPaddingH) + .padding(.vertical, 12) + } + + private func hookBadge(_ title: String, installed: Bool) -> some View { + HStack(spacing: 4) { + Image(systemName: installed ? "checkmark.circle.fill" : "circle") + .foregroundStyle(installed ? Color.green : AhakeySettingsTheme.tertiaryText) + Text(title) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + } + } + + private func rowLabel(_ title: String, detail: String) -> some View { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(AhakeySettingsTheme.rowTitleFont) + .foregroundStyle(AhakeySettingsTheme.primaryText) + Text(detail) + .font(AhakeySettingsTheme.rowSubtitleFont) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + } + Spacer() + Image(systemName: "chevron.right") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(AhakeySettingsTheme.tertiaryText) + } + .padding(.horizontal, AhakeySettingsTheme.rowPaddingH) + .padding(.vertical, 12) + } +} diff --git a/ahakeyconfig-mac/Sources/Views/Settings/AhaKeyAgentWorkspacePane.swift b/ahakeyconfig-mac/Sources/Views/Settings/AhaKeyAgentWorkspacePane.swift new file mode 100644 index 00000000..3e2bc8e3 --- /dev/null +++ b/ahakeyconfig-mac/Sources/Views/Settings/AhaKeyAgentWorkspacePane.swift @@ -0,0 +1,44 @@ +import SwiftUI +import VibeBar + +/// Settings「Agent」根容器:助手 / 联动 / 设置。 +struct AhaKeyAgentWorkspacePane: View { + @ObservedObject var bleManager: AhaKeyBLEManager + @ObservedObject var islandState: VibeBarState + @Binding var section: AhaKeyAgentConfigSection + + var body: some View { + VStack(spacing: 0) { + AhaKeyAgentSectionPicker(selection: $section) + .padding(.horizontal, AhakeySettingsTheme.contentPadding) + .padding(.top, 20) + .padding(.bottom, 12) + + Group { + switch section { + case .assistant: + // Chatbot:消息区可滚、输入贴底,不能包在整页 ScrollView 里。 + AhaKeyAgentAssistantPane(bleManager: bleManager) + .padding(.horizontal, AhakeySettingsTheme.contentPadding) + .padding(.bottom, 16) + .frame(maxWidth: .infinity, maxHeight: .infinity) + case .status: + ScrollView(.vertical, showsIndicators: true) { + AhaKeyAgentStatusPane(bleManager: bleManager) + .padding(.horizontal, AhakeySettingsTheme.contentPadding) + .padding(.bottom, 28) + } + case .general: + ScrollView(.vertical, showsIndicators: true) { + AhaKeyAgentGeneralPane(islandState: islandState) + .padding(.horizontal, AhakeySettingsTheme.contentPadding) + .padding(.bottom, 28) + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .background(AhakeySettingsTheme.contentBackground) + } +} diff --git a/ahakeyconfig-mac/Sources/Views/Settings/AhaKeyDeviceManagementPane.swift b/ahakeyconfig-mac/Sources/Views/Settings/AhaKeyDeviceManagementPane.swift new file mode 100644 index 00000000..1a8c587d --- /dev/null +++ b/ahakeyconfig-mac/Sources/Views/Settings/AhaKeyDeviceManagementPane.swift @@ -0,0 +1,191 @@ +import SwiftUI + +/// 左下角「我的设备」:点设备卡进入详情(迁入 DeviceInfoView)。 +struct AhaKeyDeviceManagementPane: View { + @ObservedObject var bleManager: AhaKeyBLEManager + @Binding var showsDetail: Bool + var onClose: (() -> Void)? = nil + @ObservedObject private var coachTips = FeatureCoachTipController.shared + + var body: some View { + Group { + if showsDetail { + deviceDetail + } else { + deviceList + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .background(AhakeySettingsTheme.contentBackground) + .featureCoachTip( + .deviceConnect, + isActive: true, + alignment: .topTrailing, + enablePrimaryAction: false + ) + } + + private var deviceList: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + HStack(alignment: .firstTextBaseline) { + VStack(alignment: .leading, spacing: 6) { + Text("我的设备") + .font(AhakeySettingsTheme.pageTitleFont) + .foregroundStyle(AhakeySettingsTheme.primaryText) + Text("管理已连接或可连接的 AhaKey 键盘:改名、蓝牙占用与设备信息。") + .font(AhakeySettingsTheme.rowSubtitleFont) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + } + Spacer(minLength: 0) + if let onClose { + Button("关闭", action: onClose) + .buttonStyle(.plain) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + } + } + + Button { + showsDetail = true + } label: { + deviceListCard + } + .buttonStyle(.plain) + + if !bleManager.isConnected { + emptyHint + } + } + .padding(.horizontal, AhakeySettingsTheme.contentPadding) + .padding(.top, 20) + .padding(.bottom, 28) + } + } + + private var deviceListCard: some View { + HStack(spacing: 12) { + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(AhakeySettingsTheme.controlFill) + .frame(width: 52, height: 52) + .overlay( + Image(systemName: "keyboard.fill") + .font(.system(size: 22)) + .foregroundStyle(AhakeySettingsTheme.accentBlue) + ) + + VStack(alignment: .leading, spacing: 4) { + Text(bleManager.deviceName ?? "AhaKey Mini") + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(AhakeySettingsTheme.primaryText) + HStack(spacing: 8) { + Circle() + .fill(bleManager.isConnected ? Color.green : Color.orange) + .frame(width: 7, height: 7) + Text(bleManager.isConnected ? "已连接" : "未连接") + .font(.system(size: 12)) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + if bleManager.isConnected { + Text("电量 \(bleManager.batteryLevel)%") + .font(.system(size: 12)) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + } + } + } + + Spacer(minLength: 0) + + Text("设置") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + Image(systemName: "chevron.right") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(AhakeySettingsTheme.tertiaryText) + } + .padding(14) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(AhakeySettingsTheme.cardBackground) + ) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(AhakeySettingsTheme.divider, lineWidth: 1) + ) + } + + private var emptyHint: some View { + HStack(alignment: .center, spacing: 16) { + VStack(alignment: .leading, spacing: 6) { + Text("尚未连接键盘") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(AhakeySettingsTheme.primaryText) + Text("将蓝牙交给本 App 后,点右侧「连接设备」。也可进入详情查看更多选项。") + .font(.system(size: 12)) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + .fixedSize(horizontal: false, vertical: true) + } + + Spacer(minLength: 12) + + Button(bleManager.isScanning ? "扫描中…" : "连接设备") { + showsDetail = true + bleManager.userInitiatedConnect() + } + .buttonStyle(.borderedProminent) + .controlSize(.regular) + .disabled(bleManager.isScanning) + .overlay(alignment: .top) { + if coachTips.isShowing(.deviceConnect) { + Text("点这里") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.white) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background( + Capsule(style: .continuous) + .fill(AhakeySettingsTheme.accentBlue) + ) + .offset(y: -28) + } + } + } + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .strokeBorder(style: StrokeStyle(lineWidth: 1, dash: [5, 4])) + .foregroundStyle(AhakeySettingsTheme.divider) + ) + } + + private var deviceDetail: some View { + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 12) { + Button { + if let onClose { + onClose() + } else { + showsDetail = false + } + } label: { + Label("返回", systemImage: "chevron.left") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + } + .buttonStyle(.plain) + + Spacer(minLength: 0) + + Text(bleManager.deviceName ?? "AhaKey Mini") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(AhakeySettingsTheme.primaryText) + .lineLimit(1) + } + .padding(.horizontal, AhakeySettingsTheme.contentPadding) + .padding(.top, 16) + .padding(.bottom, 10) + + DeviceInfoView(bleManager: bleManager, chrome: .settingsCards) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } +} diff --git a/ahakeyconfig-mac/Sources/Views/Settings/AhaKeyDynamicIslandSettingsPane.swift b/ahakeyconfig-mac/Sources/Views/Settings/AhaKeyDynamicIslandSettingsPane.swift new file mode 100644 index 00000000..1ce7fec2 --- /dev/null +++ b/ahakeyconfig-mac/Sources/Views/Settings/AhaKeyDynamicIslandSettingsPane.swift @@ -0,0 +1,225 @@ +import SwiftUI +import VibeBar + +/// 灵动岛 Settings 根容器:常驻岛 / 展开岛 / 通用 + 子路由(空壳保留占位)。 +struct AhaKeyDynamicIslandSettingsPane: View { + @ObservedObject var state: VibeBarState + @Binding var route: AhaKeyDynamicIslandSettingsRoute + let brandName: String + + @State private var configSection: AhaKeyDynamicIslandConfigSection = .notchConfig + @AppStorage(VibeBarIslandSoundSettings.mutedDefaultsKey) private var isIslandSoundMuted = false + @AppStorage(VibeBarIslandAppearanceSettings.hoverExpandKey) private var hoverExpandEnabled = true + + var body: some View { + Group { + switch route { + case .root: + rootContent + case .componentLibrary: + AhaKeyIslandComponentLibraryPane( + state: state, + route: $route, + brandName: brandName, + onBack: popToRoot + ) + case .keyPadSettings: + AhaKeyKeyPadSettingsPane( + state: state, + brandName: brandName, + onBack: { route = .componentLibrary } + ) + case .oledPetSettings: + AhaKeyOledPetSettingsPane( + state: state, + brandName: brandName, + onBack: { route = .componentLibrary } + ) + case .agentSessions: + agentSessionsPane + case .display: + displayPane + case .sound: + soundPane + } + } + } + + private var rootContent: some View { + ScrollView(.vertical, showsIndicators: true) { + VStack(alignment: .leading, spacing: 20) { + AhaKeyDynamicIslandSectionPicker(selection: $configSection) + + switch configSection { + case .notchConfig: + AhaKeyIslandStatusConfigPane(state: state, route: $route, brandName: brandName) + case .islandConfig: + AhaKeyIslandBehaviorConfigPane(state: state, route: $route, brandName: brandName) + case .general: + AhaKeyIslandGeneralConfigPane(route: $route) + } + } + .padding(.horizontal, AhakeySettingsTheme.contentPadding) + .padding(.top, 20) + .padding(.bottom, 28) + } + .featureCoachTip(.islandFirst, isActive: true, alignment: .topTrailing) + } + + /// 显示子页:保留空壳,后续接信息密度等能力。 + private var displayPane: some View { + AhakeyWrappedSettingsPane( + title: "显示", + brandName: brandName, + onBack: popToRoot + ) { + AhakeySettingsCard(sectionTitle: "即将推出") { + placeholderRow( + title: "信息密度", + detail: "紧凑 / 标准 / 详细等布局选项,后续版本可配置。" + ) + AhakeySettingsCardDivider() + placeholderRow( + title: "展开面板细节", + detail: "模块间距、字号与对齐等显示微调入口占位。" + ) + } + + AhakeySettingsCard(sectionTitle: "临时调试") { + AhakeySettingsToggleRow( + title: "悬停时展开", + subtitle: "正式入口在「展开岛 → 交互」;此处仅作联调占位。", + isOn: $hoverExpandEnabled + ) + .onChange(of: hoverExpandEnabled) { _ in + NotificationCenter.default.post( + name: VibeBarIslandAppearanceSettings.appearanceDidChangeNotification, + object: nil + ) + } + } + } + } + + /// 声音详情子页:主开关在「通用」;此处保留更多选项空壳。 + private var soundPane: some View { + AhakeyWrappedSettingsPane( + title: "声音详情", + brandName: brandName, + onBack: popToRoot + ) { + AhakeySettingsCard(sectionTitle: "提示音") { + AhakeySettingsToggleRow( + title: "展开/收起音效", + subtitle: "与「通用 → 声音」同步", + isOn: Binding( + get: { !isIslandSoundMuted }, + set: { enabled in + isIslandSoundMuted = !enabled + if enabled { + VibeBarIslandSoundSettings.playInteractionIfEnabled() + } + } + ) + ) + } + + AhakeySettingsCard(sectionTitle: "即将推出") { + placeholderRow( + title: "自定义提示音", + detail: "按交互类型选择系统音或自定义文件。" + ) + AhakeySettingsCardDivider() + placeholderRow( + title: "音量与均衡", + detail: "独立于系统音量的灵动岛提示音调节。" + ) + } + } + } + + private var agentSessionsPane: some View { + AhakeyWrappedSettingsPane( + title: "Agent 会话", + brandName: brandName, + onBack: popToRoot + ) { + AhakeySettingsCard(sectionTitle: "即将推出") { + placeholderRow( + title: "会话列表规则", + detail: "按 Agent / 状态筛选、排序与聚合展示,后续版本接入。" + ) + } + + AhakeySettingsCard(sectionTitle: "预览占位") { + sessionPreviewRow(title: "Claude · editing", badge: "运行中", tint: .orange) + AhakeySettingsCardDivider() + sessionPreviewRow(title: "Cursor · composer", badge: "等待", tint: AhakeySettingsTheme.accentBlue) + AhakeySettingsCardDivider() + sessionPreviewRow(title: "Codex · terminal", badge: "空闲", tint: AhakeySettingsTheme.secondaryText) + } + + AhakeySettingsCard(sectionTitle: "右槽计数(调试)") { + Stepper(value: Binding( + get: { VibeBarIslandAppearanceSettings.previewSessionCount }, + set: { VibeBarIslandAppearanceSettings.previewSessionCount = $0 } + ), in: 1...9) { + HStack { + Text("预览会话数") + .font(AhakeySettingsTheme.rowTitleFont) + .foregroundStyle(AhakeySettingsTheme.primaryText) + Spacer() + Text("x\(VibeBarIslandAppearanceSettings.previewSessionCount)") + .font(.system(size: 14, weight: .semibold, design: .rounded)) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + } + } + .padding(.horizontal, AhakeySettingsTheme.rowPaddingH) + .padding(.vertical, 12) + } + } + } + + /// 返回根页时保留当前分段(从哪进子页就回哪)。 + private func popToRoot() { + route = .root + } + + private func placeholderRow(title: String, detail: String) -> some View { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 8) { + Text(title) + .font(AhakeySettingsTheme.rowTitleFont) + .foregroundStyle(AhakeySettingsTheme.primaryText) + Text("占位") + .font(.system(size: 9, weight: .semibold)) + .foregroundStyle(AhakeySettingsTheme.tertiaryText) + .padding(.horizontal, 5) + .padding(.vertical, 2) + .background(Capsule().fill(AhakeySettingsTheme.controlFill)) + } + Text(detail) + .font(AhakeySettingsTheme.rowSubtitleFont) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + } + .padding(.horizontal, AhakeySettingsTheme.rowPaddingH) + .padding(.vertical, 12) + } + + private func sessionPreviewRow(title: String, badge: String, tint: Color) -> some View { + HStack(spacing: 12) { + RoundedRectangle(cornerRadius: 2, style: .continuous) + .fill(tint) + .frame(width: 10, height: 10) + Text(title) + .font(AhakeySettingsTheme.rowTitleFont) + .foregroundStyle(AhakeySettingsTheme.primaryText) + Spacer() + Text(badge) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + } + .padding(.horizontal, AhakeySettingsTheme.rowPaddingH) + .padding(.vertical, 12) + } +} diff --git a/ahakeyconfig-mac/Sources/Views/Settings/AhaKeyIslandBehaviorConfigPane.swift b/ahakeyconfig-mac/Sources/Views/Settings/AhaKeyIslandBehaviorConfigPane.swift new file mode 100644 index 00000000..ad64dfcb --- /dev/null +++ b/ahakeyconfig-mac/Sources/Views/Settings/AhaKeyIslandBehaviorConfigPane.swift @@ -0,0 +1,246 @@ +import SwiftUI +import VibeBar + +/// 展开岛配置:预览 → 组件库 → 交互 → 个性化;空壳入口保留占位。 +struct AhaKeyIslandBehaviorConfigPane: View { + @ObservedObject var state: VibeBarState + @Binding var route: AhaKeyDynamicIslandSettingsRoute + let brandName: String + + @AppStorage(VibeBarIslandAppearanceSettings.hoverExpandKey) private var hoverExpandEnabled = true + @AppStorage(VibeBarIslandAppearanceSettings.autoCollapseOnLeaveKey) private var autoCollapseOnLeave = true + @AppStorage(VibeBarIslandAppearanceSettings.hapticFeedbackKey) private var hapticFeedbackEnabled = true + + @State private var islandWidth = VibeBarIslandAppearanceSettings.islandWidth + @State private var hoverExpandDelayMs = Double(VibeBarIslandAppearanceSettings.hoverExpandDelayMs) + @State private var showMoreInteraction = false + + var body: some View { + VStack(alignment: .leading, spacing: 20) { + VStack(alignment: .leading, spacing: 6) { + Text("展开岛配置") + .font(AhakeySettingsTheme.pageTitleFont) + .foregroundStyle(AhakeySettingsTheme.primaryText) + Text("悬停后的展开面板:模块、交互与个性化。系统级选项请到「通用」。") + .font(AhakeySettingsTheme.rowSubtitleFont) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + } + + AhaKeyIslandExpandedPreview( + state: state, + islandWidth: islandWidth, + sectionTitle: "展开岛预览" + ) + + modulesSection + appearanceSection + + AhaKeySettingsDisclosureSection( + title: "交互与更多", + subtitle: "悬停、尺寸与高级功能", + isExpanded: $showMoreInteraction + ) { + interactionSection + widthHintSection + placeholderSection + } + } + .onAppear(perform: reloadFromStorage) + .onReceive(NotificationCenter.default.publisher(for: VibeBarIslandAppearanceSettings.appearanceDidChangeNotification)) { _ in + reloadFromStorage() + } + } + + private var modulesSection: some View { + AhakeySettingsCard(sectionTitle: "模块") { + Button { route = .componentLibrary } label: { + HStack(spacing: 12) { + Image(systemName: "square.stack.3d.up") + .font(.system(size: 16, weight: .medium)) + .foregroundStyle(AhakeySettingsTheme.accentBlue) + .frame(width: 22) + VStack(alignment: .leading, spacing: 3) { + Text("组件库") + .font(AhakeySettingsTheme.rowTitleFont) + .foregroundStyle(AhakeySettingsTheme.primaryText) + Text("管理灯带、OLED、四键、底座等模块显隐与顺序") + .font(AhakeySettingsTheme.rowSubtitleFont) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + } + Spacer() + Image(systemName: "chevron.right") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(AhakeySettingsTheme.tertiaryText) + } + .padding(.horizontal, AhakeySettingsTheme.rowPaddingH) + .padding(.vertical, 14) + } + .buttonStyle(.plain) + } + } + + private var interactionSection: some View { + AhakeySettingsCard(sectionTitle: "交互") { + AhakeySettingsToggleRow( + title: "悬停时展开", + subtitle: nil, + isOn: $hoverExpandEnabled + ) + .onChange(of: hoverExpandEnabled) { _ in notifyAppearanceChanged() } + + AhakeySettingsCardDivider() + AhakeySettingsToggleRow( + title: "鼠标离开时自动收起", + subtitle: nil, + isOn: $autoCollapseOnLeave + ) + .onChange(of: autoCollapseOnLeave) { _ in notifyAppearanceChanged() } + + AhakeySettingsCardDivider() + AhakeySettingsToggleRow( + title: "悬停时震动反馈", + subtitle: nil, + isOn: $hapticFeedbackEnabled + ) + .onChange(of: hapticFeedbackEnabled) { _ in notifyAppearanceChanged() } + + AhakeySettingsCardDivider() + VStack(alignment: .leading, spacing: 10) { + HStack { + Text("悬停展开延迟") + .font(AhakeySettingsTheme.rowTitleFont) + .foregroundStyle(AhakeySettingsTheme.primaryText) + Spacer() + Text(String(format: "%.1f s", hoverExpandDelayMs / 1000)) + .font(.system(size: 13, weight: .semibold, design: .rounded)) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + } + Slider(value: $hoverExpandDelayMs, in: 100...2000, step: 100) + .onChange(of: hoverExpandDelayMs) { value in + VibeBarIslandAppearanceSettings.hoverExpandDelayMs = Int(value) + } + } + .padding(.horizontal, AhakeySettingsTheme.rowPaddingH) + .padding(.vertical, 12) + } + } + + private var widthHintSection: some View { + AhakeySettingsCard(sectionTitle: "尺寸") { + HStack { + VStack(alignment: .leading, spacing: 3) { + Text("岛宽度") + .font(AhakeySettingsTheme.rowTitleFont) + .foregroundStyle(AhakeySettingsTheme.primaryText) + Text("与常驻岛共用,请在「常驻岛」中调整") + .font(AhakeySettingsTheme.rowSubtitleFont) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + } + Spacer() + Text("\(Int(islandWidth)) pt") + .font(.system(size: 13, weight: .semibold, design: .rounded)) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + } + .padding(.horizontal, AhakeySettingsTheme.rowPaddingH) + .padding(.vertical, 14) + } + } + + private var appearanceSection: some View { + AhakeySettingsCard(sectionTitle: "个性化") { + HStack(alignment: .center, spacing: 12) { + Image(systemName: "paintpalette.fill") + .font(.system(size: 16, weight: .medium)) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + .frame(width: 22) + + VStack(alignment: .leading, spacing: 3) { + HStack(spacing: 8) { + Text("焕肤") + .font(AhakeySettingsTheme.rowTitleFont) + .foregroundStyle(AhakeySettingsTheme.primaryText) + Text("即将推出") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(AhakeySettingsTheme.tertiaryText) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Capsule().fill(AhakeySettingsTheme.controlFill)) + } + Text("更换机身材质、灯效主题与键帽风格") + .font(AhakeySettingsTheme.rowSubtitleFont) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + } + + Spacer(minLength: 0) + + Image(systemName: "lock.fill") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(AhakeySettingsTheme.tertiaryText) + } + .padding(.horizontal, AhakeySettingsTheme.rowPaddingH) + .padding(.vertical, 14) + .opacity(0.72) + .accessibilityLabel("焕肤,即将推出") + } + } + + private var placeholderSection: some View { + AhakeySettingsCard(sectionTitle: "高级功能") { + settingsLinkRow( + route: .display, + title: "显示与布局", + subtitle: "展开面板信息密度等(占位)" + ) + } + } + + private func settingsLinkRow( + route target: AhaKeyDynamicIslandSettingsRoute, + title: String, + subtitle: String? + ) -> some View { + Button { route = target } label: { + HStack(alignment: .center, spacing: 12) { + VStack(alignment: .leading, spacing: subtitle == nil ? 0 : 3) { + HStack(spacing: 8) { + Text(title) + .font(AhakeySettingsTheme.rowTitleFont) + .foregroundStyle(AhakeySettingsTheme.primaryText) + Text("占位") + .font(.system(size: 9, weight: .semibold)) + .foregroundStyle(AhakeySettingsTheme.tertiaryText) + .padding(.horizontal, 5) + .padding(.vertical, 2) + .background(Capsule().fill(AhakeySettingsTheme.controlFill)) + } + if let subtitle, !subtitle.isEmpty { + Text(subtitle) + .font(AhakeySettingsTheme.rowSubtitleFont) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + } + } + Spacer() + Image(systemName: "chevron.right") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(AhakeySettingsTheme.tertiaryText) + } + .padding(.horizontal, AhakeySettingsTheme.rowPaddingH) + .padding(.vertical, 12) + } + .buttonStyle(.plain) + } + + private func reloadFromStorage() { + islandWidth = VibeBarIslandAppearanceSettings.islandWidth + hoverExpandDelayMs = Double(VibeBarIslandAppearanceSettings.hoverExpandDelayMs) + state.islandWidth = islandWidth + state.enabledExpandedModules = VibeBarIslandAppearanceSettings.enabledExpandedModules + } + + private func notifyAppearanceChanged() { + NotificationCenter.default.post( + name: VibeBarIslandAppearanceSettings.appearanceDidChangeNotification, + object: nil + ) + } +} diff --git a/ahakeyconfig-mac/Sources/Views/Settings/AhaKeyIslandComponentLibraryPane.swift b/ahakeyconfig-mac/Sources/Views/Settings/AhaKeyIslandComponentLibraryPane.swift new file mode 100644 index 00000000..94e25e5f --- /dev/null +++ b/ahakeyconfig-mac/Sources/Views/Settings/AhaKeyIslandComponentLibraryPane.swift @@ -0,0 +1,242 @@ +import SwiftUI +import VibeBar + +/// 组件库:管理展开岛模块显隐与顺序(非常驻岛槽位)。 +struct AhaKeyIslandComponentLibraryPane: View { + @ObservedObject var state: VibeBarState + @Binding var route: AhaKeyDynamicIslandSettingsRoute + let brandName: String + var onBack: () -> Void + + @State private var enabledModules = VibeBarIslandAppearanceSettings.enabledExpandedModules + @State private var disabledModules = VibeBarIslandAppearanceSettings.disabledExpandedModules + @State private var statusMessage: String? + + var body: some View { + AhakeyWrappedSettingsPane(title: "组件库", brandName: brandName, onBack: onBack) { + VStack(alignment: .leading, spacing: 6) { + Text("展开岛模块") + .font(AhakeySettingsTheme.sectionTitleFont) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + Text("组件库只影响悬停展开后的面板;常驻岛左/右槽请在「常驻岛配置」中设置。四键与 OLED Pet 可点进进一步配置;皮肤与图标在 Agent · 设置。") + .font(AhakeySettingsTheme.rowSubtitleFont) + .foregroundStyle(AhakeySettingsTheme.tertiaryText) + } + + if let statusMessage { + Text(statusMessage) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(AhakeySettingsTheme.accentBlue) + } + + enabledSection + disabledSection + + Text("OLED Pet 为展开岛核心模块,不可关闭。点进可配置信息层、尺寸、动画与 GIF;皮肤与图标在 Agent · 设置。") + .font(.caption) + .foregroundStyle(AhakeySettingsTheme.tertiaryText) + } + .featureCoachTip(.islandLibrary, isActive: true, alignment: .topTrailing) + .onAppear(perform: reload) + .onReceive(NotificationCenter.default.publisher(for: VibeBarIslandAppearanceSettings.appearanceDidChangeNotification)) { _ in + reload() + } + } + + private var enabledSection: some View { + VStack(alignment: .leading, spacing: 10) { + Text("已启用 · 可调整顺序") + .font(AhakeySettingsTheme.sectionTitleFont) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + .padding(.leading, 4) + + AhakeySettingsCard(sectionTitle: nil) { + ForEach(Array(enabledModules.enumerated()), id: \.element.id) { index, module in + if index > 0 { AhakeySettingsCardDivider() } + enabledRow(module, index: index) + } + } + } + } + + private var disabledSection: some View { + VStack(alignment: .leading, spacing: 10) { + Text("未启用") + .font(AhakeySettingsTheme.sectionTitleFont) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + .padding(.leading, 4) + + AhakeySettingsCard(sectionTitle: nil) { + if disabledModules.isEmpty { + Text("所有展开岛模块均已启用") + .font(AhakeySettingsTheme.rowSubtitleFont) + .foregroundStyle(AhakeySettingsTheme.tertiaryText) + .padding(.horizontal, AhakeySettingsTheme.rowPaddingH) + .padding(.vertical, 12) + } else { + ForEach(Array(disabledModules.enumerated()), id: \.element.id) { index, module in + if index > 0 { AhakeySettingsCardDivider() } + disabledRow(module) + } + } + } + } + } + + private func detailRoute(for module: VibeBarExpandedModule) -> AhaKeyDynamicIslandSettingsRoute? { + switch module { + case .keyPad: return .keyPadSettings + case .oledPet: return .oledPetSettings + default: return nil + } + } + + private func enabledRow(_ module: VibeBarExpandedModule, index: Int) -> some View { + let detail = detailRoute(for: module) + return HStack(spacing: 10) { + VStack(spacing: 2) { + Button { move(module, direction: -1) } label: { + Image(systemName: "chevron.up") + .font(.system(size: 9, weight: .bold)) + } + .buttonStyle(.plain) + .disabled(index == 0) + .foregroundStyle(index == 0 ? AhakeySettingsTheme.tertiaryText : AhakeySettingsTheme.secondaryText) + + Image(systemName: "line.3.horizontal") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(AhakeySettingsTheme.tertiaryText) + + Button { move(module, direction: 1) } label: { + Image(systemName: "chevron.down") + .font(.system(size: 9, weight: .bold)) + } + .buttonStyle(.plain) + .disabled(index >= enabledModules.count - 1) + .foregroundStyle( + index >= enabledModules.count - 1 + ? AhakeySettingsTheme.tertiaryText + : AhakeySettingsTheme.secondaryText + ) + } + .frame(width: 18) + + Button { + if let detail { route = detail } + } label: { + HStack(spacing: 10) { + Image(systemName: module.systemImage) + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + .frame(width: 22) + + VStack(alignment: .leading, spacing: 3) { + HStack(spacing: 6) { + Text(module.title) + .font(AhakeySettingsTheme.rowTitleFont) + .foregroundStyle(AhakeySettingsTheme.primaryText) + if module == .oledPet { + Text("核心") + .font(.system(size: 9, weight: .semibold)) + .foregroundStyle(Color.orange.opacity(0.95)) + .padding(.horizontal, 5) + .padding(.vertical, 2) + .background(Capsule().fill(Color.orange.opacity(0.15))) + } + } + Text(module == .oledPet + ? "点击配置 · 信息层、尺寸、动画与素材" + : (detail == nil ? module.detail : "点击配置 · \(module.detail)")) + .font(AhakeySettingsTheme.rowSubtitleFont) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + } + + Spacer(minLength: 0) + + if detail != nil { + Image(systemName: "chevron.right") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(AhakeySettingsTheme.tertiaryText) + } + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(detail == nil) + + Toggle( + "", + isOn: Binding( + get: { true }, + set: { applyEnable(module, enabled: $0) } + ) + ) + .labelsHidden() + .toggleStyle(.switch) + .controlSize(.small) + .disabled(module == .oledPet) + } + .padding(.horizontal, AhakeySettingsTheme.rowPaddingH) + .padding(.vertical, 10) + } + + private func disabledRow(_ module: VibeBarExpandedModule) -> some View { + HStack(spacing: 12) { + Image(systemName: module.systemImage) + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(AhakeySettingsTheme.tertiaryText) + .frame(width: 22) + VStack(alignment: .leading, spacing: 3) { + Text(module.title) + .font(AhakeySettingsTheme.rowTitleFont) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + Text(module.detail) + .font(AhakeySettingsTheme.rowSubtitleFont) + .foregroundStyle(AhakeySettingsTheme.tertiaryText) + } + Spacer() + Toggle( + "", + isOn: Binding( + get: { false }, + set: { applyEnable(module, enabled: $0) } + ) + ) + .labelsHidden() + .toggleStyle(.switch) + .controlSize(.small) + } + .padding(.horizontal, AhakeySettingsTheme.rowPaddingH) + .padding(.vertical, 12) + } + + private func applyEnable(_ module: VibeBarExpandedModule, enabled: Bool) { + let message = VibeBarIslandAppearanceSettings.setExpandedModuleEnabled(module, enabled: enabled) + statusMessage = message + reload() + state.enabledExpandedModules = VibeBarIslandAppearanceSettings.enabledExpandedModules + NotificationCenter.default.post(name: .ahaKeyIslandAppearanceApplyRequested, object: nil) + if let message { + DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) { + if statusMessage == message { statusMessage = nil } + } + } + } + + private func move(_ module: VibeBarExpandedModule, direction: Int) { + guard let index = enabledModules.firstIndex(of: module) else { return } + let target = index + direction + guard enabledModules.indices.contains(target) else { return } + var list = enabledModules + list.swapAt(index, target) + VibeBarIslandAppearanceSettings.enabledExpandedModules = list + reload() + state.enabledExpandedModules = list + NotificationCenter.default.post(name: .ahaKeyIslandAppearanceApplyRequested, object: nil) + } + + private func reload() { + enabledModules = VibeBarIslandAppearanceSettings.enabledExpandedModules + disabledModules = VibeBarIslandAppearanceSettings.disabledExpandedModules + } +} diff --git a/ahakeyconfig-mac/Sources/Views/Settings/AhaKeyIslandGeneralConfigPane.swift b/ahakeyconfig-mac/Sources/Views/Settings/AhaKeyIslandGeneralConfigPane.swift new file mode 100644 index 00000000..1311a8ba --- /dev/null +++ b/ahakeyconfig-mac/Sources/Views/Settings/AhaKeyIslandGeneralConfigPane.swift @@ -0,0 +1,218 @@ +import SwiftUI +import VibeBar + +/// 灵动岛「通用」:应用级设置(登录 / Dock / 显示器 / 语言 / 声音 / 通知策略)。 +struct AhaKeyIslandGeneralConfigPane: View { + @Binding var route: AhaKeyDynamicIslandSettingsRoute + + @AppStorage(VibeBarIslandSoundSettings.mutedDefaultsKey) private var isIslandSoundMuted = false + @AppStorage(AhaKeyIslandAppSettings.showInDockKey) private var showInDock = true + @AppStorage(AhaKeyIslandAppSettings.openAtLoginKey) private var openAtLogin = false + @AppStorage(VibeBarIslandAppearanceSettings.suppressForegroundNotificationsKey) private var suppressForegroundNotifications = true + @AppStorage(VibeBarIslandAppearanceSettings.replyInCompletionCardKey) private var replyInCompletionCard = true + + @State private var preferredDisplay = VibeBarIslandAppearanceSettings.preferredDisplay + @State private var languageMode = VibeBarIslandAppearanceSettings.languageMode + @State private var showMoreGeneral = false + + var body: some View { + VStack(alignment: .leading, spacing: 20) { + VStack(alignment: .leading, spacing: 6) { + Text("通用") + .font(AhakeySettingsTheme.pageTitleFont) + .foregroundStyle(AhakeySettingsTheme.primaryText) + Text("登录、Dock、语言与声音等应用级选项;与常驻/展开面板内容无关。") + .font(AhakeySettingsTheme.rowSubtitleFont) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + } + + systemSection + soundPrimarySection + + AhaKeySettingsDisclosureSection( + title: "更多通用", + subtitle: "语言、更多声音、通知与高级功能", + isExpanded: $showMoreGeneral + ) { + languageSection + soundMoreSection + notificationSection + placeholderSection + } + } + .onAppear(perform: reloadFromStorage) + .onReceive(NotificationCenter.default.publisher(for: VibeBarIslandAppearanceSettings.appearanceDidChangeNotification)) { _ in + reloadFromStorage() + } + } + + private var systemSection: some View { + AhakeySettingsCard(sectionTitle: "系统") { + AhakeySettingsToggleRow( + title: "登录时打开", + subtitle: nil, + isOn: Binding( + get: { openAtLogin }, + set: { enabled in + openAtLogin = enabled + AhaKeyIslandAppSettings.openAtLogin = enabled + } + ) + ) + AhakeySettingsCardDivider() + AhakeySettingsToggleRow( + title: "在 Dock 中显示图标", + subtitle: nil, + isOn: Binding( + get: { showInDock }, + set: { enabled in + showInDock = enabled + AhaKeyIslandAppSettings.showInDock = enabled + } + ) + ) + AhakeySettingsCardDivider() + AhakeySettingsMenuRow( + title: "显示器", + selectionTitle: preferredDisplay.title + ) { + ForEach(VibeBarIslandAppearanceSettings.PreferredDisplay.allCases) { option in + Button(option.title) { + preferredDisplay = option + VibeBarIslandAppearanceSettings.preferredDisplay = option + } + } + } + } + } + + private var languageSection: some View { + AhakeySettingsCard(sectionTitle: "语言") { + AhakeySettingsMenuRow( + title: "语言", + selectionTitle: languageMode.title + ) { + ForEach(VibeBarIslandAppearanceSettings.LanguageMode.allCases) { option in + Button(option.title) { + languageMode = option + VibeBarIslandAppearanceSettings.languageMode = option + } + } + } + } + } + + private var soundPrimarySection: some View { + AhakeySettingsCard(sectionTitle: "声音") { + AhakeySettingsToggleRow( + title: "展开/收起音效", + subtitle: nil, + isOn: Binding( + get: { !isIslandSoundMuted }, + set: { enabled in + isIslandSoundMuted = !enabled + if enabled { + VibeBarIslandSoundSettings.playInteractionIfEnabled() + } + } + ) + ) + } + } + + private var soundMoreSection: some View { + AhakeySettingsCard(sectionTitle: "更多声音") { + settingsLinkRow( + route: .sound, + title: "更多声音设置", + subtitle: "均衡器、自定义提示音等(占位)" + ) + } + } + + private var notificationSection: some View { + AhakeySettingsCard(sectionTitle: "通知与回复") { + AhakeySettingsToggleRow( + title: "在完成卡片中回复", + subtitle: nil, + isOn: $replyInCompletionCard + ) + .onChange(of: replyInCompletionCard) { _ in notifyAppearanceChanged() } + + AhakeySettingsCardDivider() + AhakeySettingsToggleRow( + title: "前台会话不弹出通知", + subtitle: nil, + isOn: $suppressForegroundNotifications + ) + .onChange(of: suppressForegroundNotifications) { _ in notifyAppearanceChanged() } + } + } + + /// 高级功能入口占位,保持可发现。 + private var placeholderSection: some View { + AhakeySettingsCard(sectionTitle: "高级功能") { + settingsLinkRow( + route: .display, + title: "显示与布局", + subtitle: "信息密度等(即将推出)" + ) + AhakeySettingsCardDivider() + settingsLinkRow( + route: .agentSessions, + title: "Agent 会话", + subtitle: "会话列表规则(即将推出)" + ) + } + } + + private func settingsLinkRow( + route target: AhaKeyDynamicIslandSettingsRoute, + title: String, + subtitle: String? + ) -> some View { + Button { route = target } label: { + HStack(alignment: .center, spacing: 12) { + VStack(alignment: .leading, spacing: subtitle == nil ? 0 : 3) { + HStack(spacing: 8) { + Text(title) + .font(AhakeySettingsTheme.rowTitleFont) + .foregroundStyle(AhakeySettingsTheme.primaryText) + Text("占位") + .font(.system(size: 9, weight: .semibold)) + .foregroundStyle(AhakeySettingsTheme.tertiaryText) + .padding(.horizontal, 5) + .padding(.vertical, 2) + .background(Capsule().fill(AhakeySettingsTheme.controlFill)) + } + if let subtitle, !subtitle.isEmpty { + Text(subtitle) + .font(AhakeySettingsTheme.rowSubtitleFont) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + } + } + Spacer() + Image(systemName: "chevron.right") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(AhakeySettingsTheme.tertiaryText) + } + .padding(.horizontal, AhakeySettingsTheme.rowPaddingH) + .padding(.vertical, 12) + } + .buttonStyle(.plain) + } + + private func reloadFromStorage() { + preferredDisplay = VibeBarIslandAppearanceSettings.preferredDisplay + languageMode = VibeBarIslandAppearanceSettings.languageMode + openAtLogin = AhaKeyIslandAppSettings.openAtLogin + showInDock = AhaKeyIslandAppSettings.showInDock + } + + private func notifyAppearanceChanged() { + NotificationCenter.default.post( + name: VibeBarIslandAppearanceSettings.appearanceDidChangeNotification, + object: nil + ) + } +} diff --git a/ahakeyconfig-mac/Sources/Views/Settings/AhaKeyIslandSettingsComponents.swift b/ahakeyconfig-mac/Sources/Views/Settings/AhaKeyIslandSettingsComponents.swift new file mode 100644 index 00000000..fcd71f2d --- /dev/null +++ b/ahakeyconfig-mac/Sources/Views/Settings/AhaKeyIslandSettingsComponents.swift @@ -0,0 +1,414 @@ +import SwiftUI +import VibeBar + +/// 常驻岛 / 展开岛预览共用舞台背景。 +enum AhaKeyIslandPreviewStage { + static let cornerRadius: CGFloat = 18 + + static var gradient: LinearGradient { + LinearGradient( + colors: [ + Color(red: 0.28, green: 0.12, blue: 0.42), + Color(red: 0.55, green: 0.22, blue: 0.58), + Color(red: 0.72, green: 0.32, blue: 0.62), + ], + startPoint: .leading, + endPoint: .trailing + ) + } + + static func background(cornerRadius: CGFloat = cornerRadius) -> some View { + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + .fill(gradient) + } +} + +/// 设置页「常驻岛预览」:胶囊居中 + 左下角状态切换。 +struct AhaKeyIslandNotchPreview: View { + let model: VibeBarCompactNotchModel + @Binding var stateKind: VibeBarRestingStateKind + var islandWidth: CGFloat = VibeBarIslandAppearanceSettings.islandWidth + var agentStatus: VibeBarAgentStatus = .idle + var sectionTitle: String = "常驻岛预览" + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + Text(sectionTitle) + .font(AhakeySettingsTheme.sectionTitleFont) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + .padding(.leading, 4) + + ZStack { + AhaKeyIslandPreviewStage.background() + + VibeBarCompactNotchPreviewCapsule( + model: model, + width: min(islandWidth, VibeBarIslandAppearanceSettings.islandWidthMax), + agentStatus: agentStatus + ) + + VStack { + Spacer(minLength: 0) + HStack { + AhaKeyIslandStateKindPicker(selection: $stateKind) + Spacer(minLength: 0) + } + .padding(.leading, 14) + .padding(.bottom, 12) + } + } + .frame(height: 168) + .clipShape(RoundedRectangle(cornerRadius: AhaKeyIslandPreviewStage.cornerRadius, style: .continuous)) + } + } +} + +/// 设置页「展开岛预览」:渲染真实展开机身(非常驻胶囊)。 +struct AhaKeyIslandExpandedPreview: View { + @ObservedObject var state: VibeBarState + var islandWidth: CGFloat = VibeBarIslandAppearanceSettings.islandWidth + var sectionTitle: String = "展开岛预览" + + @State private var isSoundMuted = false + @State private var petAppearance = VibeBarPetAppearanceSettings.appearance + @State private var keyPadSlots = VibeBarKeyPadSettings.slots + + private var previewStatus: VibeBarAgentStatus { + if state.voiceRecording { return .listening } + if state.agentRunning { return .thinking } + return state.agentStatus == .idle ? .coding : state.agentStatus + } + + private var previewKeys: [VibeBarCommandKey] { + let visible = keyPadSlots.filter(\.isEnabled) + let slots = visible.isEmpty ? Array(keyPadSlots.prefix(1)) : visible + return slots.map { slot in + VibeBarCommandKey( + id: slot.role.rawValue, + title: slot.title, + subtitle: slot.id.uppercased(), + systemName: slot.systemImage, + tint: slot.tintColor, + action: {} + ) + } + } + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + Text(sectionTitle) + .font(AhakeySettingsTheme.sectionTitleFont) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + .padding(.leading, 4) + + ZStack { + AhaKeyIslandPreviewStage.background() + + VibeBarHardwareChassis( + status: previewStatus, + state: state, + taskTitle: previewTaskTitle, + progress: previewStatus == .coding || previewStatus == .thinking ? 0.55 : 0, + keys: previewKeys, + isSoundMuted: $isSoundMuted, + onOpenMainWindow: {}, + onQuit: {}, + onPetTap: {}, + petAppearance: petAppearance + ) + .frame(width: min(islandWidth, VibeBarIslandAppearanceSettings.islandWidthMax)) + .background( + RoundedRectangle(cornerRadius: 20, style: .continuous) + .fill(Color.black) + ) + .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) + .padding(.vertical, 16) + .padding(.horizontal, 16) + } + .clipShape(RoundedRectangle(cornerRadius: AhaKeyIslandPreviewStage.cornerRadius, style: .continuous)) + } + .onAppear { + petAppearance = VibeBarPetAppearanceSettings.appearance + keyPadSlots = VibeBarKeyPadSettings.slots + } + .onReceive(NotificationCenter.default.publisher(for: VibeBarPetAppearanceSettings.didChangeNotification)) { _ in + petAppearance = VibeBarPetAppearanceSettings.appearance + } + .onReceive(NotificationCenter.default.publisher(for: VibeBarKeyPadSettings.didChangeNotification)) { _ in + keyPadSlots = VibeBarKeyPadSettings.slots + } + } + + private var previewTaskTitle: String { + switch previewStatus { + case .listening: return "Voice input active" + case .thinking: return "Analyzing context" + case .coding: return "Generating changes" + case .approval: return "Waiting for your confirm" + default: return state.agentTaskTitle.isEmpty ? "Ready for next task" : state.agentTaskTitle + } + } +} + +/// 左槽选项卡片。 +struct AhaKeyIslandLeftSlotOptionCard: View { + let display: VibeBarLeftSlotDisplay + let isSelected: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + VStack(spacing: 8) { + optionGlyph + .frame(height: 26) + Text(display.title) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + .lineLimit(1) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(AhakeySettingsTheme.cardBackground) + ) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(isSelected ? AhakeySettingsTheme.accentBlue.opacity(0.55) : AhakeySettingsTheme.divider, lineWidth: isSelected ? 1.2 : 1) + ) + } + .buttonStyle(.plain) + } + + @ViewBuilder + private var optionGlyph: some View { + switch display { + case .statusLine: + Text("A·b") + .font(.system(size: 14, weight: .bold, design: .rounded)) + .foregroundStyle(AhakeySettingsTheme.primaryText) + case .deviceName: + Image(systemName: "keyboard") + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(AhakeySettingsTheme.primaryText) + case .battery: + Image(systemName: "battery.75percent") + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(AhakeySettingsTheme.primaryText) + case .agentStatus: + Text("Think") + .font(.system(size: 13, weight: .bold, design: .rounded)) + .foregroundStyle(AhakeySettingsTheme.primaryText) + case .clock: + Image(systemName: "clock") + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(AhakeySettingsTheme.primaryText) + case .focusTimer: + Text("25:00") + .font(.system(size: 14, weight: .bold, design: .rounded)) + .foregroundStyle(AhakeySettingsTheme.primaryText) + .monospacedDigit() + case .hidden: + Text("—") + .font(.system(size: 18, weight: .medium)) + .foregroundStyle(AhakeySettingsTheme.tertiaryText) + } + } +} + +/// 右槽选项卡片。 +struct AhaKeyIslandRightSlotOptionCard: View { + let display: VibeBarRightSlotDisplay + let sessionCount: Int + let isSelected: Bool + let action: () -> Void + @ObservedObject private var focusTimer = VibeBarFocusTimerStore.shared + + var body: some View { + Button(action: action) { + VStack(spacing: 8) { + optionGlyph + .frame(height: 26) + Text(display.title) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + .lineLimit(1) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(AhakeySettingsTheme.cardBackground) + ) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(isSelected ? AhakeySettingsTheme.accentBlue.opacity(0.55) : AhakeySettingsTheme.divider, lineWidth: isSelected ? 1.2 : 1) + ) + } + .buttonStyle(.plain) + } + + @ViewBuilder + private var optionGlyph: some View { + switch display { + case .sessionCount: + Text("x\(sessionCount)") + .font(.system(size: 18, weight: .bold, design: .rounded)) + .foregroundStyle(AhakeySettingsTheme.primaryText) + case .agent: + HStack(spacing: 3) { + ForEach(0..<3, id: \.self) { _ in + RoundedRectangle(cornerRadius: 2, style: .continuous) + .fill(Color(red: 1.0, green: 0.55, blue: 0.18)) + .frame(width: 8, height: 8) + } + } + case .focusTimer: + Text(focusTimer.displayText) + .font(.system(size: 15, weight: .bold, design: .rounded)) + .foregroundStyle(AhakeySettingsTheme.primaryText) + .monospacedDigit() + case .battery: + Image(systemName: "battery.75percent") + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(AhakeySettingsTheme.primaryText) + case .lever: + Text("Auto") + .font(.system(size: 14, weight: .bold, design: .rounded)) + .foregroundStyle(AhakeySettingsTheme.primaryText) + case .clock: + Image(systemName: "clock") + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(AhakeySettingsTheme.primaryText) + case .agentStatus: + Text("Think") + .font(.system(size: 13, weight: .bold, design: .rounded)) + .foregroundStyle(AhakeySettingsTheme.primaryText) + case .hidden: + Text("—") + .font(.system(size: 18, weight: .medium)) + .foregroundStyle(AhakeySettingsTheme.tertiaryText) + } + } +} + +/// 常驻岛预览内嵌的状态切换(自动 / 空间 / 运行 / 等待)。 +struct AhaKeyIslandStateKindPicker: View { + @Binding var selection: VibeBarRestingStateKind + + var body: some View { + HStack(spacing: 6) { + ForEach(VibeBarRestingStateKind.allCases) { kind in + let isSelected = selection == kind + Button { + selection = kind + } label: { + HStack(spacing: 4) { + Text(kind.title) + .font(.system(size: 12, weight: .semibold)) + if isSelected { + Image(systemName: "chevron.down") + .font(.system(size: 8, weight: .bold)) + } + } + .foregroundStyle(isSelected ? AhakeySettingsTheme.primaryText : AhakeySettingsTheme.secondaryText) + .padding(.horizontal, 12) + .padding(.vertical, 7) + .background( + Capsule(style: .continuous) + .fill(isSelected ? AhakeySettingsTheme.cardBackground : AhakeySettingsTheme.controlFill) + ) + } + .buttonStyle(.plain) + } + } + } +} + +/// 常驻岛 / 展开岛 / 通用 顶部分段。 +struct AhaKeyDynamicIslandSectionPicker: View { + @Binding var selection: AhaKeyDynamicIslandConfigSection + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 0) { + ForEach(AhaKeyDynamicIslandConfigSection.allCases) { section in + Button { + selection = section + } label: { + VStack(spacing: 3) { + Text(section.title) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(selection == section ? AhakeySettingsTheme.primaryText : AhakeySettingsTheme.secondaryText) + Text(section.subtitle) + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(selection == section ? AhakeySettingsTheme.accentBlue : AhakeySettingsTheme.tertiaryText) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(selection == section ? AhakeySettingsTheme.controlFill : Color.clear) + ) + } + .buttonStyle(.plain) + } + } + .padding(4) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(AhakeySettingsTheme.cardBackground) + ) + } + } +} + +/// 深色 Settings 折叠区:低频选项默认收起,避免与预览同级铺开。 +struct AhaKeySettingsDisclosureSection: View { + let title: String + var subtitle: String? = nil + @Binding var isExpanded: Bool + @ViewBuilder var content: () -> Content + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Button { + withAnimation(.easeInOut(duration: 0.18)) { + isExpanded.toggle() + } + } label: { + HStack(spacing: 10) { + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(AhakeySettingsTheme.rowTitleFont) + .foregroundStyle(AhakeySettingsTheme.primaryText) + if let subtitle, !subtitle.isEmpty { + Text(subtitle) + .font(AhakeySettingsTheme.rowSubtitleFont) + .foregroundStyle(AhakeySettingsTheme.tertiaryText) + } + } + Spacer(minLength: 0) + Image(systemName: "chevron.right") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(AhakeySettingsTheme.tertiaryText) + .rotationEffect(.degrees(isExpanded ? 90 : 0)) + } + .padding(.horizontal, AhakeySettingsTheme.rowPaddingH) + .padding(.vertical, 12) + .background( + RoundedRectangle(cornerRadius: AhakeySettingsTheme.cardCornerRadius, style: .continuous) + .fill(AhakeySettingsTheme.controlFill) + ) + } + .buttonStyle(.plain) + + if isExpanded { + VStack(alignment: .leading, spacing: 16) { + content() + } + .transition(.opacity.combined(with: .move(edge: .top))) + } + } + } +} diff --git a/ahakeyconfig-mac/Sources/Views/Settings/AhaKeyIslandStatusConfigPane.swift b/ahakeyconfig-mac/Sources/Views/Settings/AhaKeyIslandStatusConfigPane.swift new file mode 100644 index 00000000..6d2dc342 --- /dev/null +++ b/ahakeyconfig-mac/Sources/Views/Settings/AhaKeyIslandStatusConfigPane.swift @@ -0,0 +1,234 @@ +import SwiftUI +import VibeBar + +/// 常驻岛配置:状态态 + 预览 + 左/右槽 + 灯条 + 尺寸。 +struct AhaKeyIslandStatusConfigPane: View { + @ObservedObject var state: VibeBarState + @Binding var route: AhaKeyDynamicIslandSettingsRoute + let brandName: String + + @State private var stateKind: VibeBarRestingStateKind = VibeBarIslandAppearanceSettings.editingStateKind + @State private var rightSlot: VibeBarRightSlotDisplay = VibeBarIslandAppearanceSettings.rightSlot(for: .auto) + @State private var leftSlot: VibeBarLeftSlotDisplay = VibeBarIslandAppearanceSettings.leftSlot(for: .auto) + @AppStorage(VibeBarIslandAppearanceSettings.previewSessionCountKey) private var previewSessionCount = 3 + @AppStorage(VibeBarIslandAppearanceSettings.compactLightStripEnabledKey) private var lightStripEnabled = true + @State private var islandWidth = VibeBarIslandAppearanceSettings.islandWidth + @State private var showMoreAppearance = false + + private var previewModel: VibeBarCompactNotchModel { + VibeBarIslandAppearanceSettings.previewModel( + for: stateKind, + rightSlot: rightSlot, + leftSlot: leftSlot, + sessionCount: previewSessionCount, + deviceState: state, + lightStripEnabled: lightStripEnabled + ) + } + + var body: some View { + VStack(alignment: .leading, spacing: 20) { + VStack(alignment: .leading, spacing: 6) { + Text(VibeBarRestingStateKind.auto.configPageTitle) + .font(AhakeySettingsTheme.pageTitleFont) + .foregroundStyle(AhakeySettingsTheme.primaryText) + Text("静息态收起胶囊;在预览左下角切换状态,并分别配置左槽、右槽与灯条。") + .font(AhakeySettingsTheme.rowSubtitleFont) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + } + + AhaKeyIslandNotchPreview( + model: previewModel, + stateKind: $stateKind, + islandWidth: islandWidth, + agentStatus: state.agentStatus, + sectionTitle: "常驻岛预览" + ) + + layoutSection + + AhaKeySettingsDisclosureSection( + title: "更多外观", + subtitle: "灯条、尺寸与高级功能", + isExpanded: $showMoreAppearance + ) { + lightStripSection + islandWidthSection + placeholderSection + } + } + .onAppear(perform: reloadFromStorage) + .onChange(of: stateKind) { kind in + VibeBarIslandAppearanceSettings.editingStateKind = kind + reloadFromStorage() + applyToLiveIsland() + } + .onChange(of: rightSlot) { _ in applyToLiveIsland() } + .onChange(of: leftSlot) { _ in applyToLiveIsland() } + .onChange(of: lightStripEnabled) { enabled in + VibeBarIslandAppearanceSettings.compactLightStripEnabled = enabled + applyToLiveIsland() + } + .onChange(of: previewSessionCount) { _ in applyToLiveIsland() } + .onReceive(NotificationCenter.default.publisher(for: VibeBarIslandAppearanceSettings.appearanceDidChangeNotification)) { _ in + reloadFromStorage() + } + } + + private var lightStripSection: some View { + AhakeySettingsCard(sectionTitle: "灯条") { + AhakeySettingsToggleRow( + title: "显示 Agent 灯条", + subtitle: "常驻岛底部状态灯效,关闭后胶囊更紧凑", + isOn: $lightStripEnabled + ) + } + } + + private var islandWidthSection: some View { + AhakeySettingsCard(sectionTitle: "尺寸") { + VStack(alignment: .leading, spacing: 10) { + HStack { + Text("岛宽度") + .font(AhakeySettingsTheme.rowTitleFont) + .foregroundStyle(AhakeySettingsTheme.primaryText) + Spacer() + Text("\(Int(islandWidth)) pt") + .font(.system(size: 13, weight: .semibold, design: .rounded)) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + } + + Slider( + value: Binding( + get: { Double(islandWidth) }, + set: { islandWidth = CGFloat($0) } + ), + in: Double(VibeBarIslandAppearanceSettings.islandWidthMin)...Double(VibeBarIslandAppearanceSettings.islandWidthMax), + step: 10 + ) + .onChange(of: islandWidth) { width in + VibeBarIslandAppearanceSettings.islandWidth = width + state.islandWidth = width + applyToLiveIsland() + } + + Text("可调范围 \(Int(VibeBarIslandAppearanceSettings.islandWidthMin))–\(Int(VibeBarIslandAppearanceSettings.islandWidthMax)) pt;常驻岛与展开岛共用。") + .font(AhakeySettingsTheme.rowSubtitleFont) + .foregroundStyle(AhakeySettingsTheme.tertiaryText) + } + .padding(.horizontal, AhakeySettingsTheme.rowPaddingH) + .padding(.vertical, 12) + } + } + + private var layoutSection: some View { + VStack(alignment: .leading, spacing: 16) { + Text("布局") + .font(AhakeySettingsTheme.sectionTitleFont) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + .padding(.leading, 4) + + leftSlotSection + rightSlotSection + } + } + + private var leftSlotSection: some View { + VStack(alignment: .leading, spacing: 10) { + Text("左槽") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(AhakeySettingsTheme.tertiaryText) + .padding(.leading, 4) + + LazyVGrid(columns: [GridItem(.adaptive(minimum: 72), spacing: 10)], spacing: 10) { + ForEach(VibeBarLeftSlotDisplay.allCases) { option in + AhaKeyIslandLeftSlotOptionCard( + display: option, + isSelected: leftSlot == option + ) { + leftSlot = option + VibeBarIslandAppearanceSettings.setLeftSlot(option, for: stateKind) + applyToLiveIsland() + } + } + } + } + } + + private var rightSlotSection: some View { + VStack(alignment: .leading, spacing: 10) { + Text("右槽") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(AhakeySettingsTheme.tertiaryText) + .padding(.leading, 4) + + LazyVGrid(columns: [GridItem(.adaptive(minimum: 72), spacing: 10)], spacing: 10) { + ForEach(VibeBarRightSlotDisplay.allCases) { option in + AhaKeyIslandRightSlotOptionCard( + display: option, + sessionCount: previewSessionCount, + isSelected: rightSlot == option + ) { + rightSlot = option + VibeBarIslandAppearanceSettings.setRightSlot(option, for: stateKind) + applyToLiveIsland() + } + } + } + } + } + + /// 常驻岛相关高级功能占位;系统/声音已迁至「通用」。 + private var placeholderSection: some View { + AhakeySettingsCard(sectionTitle: "高级功能") { + Button { route = .agentSessions } label: { + HStack(spacing: 12) { + Image(systemName: AhaKeyDynamicIslandSettingsRoute.agentSessions.icon) + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + .frame(width: 22) + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 8) { + Text("Agent 会话") + .font(AhakeySettingsTheme.rowTitleFont) + .foregroundStyle(AhakeySettingsTheme.primaryText) + Text("占位") + .font(.system(size: 9, weight: .semibold)) + .foregroundStyle(AhakeySettingsTheme.tertiaryText) + .padding(.horizontal, 5) + .padding(.vertical, 2) + .background(Capsule().fill(AhakeySettingsTheme.controlFill)) + } + Text("会话列表在胶囊中的展示规则(即将推出)") + .font(AhakeySettingsTheme.rowSubtitleFont) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + } + Spacer() + Image(systemName: "chevron.right") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(AhakeySettingsTheme.tertiaryText) + } + .padding(.horizontal, AhakeySettingsTheme.rowPaddingH) + .padding(.vertical, 12) + } + .buttonStyle(.plain) + } + } + + private func reloadFromStorage() { + stateKind = VibeBarIslandAppearanceSettings.editingStateKind + rightSlot = VibeBarIslandAppearanceSettings.rightSlot(for: stateKind) + leftSlot = VibeBarIslandAppearanceSettings.leftSlot(for: stateKind) + previewSessionCount = VibeBarIslandAppearanceSettings.previewSessionCount + islandWidth = VibeBarIslandAppearanceSettings.islandWidth + lightStripEnabled = VibeBarIslandAppearanceSettings.compactLightStripEnabled + } + + private func applyToLiveIsland() { + NotificationCenter.default.post(name: .ahaKeyIslandAppearanceApplyRequested, object: nil) + } +} + +extension Notification.Name { + static let ahaKeyIslandAppearanceApplyRequested = Notification.Name("ahaKey.island.appearanceApplyRequested") +} diff --git a/ahakeyconfig-mac/Sources/Views/Settings/AhaKeyModuleDetailSettingsPanes.swift b/ahakeyconfig-mac/Sources/Views/Settings/AhaKeyModuleDetailSettingsPanes.swift new file mode 100644 index 00000000..aed2f09d --- /dev/null +++ b/ahakeyconfig-mac/Sources/Views/Settings/AhaKeyModuleDetailSettingsPanes.swift @@ -0,0 +1,714 @@ +import SwiftUI +import VibeBar +import UniformTypeIdentifiers +import AppKit + +/// 四键键帽二级设置:外观 / 显隐 / 角色映射。 +struct AhaKeyKeyPadSettingsPane: View { + @ObservedObject var state: VibeBarState + let brandName: String + var onBack: () -> Void + + @State private var slots = VibeBarKeyPadSettings.slots + @State private var selectedSlotID: String? + @State private var statusMessage: String? + + private let presetIcons = [ + "mic", "mic.fill", "checkmark.circle.fill", "xmark.circle.fill", + "return", "arrow.up.circle.fill", "hand.thumbsup.fill", "hand.thumbsdown.fill", + "bolt.fill", "star.fill", "heart.fill", "flag.fill", + ] + + private let presetColors: [(title: String, hex: String)] = [ + ("紫", "#BF59F2"), ("绿", "#42E86B"), ("红", "#FF7359"), + ("蓝", "#56C2FF"), ("橙", "#FF9F0A"), ("白", "#FFFFFF"), + ] + + var body: some View { + AhakeyWrappedSettingsPane(title: "四键键帽", brandName: brandName, onBack: onBack) { + Text("配置展开岛四键的外观与动作。隐藏后键区会压缩布局。") + .font(AhakeySettingsTheme.rowSubtitleFont) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + + if let statusMessage { + Text(statusMessage) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(AhakeySettingsTheme.accentBlue) + } + + previewCard + ForEach(slots) { slot in + slotEditor(slot) + } + + Button("重置为默认") { + VibeBarKeyPadSettings.resetToDefaults() + reload() + statusMessage = "已恢复默认四键" + clearStatusLater() + } + .buttonStyle(.bordered) + } + .onAppear(perform: reload) + .onReceive(NotificationCenter.default.publisher(for: VibeBarKeyPadSettings.didChangeNotification)) { _ in + reload() + } + } + + private var previewCard: some View { + AhakeySettingsCard(sectionTitle: "预览") { + VibeBarCommandKeyPad( + keys: previewKeys, + highlightStatus: state.agentStatus + ) + .padding(.horizontal, AhakeySettingsTheme.rowPaddingH) + .padding(.vertical, 14) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color.black.opacity(0.55)) + .padding(.horizontal, 8) + .padding(.vertical, 4) + ) + } + } + + private var previewKeys: [VibeBarCommandKey] { + VibeBarKeyPadSettings.visibleSlots.map { slot in + VibeBarCommandKey( + id: slot.role.rawValue, + title: slot.title, + subtitle: slot.id.uppercased(), + systemName: slot.systemImage, + tint: slot.tintColor, + action: { + selectedSlotID = slot.id + } + ) + } + } + + private func slotEditor(_ slot: VibeBarKeyPadSlot) -> some View { + let isSelected = selectedSlotID == slot.id + return AhakeySettingsCard(sectionTitle: "\(slot.id.uppercased()) · \(slot.role.title)") { + VStack(alignment: .leading, spacing: 12) { + AhakeySettingsToggleRow( + title: "显示此键", + subtitle: slots.filter(\.isEnabled).count <= 1 && slot.isEnabled + ? "至少保留一颗可见键" + : nil, + isOn: Binding( + get: { slot.isEnabled }, + set: { enabled in + var next = slot + next.isEnabled = enabled + commit(next) + } + ) + ) + + AhakeySettingsCardDivider() + + labeledRow("角色") { + Picker("", selection: Binding( + get: { slot.role }, + set: { role in + var next = slot + next.role = role + if next.title == slot.role.title || next.title.isEmpty { + next.title = role.title + } + if next.systemImage == slot.role.defaultSystemImage { + next.systemImage = role.defaultSystemImage + } + commit(next) + } + )) { + ForEach(VibeBarKeyPadRole.allCases) { role in + Text(role.title).tag(role) + } + } + .labelsHidden() + .pickerStyle(.menu) + .frame(width: 120) + } + + labeledRow("标题") { + TextField("标题", text: Binding( + get: { slot.title }, + set: { title in + var next = slot + next.title = title + commit(next) + } + )) + .textFieldStyle(.roundedBorder) + .frame(maxWidth: 180) + } + + VStack(alignment: .leading, spacing: 8) { + Text("图标") + .font(AhakeySettingsTheme.rowTitleFont) + .foregroundStyle(AhakeySettingsTheme.primaryText) + LazyVGrid(columns: [GridItem(.adaptive(minimum: 36), spacing: 8)], spacing: 8) { + ForEach(presetIcons, id: \.self) { icon in + Button { + var next = slot + next.systemImage = icon + commit(next) + } label: { + Image(systemName: icon) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(slot.systemImage == icon ? .white : AhakeySettingsTheme.secondaryText) + .frame(width: 36, height: 36) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(slot.systemImage == icon ? AhakeySettingsTheme.accentBlue.opacity(0.85) : AhakeySettingsTheme.controlFill) + ) + } + .buttonStyle(.plain) + } + } + } + .padding(.horizontal, AhakeySettingsTheme.rowPaddingH) + + VStack(alignment: .leading, spacing: 8) { + Text("颜色") + .font(AhakeySettingsTheme.rowTitleFont) + .foregroundStyle(AhakeySettingsTheme.primaryText) + HStack(spacing: 8) { + ForEach(presetColors, id: \.hex) { item in + Button { + var next = slot + next.tintHex = item.hex + commit(next) + } label: { + Circle() + .fill(Color(hex: item.hex) ?? .gray) + .frame(width: 22, height: 22) + .overlay( + Circle().stroke(slot.tintHex == item.hex ? AhakeySettingsTheme.primaryText : AhakeySettingsTheme.divider, lineWidth: slot.tintHex == item.hex ? 2 : 1) + ) + } + .buttonStyle(.plain) + .help(item.title) + } + } + } + .padding(.horizontal, AhakeySettingsTheme.rowPaddingH) + .padding(.bottom, 8) + } + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(isSelected ? AhakeySettingsTheme.accentBlue.opacity(0.5) : Color.clear, lineWidth: 1) + ) + } + .onTapGesture { selectedSlotID = slot.id } + } + + private func labeledRow(_ title: String, @ViewBuilder content: () -> Content) -> some View { + HStack { + Text(title) + .font(AhakeySettingsTheme.rowTitleFont) + .foregroundStyle(AhakeySettingsTheme.primaryText) + Spacer() + content() + } + .padding(.horizontal, AhakeySettingsTheme.rowPaddingH) + } + + private func commit(_ slot: VibeBarKeyPadSlot) { + VibeBarKeyPadSettings.updateSlot(slot) + reload() + NotificationCenter.default.post(name: .ahaKeyIslandAppearanceApplyRequested, object: nil) + } + + private func reload() { + slots = VibeBarKeyPadSettings.slots + if selectedSlotID == nil { + selectedSlotID = slots.first?.id + } + } + + private func clearStatusLater() { + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + statusMessage = nil + } + } +} + +/// Agent · 设置:仅皮肤与状态图标。 +struct AhaKeyPetAppearanceEditor: View { + var agentTaskTitle: String = "" + + @State private var appearance = VibeBarPetAppearanceSettings.appearance + @State private var previewStatus: VibeBarAgentStatus = .thinking + @State private var statusMessage: String? + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text("选择极简圆点(默认)或状态 emoji,并可自定义各状态图标。动画、尺寸、信息层与 GIF 请到灵动岛 · 组件库 · OLED Pet。") + .font(AhakeySettingsTheme.rowSubtitleFont) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + + if let statusMessage { + Text(statusMessage) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(AhakeySettingsTheme.accentBlue) + } + + previewCard + skinCard + if appearance.skinId == .classicEmoji { + emojiCustomizeCard + } + + Button("重置皮肤与图标") { + commit { value in + value.skinId = .minimalDot + value.statusOverrides = [:] + } + statusMessage = "已恢复默认皮肤与图标" + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { statusMessage = nil } + } + .buttonStyle(.bordered) + } + .onAppear(perform: reload) + .onReceive(NotificationCenter.default.publisher(for: VibeBarPetAppearanceSettings.didChangeNotification)) { _ in + reload() + } + } + + private var previewCard: some View { + AhakeySettingsCard(sectionTitle: "预览") { + VStack(alignment: .leading, spacing: 12) { + Picker("预览状态", selection: $previewStatus) { + ForEach(VibeBarAgentStatus.allCases) { status in + Text(status.title).tag(status) + } + } + .pickerStyle(.menu) + + VibeBarAIPetOLED( + status: previewStatus, + taskTitle: previewTaskTitle, + progress: previewStatus == .coding || previewStatus == .thinking ? 0.55 : 0, + appearance: appearance, + onTap: { + let all = VibeBarAgentStatus.allCases + if let idx = all.firstIndex(of: previewStatus) { + previewStatus = all[(idx + 1) % all.count] + } + } + ) + } + .padding(.horizontal, AhakeySettingsTheme.rowPaddingH) + .padding(.vertical, 12) + } + } + + private var previewTaskTitle: String { + switch previewStatus { + case .listening: return "Voice input active" + case .thinking: return "Analyzing context" + case .coding: return "Generating changes" + case .approval: return "Waiting for your confirm" + default: return agentTaskTitle.isEmpty ? "Ready for next task" : agentTaskTitle + } + } + + private var skinCard: some View { + AhakeySettingsCard(sectionTitle: "外观模式") { + HStack(spacing: 10) { + ForEach(VibeBarPetSkin.allCases) { skin in + Button { + commit { $0.skinId = skin } + } label: { + VStack(spacing: 6) { + Text(skin.glyph(for: previewStatus)) + .font(.system(size: 28)) + Text(skin.title) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(AhakeySettingsTheme.primaryText) + Text(skin.detail) + .font(.system(size: 10)) + .foregroundStyle(AhakeySettingsTheme.tertiaryText) + .lineLimit(1) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(AhakeySettingsTheme.controlFill) + ) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .stroke(appearance.skinId == skin ? AhakeySettingsTheme.accentBlue.opacity(0.55) : AhakeySettingsTheme.divider, lineWidth: appearance.skinId == skin ? 1.2 : 1) + ) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, AhakeySettingsTheme.rowPaddingH) + .padding(.vertical, 12) + } + } + + private var emojiCustomizeCard: some View { + AhakeySettingsCard(sectionTitle: "自定义状态图标") { + Text("为各 Agent 状态填写 emoji 或符号;留空则使用默认状态 emoji。") + .font(AhakeySettingsTheme.rowSubtitleFont) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + .padding(.horizontal, AhakeySettingsTheme.rowPaddingH) + .padding(.top, 12) + + ForEach(VibeBarAgentStatus.allCases) { status in + statusGlyphRow(status) + if status != VibeBarAgentStatus.allCases.last { + AhakeySettingsCardDivider() + } + } + } + } + + private func statusGlyphRow(_ status: VibeBarAgentStatus) -> some View { + let override = appearance.statusOverrides[status.rawValue] ?? VibeBarPetStatusOverride() + return VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 8) { + Text(appearance.glyph(for: status)) + .font(.system(size: 18)) + .frame(width: 28) + Text(status.title) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + Spacer(minLength: 0) + Text("默认 \(status.petEmoji)") + .font(.system(size: 10)) + .foregroundStyle(AhakeySettingsTheme.tertiaryText) + } + + TextField("自定义 emoji / 符号", text: Binding( + get: { override.glyph ?? "" }, + set: { text in + commit { appearance in + var map = appearance.statusOverrides + var value = map[status.rawValue] ?? VibeBarPetStatusOverride() + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + value.glyph = trimmed.isEmpty ? nil : trimmed + if value.glyph == nil && value.animationStyle == nil { + map.removeValue(forKey: status.rawValue) + } else { + map[status.rawValue] = value + } + appearance.statusOverrides = map + } + } + )) + .textFieldStyle(.roundedBorder) + } + .padding(.horizontal, AhakeySettingsTheme.rowPaddingH) + .padding(.vertical, 8) + } + + private func commit(_ mutate: (inout VibeBarPetAppearance) -> Void) { + VibeBarPetAppearanceSettings.update(mutate) + reload() + NotificationCenter.default.post(name: .ahaKeyIslandAppearanceApplyRequested, object: nil) + } + + private func reload() { + appearance = VibeBarPetAppearanceSettings.appearance + } +} + +/// 灵动岛 · 组件库 · OLED Pet:信息层、尺寸、动画、自定义 GIF。 +struct AhaKeyOledPetIslandSettingsEditor: View { + @State private var appearance = VibeBarPetAppearanceSettings.appearance + @State private var showAdvanced = false + @State private var statusMessage: String? + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text("配置展开岛 Pet 的信息层、尺寸、动画与软件岛 GIF。皮肤与状态图标请到 Agent · 设置。") + .font(AhakeySettingsTheme.rowSubtitleFont) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + + Button { + StudioNavigationRouter.shared.openPetAppearanceSettings() + } label: { + HStack { + Text("去 Agent · 设置改外观(皮肤 / 图标)") + .font(AhakeySettingsTheme.rowTitleFont) + .foregroundStyle(AhakeySettingsTheme.accentBlue) + Spacer() + Image(systemName: "arrow.right") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(AhakeySettingsTheme.accentBlue) + } + .padding(.horizontal, AhakeySettingsTheme.rowPaddingH) + .padding(.vertical, 12) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(AhakeySettingsTheme.controlFill) + ) + } + .buttonStyle(.plain) + + if let statusMessage { + Text(statusMessage) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(AhakeySettingsTheme.accentBlue) + } + + layoutCard + sizeCard + animationCard + advancedCard + customAssetCard + + Button("重置岛侧设置") { + commit { value in + let defaults = VibeBarPetAppearance.default + value.showsStatusLabel = defaults.showsStatusLabel + value.showsTaskTitle = defaults.showsTaskTitle + value.showsProgress = defaults.showsProgress + value.petSize = defaults.petSize + value.animationStyle = defaults.animationStyle + value.animationSpeed = defaults.animationSpeed + value.customAssetPath = nil + // 保留皮肤与图标覆盖,只清动画覆盖 + var map = value.statusOverrides + for key in Array(map.keys) { + guard var entry = map[key] else { continue } + entry.animationStyle = nil + if entry.glyph == nil { + map.removeValue(forKey: key) + } else { + map[key] = entry + } + } + value.statusOverrides = map + } + statusMessage = "已恢复默认岛侧设置" + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { statusMessage = nil } + } + .buttonStyle(.bordered) + } + .onAppear(perform: reload) + .onReceive(NotificationCenter.default.publisher(for: VibeBarPetAppearanceSettings.didChangeNotification)) { _ in + reload() + } + } + + private var layoutCard: some View { + AhakeySettingsCard(sectionTitle: "信息层") { + AhakeySettingsToggleRow( + title: "显示状态名", + subtitle: nil, + isOn: Binding( + get: { appearance.showsStatusLabel }, + set: { value in commit { $0.showsStatusLabel = value } } + ) + ) + AhakeySettingsCardDivider() + AhakeySettingsToggleRow( + title: "显示任务标题", + subtitle: nil, + isOn: Binding( + get: { appearance.showsTaskTitle }, + set: { value in commit { $0.showsTaskTitle = value } } + ) + ) + AhakeySettingsCardDivider() + AhakeySettingsToggleRow( + title: "显示进度条", + subtitle: nil, + isOn: Binding( + get: { appearance.showsProgress }, + set: { value in commit { $0.showsProgress = value } } + ) + ) + } + } + + private var sizeCard: some View { + AhakeySettingsCard(sectionTitle: "尺寸") { + HStack { + Text("Pet 尺寸") + .font(AhakeySettingsTheme.rowTitleFont) + .foregroundStyle(AhakeySettingsTheme.primaryText) + Spacer() + Picker("", selection: Binding( + get: { appearance.petSize }, + set: { value in commit { $0.petSize = value } } + )) { + ForEach(VibeBarPetSize.allCases) { size in + Text(size.title).tag(size) + } + } + .labelsHidden() + .pickerStyle(.segmented) + .frame(maxWidth: 200) + } + .padding(.horizontal, AhakeySettingsTheme.rowPaddingH) + .padding(.vertical, 12) + } + } + + private var animationCard: some View { + AhakeySettingsCard(sectionTitle: "动画") { + HStack { + Text("样式") + .font(AhakeySettingsTheme.rowTitleFont) + .foregroundStyle(AhakeySettingsTheme.primaryText) + Spacer() + Picker("", selection: Binding( + get: { appearance.animationStyle }, + set: { value in commit { $0.animationStyle = value } } + )) { + ForEach(VibeBarPetAnimationStyle.allCases) { style in + Text(style.title).tag(style) + } + } + .labelsHidden() + .pickerStyle(.menu) + .frame(width: 100) + } + .padding(.horizontal, AhakeySettingsTheme.rowPaddingH) + .padding(.vertical, 10) + + AhakeySettingsCardDivider() + + HStack { + Text("速度") + .font(AhakeySettingsTheme.rowTitleFont) + .foregroundStyle(AhakeySettingsTheme.primaryText) + Spacer() + Picker("", selection: Binding( + get: { appearance.animationSpeed }, + set: { value in commit { $0.animationSpeed = value } } + )) { + ForEach(VibeBarPetAnimationSpeed.allCases) { speed in + Text(speed.title).tag(speed) + } + } + .labelsHidden() + .pickerStyle(.segmented) + .frame(maxWidth: 200) + } + .padding(.horizontal, AhakeySettingsTheme.rowPaddingH) + .padding(.vertical, 12) + } + } + + private var advancedCard: some View { + AhakeySettingsCard(sectionTitle: "高级 · 按状态动画覆盖") { + DisclosureGroup(isExpanded: $showAdvanced) { + ForEach(VibeBarAgentStatus.allCases) { status in + statusAnimationRow(status) + if status != VibeBarAgentStatus.allCases.last { + AhakeySettingsCardDivider() + } + } + } label: { + Text(showAdvanced ? "收起状态动画表" : "展开状态动画表") + .font(AhakeySettingsTheme.rowTitleFont) + .foregroundStyle(AhakeySettingsTheme.primaryText) + } + .padding(.horizontal, AhakeySettingsTheme.rowPaddingH) + .padding(.vertical, 12) + } + } + + private func statusAnimationRow(_ status: VibeBarAgentStatus) -> some View { + let override = appearance.statusOverrides[status.rawValue] ?? VibeBarPetStatusOverride() + return HStack { + Text(status.title) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + Spacer() + Picker("动画", selection: Binding( + get: { override.animationStyle?.rawValue ?? "inherit" }, + set: { raw in + commit { appearance in + var map = appearance.statusOverrides + var value = map[status.rawValue] ?? VibeBarPetStatusOverride() + value.animationStyle = raw == "inherit" ? nil : VibeBarPetAnimationStyle(rawValue: raw) + if value.glyph == nil && value.animationStyle == nil { + map.removeValue(forKey: status.rawValue) + } else { + map[status.rawValue] = value + } + appearance.statusOverrides = map + } + } + )) { + Text("跟随全局").tag("inherit") + ForEach(VibeBarPetAnimationStyle.allCases) { style in + Text(style.title).tag(style.rawValue) + } + } + .labelsHidden() + .frame(width: 110) + } + .padding(.horizontal, AhakeySettingsTheme.rowPaddingH) + .padding(.vertical, 8) + } + + private var customAssetCard: some View { + AhakeySettingsCard(sectionTitle: "自定义素材(软件岛)") { + VStack(alignment: .leading, spacing: 10) { + Text(appearance.customAssetPath?.isEmpty == false + ? (appearance.resolvedCustomAssetURL?.lastPathComponent ?? "路径无效,将回退皮肤") + : "未选择自定义 GIF") + .font(AhakeySettingsTheme.rowSubtitleFont) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + + HStack(spacing: 10) { + Button("选择 GIF…") { pickCustomGIF() } + .buttonStyle(.borderedProminent) + Button("清除") { + commit { $0.customAssetPath = nil } + } + .buttonStyle(.bordered) + .disabled(appearance.customAssetPath == nil) + } + } + .padding(.horizontal, AhakeySettingsTheme.rowPaddingH) + .padding(.vertical, 12) + } + } + + private func pickCustomGIF() { + let panel = NSOpenPanel() + panel.allowedContentTypes = [.gif] + panel.allowsMultipleSelection = false + panel.canChooseDirectories = false + guard panel.runModal() == .OK, let url = panel.url else { return } + commit { $0.customAssetPath = url.path } + statusMessage = "已设置自定义 GIF(仅软件岛)" + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { statusMessage = nil } + } + + private func commit(_ mutate: (inout VibeBarPetAppearance) -> Void) { + VibeBarPetAppearanceSettings.update(mutate) + reload() + NotificationCenter.default.post(name: .ahaKeyIslandAppearanceApplyRequested, object: nil) + } + + private func reload() { + appearance = VibeBarPetAppearanceSettings.appearance + } +} + +/// 灵动岛 · 组件库 → OLED Pet 子页。 +struct AhaKeyOledPetSettingsPane: View { + @ObservedObject var state: VibeBarState + let brandName: String + var onBack: () -> Void + + var body: some View { + AhakeyWrappedSettingsPane(title: "OLED Pet", brandName: brandName, onBack: onBack) { + AhaKeyOledPetIslandSettingsEditor() + } + } +} diff --git a/ahakeyconfig-mac/Sources/Views/Settings/AhaKeySettingsPanes.swift b/ahakeyconfig-mac/Sources/Views/Settings/AhaKeySettingsPanes.swift new file mode 100644 index 00000000..665f611c --- /dev/null +++ b/ahakeyconfig-mac/Sources/Views/Settings/AhaKeySettingsPanes.swift @@ -0,0 +1,55 @@ +import SwiftUI +import VibeBar + +struct VoiceInputWorkspacePane: View { + @State private var configSection: AhaKeyVoiceInputConfigSection = .history + + var body: some View { + ScrollView(.vertical, showsIndicators: true) { + VStack(alignment: .leading, spacing: 20) { + AhaKeyVoiceInputSectionPicker(selection: $configSection) + + switch configSection { + case .history: + AhaKeyVoiceHistoryPane() + case .dictionary: + AhaKeyVoiceDictionaryPane() + case .general: + AhaKeyVoiceGeneralConfigPane() + } + } + .padding(.horizontal, AhakeySettingsTheme.contentPadding) + .padding(.top, 20) + .padding(.bottom, 28) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .background(AhakeySettingsTheme.contentBackground) + .onReceive(NotificationCenter.default.publisher(for: .ahaKeySettingsSelectTab)) { notification in + guard let tabRaw = notification.userInfo?[StudioNavigationUserInfoKey.tab] as? String, + tabRaw == AhaKeySettingsTab.voiceInput.rawValue, + let sectionRaw = notification.userInfo?[StudioNavigationUserInfoKey.voiceSection] as? String, + let section = AhaKeyVoiceInputConfigSection(rawValue: sectionRaw) else { return } + configSection = section + } + } +} + +struct HardwareStudioHost: View { + @ObservedObject var bleManager: AhaKeyBLEManager + + var body: some View { + AhaKeyStudioView(bleManager: bleManager, presentation: .embeddedClient) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + +struct AgentWorkbenchHost: View { + @ObservedObject var bleManager: AhaKeyBLEManager + @ObservedObject var islandState: VibeBarState + @Binding var section: AhaKeyAgentConfigSection + + var body: some View { + AhaKeyAgentWorkspacePane(bleManager: bleManager, islandState: islandState, section: $section) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} diff --git a/ahakeyconfig-mac/Sources/Views/Settings/AhaKeySettingsRootView.swift b/ahakeyconfig-mac/Sources/Views/Settings/AhaKeySettingsRootView.swift new file mode 100644 index 00000000..4fd75318 --- /dev/null +++ b/ahakeyconfig-mac/Sources/Views/Settings/AhaKeySettingsRootView.swift @@ -0,0 +1,216 @@ +import SwiftUI +import VibeBar + +struct AhaKeySettingsRootView: View { + @ObservedObject var bleManager: AhaKeyBLEManager + @ObservedObject var islandState: VibeBarState + + @State private var selectedTab: AhaKeySettingsTab = .hardware + @State private var dynamicIslandRoute: AhaKeyDynamicIslandSettingsRoute = .root + @State private var agentSection: AhaKeyAgentConfigSection = .assistant + @State private var showsPluginMarket = false + @State private var pluginMarketSection: AhakeyPluginMarketSection = .mine + @State private var showsUserCenter = false + @State private var userCenterSection: AhaKeyUserCenterSection = .account + @State private var showsMyDevices = false + @State private var showsDeviceDetail = false + + @AppStorage(AhaKeyAppearanceMode.storageKey) private var appearanceModeRaw = AhaKeyAppearanceMode.defaultMode.rawValue + @AppStorage(AhakeyGeneralSettingsStore.languageKey) private var languageRaw = AhaKeyAppLanguage.system.rawValue + @AppStorage(AhakeyGeneralSettingsStore.regionKey) private var regionRaw = AhaKeyAppRegion.system.rawValue + + private let brandName = "AhaKey" + + private var appearanceMode: AhaKeyAppearanceMode { + AhaKeyAppearanceMode(rawValue: appearanceModeRaw) ?? .defaultMode + } + + private var resolvedLocale: Locale { + let language = AhaKeyAppLanguage(rawValue: languageRaw) ?? .system + let region = AhaKeyAppRegion(rawValue: regionRaw) ?? .system + return AhaKeyLocaleResolver.locale(language: language, region: region) + } + + var body: some View { + AhakeySettingsRootChrome { + AhakeySettingsFlatSidebar( + bleManager: bleManager, + selection: $selectedTab, + onUserTap: { openUserCenter(.account) }, + onDeviceTap: { openMyDevices() } + ) + .onChange(of: selectedTab) { _ in + showsPluginMarket = false + showsUserCenter = false + showsMyDevices = false + showsDeviceDetail = false + } + } detail: { + VStack(spacing: 0) { + AhakeySettingsGlobalTopBar(onPluginMarketTap: { openPluginMarket(.mine) }) + detailView + } + } + .frame(minWidth: 900, idealWidth: 1180, minHeight: 560, idealHeight: 680) + .preferredColorScheme(appearanceMode.colorScheme) + .environment(\.locale, resolvedLocale) + .sheet(isPresented: $showsUserCenter) { + AhaKeyUserCenterPane( + bleManager: bleManager, + section: $userCenterSection, + onClose: { showsUserCenter = false } + ) + .frame(width: 880, height: 620) + .preferredColorScheme(appearanceMode.colorScheme) + .environment(\.locale, resolvedLocale) + } + .onReceive(NotificationCenter.default.publisher(for: .ahaKeyStudioNavigate)) { notification in + handleStudioNavigation(notification) + } + .onReceive(NotificationCenter.default.publisher(for: .ahaKeySettingsSelectTab)) { notification in + if let raw = notification.userInfo?[StudioNavigationUserInfoKey.tab] as? String, + let tab = AhaKeySettingsTab(rawValue: raw) { + selectedTab = tab + showsPluginMarket = false + showsUserCenter = false + showsMyDevices = false + showsDeviceDetail = false + if tab == .agent, + let sectionRaw = notification.userInfo?[StudioNavigationUserInfoKey.agentSection] as? String, + let section = AhaKeyAgentConfigSection(rawValue: sectionRaw) { + agentSection = section + } + if tab == .dynamicIsland, + let routeRaw = notification.userInfo?[StudioNavigationUserInfoKey.dynamicIslandRoute] as? String, + let route = AhaKeyDynamicIslandSettingsRoute(rawValue: routeRaw) { + dynamicIslandRoute = route + } + } + } + .onReceive(NotificationCenter.default.publisher(for: .ahaKeyOpenUserCenter)) { notification in + let section: AhaKeyUserCenterSection + if let raw = notification.userInfo?[StudioNavigationUserInfoKey.userCenterSection] as? String, + let parsed = AhaKeyUserCenterSection(rawValue: raw) { + section = parsed + } else { + section = .account + } + openUserCenter(section) + } + .onReceive(NotificationCenter.default.publisher(for: .ahaKeyOpenPluginMarket)) { notification in + let section: AhakeyPluginMarketSection + if let raw = notification.userInfo?[StudioNavigationUserInfoKey.pluginMarketSection] as? String, + let parsed = AhakeyPluginMarketSection(rawValue: raw) { + section = parsed + } else { + section = .mine + } + openPluginMarket(section) + } + .onReceive(NotificationCenter.default.publisher(for: .ahaKeyOpenMyDevices)) { notification in + let openDetail = (notification.userInfo?[StudioNavigationUserInfoKey.openDeviceDetail] as? Bool) ?? true + openMyDevices(showDetail: openDetail) + } + } + + @ViewBuilder + private var detailView: some View { + if showsPluginMarket { + AhakeyPluginMarketPane( + section: $pluginMarketSection, + onClose: { showsPluginMarket = false } + ) + } else if showsMyDevices { + AhaKeyDeviceManagementPane( + bleManager: bleManager, + showsDetail: $showsDeviceDetail, + onClose: closeMyDevices + ) + } else { + switch selectedTab { + case .dynamicIsland: + AhaKeyDynamicIslandSettingsPane( + state: islandState, + route: $dynamicIslandRoute, + brandName: brandName + ) + case .hardware: + HardwareStudioHost(bleManager: bleManager) + case .voiceInput: + VoiceInputWorkspacePane() + case .agent: + AgentWorkbenchHost(bleManager: bleManager, islandState: islandState, section: $agentSection) + } + } + } + + private func openUserCenter(_ section: AhaKeyUserCenterSection = .account) { + userCenterSection = section + showsUserCenter = true + } + + private func openPluginMarket(_ section: AhakeyPluginMarketSection = .mine) { + pluginMarketSection = section + showsUserCenter = false + showsMyDevices = false + showsDeviceDetail = false + showsPluginMarket = true + } + + private func openMyDevices(showDetail: Bool = true) { + showsPluginMarket = false + showsUserCenter = false + showsMyDevices = true + showsDeviceDetail = showDetail + } + + private func closeMyDevices() { + showsMyDevices = false + showsDeviceDetail = false + } + + private func openHardware(part: AhaKeyStudioPart) { + showsMyDevices = false + showsDeviceDetail = false + selectedTab = .hardware + NotificationCenter.default.post( + name: .ahaKeyStudioSelectPart, + object: nil, + userInfo: [StudioNavigationUserInfoKey.part: part.rawValue] + ) + } + + private func handleStudioNavigation(_ notification: Notification) { + guard let raw = notification.userInfo?[StudioNavigationUserInfoKey.section] as? String, + let section = StudioNavigationSection(rawValue: raw) else { return } + + showsPluginMarket = false + showsUserCenter = false + + switch section { + case .voiceAgent: + showsMyDevices = false + showsDeviceDetail = false + selectedTab = .agent + agentSection = .assistant + case .approve: + openHardware(part: .key2) + case .voice: + openHardware(part: .key1) + case .oled: + openHardware(part: .oledDisplay) + case .device: + openMyDevices(showDetail: true) + } + + if let partRaw = notification.userInfo?[StudioNavigationUserInfoKey.part] as? String, + let part = AhaKeyStudioPart(rawValue: partRaw), + selectedTab == .hardware { + NotificationCenter.default.post( + name: .ahaKeyStudioSelectPart, + object: nil, + userInfo: [StudioNavigationUserInfoKey.part: part.rawValue] + ) + } + } +} diff --git a/ahakeyconfig-mac/Sources/Views/Settings/AhaKeySettingsTypes.swift b/ahakeyconfig-mac/Sources/Views/Settings/AhaKeySettingsTypes.swift new file mode 100644 index 00000000..3aadf3e3 --- /dev/null +++ b/ahakeyconfig-mac/Sources/Views/Settings/AhaKeySettingsTypes.swift @@ -0,0 +1,196 @@ +import Foundation + +enum AhaKeySettingsTab: String, CaseIterable, Identifiable, Hashable { + case hardware + case voiceInput + case agent + case dynamicIsland + + var id: String { rawValue } + + var title: String { + switch self { + case .dynamicIsland: "灵动岛" + case .hardware: "硬件设备" + case .voiceInput: "语音输入" + case .agent: "Agent" + } + } + + var icon: String { + switch self { + case .dynamicIsland: "sparkles.rectangle.stack" + case .hardware: "keyboard" + case .voiceInput: "mic.fill" + case .agent: "brain.head.profile" + } + } +} + +/// 灵动岛 Tab 三分段:常驻 / 展开 / 通用。 +enum AhaKeyDynamicIslandConfigSection: String, CaseIterable, Identifiable, Hashable { + case notchConfig + case islandConfig + case general + + var id: String { rawValue } + + var title: String { + switch self { + case .notchConfig: "常驻岛" + case .islandConfig: "展开岛" + case .general: "通用" + } + } + + var subtitle: String { + switch self { + case .notchConfig: "静息态胶囊" + case .islandConfig: "悬停展开面板" + case .general: "系统与声音" + } + } +} + +/// 语音输入 Tab 三分段:历史 / 词典 / 通用。 +enum AhaKeyVoiceInputConfigSection: String, CaseIterable, Identifiable, Hashable { + case history + case dictionary + case general + + var id: String { rawValue } + + var title: String { + switch self { + case .history: "历史记录" + case .dictionary: "词典" + case .general: "通用设置" + } + } + + var subtitle: String { + switch self { + case .history: "口述与转写" + case .dictionary: "替换词条" + case .general: "试录与权限" + } + } +} + +/// 用户中心左侧导航分段。 +enum AhaKeyUserCenterSection: String, CaseIterable, Identifiable, Hashable { + case account + case settings + case usageData + case about + case help + case releaseNotes + + var id: String { rawValue } + + var title: String { + switch self { + case .account: "用户中心" + case .settings: "设置" + case .usageData: "使用数据" + case .about: "关于我们" + case .help: "帮助中心" + case .releaseNotes: "版本说明" + } + } + + var systemImage: String { + switch self { + case .account: "person.crop.circle" + case .settings: "gearshape" + case .usageData: "chart.bar.doc.horizontal" + case .about: "info.circle" + case .help: "book" + case .releaseNotes: "doc.text" + } + } + + var showsExternalLinkHint: Bool { + self == .help || self == .releaseNotes + } + + static let primarySections: [AhaKeyUserCenterSection] = [.account, .settings, .usageData, .about] + static let secondarySections: [AhaKeyUserCenterSection] = [.help, .releaseNotes] +} + +/// Agent Tab 三分段:助手 / 联动 / 设置。 +enum AhaKeyAgentConfigSection: String, CaseIterable, Identifiable, Hashable { + case assistant + case status + case general + + var id: String { rawValue } + + var title: String { + switch self { + case .assistant: "助手" + case .status: "联动" + case .general: "设置" + } + } + + var subtitle: String { + switch self { + case .assistant: "对话下达" + case .status: "启用与蓝牙" + case .general: "皮肤与图标" + } + } +} + +enum AhaKeyDynamicIslandSettingsRoute: String, Hashable, Identifiable, CaseIterable { + case root + case componentLibrary + case keyPadSettings + case oledPetSettings + case agentSessions + case display + case sound + + var id: String { rawValue } + + var title: String { + switch self { + case .root: "灵动岛" + case .componentLibrary: "组件库" + case .keyPadSettings: "四键键帽" + case .oledPetSettings: "OLED Pet" + case .agentSessions: "Agent 会话" + case .display: "显示" + case .sound: "声音详情" + } + } + + var detail: String? { + switch self { + case .componentLibrary: "管理展开岛模块显隐与顺序" + case .keyPadSettings: "外观、显隐与动作映射" + case .oledPetSettings: "信息层、尺寸、动画与素材" + case .agentSessions: "会话列表规则(占位)" + case .display: "信息密度等(占位)" + case .sound: "更多声音选项(占位)" + case .root: nil + } + } + + var icon: String { + switch self { + case .root: "sparkles.rectangle.stack" + case .componentLibrary: "square.stack.3d.up" + case .keyPadSettings: "keyboard" + case .oledPetSettings: "rectangle.inset.filled" + case .agentSessions: "list.bullet.rectangle" + case .display: "textformat.size" + case .sound: "speaker.wave.2.fill" + } + } + + var hasDetailSettings: Bool { + self == .keyPadSettings || self == .oledPetSettings + } +} diff --git a/ahakeyconfig-mac/Sources/Views/Settings/AhaKeyStudioEmbeddedTheme.swift b/ahakeyconfig-mac/Sources/Views/Settings/AhaKeyStudioEmbeddedTheme.swift new file mode 100644 index 00000000..27ac04c9 --- /dev/null +++ b/ahakeyconfig-mac/Sources/Views/Settings/AhaKeyStudioEmbeddedTheme.swift @@ -0,0 +1,22 @@ +import SwiftUI + +/// 嵌入 Settings 硬件 Tab 的 Studio 色板;与 `AhakeySettingsTheme` 共用自适应 token。 +enum AhaKeyStudioEmbeddedTheme { + static var windowBackground: Color { AhakeySettingsTheme.windowBackground } + static var contentBackground: Color { AhakeySettingsTheme.contentBackground } + static var cardBackground: Color { AhakeySettingsTheme.cardBackground } + static var cardHover: Color { AhakeySettingsTheme.cardHover } + + static var primaryText: Color { AhakeySettingsTheme.primaryText } + static var secondaryText: Color { AhakeySettingsTheme.secondaryText } + static var tertiaryText: Color { AhakeySettingsTheme.tertiaryText } + + static var accentBlue: Color { AhakeySettingsTheme.accentBlue } + static var divider: Color { AhakeySettingsTheme.divider } + static var controlFill: Color { AhakeySettingsTheme.controlFill } +} + +enum AhaKeyStudioPresentation { + case standalone + case embeddedClient +} diff --git a/ahakeyconfig-mac/Sources/Views/Settings/AhaKeyUserCenterPane.swift b/ahakeyconfig-mac/Sources/Views/Settings/AhaKeyUserCenterPane.swift new file mode 100644 index 00000000..508937ce --- /dev/null +++ b/ahakeyconfig-mac/Sources/Views/Settings/AhaKeyUserCenterPane.swift @@ -0,0 +1,1033 @@ +import AppKit +import CoreImage +import SwiftUI + +/// 用户中心弹窗:左导航(用户中心 / 设置 / 使用数据 / 关于我们 / 帮助 / 版本说明)+ 右内容。 +struct AhaKeyUserCenterPane: View { + @ObservedObject var bleManager: AhaKeyBLEManager + @Binding var section: AhaKeyUserCenterSection + var onClose: () -> Void + + var body: some View { + HStack(spacing: 0) { + userCenterSidebar + .frame(width: 188) + .background(AhakeySettingsTheme.sidebarBackground) + + Rectangle() + .fill(AhakeySettingsTheme.divider) + .frame(width: 1) + + VStack(alignment: .leading, spacing: 0) { + HStack(alignment: .center, spacing: 12) { + Text(section.title) + .font(AhakeySettingsTheme.pageTitleFont) + .foregroundStyle(AhakeySettingsTheme.primaryText) + Spacer(minLength: 0) + Button(action: onClose) { + Image(systemName: "xmark") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + .frame(width: 28, height: 28) + .background( + Circle() + .fill(AhakeySettingsTheme.controlFill) + ) + } + .buttonStyle(.plain) + .help("关闭") + .keyboardShortcut(.cancelAction) + } + .padding(.horizontal, 28) + .padding(.top, 22) + .padding(.bottom, 16) + + ScrollView(.vertical, showsIndicators: true) { + sectionContent + .padding(.horizontal, 28) + .padding(.bottom, 28) + .frame(maxWidth: .infinity, alignment: .topLeading) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .background(AhakeySettingsTheme.contentBackground) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(AhakeySettingsTheme.contentBackground) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + } + + private var userCenterSidebar: some View { + VStack(alignment: .leading, spacing: 4) { + ForEach(AhaKeyUserCenterSection.primarySections) { item in + sidebarRow(item) + } + + Rectangle() + .fill(AhakeySettingsTheme.divider) + .frame(height: 1) + .padding(.vertical, 10) + .padding(.horizontal, 8) + + ForEach(AhaKeyUserCenterSection.secondarySections) { item in + sidebarRow(item) + } + + Spacer(minLength: 0) + } + .padding(.horizontal, 10) + .padding(.top, 20) + .padding(.bottom, 16) + } + + private func sidebarRow(_ item: AhaKeyUserCenterSection) -> some View { + Button { + section = item + } label: { + HStack(spacing: 10) { + Image(systemName: item.systemImage) + .font(.system(size: 13, weight: .medium)) + .frame(width: 18) + Text(item.title) + .font(.system(size: 13, weight: .medium)) + Spacer(minLength: 0) + if item.showsExternalLinkHint { + Image(systemName: "arrow.up.right") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(AhakeySettingsTheme.tertiaryText) + } + } + .foregroundStyle( + section == item + ? AhakeySettingsTheme.primaryText + : AhakeySettingsTheme.secondaryText + ) + .padding(.horizontal, 12) + .padding(.vertical, 9) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(section == item ? AhakeySettingsTheme.controlFill : Color.clear) + ) + } + .buttonStyle(.plain) + } + + @ViewBuilder + private var sectionContent: some View { + switch section { + case .account: + AhaKeyUserCenterContent(showsHero: false, layoutStyle: .referenceRows) + case .settings: + AhaKeyUserCenterSettingsContent( + bleManager: bleManager, + onOpenAccount: { section = .account } + ) + case .usageData: + AhaKeyUserCenterUsageDataContent() + case .about: + AhaKeyUserCenterAboutContent() + case .help: + AhaKeyUserCenterHelpContent(onCloseUserCenter: onClose) + case .releaseNotes: + AhaKeyUserCenterReleaseNotesContent() + } + } +} + +/// 可复用的用户中心账户内容(整页与 Studio Sheet 共用)。 +struct AhaKeyUserCenterContent: View { + enum LayoutStyle { + case stackedCards + case referenceRows + } + + var showsHero: Bool = true + var layoutStyle: LayoutStyle = .stackedCards + + @StateObject private var account = CloudAccountManager.shared + @StateObject private var optimizer = AhaTypeTextOptimizer.shared + @FocusState private var focusedLoginField: LoginField? + @State private var hoveredPlan: CloudRechargePlan? + @State private var showsUpgradePanel = false + @State private var showsGiftPanel = false + @State private var showMembership = false + @State private var installedPluginCount = 0 + + private enum LoginField { + case phone + case password + } + + private var shelfPluginCount: Int { AhakeyPluginMarketCatalog.items.count } + + var body: some View { + Group { + switch layoutStyle { + case .stackedCards: + stackedBody + case .referenceRows: + referenceBody + } + } + .alert("云端账号", isPresented: Binding( + get: { account.alertMessage != nil }, + set: { if !$0 { account.alertMessage = nil } } + )) { + Button("好", role: .cancel) { account.alertMessage = nil } + } message: { + Text(account.alertMessage ?? "") + } + .onAppear { + refreshCommunityStats() + optimizer.refreshFromDisk() + if account.isLoggedIn { + account.refreshProfile() + } else { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + focusedLoginField = .phone + } + } + } + } + + private var stackedBody: some View { + VStack(alignment: .leading, spacing: 18) { + if showsHero { + UserCenterHero( + isLoggedIn: account.isLoggedIn, + isBusy: account.isBusy, + title: heroTitle, + subtitle: heroSubtitle, + avatarInitial: account.avatarInitial + ) + } + + if account.isLoggedIn { + profileCard + ahaTypeCard + billingCard + couponCard + } else { + loginCard + ahaTypeCard + } + } + } + + private var referenceBody: some View { + VStack(alignment: .leading, spacing: 20) { + communityIdentityCard + communityStatsCard + communityLinksCard + + if account.isLoggedIn { + AhaKeySettingsDisclosureSection( + title: "会员与额度", + subtitle: "订阅、礼品卡与云端整理", + isExpanded: $showMembership + ) { + membershipSection + } + + HStack { + Spacer(minLength: 0) + Button("退出") { account.logout() } + .buttonStyle(UserCenterDangerButtonStyle()) + .disabled(account.isBusy) + .frame(height: 36) + } + .padding(.top, 4) + } else { + loginCard + ahaTypeCard + } + } + } + + private var communityIdentityCard: some View { + AhakeySettingsCard(sectionTitle: nil) { + HStack(alignment: .center, spacing: 16) { + UserCenterAvatar( + initial: account.isLoggedIn ? account.avatarInitial : "?", + size: 56 + ) + + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 8) { + Text(communityDisplayName) + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(AhakeySettingsTheme.primaryText) + .lineLimit(1) + + Text(account.isLoggedIn ? "社区成员" : "访客") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle( + account.isLoggedIn + ? AhakeySettingsTheme.accentBlue + : AhakeySettingsTheme.tertiaryText + ) + .padding(.horizontal, 7) + .padding(.vertical, 3) + .background( + Capsule(style: .continuous) + .fill( + account.isLoggedIn + ? AhakeySettingsTheme.accentBlue.opacity(0.14) + : AhakeySettingsTheme.controlFill + ) + ) + } + + Text(communityTagline) + .font(AhakeySettingsTheme.rowSubtitleFont) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + .fixedSize(horizontal: false, vertical: true) + + if account.isLoggedIn, !account.phoneDisplay.isEmpty { + Text(account.phoneDisplay) + .font(.system(size: 11, design: .monospaced)) + .foregroundStyle(AhakeySettingsTheme.tertiaryText) + .textSelection(.enabled) + } + } + + Spacer(minLength: 0) + + if account.isBusy { + ProgressView() + .controlSize(.small) + } + } + .padding(.horizontal, AhakeySettingsTheme.rowPaddingH) + .padding(.vertical, 16) + } + } + + private var communityDisplayName: String { + if account.isLoggedIn { + let title = account.sidebarTitle + if !title.isEmpty { return title } + if !account.phoneDisplay.isEmpty { return account.phoneDisplay } + return "AhaKey 成员" + } + return "加入开源社区" + } + + private var communityTagline: String { + if account.isLoggedIn { + return "发现、安装并回馈开源插件;你的本机扩展与社区货架相连。" + } + return "登录后同步会员额度;浏览开源市场、安装插件与贡献无需等待。" + } + + private var communityStatsCard: some View { + AhakeySettingsCard(sectionTitle: "社区摘要") { + HStack(spacing: 12) { + communityStatTile( + title: "本机插件", + value: "\(installedPluginCount)", + detail: "已安装" + ) + communityStatTile( + title: "开源货架", + value: "\(shelfPluginCount)", + detail: "可发现" + ) + } + .padding(.horizontal, AhakeySettingsTheme.rowPaddingH) + .padding(.vertical, 14) + } + } + + private func communityStatTile(title: String, value: String, detail: String) -> some View { + VStack(alignment: .leading, spacing: 6) { + Text(title) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(AhakeySettingsTheme.tertiaryText) + Text(value) + .font(.system(size: 22, weight: .semibold, design: .rounded)) + .foregroundStyle(AhakeySettingsTheme.primaryText) + Text(detail) + .font(AhakeySettingsTheme.rowSubtitleFont) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(12) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(AhakeySettingsTheme.controlFill) + ) + } + + private var communityLinksCard: some View { + AhakeySettingsCard(sectionTitle: "社区入口") { + communityLinkRow( + title: "开源市场", + detail: "浏览开发中与示例插件货架", + systemImage: "bag" + ) { + StudioNavigationRouter.shared.openPluginMarket(section: .store) + } + AhakeySettingsCardDivider() + communityLinkRow( + title: "我的插件", + detail: "本机已装列表与安装教程", + systemImage: "puzzlepiece.extension" + ) { + StudioNavigationRouter.shared.openPluginMarket(section: .mine) + } + AhakeySettingsCardDivider() + communityLinkRow( + title: "开发与贡献", + detail: "SDK 示例与回馈社区货架", + systemImage: "hammer" + ) { + StudioNavigationRouter.shared.openPluginMarket(section: .store) + } + } + } + + private func communityLinkRow( + title: String, + detail: String, + systemImage: String, + action: @escaping () -> Void + ) -> some View { + Button(action: action) { + HStack(spacing: 12) { + Image(systemName: systemImage) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(AhakeySettingsTheme.accentBlue) + .frame(width: 28, height: 28) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(AhakeySettingsTheme.accentBlue.opacity(0.12)) + ) + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(AhakeySettingsTheme.rowTitleFont) + .foregroundStyle(AhakeySettingsTheme.primaryText) + Text(detail) + .font(AhakeySettingsTheme.rowSubtitleFont) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + } + Spacer(minLength: 0) + Image(systemName: "chevron.right") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(AhakeySettingsTheme.tertiaryText) + } + .padding(.horizontal, AhakeySettingsTheme.rowPaddingH) + .padding(.vertical, 12) + } + .buttonStyle(.plain) + } + + private var membershipSection: some View { + VStack(alignment: .leading, spacing: 16) { + membershipSummaryRows + if showsUpgradePanel { + billingCard + } + if showsGiftPanel { + couponCard + } + ahaTypeCard + } + } + + private var heroTitle: String { + if account.isLoggedIn { + let title = account.sidebarTitle + return title.isEmpty ? "社区成员" : title + } + return "加入开源社区" + } + + private var heroSubtitle: String { + if account.isLoggedIn { + return "参与 AhaKey 开源插件生态" + } + return "登录后同步额度;插件市场随时可逛" + } + + private var subscriptionStatusText: String { + let valid = account.validUntilDisplay + if valid.isEmpty || valid.contains("无") { + return "Free" + } + return valid + } + + // MARK: - Membership rows + + private var membershipSummaryRows: some View { + AhakeySettingsCard(sectionTitle: nil) { + VStack(alignment: .leading, spacing: 0) { + HStack(alignment: .center, spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + Text("订阅") + .font(AhakeySettingsTheme.rowTitleFont) + .foregroundStyle(AhakeySettingsTheme.primaryText) + Text(subscriptionStatusText) + .font(AhakeySettingsTheme.rowSubtitleFont) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + } + Spacer(minLength: 8) + Button("升级") { + withAnimation(.easeInOut(duration: 0.18)) { + showsUpgradePanel.toggle() + if showsUpgradePanel { showsGiftPanel = false } + } + } + .buttonStyle(.borderedProminent) + .tint(AhakeySettingsTheme.accentBlue) + } + .padding(.horizontal, AhakeySettingsTheme.rowPaddingH) + .padding(.vertical, 14) + + AhakeySettingsCardDivider() + + HStack(alignment: .center, spacing: 12) { + Text("礼品卡") + .font(AhakeySettingsTheme.rowTitleFont) + .foregroundStyle(AhakeySettingsTheme.primaryText) + Spacer(minLength: 8) + Button("兑换") { + withAnimation(.easeInOut(duration: 0.18)) { + showsGiftPanel.toggle() + if showsGiftPanel { showsUpgradePanel = false } + } + } + .buttonStyle(UserCenterSecondaryButtonStyle()) + .frame(height: 32) + } + .padding(.horizontal, AhakeySettingsTheme.rowPaddingH) + .padding(.vertical, 14) + } + } + } + + private func refreshCommunityStats() { + installedPluginCount = AhakeyInstalledPluginsStore.discover().count + } + + // MARK: - Login + + private var loginCard: some View { + AhakeySettingsCard(sectionTitle: "登录") { + VStack(alignment: .leading, spacing: 14) { + UserCenterLabeledField(label: "手机号") { + TextField("", text: $account.phone, prompt: Text("11 位手机号").foregroundColor(AhakeySettingsTheme.tertiaryText)) + .textFieldStyle(.plain) + .focused($focusedLoginField, equals: .phone) + .onSubmit { focusedLoginField = .password } + } + + UserCenterLabeledField(label: "密码") { + SecureField("", text: $account.password, prompt: Text("输入密码").foregroundColor(AhakeySettingsTheme.tertiaryText)) + .textFieldStyle(.plain) + .focused($focusedLoginField, equals: .password) + .onSubmit { account.login() } + } + + Toggle("记住密码", isOn: $account.rememberPassword) + .toggleStyle(.switch) + .font(AhakeySettingsTheme.rowSubtitleFont) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + + HStack(spacing: 10) { + UserCenterPrimaryButton( + title: "登录", + isBusy: account.isBusy, + action: { account.login() } + ) + + Button("注册") { account.register() } + .buttonStyle(UserCenterSecondaryButtonStyle()) + .disabled(account.isBusy) + .frame(height: 36) + } + + if !account.statusMessage.isEmpty { + Text(account.statusMessage) + .font(AhakeySettingsTheme.rowSubtitleFont) + .foregroundStyle(AhakeySettingsTheme.tertiaryText) + .lineLimit(2) + } + } + .padding(.horizontal, AhakeySettingsTheme.rowPaddingH) + .padding(.vertical, 14) + } + } + + // MARK: - Profile + + private var profileCard: some View { + AhakeySettingsCard(sectionTitle: "账户") { + VStack(alignment: .leading, spacing: 16) { + HStack(alignment: .center, spacing: 14) { + UserCenterAvatar(initial: account.avatarInitial, size: 44) + + VStack(alignment: .leading, spacing: 4) { + Text(account.phoneDisplay.isEmpty ? "已登录" : account.phoneDisplay) + .font(AhakeySettingsTheme.rowTitleFont) + .foregroundStyle(AhakeySettingsTheme.primaryText) + .textSelection(.enabled) + Text(account.validUntilDisplay.isEmpty ? "账户正常" : account.validUntilDisplay) + .font(AhakeySettingsTheme.rowSubtitleFont) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + } + Spacer(minLength: 0) + } + + HStack(spacing: 8) { + UserCenterQuotaMetric(label: "每日", value: account.quotaText("daily")) + UserCenterQuotaMetric(label: "每周", value: account.quotaText("weekly")) + UserCenterQuotaMetric(label: "每月", value: account.quotaText("monthly")) + } + + HStack(spacing: 10) { + UserCenterPrimaryButton( + title: "刷新资料", + isBusy: account.isBusy, + action: { account.refreshProfile() } + ) + .frame(maxWidth: 140) + + Button("切换账号") { + account.prepareForRelogin() + focusedLoginField = .phone + } + .buttonStyle(UserCenterSecondaryButtonStyle()) + .disabled(account.isBusy) + .frame(height: 36) + + Button("退出登录") { account.logout() } + .buttonStyle(UserCenterDangerButtonStyle()) + .disabled(account.isBusy) + .frame(height: 36) + } + + if !account.statusMessage.isEmpty { + Text(account.statusMessage) + .font(AhakeySettingsTheme.rowSubtitleFont) + .foregroundStyle(AhakeySettingsTheme.tertiaryText) + .lineLimit(2) + } + } + .padding(.horizontal, AhakeySettingsTheme.rowPaddingH) + .padding(.vertical, 14) + } + } + + // MARK: - Billing + + private var billingCard: some View { + AhakeySettingsCard(sectionTitle: "订阅与充值") { + VStack(alignment: .leading, spacing: 14) { + HStack(spacing: 8) { + ForEach(CloudRechargePlan.allCases) { plan in + UserCenterPlanTile( + plan: plan, + priceText: account.priceText(for: plan), + isHovered: hoveredPlan == plan, + isDisabled: account.isBusy + ) { + account.createWechatOrder(plan: plan) + } + .onHover { hovering in + hoveredPlan = hovering ? plan : (hoveredPlan == plan ? nil : hoveredPlan) + } + } + } + + if let order = account.paymentOrder { + paymentOrderBlock(order) + } + } + .padding(.horizontal, AhakeySettingsTheme.rowPaddingH) + .padding(.vertical, 14) + } + } + + // MARK: - Coupon + + private var couponCard: some View { + AhakeySettingsCard(sectionTitle: "免费券") { + HStack(spacing: 10) { + UserCenterFieldChrome { + TextField("", text: $account.couponCode, prompt: Text("输入兑换码").foregroundColor(AhakeySettingsTheme.tertiaryText)) + .textFieldStyle(.plain) + } + + Button("兑换") { account.redeemCoupon() } + .buttonStyle(UserCenterSecondaryButtonStyle()) + .disabled(account.isBusy) + .frame(width: 72, height: 36) + } + .padding(.horizontal, AhakeySettingsTheme.rowPaddingH) + .padding(.vertical, 14) + } + } + + // MARK: - AhaType + + private var ahaTypeCard: some View { + AhakeySettingsCard(sectionTitle: "AhaType") { + AhakeySettingsToggleRow( + title: "启用云端整理", + subtitle: account.isLoggedIn + ? "转写完成后经云端整理再粘贴;失败时回退原文。" + : "登录后可开启云端整理。", + isOn: Binding( + get: { optimizer.isEnabled }, + set: { enabled in + if enabled, !account.isLoggedIn { + account.alertMessage = "请先登录后再开启 AhaType 云端整理。" + return + } + optimizer.setEnabled(enabled) + } + ) + ) + + if !account.isLoggedIn { + AhakeySettingsCardDivider() + Text("去上方登录后即可开启") + .font(AhakeySettingsTheme.rowSubtitleFont) + .foregroundStyle(AhakeySettingsTheme.accentBlue) + .padding(.horizontal, AhakeySettingsTheme.rowPaddingH) + .padding(.vertical, 10) + } else if !optimizer.statusMessage.isEmpty || !optimizer.lastQuotaSummary.isEmpty { + AhakeySettingsCardDivider() + VStack(alignment: .leading, spacing: 4) { + if !optimizer.statusMessage.isEmpty { + Text(optimizer.statusMessage) + .font(AhakeySettingsTheme.rowSubtitleFont) + .foregroundStyle(AhakeySettingsTheme.tertiaryText) + } + if !optimizer.lastQuotaSummary.isEmpty { + Text(optimizer.lastQuotaSummary) + .font(.system(size: 11)) + .foregroundStyle(AhakeySettingsTheme.tertiaryText) + } + } + .padding(.horizontal, AhakeySettingsTheme.rowPaddingH) + .padding(.vertical, 10) + } + } + } + + @ViewBuilder + private func paymentOrderBlock(_ order: CloudPaymentOrder) -> some View { + VStack(alignment: .leading, spacing: 12) { + HStack(alignment: .top, spacing: 14) { + if let image = makeQRCodeImage(from: order.paymentURL) { + Image(nsImage: image) + .interpolation(.none) + .resizable() + .frame(width: 120, height: 120) + .padding(8) + .background(Color.white) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + + VStack(alignment: .leading, spacing: 6) { + Text("\(order.plan.title) · \(order.amountText)") + .font(AhakeySettingsTheme.rowTitleFont) + .foregroundStyle(AhakeySettingsTheme.primaryText) + Text("微信扫码完成支付,成功后自动刷新额度") + .font(AhakeySettingsTheme.rowSubtitleFont) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + Text("订单 \(order.outTradeNo)") + .font(.system(size: 11)) + .foregroundStyle(AhakeySettingsTheme.tertiaryText) + .textSelection(.enabled) + Text("状态 \(order.status)") + .font(.system(size: 11)) + .foregroundStyle(AhakeySettingsTheme.tertiaryText) + } + } + + HStack(spacing: 8) { + Button("复制链接") { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(order.paymentURL, forType: .string) + } + .buttonStyle(UserCenterSecondaryButtonStyle()) + .frame(height: 32) + + Button("刷新到账") { account.refreshCurrentPaymentOrder() } + .buttonStyle(UserCenterSecondaryButtonStyle()) + .disabled(account.isBusy) + .frame(height: 32) + + Button("关闭") { account.clearPaymentOrder() } + .buttonStyle(.plain) + .font(AhakeySettingsTheme.rowSubtitleFont) + .foregroundStyle(AhakeySettingsTheme.tertiaryText) + .frame(height: 32) + } + } + .padding(12) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(AhakeySettingsTheme.controlFill) + ) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .stroke(AhakeySettingsTheme.divider.opacity(0.9), lineWidth: 1) + ) + } + + private func makeQRCodeImage(from text: String) -> NSImage? { + guard let data = text.data(using: .utf8), + let filter = CIFilter(name: "CIQRCodeGenerator") else { return nil } + filter.setValue(data, forKey: "inputMessage") + filter.setValue("M", forKey: "inputCorrectionLevel") + guard let output = filter.outputImage else { return nil } + let scaled = output.transformed(by: CGAffineTransform(scaleX: 8, y: 8)) + let representation = NSCIImageRep(ciImage: scaled) + let image = NSImage(size: representation.size) + image.addRepresentation(representation) + return image + } +} + +// MARK: - Private components + +private struct UserCenterHero: View { + let isLoggedIn: Bool + let isBusy: Bool + let title: String + let subtitle: String + let avatarInitial: String + + var body: some View { + HStack(alignment: .center, spacing: 16) { + UserCenterAvatar(initial: avatarInitial, size: 60) + + VStack(alignment: .leading, spacing: 5) { + Text(title) + .font(AhakeySettingsTheme.pageTitleFont) + .foregroundStyle(AhakeySettingsTheme.primaryText) + .lineLimit(1) + + Text(subtitle) + .font(AhakeySettingsTheme.rowSubtitleFont) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + .lineLimit(2) + } + + Spacer(minLength: 8) + + if isBusy { + ProgressView() + .controlSize(.small) + } else if isLoggedIn { + HStack(spacing: 6) { + Circle() + .fill(Color(red: 0.35, green: 0.82, blue: 0.48)) + .frame(width: 7, height: 7) + Text("已登录") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + } + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background( + Capsule(style: .continuous) + .fill(AhakeySettingsTheme.controlFill) + ) + } + } + .padding(.vertical, 4) + } +} + +private struct UserCenterAvatar: View { + let initial: String + var size: CGFloat = 44 + + var body: some View { + ZStack { + Circle() + .fill( + LinearGradient( + colors: [Color.purple.opacity(0.85), AhakeySettingsTheme.accentBlue], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + Text(initial) + .font(.system(size: size * 0.38, weight: .semibold, design: .rounded)) + .foregroundStyle(.white) + } + .frame(width: size, height: size) + } +} + +private struct UserCenterLabeledField: View { + let label: String + @ViewBuilder var content: () -> Content + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Text(label) + .font(AhakeySettingsTheme.rowSubtitleFont) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + UserCenterFieldChrome(content: content) + } + } +} + +private struct UserCenterFieldChrome: View { + @ViewBuilder var content: () -> Content + + var body: some View { + content() + .font(AhakeySettingsTheme.rowTitleFont) + .foregroundStyle(AhakeySettingsTheme.primaryText) + .padding(.horizontal, 12) + .padding(.vertical, 9) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(AhakeySettingsTheme.controlFill) + ) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .stroke(AhakeySettingsTheme.divider.opacity(0.95), lineWidth: 1) + ) + } +} + +private struct UserCenterQuotaMetric: View { + let label: String + let value: String + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Text(label) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(AhakeySettingsTheme.tertiaryText) + Text(value) + .font(.system(size: 15, weight: .semibold, design: .rounded)) + .foregroundStyle(AhakeySettingsTheme.primaryText) + .lineLimit(1) + .minimumScaleFactor(0.75) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 12) + .padding(.vertical, 12) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(AhakeySettingsTheme.controlFill) + ) + } +} + +private struct UserCenterPlanTile: View { + let plan: CloudRechargePlan + let priceText: String + let isHovered: Bool + let isDisabled: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + VStack(alignment: .leading, spacing: 6) { + Text(plan.title) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(AhakeySettingsTheme.primaryText) + Text(priceText) + .font(.system(size: 16, weight: .semibold, design: .rounded)) + .foregroundStyle(AhakeySettingsTheme.accentBlue) + Text(plan.subtitle) + .font(.system(size: 10)) + .foregroundStyle(AhakeySettingsTheme.tertiaryText) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 12) + .padding(.vertical, 12) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(AhakeySettingsTheme.controlFill) + ) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .stroke( + isHovered ? AhakeySettingsTheme.accentBlue.opacity(0.55) : AhakeySettingsTheme.divider.opacity(0.9), + lineWidth: 1 + ) + ) + } + .buttonStyle(.plain) + .disabled(isDisabled) + .opacity(isDisabled ? 0.55 : 1) + } +} + +private struct UserCenterPrimaryButton: View { + let title: String + let isBusy: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(spacing: 8) { + if isBusy { + ProgressView() + .controlSize(.small) + .tint(.white) + } + Text(title) + .font(.system(size: 13, weight: .semibold)) + } + .foregroundStyle(.white) + .frame(maxWidth: .infinity) + .frame(height: 36) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(AhakeySettingsTheme.accentBlue.opacity(isBusy ? 0.7 : 1)) + ) + } + .buttonStyle(.plain) + .disabled(isBusy) + } +} + +private struct UserCenterSecondaryButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(AhakeySettingsTheme.primaryText) + .padding(.horizontal, 14) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(AhakeySettingsTheme.controlFill) + .opacity(configuration.isPressed ? 0.75 : 1) + ) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .stroke(AhakeySettingsTheme.divider, lineWidth: 1) + ) + } +} + +private struct UserCenterDangerButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(AhakeySettingsTheme.dangerText) + .padding(.horizontal, 14) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(AhakeySettingsTheme.controlFill.opacity(configuration.isPressed ? 0.75 : 1)) + ) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .stroke(AhakeySettingsTheme.dangerText.opacity(0.35), lineWidth: 1) + ) + } +} diff --git a/ahakeyconfig-mac/Sources/Views/Settings/AhaKeyUserCenterPreferenceSections.swift b/ahakeyconfig-mac/Sources/Views/Settings/AhaKeyUserCenterPreferenceSections.swift new file mode 100644 index 00000000..81f514f8 --- /dev/null +++ b/ahakeyconfig-mac/Sources/Views/Settings/AhaKeyUserCenterPreferenceSections.swift @@ -0,0 +1,698 @@ +import SwiftUI + +/// 用户中心「设置」:外观、权限、接入、设备联动。 +struct AhaKeyUserCenterSettingsContent: View { + @ObservedObject var bleManager: AhaKeyBLEManager + var onOpenAccount: (() -> Void)? = nil + + @StateObject private var cloudAccount = CloudAccountManager.shared + @StateObject private var optimizer = AhaTypeTextOptimizer.shared + @ObservedObject private var agentManager = AgentManager.shared + @ObservedObject private var voiceRelay = VoiceRelayService.shared + @ObservedObject private var nativeSpeech = NativeSpeechTranscriptionService.shared + + @AppStorage(AhaKeyAppearanceMode.storageKey) private var appearanceModeRaw = AhaKeyAppearanceMode.defaultMode.rawValue + @AppStorage(AhakeyGeneralSettingsStore.languageKey) private var languageRaw = AhaKeyAppLanguage.system.rawValue + @AppStorage(AhakeyGeneralSettingsStore.regionKey) private var regionRaw = AhaKeyAppRegion.system.rawValue + + @State private var apiKeyDraft = AhakeyGeneralSettingsStore.agentAPIKey + @State private var apiKeyRevealed = false + @State private var apiKeySavedMessage: String? + @State private var showAppearanceLanguage = true + @State private var showPermissions = false + @State private var showServices = false + @State private var showDeviceLinkage = false + @FocusState private var apiKeyFocused: Bool + + private var permissionsReady: Bool { + voiceRelay.inputMonitoringGranted + && voiceRelay.accessibilityGranted + && nativeSpeech.microphoneGranted + && nativeSpeech.speechRecognitionGranted + } + + private var appearanceBinding: Binding { + Binding( + get: { AhaKeyAppearanceMode(rawValue: appearanceModeRaw) ?? .defaultMode }, + set: { appearanceModeRaw = $0.rawValue } + ) + } + + private var languageBinding: Binding { + Binding( + get: { AhaKeyAppLanguage(rawValue: languageRaw) ?? .system }, + set: { languageRaw = $0.rawValue } + ) + } + + private var regionBinding: Binding { + Binding( + get: { AhaKeyAppRegion(rawValue: regionRaw) ?? .system }, + set: { regionRaw = $0.rawValue } + ) + } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + AhaKeySettingsDisclosureSection( + title: "外观与语言", + subtitle: "主题、界面语言与地区格式", + isExpanded: $showAppearanceLanguage + ) { + appearanceCard + languageRegionCard + } + + AhaKeySettingsDisclosureSection( + title: "权限与隐私", + subtitle: permissionsReady ? "系统权限已就绪" : "一次性授权,用于语音与快捷键联动", + isExpanded: $showPermissions + ) { + permissionsCard + privacyCard + } + .featureCoachTip(.userCenterPermissions, isActive: true, alignment: .topTrailing) + .onReceive(NotificationCenter.default.publisher(for: .ahaKeyExpandUserCenterPermissions)) { _ in + showPermissions = true + } + + AhaKeySettingsDisclosureSection( + title: "接入与服务", + subtitle: "Agent API 与 AhaType", + isExpanded: $showServices + ) { + agentAPISection + ahaTypeSection + } + + AhaKeySettingsDisclosureSection( + title: "设备联动", + subtitle: "Studio 与 Agent 蓝牙占用", + isExpanded: $showDeviceLinkage + ) { + bluetoothOwnerSection + } + } + .onAppear { + apiKeyDraft = AhakeyGeneralSettingsStore.agentAPIKey + optimizer.refreshFromDisk() + voiceRelay.refreshPermissions(deferredTCCRequery: true) + nativeSpeech.refreshPermissions(deferredTCCRequery: true) + } + } + + private var appearanceCard: some View { + AhakeySettingsCard(sectionTitle: "外观") { + VStack(alignment: .leading, spacing: 10) { + Picker("外观", selection: appearanceBinding) { + ForEach(AhaKeyAppearanceMode.allCases) { mode in + Text(mode.title).tag(mode) + } + } + .pickerStyle(.segmented) + .labelsHidden() + + Text("默认深色。切换后侧栏、内容区与卡片整页同步;跟随系统时随 macOS 浅色 / 深色自动切换。") + .font(AhakeySettingsTheme.rowSubtitleFont) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + } + .padding(.horizontal, AhakeySettingsTheme.rowPaddingH) + .padding(.vertical, 14) + } + } + + private var languageRegionCard: some View { + AhakeySettingsCard(sectionTitle: "语言与地区") { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text("语言") + .font(AhakeySettingsTheme.rowTitleFont) + .foregroundStyle(AhakeySettingsTheme.primaryText) + Spacer() + Picker("", selection: languageBinding) { + ForEach(AhaKeyAppLanguage.allCases) { item in + Text(item.title).tag(item) + } + } + .labelsHidden() + .frame(maxWidth: 140) + } + + AhakeySettingsCardDivider() + + HStack { + Text("地区") + .font(AhakeySettingsTheme.rowTitleFont) + .foregroundStyle(AhakeySettingsTheme.primaryText) + Spacer() + Picker("", selection: regionBinding) { + ForEach(AhaKeyAppRegion.allCases) { item in + Text(item.title).tag(item) + } + } + .labelsHidden() + .frame(maxWidth: 140) + } + + Text("完整界面翻译后续补齐;本版先影响日期/数字等系统格式与后续本地化入口。") + .font(AhakeySettingsTheme.rowSubtitleFont) + .foregroundStyle(AhakeySettingsTheme.tertiaryText) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.horizontal, AhakeySettingsTheme.rowPaddingH) + .padding(.vertical, 14) + } + } + + private var permissionsCard: some View { + AhakeySettingsCard(sectionTitle: "系统权限") { + permissionRow("输入监控", granted: voiceRelay.inputMonitoringGranted) + AhakeySettingsCardDivider() + permissionRow("辅助功能", granted: voiceRelay.accessibilityGranted) + AhakeySettingsCardDivider() + permissionRow("麦克风", granted: nativeSpeech.microphoneGranted) + AhakeySettingsCardDivider() + permissionRow("语音转写", granted: nativeSpeech.speechRecognitionGranted) + AhakeySettingsCardDivider() + HStack { + Button("重新检查权限") { + voiceRelay.refreshPermissions(deferredTCCRequery: true) + nativeSpeech.refreshPermissions(deferredTCCRequery: true) + } + .buttonStyle(.bordered) + Spacer(minLength: 0) + } + .padding(.horizontal, AhakeySettingsTheme.rowPaddingH) + .padding(.vertical, 12) + } + } + + private var privacyCard: some View { + AhakeySettingsCard(sectionTitle: "隐私") { + HStack(alignment: .top, spacing: 12) { + Image(systemName: "lock.fill") + .foregroundStyle(AhakeySettingsTheme.secondaryText) + Text("您的语音口述是私密的,零数据保留。它们仅存储在您的设备上,无法从其他地方访问。") + .font(AhakeySettingsTheme.rowSubtitleFont) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.horizontal, AhakeySettingsTheme.rowPaddingH) + .padding(.vertical, 14) + } + } + + private func permissionRow(_ title: String, granted: Bool) -> some View { + HStack { + Circle() + .fill(granted ? Color.green : Color.orange) + .frame(width: 8, height: 8) + Text(title) + .font(AhakeySettingsTheme.rowTitleFont) + .foregroundStyle(AhakeySettingsTheme.primaryText) + Spacer() + Text(granted ? "已授权" : "未授权") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + } + .padding(.horizontal, AhakeySettingsTheme.rowPaddingH) + .padding(.vertical, 12) + } + + private var agentAPISection: some View { + AhakeySettingsCard(sectionTitle: "Agent 接入") { + VStack(alignment: .leading, spacing: 12) { + Text("API Key") + .font(AhakeySettingsTheme.rowTitleFont) + .foregroundStyle(AhakeySettingsTheme.primaryText) + + HStack(spacing: 8) { + Group { + if apiKeyRevealed { + TextField("sk-...", text: $apiKeyDraft) + .textFieldStyle(.plain) + } else { + SecureField("sk-...", text: $apiKeyDraft) + .textFieldStyle(.plain) + } + } + .font(.system(size: 13, design: .monospaced)) + .focused($apiKeyFocused) + + Button { + apiKeyRevealed.toggle() + } label: { + Image(systemName: apiKeyRevealed ? "eye.slash" : "eye") + .foregroundStyle(AhakeySettingsTheme.secondaryText) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(AhakeySettingsTheme.controlFill) + ) + + HStack(spacing: 10) { + Button("保存") { + AhakeyGeneralSettingsStore.setAgentAPIKey(apiKeyDraft) + apiKeySavedMessage = "已保存" + } + .buttonStyle(.borderedProminent) + + if !AhakeyGeneralSettingsStore.agentAPIKey.isEmpty { + Text("当前:\(AhakeyGeneralSettingsStore.maskedAgentAPIKey)") + .font(AhakeySettingsTheme.rowSubtitleFont) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + } + } + + if let apiKeySavedMessage { + Text(apiKeySavedMessage) + .font(AhakeySettingsTheme.rowSubtitleFont) + .foregroundStyle(Color.green.opacity(0.85)) + } + } + .padding(.horizontal, AhakeySettingsTheme.rowPaddingH) + .padding(.vertical, AhakeySettingsTheme.rowPaddingV) + } + } + + private var ahaTypeSection: some View { + AhakeySettingsCard(sectionTitle: "AhaType") { + AhakeySettingsToggleRow( + title: "启用 AhaType 云端整理", + subtitle: "语音转写完成后先经云端整理再粘贴;失败时回退原始转写。", + isOn: Binding( + get: { optimizer.isEnabled }, + set: { enabled in + if enabled, !cloudAccount.isLoggedIn { + onOpenAccount?() + return + } + optimizer.setEnabled(enabled) + } + ) + ) + AhakeySettingsCardDivider() + VStack(alignment: .leading, spacing: 8) { + Text(optimizer.statusMessage) + .font(AhakeySettingsTheme.rowSubtitleFont) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + Text(optimizer.lastQuotaSummary) + .font(.system(size: 11)) + .foregroundStyle(AhakeySettingsTheme.tertiaryText) + + if !cloudAccount.isLoggedIn { + Button { + onOpenAccount?() + } label: { + HStack(spacing: 6) { + Text("去账户登录") + .font(AhakeySettingsTheme.rowTitleFont) + Image(systemName: "arrow.right") + .font(.system(size: 11, weight: .semibold)) + } + .foregroundStyle(AhakeySettingsTheme.accentBlue) + } + .buttonStyle(.plain) + .padding(.top, 4) + } + } + .padding(.horizontal, AhakeySettingsTheme.rowPaddingH) + .padding(.vertical, 12) + } + } + + private var bluetoothOwnerSection: some View { + AhakeySettingsCard(sectionTitle: "蓝牙占用方") { + VStack(alignment: .leading, spacing: 10) { + Picker("连接方", selection: bluetoothOwnerSelection) { + ForEach(BluetoothConnectionOwner.allCases) { owner in + Text(owner.title).tag(owner) + } + } + .pickerStyle(.segmented) + + Text(agentManager.bluetoothConnectionOwner.shortDetail) + .font(AhakeySettingsTheme.rowSubtitleFont) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.horizontal, AhakeySettingsTheme.rowPaddingH) + .padding(.vertical, 12) + } + } + + private var bluetoothOwnerSelection: Binding { + Binding( + get: { agentManager.bluetoothConnectionOwner }, + set: { agentManager.setBluetoothConnectionOwner($0, bleManager: bleManager) } + ) + } +} + +/// 用户中心「使用数据」。 +struct AhaKeyUserCenterUsageDataContent: View { + @StateObject private var account = CloudAccountManager.shared + @StateObject private var optimizer = AhaTypeTextOptimizer.shared + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + AhakeySettingsCard(sectionTitle: "数据说明") { + VStack(alignment: .leading, spacing: 10) { + Text("本地数据(语音转写、按键配置、插件目录)保存在本机;云端整理与额度仅在登录并开启 AhaType 后使用。") + .font(AhakeySettingsTheme.rowSubtitleFont) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + .fixedSize(horizontal: false, vertical: true) + + Text("您的语音口述是私密的,零数据保留。它们仅存储在您的设备上,无法从其他地方访问。") + .font(AhakeySettingsTheme.rowSubtitleFont) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.horizontal, AhakeySettingsTheme.rowPaddingH) + .padding(.vertical, 14) + } + + if account.isLoggedIn { + AhakeySettingsCard(sectionTitle: "额度摘要") { + VStack(alignment: .leading, spacing: 0) { + quotaRow("每日", account.quotaText("daily")) + AhakeySettingsCardDivider() + quotaRow("每周", account.quotaText("weekly")) + AhakeySettingsCardDivider() + quotaRow("每月", account.quotaText("monthly")) + if !optimizer.lastQuotaSummary.isEmpty { + AhakeySettingsCardDivider() + Text(optimizer.lastQuotaSummary) + .font(.system(size: 11)) + .foregroundStyle(AhakeySettingsTheme.tertiaryText) + .padding(.horizontal, AhakeySettingsTheme.rowPaddingH) + .padding(.vertical, 10) + } + } + } + } else { + AhakeySettingsCard(sectionTitle: "额度摘要") { + Text("登录后可查看云端整理额度。") + .font(AhakeySettingsTheme.rowSubtitleFont) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + .padding(.horizontal, AhakeySettingsTheme.rowPaddingH) + .padding(.vertical, 14) + } + } + + AhakeySettingsCard(sectionTitle: "管理") { + disabledActionRow(title: "清除本地缓存", detail: "清理临时文件与非必要缓存") + AhakeySettingsCardDivider() + disabledActionRow(title: "导出使用数据", detail: "导出本机配置与诊断摘要") + } + } + .onAppear { optimizer.refreshFromDisk() } + } + + private func quotaRow(_ title: String, _ value: String) -> some View { + HStack { + Text(title) + .font(AhakeySettingsTheme.rowTitleFont) + .foregroundStyle(AhakeySettingsTheme.primaryText) + Spacer() + Text(value) + .font(.system(size: 13, weight: .medium, design: .rounded)) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + } + .padding(.horizontal, AhakeySettingsTheme.rowPaddingH) + .padding(.vertical, 12) + } + + private func disabledActionRow(title: String, detail: String) -> some View { + HStack { + VStack(alignment: .leading, spacing: 3) { + Text(title) + .font(AhakeySettingsTheme.rowTitleFont) + .foregroundStyle(AhakeySettingsTheme.primaryText) + Text(detail) + .font(AhakeySettingsTheme.rowSubtitleFont) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + } + Spacer() + Text("即将开放") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(AhakeySettingsTheme.tertiaryText) + } + .padding(.horizontal, AhakeySettingsTheme.rowPaddingH) + .padding(.vertical, 12) + .opacity(0.85) + } +} + +/// 用户中心「帮助中心」。 +struct AhaKeyUserCenterHelpContent: View { + var onCloseUserCenter: (() -> Void)? = nil + + @ObservedObject private var voiceRelay = VoiceRelayService.shared + @AppStorage(UnifiedOnboardingStorage.completedKey) private var unifiedOnboardingCompleted = false + @State private var showsHelpDocsDetail = false + + var body: some View { + Group { + if showsHelpDocsDetail { + helpDocsDetail + } else { + VStack(alignment: .leading, spacing: 16) { + AhakeySettingsCard(sectionTitle: "新手引导") { + Button(action: replayOnboarding) { + helpActionRow( + title: "重新打开新手引导", + detail: unifiedOnboardingCompleted + ? "将再次走全屏权限引导,并重置各功能页的一次性气泡提示。" + : "首次使用引导尚未完成,点此继续。", + trailing: "开始", + trailingIsBadge: false + ) + } + .buttonStyle(.plain) + } + + AhakeySettingsCard(sectionTitle: "帮助文档") { + Button { + showsHelpDocsDetail = true + } label: { + helpActionRow( + title: "查看帮助文档", + detail: "产品说明、常见问题与操作指引(即将补齐)。", + trailing: "占位", + trailingIsBadge: true + ) + } + .buttonStyle(.plain) + } + } + } + } + } + + private var helpDocsDetail: some View { + VStack(alignment: .leading, spacing: 16) { + Button { + showsHelpDocsDetail = false + } label: { + Label("返回帮助中心", systemImage: "chevron.left") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + } + .buttonStyle(.plain) + + AhakeySettingsCard(sectionTitle: "即将开放") { + VStack(alignment: .leading, spacing: 12) { + Text("这里将汇总硬件改键、语音输入、Agent 联动与灵动岛等说明,并接入可检索的帮助条目。") + .font(AhakeySettingsTheme.rowSubtitleFont) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + .fixedSize(horizontal: false, vertical: true) + + Text("现阶段可先使用「新手引导」完成权限与基础体验。") + .font(AhakeySettingsTheme.rowSubtitleFont) + .foregroundStyle(AhakeySettingsTheme.tertiaryText) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.horizontal, AhakeySettingsTheme.rowPaddingH) + .padding(.vertical, 14) + } + + AhakeySettingsCard(sectionTitle: "相关入口") { + Button(action: replayOnboarding) { + helpActionRow( + title: "打开新手引导", + detail: "权限授权与语音体验流程", + trailing: nil, + trailingIsBadge: false + ) + } + .buttonStyle(.plain) + } + } + } + + private func replayOnboarding() { + voiceRelay.suppressPermissionOnboarding(for: 60) + UnifiedOnboardingStorage.resetForReplay() + unifiedOnboardingCompleted = false + showsHelpDocsDetail = false + onCloseUserCenter?() + } + + private func helpActionRow( + title: String, + detail: String, + trailing: String?, + trailingIsBadge: Bool + ) -> some View { + HStack(alignment: .center, spacing: 12) { + VStack(alignment: .leading, spacing: 3) { + Text(title) + .font(AhakeySettingsTheme.rowTitleFont) + .foregroundStyle(AhakeySettingsTheme.primaryText) + Text(detail) + .font(AhakeySettingsTheme.rowSubtitleFont) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + .fixedSize(horizontal: false, vertical: true) + } + Spacer(minLength: 8) + if let trailing { + if trailingIsBadge { + Text(trailing) + .font(.system(size: 9, weight: .semibold)) + .foregroundStyle(AhakeySettingsTheme.tertiaryText) + .padding(.horizontal, 5) + .padding(.vertical, 2) + .background(Capsule().fill(AhakeySettingsTheme.controlFill)) + } else { + Text(trailing) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(AhakeySettingsTheme.accentBlue) + } + } + Image(systemName: "chevron.right") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(AhakeySettingsTheme.tertiaryText) + } + .padding(.horizontal, AhakeySettingsTheme.rowPaddingH) + .padding(.vertical, 12) + } +} + +/// 用户中心「关于我们」。 +struct AhaKeyUserCenterAboutContent: View { + private var appName: String { + Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String + ?? Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String + ?? "AhaKey Studio" + } + + private var versionText: String { + let short = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "—" + let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "—" + return "\(short)(\(build))" + } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + AhakeySettingsCard(sectionTitle: nil) { + VStack(alignment: .leading, spacing: 12) { + Text("AhaKey") + .font(.system(size: 22, weight: .semibold)) + .foregroundStyle(AhakeySettingsTheme.primaryText) + Text("为创造者打造的模块化输入与工作流工具。") + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + .fixedSize(horizontal: false, vertical: true) + Text("AhaKey 把磁吸硬件、语音输入、Agent 联动与灵动岛状态层连成一体:在键盘上完成改键与模式切换,在桌面上用 Studio 配置、调试并扩展能力。我们相信输入设备不该只是按键集合,而应成为可组合、可开源扩展的创作伙伴。") + .font(AhakeySettingsTheme.rowSubtitleFont) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.horizontal, AhakeySettingsTheme.rowPaddingH) + .padding(.vertical, 16) + } + + AhakeySettingsCard(sectionTitle: "我们在做什么") { + VStack(alignment: .leading, spacing: 0) { + aboutPillar( + title: "硬件与 Studio", + detail: "原生 macOS 工作台连接 AhaKey 设备,完成布局映射、语音与 Agent 配置。" + ) + AhakeySettingsCardDivider() + aboutPillar( + title: "开源插件生态", + detail: "通过插件市场发现、安装并回馈社区扩展,让能力随社区一起生长。" + ) + AhakeySettingsCardDivider() + aboutPillar( + title: "创造者工作流", + detail: "把常用动作放回指尖与岛上状态,减少在窗口之间来回切换的成本。" + ) + } + } + + AhakeySettingsCard(sectionTitle: "本应用") { + VStack(alignment: .leading, spacing: 8) { + Text(appName) + .font(AhakeySettingsTheme.rowTitleFont) + .foregroundStyle(AhakeySettingsTheme.primaryText) + Text("版本 \(versionText)") + .font(AhakeySettingsTheme.rowSubtitleFont) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + Text("硬件改键、语音输入、Agent 联动与灵动岛的统一工作台。") + .font(AhakeySettingsTheme.rowSubtitleFont) + .foregroundStyle(AhakeySettingsTheme.tertiaryText) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.horizontal, AhakeySettingsTheme.rowPaddingH) + .padding(.vertical, 14) + } + } + } + + private func aboutPillar(title: String, detail: String) -> some View { + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(AhakeySettingsTheme.rowTitleFont) + .foregroundStyle(AhakeySettingsTheme.primaryText) + Text(detail) + .font(AhakeySettingsTheme.rowSubtitleFont) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + .fixedSize(horizontal: false, vertical: true) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, AhakeySettingsTheme.rowPaddingH) + .padding(.vertical, 12) + } +} + +/// 用户中心「版本说明」。 +struct AhaKeyUserCenterReleaseNotesContent: View { + private var versionText: String { + let short = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "—" + return short + } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + AhakeySettingsCard(sectionTitle: "当前版本") { + VStack(alignment: .leading, spacing: 10) { + Text("AhaKey Studio \(versionText)") + .font(AhakeySettingsTheme.rowTitleFont) + .foregroundStyle(AhakeySettingsTheme.primaryText) + Text("完整版本说明与更新日志即将开放。") + .font(AhakeySettingsTheme.rowSubtitleFont) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.horizontal, AhakeySettingsTheme.rowPaddingH) + .padding(.vertical, 14) + } + } + } +} diff --git a/ahakeyconfig-mac/Sources/Views/Settings/AhaKeyVoiceDictionaryPane.swift b/ahakeyconfig-mac/Sources/Views/Settings/AhaKeyVoiceDictionaryPane.swift new file mode 100644 index 00000000..cb1980a2 --- /dev/null +++ b/ahakeyconfig-mac/Sources/Views/Settings/AhaKeyVoiceDictionaryPane.swift @@ -0,0 +1,156 @@ +import SwiftUI + +struct AhaKeyVoiceDictionaryPane: View { + @ObservedObject private var store = VoiceInputStore.shared + @State private var showsEditor = false + @State private var editingEntry: VoiceDictionaryEntry? + @State private var draftPhrase = "" + @State private var draftReplacement = "" + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + HStack(alignment: .firstTextBaseline) { + VStack(alignment: .leading, spacing: 6) { + Text("词典") + .font(AhakeySettingsTheme.pageTitleFont) + .foregroundStyle(AhakeySettingsTheme.primaryText) + Text("转写完成后按词条做本地替换;较长短语优先匹配。") + .font(AhakeySettingsTheme.rowSubtitleFont) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + } + Spacer(minLength: 8) + Button { + editingEntry = nil + draftPhrase = "" + draftReplacement = "" + showsEditor = true + } label: { + Label("添加", systemImage: "plus") + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + } + + if store.dictionary.isEmpty { + emptyState + } else { + VStack(spacing: 0) { + ForEach(Array(store.dictionary.enumerated()), id: \.element.id) { index, entry in + dictionaryRow(entry) + if index < store.dictionary.count - 1 { + Rectangle() + .fill(AhakeySettingsTheme.divider) + .frame(height: 1) + .padding(.leading, AhakeySettingsTheme.rowPaddingH) + } + } + } + .background( + RoundedRectangle(cornerRadius: AhakeySettingsTheme.cardCornerRadius, style: .continuous) + .fill(AhakeySettingsTheme.cardBackground) + ) + } + } + .sheet(isPresented: $showsEditor) { + dictionaryEditorSheet + } + } + + private var emptyState: some View { + VStack(alignment: .leading, spacing: 8) { + Text("暂无词条") + .font(AhakeySettingsTheme.rowTitleFont) + .foregroundStyle(AhakeySettingsTheme.primaryText) + Text("添加「原词 → 替换为」后,后续听写结果会自动替换。") + .font(AhakeySettingsTheme.rowSubtitleFont) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: AhakeySettingsTheme.cardCornerRadius, style: .continuous) + .strokeBorder(style: StrokeStyle(lineWidth: 1, dash: [5, 4])) + .foregroundStyle(AhakeySettingsTheme.divider) + ) + } + + private func dictionaryRow(_ entry: VoiceDictionaryEntry) -> some View { + HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + Text(entry.phrase) + .font(AhakeySettingsTheme.rowTitleFont) + .foregroundStyle(AhakeySettingsTheme.primaryText) + Text("→ \(entry.replacement)") + .font(AhakeySettingsTheme.rowSubtitleFont) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + } + Spacer(minLength: 0) + Toggle("", isOn: Binding( + get: { entry.enabled }, + set: { enabled in + var next = entry + next.enabled = enabled + store.updateDictionaryEntry(next) + } + )) + .labelsHidden() + .toggleStyle(.switch) + .controlSize(.small) + + Button { + editingEntry = entry + draftPhrase = entry.phrase + draftReplacement = entry.replacement + showsEditor = true + } label: { + Image(systemName: "pencil") + } + .buttonStyle(.borderless) + .help("编辑") + + Button(role: .destructive) { + store.deleteDictionaryEntry(id: entry.id) + } label: { + Image(systemName: "trash") + } + .buttonStyle(.borderless) + .help("删除") + } + .padding(.horizontal, AhakeySettingsTheme.rowPaddingH) + .padding(.vertical, 12) + } + + private var dictionaryEditorSheet: some View { + VStack(alignment: .leading, spacing: 16) { + Text(editingEntry == nil ? "添加词条" : "编辑词条") + .font(.headline) + TextField("原词 / 短语", text: $draftPhrase) + .textFieldStyle(.roundedBorder) + TextField("替换为", text: $draftReplacement) + .textFieldStyle(.roundedBorder) + HStack { + Spacer() + Button("取消") { showsEditor = false } + Button("保存") { + saveEditor() + } + .buttonStyle(.borderedProminent) + .disabled(draftPhrase.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + || draftReplacement.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } + .padding(20) + .frame(width: 360) + } + + private func saveEditor() { + if var editing = editingEntry { + editing.phrase = draftPhrase.trimmingCharacters(in: .whitespacesAndNewlines) + editing.replacement = draftReplacement.trimmingCharacters(in: .whitespacesAndNewlines) + store.updateDictionaryEntry(editing) + } else { + store.addDictionaryEntry(phrase: draftPhrase, replacement: draftReplacement) + } + showsEditor = false + } +} diff --git a/ahakeyconfig-mac/Sources/Views/Settings/AhaKeyVoiceGeneralConfigPane.swift b/ahakeyconfig-mac/Sources/Views/Settings/AhaKeyVoiceGeneralConfigPane.swift new file mode 100644 index 00000000..404684a4 --- /dev/null +++ b/ahakeyconfig-mac/Sources/Views/Settings/AhaKeyVoiceGeneralConfigPane.swift @@ -0,0 +1,147 @@ +import SwiftUI + +struct AhaKeyVoiceGeneralConfigPane: View { + @ObservedObject private var store = VoiceInputStore.shared + @ObservedObject private var voiceRelay = VoiceRelayService.shared + @ObservedObject private var nativeSpeech = NativeSpeechTranscriptionService.shared + + var body: some View { + VStack(alignment: .leading, spacing: 20) { + VStack(alignment: .leading, spacing: 6) { + Text("通用设置") + .font(AhakeySettingsTheme.pageTitleFont) + .foregroundStyle(AhakeySettingsTheme.primaryText) + Text("试录、路由与历史保存时长。系统权限请到「用户中心 · 设置」。") + .font(AhakeySettingsTheme.rowSubtitleFont) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + } + + tryRecordCard + routingCard + retentionCard + preferencesHintCard + } + } + + private var tryRecordCard: some View { + AhakeySettingsCard(sectionTitle: "试录") { + VStack(alignment: .leading, spacing: 10) { + if nativeSpeech.isRecording { + Text(nativeSpeech.transcriptPreview.isEmpty ? "暂无转写" : nativeSpeech.transcriptPreview) + .font(.system(size: 13)) + .foregroundStyle(AhakeySettingsTheme.primaryText) + .frame(maxWidth: .infinity, alignment: .leading) + } else if !nativeSpeech.lastCommittedText.isEmpty { + Text(nativeSpeech.lastCommittedText) + .font(.system(size: 13)) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + .frame(maxWidth: .infinity, alignment: .leading) + } else { + Text("点击下方按钮试录,转写会写入历史并同步到展开岛虚拟键盘。") + .font(.system(size: 12)) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + } + + HStack(spacing: 10) { + Button(nativeSpeech.isRecording ? "结束并写入" : "试录") { + nativeSpeech.toggleRecordingFromVoiceKey() + } + .buttonStyle(.borderedProminent) + + Button("重新检查权限") { + voiceRelay.refreshPermissions(deferredTCCRequery: true) + nativeSpeech.refreshPermissions(deferredTCCRequery: true) + } + .buttonStyle(.bordered) + } + } + .padding(.horizontal, AhakeySettingsTheme.rowPaddingH) + .padding(.vertical, 14) + } + } + + private var routingCard: some View { + AhakeySettingsCard(sectionTitle: "语音路由") { + VStack(alignment: .leading, spacing: 8) { + Text(voiceRelay.activeRouteSummary) + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(AhakeySettingsTheme.primaryText) + Text(voiceRelay.statusMessage) + .font(.system(size: 12)) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, AhakeySettingsTheme.rowPaddingH) + .padding(.vertical, 14) + } + } + + private var retentionCard: some View { + AhakeySettingsCard(sectionTitle: "保存历史") { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("保存时长") + .font(AhakeySettingsTheme.rowTitleFont) + .foregroundStyle(AhakeySettingsTheme.primaryText) + Text("控制「历史记录」页保留多久;到期自动清理。") + .font(AhakeySettingsTheme.rowSubtitleFont) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + } + Spacer() + Picker("", selection: $store.retention) { + ForEach(VoiceHistoryRetention.allCases) { item in + Text(item.title).tag(item) + } + } + .labelsHidden() + .frame(maxWidth: 110) + } + .padding(.horizontal, AhakeySettingsTheme.rowPaddingH) + .padding(.vertical, 14) + } + } + + private var preferencesHintCard: some View { + AhakeySettingsCard(sectionTitle: "相关入口") { + VStack(alignment: .leading, spacing: 10) { + Text("系统权限与隐私说明已迁至「用户中心 · 设置」。") + .font(AhakeySettingsTheme.rowSubtitleFont) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + .fixedSize(horizontal: false, vertical: true) + + Button { + StudioNavigationRouter.shared.openUserCenter(section: .settings) + } label: { + HStack { + Text("打开用户中心 · 设置") + .font(AhakeySettingsTheme.rowTitleFont) + .foregroundStyle(AhakeySettingsTheme.primaryText) + Spacer() + Image(systemName: "chevron.right") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(AhakeySettingsTheme.tertiaryText) + } + } + .buttonStyle(.plain) + + Button { + StudioNavigationRouter.shared.selectSettingsTab(.hardware) + StudioNavigationRouter.shared.navigate(to: .voice) + } label: { + HStack { + Text("在硬件设备中配置语音键") + .font(AhakeySettingsTheme.rowTitleFont) + .foregroundStyle(AhakeySettingsTheme.primaryText) + Spacer() + Image(systemName: "chevron.right") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(AhakeySettingsTheme.tertiaryText) + } + } + .buttonStyle(.plain) + } + .padding(.horizontal, AhakeySettingsTheme.rowPaddingH) + .padding(.vertical, 14) + } + } +} \ No newline at end of file diff --git a/ahakeyconfig-mac/Sources/Views/Settings/AhaKeyVoiceHistoryPane.swift b/ahakeyconfig-mac/Sources/Views/Settings/AhaKeyVoiceHistoryPane.swift new file mode 100644 index 00000000..5f90b92e --- /dev/null +++ b/ahakeyconfig-mac/Sources/Views/Settings/AhaKeyVoiceHistoryPane.swift @@ -0,0 +1,130 @@ +import AppKit +import SwiftUI + +struct AhaKeyVoiceHistoryPane: View { + @ObservedObject private var store = VoiceInputStore.shared + @ObservedObject private var nativeSpeech = NativeSpeechTranscriptionService.shared + + private var groups: [(day: Date, entries: [VoiceHistoryEntry])] { + store.historyGroupedByDay(kind: nil) + } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 6) { + Text("历史记录") + .font(AhakeySettingsTheme.pageTitleFont) + .foregroundStyle(AhakeySettingsTheme.primaryText) + Text("本机口述转写记录。保存多久请到「通用」设置。") + .font(AhakeySettingsTheme.rowSubtitleFont) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + } + + if groups.isEmpty { + emptyState + .featureCoachTip(.voiceEmptyHistory, isActive: true, alignment: .topTrailing) + } else { + ForEach(groups, id: \.day) { group in + VStack(alignment: .leading, spacing: 8) { + Text(dayTitle(group.day)) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(AhakeySettingsTheme.tertiaryText) + .padding(.top, 4) + + VStack(spacing: 0) { + ForEach(Array(group.entries.enumerated()), id: \.element.id) { index, entry in + historyRow(entry) + if index < group.entries.count - 1 { + Rectangle() + .fill(AhakeySettingsTheme.divider) + .frame(height: 1) + .padding(.leading, 72) + } + } + } + .background( + RoundedRectangle(cornerRadius: AhakeySettingsTheme.cardCornerRadius, style: .continuous) + .fill(AhakeySettingsTheme.cardBackground) + ) + } + } + } + } + } + + private var emptyState: some View { + VStack(alignment: .leading, spacing: 8) { + Text("暂无历史记录") + .font(AhakeySettingsTheme.rowTitleFont) + .foregroundStyle(AhakeySettingsTheme.primaryText) + Text("试录或按下语音键完成转写后,记录会出现在这里。") + .font(AhakeySettingsTheme.rowSubtitleFont) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: AhakeySettingsTheme.cardCornerRadius, style: .continuous) + .strokeBorder(style: StrokeStyle(lineWidth: 1, dash: [5, 4])) + .foregroundStyle(AhakeySettingsTheme.divider) + ) + } + + private func historyRow(_ entry: VoiceHistoryEntry) -> some View { + HStack(alignment: .center, spacing: 12) { + Text(timeTitle(entry.createdAt)) + .font(.system(size: 12, design: .monospaced)) + .foregroundStyle(AhakeySettingsTheme.tertiaryText) + .frame(width: 56, alignment: .leading) + + Text(entry.text) + .font(.system(size: 13)) + .foregroundStyle(AhakeySettingsTheme.primaryText) + .lineLimit(2) + .frame(maxWidth: .infinity, alignment: .leading) + + if entry.status == .cancelled || entry.status == .failed { + Button { + nativeSpeech.toggleRecordingFromVoiceKey() + } label: { + Label("重试", systemImage: "arrow.clockwise") + .font(.system(size: 11, weight: .medium)) + } + .buttonStyle(.bordered) + .controlSize(.small) + } + + Menu { + Button("复制") { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(entry.text, forType: .string) + } + Button("删除", role: .destructive) { + store.deleteHistory(id: entry.id) + } + } label: { + Image(systemName: "ellipsis") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(AhakeySettingsTheme.tertiaryText) + .frame(width: 24, height: 24) + } + .menuStyle(.borderlessButton) + } + .padding(.horizontal, AhakeySettingsTheme.rowPaddingH) + .padding(.vertical, 12) + } + + private func dayTitle(_ day: Date) -> String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "MMM d, yyyy" + return formatter.string(from: day) + } + + private func timeTitle(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "hh:mm a" + return formatter.string(from: date) + } +} diff --git a/ahakeyconfig-mac/Sources/Views/Settings/AhaKeyVoiceInputSectionPicker.swift b/ahakeyconfig-mac/Sources/Views/Settings/AhaKeyVoiceInputSectionPicker.swift new file mode 100644 index 00000000..54f5182b --- /dev/null +++ b/ahakeyconfig-mac/Sources/Views/Settings/AhaKeyVoiceInputSectionPicker.swift @@ -0,0 +1,37 @@ +import SwiftUI + +/// 语音输入顶部分段:历史记录 / 词典 / 通用设置。 +struct AhaKeyVoiceInputSectionPicker: View { + @Binding var selection: AhaKeyVoiceInputConfigSection + + var body: some View { + HStack(spacing: 0) { + ForEach(AhaKeyVoiceInputConfigSection.allCases) { section in + Button { + selection = section + } label: { + VStack(spacing: 3) { + Text(section.title) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(selection == section ? AhakeySettingsTheme.primaryText : AhakeySettingsTheme.secondaryText) + Text(section.subtitle) + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(selection == section ? AhakeySettingsTheme.accentBlue : AhakeySettingsTheme.tertiaryText) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(selection == section ? AhakeySettingsTheme.controlFill : Color.clear) + ) + } + .buttonStyle(.plain) + } + } + .padding(4) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(AhakeySettingsTheme.cardBackground) + ) + } +} diff --git a/ahakeyconfig-mac/Sources/Views/Settings/AhakeyPluginMarketCatalog.swift b/ahakeyconfig-mac/Sources/Views/Settings/AhakeyPluginMarketCatalog.swift new file mode 100644 index 00000000..0644ba55 --- /dev/null +++ b/ahakeyconfig-mac/Sources/Views/Settings/AhakeyPluginMarketCatalog.swift @@ -0,0 +1,143 @@ +import Foundation +import AhaKeyPluginKit + +/// 插件市场顶部分段。 +enum AhakeyPluginMarketSection: String, CaseIterable, Identifiable, Hashable { + case mine + case store + + var id: String { rawValue } + + var title: String { + switch self { + case .mine: "我的插件" + case .store: "开源市场" + } + } + + var subtitle: String { + switch self { + case .mine: "已装与教程" + case .store: "发现与贡献" + } + } +} + +/// 插件市场货架条目(本轮静态目录;后续可接远程商店)。 +struct AhakeyPluginMarketItem: Identifiable, Equatable, Hashable { + enum Status: String, Equatable { + case inDevelopment + case example + + var title: String { + switch self { + case .inDevelopment: "开发中" + case .example: "示例" + } + } + } + + let id: String + let name: String + let version: String + let summary: String + let repoPath: String + let permissions: [String] + let status: Status + let systemImage: String +} + +enum AhakeyPluginMarketCatalog { + /// 本机插件安装目录(与 PluginManifest 约定一致)。 + static let localInstallPathHint = "~/Library/Application Support/AhaKeyConfig/plugins/" + + static let items: [AhakeyPluginMarketItem] = [ + AhakeyPluginMarketItem( + id: "dev.ahakey.keysilk-keypad", + name: "KeySilk Portable Keypad", + version: "0.1.0", + summary: "便携键区适配器:布局/绑定查询、companion 热键安装,以及 openUrl / pasteText 等宿主动作分发。", + repoPath: "plugins/keysilk-keypad", + permissions: [ + "host/getInfo", + "host/log", + "host/getSwitchState", + "host/openUrl", + "host/openPath", + "host/pasteText", + "host/registerGlobalHotkey", + ], + status: .inDevelopment, + systemImage: "keyboard" + ), + AhakeyPluginMarketItem( + id: "dev.ahakey.example.typescript", + name: "TypeScript Showcase", + version: "0.1.0", + summary: "TypeScript SDK 示例:握手宿主、读取设备信息与拨杆状态,适合上手插件开发。", + repoPath: "sdks/typescript/examples/hello-plugin", + permissions: [ + "host/getInfo", + "host/log", + "host/getSwitchState", + ], + status: .example, + systemImage: "curlybraces" + ), + AhakeyPluginMarketItem( + id: "dev.ahakey.example.lever-counter", + name: "Lever Counter", + version: "0.1.0", + summary: "拨杆翻档计数示例:统计自动/手动档切换次数与停留时长,演示后台轮询与本地快照。", + repoPath: "sdks/typescript/examples/lever-counter", + permissions: [ + "host/getInfo", + "host/log", + "host/getSwitchState", + ], + status: .example, + systemImage: "switch.2" + ), + ] +} + +/// 本机已装插件轻量发现(只读 plugin.json,不拉起进程)。 +enum AhakeyInstalledPluginsStore { + struct InstalledPlugin: Identifiable, Equatable, Hashable { + let id: String + let name: String + let version: String + let permissions: [String] + let directoryPath: String + } + + /// 扫描 `PluginManager.defaultPluginsRoot` 下一级子目录中的 `plugin.json`。 + static func discover() -> [InstalledPlugin] { + let root = PluginManager.defaultPluginsRoot + let fm = FileManager.default + guard let entries = try? fm.contentsOfDirectory( + at: root, + includingPropertiesForKeys: [.isDirectoryKey], + options: [.skipsHiddenFiles] + ) else { + return [] + } + + var out: [InstalledPlugin] = [] + for dir in entries { + let isDir = (try? dir.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false + guard isDir else { continue } + guard let manifest = try? PluginManifest.load(from: dir) else { continue } + out.append( + InstalledPlugin( + id: manifest.id, + name: manifest.name, + version: manifest.version, + permissions: manifest.permissions, + directoryPath: dir.path + ) + ) + } + return out.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + } +} diff --git a/ahakeyconfig-mac/Sources/Views/Settings/AhakeyPluginMarketPane.swift b/ahakeyconfig-mac/Sources/Views/Settings/AhakeyPluginMarketPane.swift new file mode 100644 index 00000000..4373d931 --- /dev/null +++ b/ahakeyconfig-mac/Sources/Views/Settings/AhakeyPluginMarketPane.swift @@ -0,0 +1,688 @@ +import AppKit +import SwiftUI + +/// 插件市场:默认「我的插件」;开源市场为次级入口子页。商店级版式(Hero / 货架 / 产品页)。 +struct AhakeyPluginMarketPane: View { + @Binding var section: AhakeyPluginMarketSection + var onClose: () -> Void + + @State private var installed: [AhakeyInstalledPluginsStore.InstalledPlugin] = [] + @State private var showInstallGuide = false + @State private var selectedInstalledId: String? + @State private var selectedShelfId: String? + + init(section: Binding = .constant(.mine), onClose: @escaping () -> Void) { + self._section = section + self.onClose = onClose + } + + private var featuredItem: AhakeyPluginMarketItem? { + AhakeyPluginMarketCatalog.items.first(where: { $0.status == .inDevelopment }) + ?? AhakeyPluginMarketCatalog.items.first + } + + private var shelfItems: [AhakeyPluginMarketItem] { + guard let featuredId = featuredItem?.id else { + return AhakeyPluginMarketCatalog.items + } + return AhakeyPluginMarketCatalog.items.filter { $0.id != featuredId } + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + storeChrome + .padding(.horizontal, AhakeyPluginMarketTheme.contentPadding) + .padding(.top, 18) + .padding(.bottom, 10) + + ScrollView { + VStack(alignment: .leading, spacing: 24) { + if let selectedInstalledId, + let plugin = installed.first(where: { $0.id == selectedInstalledId }) { + installedProductPage(plugin) + } else if let selectedShelfId, + let item = AhakeyPluginMarketCatalog.items.first(where: { $0.id == selectedShelfId }) { + shelfProductPage(item) + } else { + storeHeader + switch section { + case .mine: + librarySection + case .store: + storefrontSection + } + } + } + .padding(.horizontal, AhakeyPluginMarketTheme.contentPadding) + .padding(.bottom, 32) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .background(AhakeyPluginMarketTheme.canvas) + .featureCoachTip(.pluginComingSoon, isActive: true, alignment: .topTrailing) + .onAppear(perform: refreshInstalled) + .onChange(of: section) { newValue in + clearDetailSelection() + if newValue == .mine { + refreshInstalled() + } + } + } + + // MARK: - Chrome + + private var storeChrome: some View { + HStack(spacing: 12) { + Button(action: handleTopBack) { + HStack(spacing: 4) { + Image(systemName: "chevron.left") + .font(.system(size: 12, weight: .semibold)) + Text(topBackTitle) + .font(.system(size: 13, weight: .medium)) + } + .foregroundStyle(AhakeyPluginMarketTheme.secondaryText) + } + .buttonStyle(.plain) + + Spacer(minLength: 0) + } + } + + private var topBackTitle: String { + if selectedInstalledId != nil || selectedShelfId != nil { + return "返回列表" + } + if section == .store { + return "返回我的插件" + } + return "返回" + } + + private func handleTopBack() { + if selectedInstalledId != nil || selectedShelfId != nil { + clearDetailSelection() + return + } + if section == .store { + section = .mine + return + } + onClose() + } + + private var storeHeader: some View { + HStack(alignment: .center, spacing: 14) { + VStack(alignment: .leading, spacing: 6) { + Text(section == .store ? "开源市场" : "插件市场") + .font(AhakeyPluginMarketTheme.storeTitleFont) + .foregroundStyle(AhakeyPluginMarketTheme.primaryText) + Text( + section == .store + ? "发现开发中与示例插件" + : "管理本机已装扩展" + ) + .font(AhakeyPluginMarketTheme.captionFont) + .foregroundStyle(AhakeyPluginMarketTheme.secondaryText) + } + + Spacer(minLength: 8) + + if section == .mine { + Button { + clearDetailSelection() + section = .store + } label: { + HStack(spacing: 6) { + Image(systemName: "shippingbox.fill") + .font(.system(size: 11, weight: .semibold)) + Text("开源市场") + .font(.system(size: 12, weight: .semibold)) + } + .foregroundStyle(.white) + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background( + Capsule(style: .continuous) + .fill(AhakeyPluginMarketTheme.accent) + ) + } + .buttonStyle(.plain) + .help("浏览并下载开源插件(次级入口)") + } + } + } + + // MARK: - Library(我的插件) + + private var librarySection: some View { + VStack(alignment: .leading, spacing: 20) { + libraryListBlock + + DisclosureGroup(isExpanded: $showInstallGuide) { + installGuideBody + .padding(.top, 10) + } label: { + Text("安装教程") + .font(AhakeyPluginMarketTheme.captionFont) + .foregroundStyle(AhakeyPluginMarketTheme.tertiaryText) + } + .tint(AhakeyPluginMarketTheme.tertiaryText) + } + } + + private var libraryListBlock: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text("资料库 · 已安装") + .font(AhakeyPluginMarketTheme.sectionTitleFont) + .foregroundStyle(AhakeyPluginMarketTheme.primaryText) + Spacer(minLength: 0) + Button("刷新") { + refreshInstalled() + } + .buttonStyle(.borderless) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(AhakeyPluginMarketTheme.accent) + } + + if installed.isEmpty { + emptyLibraryTile + } else { + ForEach(installed) { plugin in + libraryRow(plugin) + } + } + } + } + + private var emptyLibraryTile: some View { + storeTile { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 14) { + iconTile(systemImage: "tray", tint: AhakeyPluginMarketTheme.tertiaryText) + VStack(alignment: .leading, spacing: 4) { + Text("尚未安装插件") + .font(AhakeyPluginMarketTheme.tileTitleFont) + .foregroundStyle(AhakeyPluginMarketTheme.primaryText) + Text("从开源市场挑选扩展,或按教程放入本机插件目录。") + .font(AhakeyPluginMarketTheme.captionFont) + .foregroundStyle(AhakeyPluginMarketTheme.secondaryText) + .fixedSize(horizontal: false, vertical: true) + } + } + + Button { + section = .store + } label: { + Text("去开源市场看看") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(AhakeyPluginMarketTheme.accent) + } + .buttonStyle(.plain) + } + } + } + + private func libraryRow(_ plugin: AhakeyInstalledPluginsStore.InstalledPlugin) -> some View { + Button { + selectedShelfId = nil + selectedInstalledId = plugin.id + } label: { + storeTile { + HStack(spacing: 14) { + iconTile(systemImage: "puzzlepiece.extension") + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 8) { + Text(plugin.name) + .font(AhakeyPluginMarketTheme.tileTitleFont) + .foregroundStyle(AhakeyPluginMarketTheme.primaryText) + .lineLimit(1) + statusPill(title: "已安装", tint: Color.green) + } + Text("v\(plugin.version) · \(plugin.id)") + .font(AhakeyPluginMarketTheme.metaFont) + .foregroundStyle(AhakeyPluginMarketTheme.tertiaryText) + .lineLimit(1) + } + Spacer(minLength: 0) + Image(systemName: "chevron.right") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(AhakeyPluginMarketTheme.tertiaryText) + } + } + } + .buttonStyle(.plain) + } + + private var installGuideBody: some View { + VStack(alignment: .leading, spacing: 0) { + guideStep(index: 1, title: "发现开源插件", detail: "从右上角进入「开源市场」挑选已开源的扩展能力。") + Divider().background(AhakeyPluginMarketTheme.divider) + guideStep(index: 2, title: "下载并安装到本机", detail: "安装目录:\(AhakeyPluginMarketCatalog.localInstallPathHint)") + Divider().background(AhakeyPluginMarketTheme.divider) + guideStep(index: 3, title: "宿主自动扫描加载", detail: "每个插件目录需包含 plugin.json;权限按白名单声明。") + } + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(AhakeyPluginMarketTheme.tileBackground.opacity(0.7)) + ) + } + + private func guideStep(index: Int, title: String, detail: String) -> some View { + HStack(alignment: .top, spacing: 12) { + Text("\(index)") + .font(.system(size: 11, weight: .bold, design: .rounded)) + .foregroundStyle(AhakeyPluginMarketTheme.accent) + .frame(width: 20, height: 20) + .background(Circle().fill(AhakeyPluginMarketTheme.accent.opacity(0.12))) + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(AhakeyPluginMarketTheme.secondaryText) + Text(detail) + .font(AhakeyPluginMarketTheme.captionFont) + .foregroundStyle(AhakeyPluginMarketTheme.tertiaryText) + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + } + Spacer(minLength: 0) + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + } + + // MARK: - Storefront(开源市场) + + private var storefrontSection: some View { + VStack(alignment: .leading, spacing: 24) { + if let featured = featuredItem { + featuredHero(featured) + } + shelfGrid + contributeFooter + } + } + + private func featuredHero(_ item: AhakeyPluginMarketItem) -> some View { + Button { + selectedInstalledId = nil + selectedShelfId = item.id + } label: { + HStack(alignment: .center, spacing: 20) { + iconTile( + systemImage: item.systemImage, + size: 72, + corner: 18, + tint: .white, + fill: Color.white.opacity(0.18) + ) + + VStack(alignment: .leading, spacing: 8) { + Text("精选") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(Color.white.opacity(0.72)) + Text(item.name) + .font(.system(size: 22, weight: .bold)) + .foregroundStyle(.white) + .lineLimit(2) + Text(item.summary) + .font(AhakeyPluginMarketTheme.captionFont) + .foregroundStyle(Color.white.opacity(0.78)) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) + + Text("查看") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.white) + .padding(.horizontal, 14) + .padding(.vertical, 6) + .background(Capsule(style: .continuous).fill(Color.white.opacity(0.22))) + .padding(.top, 4) + } + + Spacer(minLength: 0) + } + .padding(22) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: AhakeyPluginMarketTheme.heroCornerRadius, style: .continuous) + .fill(AhakeyPluginMarketTheme.heroGradient) + ) + } + .buttonStyle(.plain) + } + + private var shelfGrid: some View { + VStack(alignment: .leading, spacing: 12) { + Text("全部插件") + .font(AhakeyPluginMarketTheme.sectionTitleFont) + .foregroundStyle(AhakeyPluginMarketTheme.primaryText) + + LazyVGrid( + columns: [ + GridItem(.flexible(), spacing: 12), + GridItem(.flexible(), spacing: 12), + ], + spacing: 12 + ) { + ForEach(shelfItems) { item in + shelfTile(item) + } + } + } + } + + private func shelfTile(_ item: AhakeyPluginMarketItem) -> some View { + Button { + selectedInstalledId = nil + selectedShelfId = item.id + } label: { + storeTile { + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .top) { + iconTile(systemImage: item.systemImage) + Spacer(minLength: 0) + statusPill(for: item.status) + } + Text(item.name) + .font(AhakeyPluginMarketTheme.tileTitleFont) + .foregroundStyle(AhakeyPluginMarketTheme.primaryText) + .lineLimit(2) + .multilineTextAlignment(.leading) + Text(item.summary) + .font(AhakeyPluginMarketTheme.captionFont) + .foregroundStyle(AhakeyPluginMarketTheme.secondaryText) + .lineLimit(2) + .multilineTextAlignment(.leading) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .buttonStyle(.plain) + } + + private var contributeFooter: some View { + storeTile { + VStack(alignment: .leading, spacing: 10) { + Text("开发与贡献") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(AhakeyPluginMarketTheme.secondaryText) + Text("鼓励自行开发插件并开源回馈社区。本地可用 TypeScript SDK 示例起步。") + .font(AhakeyPluginMarketTheme.captionFont) + .foregroundStyle(AhakeyPluginMarketTheme.tertiaryText) + .fixedSize(horizontal: false, vertical: true) + + HStack(spacing: 10) { + Button { + revealSDKExamples() + } label: { + Label("查看 SDK 示例", systemImage: "folder") + .font(.system(size: 12, weight: .medium)) + } + .buttonStyle(.bordered) + + Button("上传到社区") {} + .buttonStyle(.bordered) + .disabled(true) + .help("即将开放:提交开源插件到社区货架") + + Spacer(minLength: 0) + } + } + } + .opacity(0.92) + } + + // MARK: - Product pages + + private func installedProductPage(_ plugin: AhakeyInstalledPluginsStore.InstalledPlugin) -> some View { + VStack(alignment: .leading, spacing: 20) { + productHero( + systemImage: "puzzlepiece.extension", + title: plugin.name, + badge: statusPill(title: "已安装", tint: Color.green), + meta: "\(plugin.id) · v\(plugin.version)" + ) { + HStack(spacing: 10) { + Button { + revealInFinder(path: plugin.directoryPath) + } label: { + Label("在 Finder 中显示", systemImage: "folder") + } + .buttonStyle(.borderedProminent) + .tint(AhakeyPluginMarketTheme.accent) + + Button("卸载") {} + .buttonStyle(.bordered) + .disabled(true) + .help("即将开放:从本机插件目录移除") + + Text("卸载即将开放") + .font(AhakeyPluginMarketTheme.captionFont) + .foregroundStyle(AhakeyPluginMarketTheme.tertiaryText) + + Spacer(minLength: 0) + } + } + + productDetailBlock { + VStack(alignment: .leading, spacing: 12) { + Text("安装路径") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(AhakeyPluginMarketTheme.secondaryText) + Text(plugin.directoryPath) + .font(AhakeyPluginMarketTheme.metaFont) + .foregroundStyle(AhakeyPluginMarketTheme.tertiaryText) + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + + if !plugin.permissions.isEmpty { + Text("权限") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(AhakeyPluginMarketTheme.secondaryText) + FlowPermissionChips(permissions: plugin.permissions) + } + } + } + } + } + + private func shelfProductPage(_ item: AhakeyPluginMarketItem) -> some View { + VStack(alignment: .leading, spacing: 20) { + productHero( + systemImage: item.systemImage, + title: item.name, + badge: statusPill(for: item.status), + meta: "\(item.id) · v\(item.version)" + ) { + HStack(spacing: 10) { + Button("下载") {} + .buttonStyle(.bordered) + .disabled(true) + .help("即将开放:从开源仓库下载插件包") + + Button("安装到本机") {} + .buttonStyle(.borderedProminent) + .tint(AhakeyPluginMarketTheme.accent) + .disabled(true) + .help("即将开放:安装到 \(AhakeyPluginMarketCatalog.localInstallPathHint)") + + Text("即将开放") + .font(AhakeyPluginMarketTheme.captionFont) + .foregroundStyle(AhakeyPluginMarketTheme.tertiaryText) + + Spacer(minLength: 0) + } + } + + productDetailBlock { + VStack(alignment: .leading, spacing: 12) { + Text(item.summary) + .font(.system(size: 13)) + .foregroundStyle(AhakeyPluginMarketTheme.secondaryText) + .fixedSize(horizontal: false, vertical: true) + + Text("源码") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(AhakeyPluginMarketTheme.secondaryText) + Text(item.repoPath) + .font(AhakeyPluginMarketTheme.metaFont) + .foregroundStyle(AhakeyPluginMarketTheme.tertiaryText) + .textSelection(.enabled) + + Text("权限") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(AhakeyPluginMarketTheme.secondaryText) + FlowPermissionChips(permissions: item.permissions) + } + } + } + } + + private func productHero( + systemImage: String, + title: String, + badge: Badge, + meta: String, + @ViewBuilder cta: () -> CTA + ) -> some View { + storeTile { + VStack(alignment: .leading, spacing: 18) { + HStack(alignment: .top, spacing: 16) { + iconTile( + systemImage: systemImage, + size: AhakeyPluginMarketTheme.productIconSize, + corner: 18 + ) + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 8) { + Text(title) + .font(.system(size: 24, weight: .bold)) + .foregroundStyle(AhakeyPluginMarketTheme.primaryText) + badge + } + Text(meta) + .font(AhakeyPluginMarketTheme.metaFont) + .foregroundStyle(AhakeyPluginMarketTheme.tertiaryText) + .textSelection(.enabled) + } + Spacer(minLength: 0) + } + cta() + } + } + } + + private func productDetailBlock(@ViewBuilder content: () -> Content) -> some View { + storeTile { + content() + } + } + + // MARK: - Store primitives + + private func storeTile(@ViewBuilder content: () -> Content) -> some View { + content() + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: AhakeyPluginMarketTheme.tileCornerRadius, style: .continuous) + .fill(AhakeyPluginMarketTheme.tileBackground) + ) + } + + private func iconTile( + systemImage: String, + size: CGFloat = AhakeyPluginMarketTheme.iconTileSize, + corner: CGFloat = AhakeyPluginMarketTheme.iconTileCorner, + tint: Color = AhakeyPluginMarketTheme.accent, + fill: Color? = nil + ) -> some View { + Image(systemName: systemImage) + .font(.system(size: size * 0.38, weight: .semibold)) + .foregroundStyle(tint) + .frame(width: size, height: size) + .background( + RoundedRectangle(cornerRadius: corner, style: .continuous) + .fill(fill ?? tint.opacity(0.12)) + ) + } + + private func statusPill(title: String, tint: Color) -> some View { + Text(title) + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(tint.opacity(0.95)) + .padding(.horizontal, 7) + .padding(.vertical, 3) + .background(Capsule(style: .continuous).fill(tint.opacity(0.14))) + } + + private func statusPill(for status: AhakeyPluginMarketItem.Status) -> some View { + statusPill( + title: status.title, + tint: status == .inDevelopment ? Color.orange : AhakeyPluginMarketTheme.accent + ) + } + + // MARK: - Helpers + + private func clearDetailSelection() { + selectedInstalledId = nil + selectedShelfId = nil + } + + private func refreshInstalled() { + installed = AhakeyInstalledPluginsStore.discover() + } + + private func revealInFinder(path: String) { + let url = URL(fileURLWithPath: path) + NSWorkspace.shared.activateFileViewerSelecting([url]) + } + + private func revealSDKExamples() { + let candidates = [ + URL(fileURLWithPath: FileManager.default.currentDirectoryPath) + .appendingPathComponent("sdks/typescript/examples"), + URL(fileURLWithPath: FileManager.default.currentDirectoryPath) + .appendingPathComponent("../sdks/typescript/examples"), + Bundle.main.bundleURL + .deletingLastPathComponent() + .appendingPathComponent("sdks/typescript/examples"), + ] + if let existing = candidates.first(where: { FileManager.default.fileExists(atPath: $0.path) }) { + NSWorkspace.shared.activateFileViewerSelecting([existing]) + return + } + NSPasteboard.general.clearContents() + NSPasteboard.general.setString("sdks/typescript/examples", forType: .string) + } +} + +/// 权限标签横向换行。 +private struct FlowPermissionChips: View { + let permissions: [String] + + var body: some View { + LazyVGrid( + columns: [GridItem(.adaptive(minimum: 120), spacing: 6, alignment: .leading)], + alignment: .leading, + spacing: 6 + ) { + ForEach(permissions, id: \.self) { permission in + Text(permission) + .font(.system(size: 10, design: .monospaced)) + .foregroundStyle(AhakeyPluginMarketTheme.secondaryText) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(AhakeyPluginMarketTheme.controlFill) + ) + } + } + } +} + +/// 兼容旧引用名。 +typealias AhakeyPluginMarketPlaceholderPane = AhakeyPluginMarketPane diff --git a/ahakeyconfig-mac/Sources/Views/Settings/AhakeyPluginMarketTheme.swift b/ahakeyconfig-mac/Sources/Views/Settings/AhakeyPluginMarketTheme.swift new file mode 100644 index 00000000..f0c25e4b --- /dev/null +++ b/ahakeyconfig-mac/Sources/Views/Settings/AhakeyPluginMarketTheme.swift @@ -0,0 +1,83 @@ +import AppKit +import SwiftUI + +/// 插件市场独立「逛店」色板:与 Settings 工具页刻意区分,形成记忆点。 +enum AhakeyPluginMarketTheme { + static let canvas = adaptive( + "AhakeyPluginMarket.canvas", + light: NSColor(srgbRed: 0.93, green: 0.94, blue: 0.96, alpha: 1), + dark: NSColor(srgbRed: 0.07, green: 0.08, blue: 0.10, alpha: 1) + ) + static let tileBackground = adaptive( + "AhakeyPluginMarket.tileBackground", + light: NSColor.white, + dark: NSColor(srgbRed: 0.14, green: 0.15, blue: 0.18, alpha: 1) + ) + static let chromeBackground = adaptive( + "AhakeyPluginMarket.chromeBackground", + light: NSColor(srgbRed: 0.93, green: 0.94, blue: 0.96, alpha: 0.92), + dark: NSColor(srgbRed: 0.07, green: 0.08, blue: 0.10, alpha: 0.92) + ) + static let primaryText = adaptive( + "AhakeyPluginMarket.primaryText", + light: NSColor(white: 0, alpha: 0.90), + dark: NSColor(white: 1, alpha: 0.96) + ) + static let secondaryText = adaptive( + "AhakeyPluginMarket.secondaryText", + light: NSColor(white: 0, alpha: 0.50), + dark: NSColor(white: 1, alpha: 0.48) + ) + static let tertiaryText = adaptive( + "AhakeyPluginMarket.tertiaryText", + light: NSColor(white: 0, alpha: 0.34), + dark: NSColor(white: 1, alpha: 0.34) + ) + static let accent = adaptive( + "AhakeyPluginMarket.accent", + light: NSColor(srgbRed: 0.0, green: 0.48, blue: 1.0, alpha: 1), + dark: NSColor(srgbRed: 0.20, green: 0.58, blue: 1.0, alpha: 1) + ) + static let divider = adaptive( + "AhakeyPluginMarket.divider", + light: NSColor(white: 0, alpha: 0.08), + dark: NSColor(white: 1, alpha: 0.08) + ) + static let controlFill = adaptive( + "AhakeyPluginMarket.controlFill", + light: NSColor(white: 0, alpha: 0.05), + dark: NSColor(white: 1, alpha: 0.08) + ) + + /// Hero 渐变:冷青蓝 → 深蓝,避免紫系套娃。 + static var heroGradient: LinearGradient { + LinearGradient( + colors: [ + Color(nsColor: NSColor(srgbRed: 0.10, green: 0.42, blue: 0.78, alpha: 1)), + Color(nsColor: NSColor(srgbRed: 0.05, green: 0.22, blue: 0.48, alpha: 1)), + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + } + + static let contentPadding: CGFloat = 28 + static let tileCornerRadius: CGFloat = 18 + static let heroCornerRadius: CGFloat = 22 + static let iconTileSize: CGFloat = 56 + static let iconTileCorner: CGFloat = 14 + static let productIconSize: CGFloat = 72 + + static let storeTitleFont = Font.system(size: 30, weight: .bold) + static let sectionTitleFont = Font.system(size: 16, weight: .semibold) + static let tileTitleFont = Font.system(size: 14, weight: .semibold) + static let captionFont = Font.system(size: 12, weight: .regular) + static let metaFont = Font.system(size: 11, weight: .regular, design: .monospaced) + + private static func adaptive(_ name: String, light: NSColor, dark: NSColor) -> Color { + Color(nsColor: NSColor(name: name, dynamicProvider: { appearance in + let isDark = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua + return isDark ? dark : light + })) + } +} diff --git a/ahakeyconfig-mac/Sources/Views/Settings/AhakeySettingsChrome.swift b/ahakeyconfig-mac/Sources/Views/Settings/AhakeySettingsChrome.swift new file mode 100644 index 00000000..25796b24 --- /dev/null +++ b/ahakeyconfig-mac/Sources/Views/Settings/AhakeySettingsChrome.swift @@ -0,0 +1,284 @@ +import SwiftUI + +struct AhakeySettingsRootChrome: View { + @ViewBuilder var sidebar: () -> Sidebar + @ViewBuilder var detail: () -> Detail + + var body: some View { + HStack(spacing: 0) { + sidebar() + .frame(width: AhakeySettingsTheme.sidebarWidth) + .background(AhakeySettingsTheme.sidebarBackground) + + detail() + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(AhakeySettingsTheme.contentBackground) + } + .background(AhakeySettingsTheme.windowBackground) + } +} + +struct AhakeySettingsTopBar: View { + var brandName: String + var onSettingsTap: (() -> Void)? = nil + + var body: some View { + HStack(spacing: 14) { + Spacer(minLength: 0) + Text(brandName) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(AhakeySettingsTheme.primaryText) + + HStack(spacing: 16) { + topBarButton("gearshape", action: onSettingsTap) + topBarIcon("bag") + topBarIcon("person.crop.circle") + } + } + .padding(.horizontal, AhakeySettingsTheme.contentPadding) + .padding(.top, 16) + .padding(.bottom, 8) + } + + private func topBarIcon(_ name: String) -> some View { + Image(systemName: name) + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + .frame(width: 22, height: 22) + } + + @ViewBuilder + private func topBarButton(_ name: String, action: (() -> Void)?) -> some View { + if let action { + Button(action: action) { + topBarIcon(name) + } + .buttonStyle(.plain) + } else { + topBarIcon(name) + } + } +} + +struct AhakeySettingsFlatSidebar: View { + @ObservedObject var bleManager: AhaKeyBLEManager + @Binding var selection: AhaKeySettingsTab + var onUserTap: (() -> Void)? = nil + var onDeviceTap: (() -> Void)? = nil + + private static let items = AhaKeySettingsTab.allCases + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + AhakeySettingsBrandHeader() + .padding(.horizontal, 16) + .padding(.top, 20) + .padding(.bottom, 20) + + VStack(alignment: .leading, spacing: 4) { + ForEach(Self.items) { tab in + sidebarRow(tab: tab) + } + } + .padding(.horizontal, 10) + + Spacer(minLength: 0) + + VStack(alignment: .leading, spacing: 8) { + Text("我的设备") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(AhakeySettingsTheme.tertiaryText) + .padding(.horizontal, 4) + + AhakeySettingsSidebarDeviceCard(bleManager: bleManager, onTap: onDeviceTap) + } + .padding(.horizontal, 12) + .padding(.bottom, 10) + + AhakeySettingsSidebarUserCard(onTap: onUserTap) + .padding(.horizontal, 12) + .padding(.bottom, 16) + } + .frame(maxHeight: .infinity) + } + + private func sidebarRow(tab: AhaKeySettingsTab) -> some View { + let isSelected = selection == tab + return Button { + selection = tab + } label: { + HStack(spacing: 10) { + Image(systemName: tab.icon) + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(isSelected ? AhakeySettingsTheme.primaryText : AhakeySettingsTheme.secondaryText) + .frame(width: 18) + + Text(tab.title) + .font(AhakeySettingsTheme.sidebarItemFont) + .foregroundStyle(isSelected ? AhakeySettingsTheme.primaryText : AhakeySettingsTheme.secondaryText) + + Spacer(minLength: 0) + } + .padding(.horizontal, 12) + .padding(.vertical, 9) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(isSelected ? AhakeySettingsTheme.sidebarSelection : Color.clear) + ) + } + .buttonStyle(.plain) + } +} + +struct AhakeySettingsDetailScaffold: View { + let title: String + let brandName: String + var onSettingsTap: (() -> Void)? = nil + var onBack: (() -> Void)? = nil + @ViewBuilder var content: () -> Content + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + ScrollView(.vertical, showsIndicators: true) { + VStack(alignment: .leading, spacing: 20) { + VStack(alignment: .leading, spacing: 10) { + if let onBack { + Button(action: onBack) { + Label("返回", systemImage: "chevron.left") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + } + .buttonStyle(.plain) + } + + Text(title) + .font(AhakeySettingsTheme.pageTitleFont) + .foregroundStyle(AhakeySettingsTheme.primaryText) + } + .padding(.bottom, 4) + + content() + } + .padding(.horizontal, AhakeySettingsTheme.contentPadding) + .padding(.top, 20) + .padding(.bottom, 28) + } + } + } +} + +struct AhakeySettingsCard: View { + let sectionTitle: String? + @ViewBuilder var content: () -> Content + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + if let sectionTitle { + Text(sectionTitle) + .font(AhakeySettingsTheme.sectionTitleFont) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + .padding(.leading, 4) + } + + VStack(spacing: 0) { + content() + } + .background( + RoundedRectangle(cornerRadius: AhakeySettingsTheme.cardCornerRadius, style: .continuous) + .fill(AhakeySettingsTheme.cardBackground) + ) + } + } +} + +struct AhakeySettingsCardDivider: View { + var body: some View { + Rectangle() + .fill(AhakeySettingsTheme.divider) + .frame(height: 1) + .padding(.leading, AhakeySettingsTheme.rowPaddingH) + } +} + +struct AhakeySettingsToggleRow: View { + let title: String + let subtitle: String? + @Binding var isOn: Bool + + var body: some View { + HStack(alignment: .center, spacing: 12) { + VStack(alignment: .leading, spacing: 3) { + Text(title) + .font(AhakeySettingsTheme.rowTitleFont) + .foregroundStyle(AhakeySettingsTheme.primaryText) + if let subtitle, !subtitle.isEmpty { + Text(subtitle) + .font(AhakeySettingsTheme.rowSubtitleFont) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + .fixedSize(horizontal: false, vertical: true) + } + } + Spacer(minLength: 8) + Toggle("", isOn: $isOn) + .labelsHidden() + .toggleStyle(.switch) + .tint(AhakeySettingsTheme.accentBlue) + } + .padding(.horizontal, AhakeySettingsTheme.rowPaddingH) + .padding(.vertical, AhakeySettingsTheme.rowPaddingV) + } +} + +struct AhakeySettingsMenuRow: View { + let title: String + let selectionTitle: String + @ViewBuilder var menuContent: () -> MenuContent + + var body: some View { + HStack(alignment: .center, spacing: 12) { + Text(title) + .font(AhakeySettingsTheme.rowTitleFont) + .foregroundStyle(AhakeySettingsTheme.primaryText) + Spacer(minLength: 8) + Menu { + menuContent() + } label: { + HStack(spacing: 4) { + Text(selectionTitle) + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + Image(systemName: "chevron.up.chevron.down") + .font(.system(size: 9, weight: .semibold)) + .foregroundStyle(AhakeySettingsTheme.tertiaryText) + } + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(AhakeySettingsTheme.controlFill) + ) + } + .menuStyle(.borderlessButton) + } + .padding(.horizontal, AhakeySettingsTheme.rowPaddingH) + .padding(.vertical, AhakeySettingsTheme.rowPaddingV) + } +} + +struct AhakeyWrappedSettingsPane: View { + let title: String + let brandName: String + var onSettingsTap: (() -> Void)? = nil + var onBack: (() -> Void)? = nil + @ViewBuilder var content: () -> Content + + var body: some View { + AhakeySettingsDetailScaffold( + title: title, + brandName: brandName, + onSettingsTap: onSettingsTap, + onBack: onBack, + content: content + ) + } +} diff --git a/ahakeyconfig-mac/Sources/Views/Settings/AhakeySettingsShellComponents.swift b/ahakeyconfig-mac/Sources/Views/Settings/AhakeySettingsShellComponents.swift new file mode 100644 index 00000000..10f2c01f --- /dev/null +++ b/ahakeyconfig-mac/Sources/Views/Settings/AhakeySettingsShellComponents.swift @@ -0,0 +1,177 @@ +import SwiftUI + +// MARK: - Sidebar chrome + +struct AhakeySettingsBrandHeader: View { + var body: some View { + Text("AhaKey Studio") + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(AhakeySettingsTheme.primaryText) + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +struct AhakeySettingsSidebarDeviceCard: View { + @ObservedObject var bleManager: AhaKeyBLEManager + var onTap: (() -> Void)? = nil + + private var isConnected: Bool { bleManager.isConnected } + + var body: some View { + Button { + onTap?() + } label: { + HStack(spacing: 10) { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(AhakeySettingsTheme.controlFill) + .frame(width: 44, height: 44) + .overlay( + Image(systemName: "keyboard") + .font(.system(size: 18)) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + ) + + VStack(alignment: .leading, spacing: 3) { + Text(bleManager.deviceName ?? "AhaKey Mini") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(AhakeySettingsTheme.primaryText) + .lineLimit(1) + HStack(spacing: 6) { + Circle() + .fill(isConnected ? Color.green : Color.orange) + .frame(width: 7, height: 7) + Text(isConnected ? "已连接" : "未连接") + .font(.system(size: 11)) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + Image(systemName: "battery.75percent") + .font(.system(size: 11)) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + Text(isConnected ? "\(bleManager.batteryLevel)%" : "—") + .font(.system(size: 11)) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + } + } + + Spacer(minLength: 0) + + Image(systemName: "chevron.right") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(AhakeySettingsTheme.tertiaryText) + } + .padding(10) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(AhakeySettingsTheme.controlFill) + ) + } + .buttonStyle(.plain) + .disabled(onTap == nil) + .help("打开我的设备") + } +} + +struct AhakeySettingsSidebarUserCard: View { + @ObservedObject private var account = CloudAccountManager.shared + var onTap: (() -> Void)? = nil + + var body: some View { + Button { + onTap?() + } label: { + HStack(spacing: 10) { + ZStack { + Circle() + .fill( + LinearGradient( + colors: [Color.purple.opacity(0.85), AhakeySettingsTheme.accentBlue], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .frame(width: 32, height: 32) + Text(account.avatarInitial) + .font(.system(size: 14, weight: .bold)) + .foregroundStyle(.white) + } + VStack(alignment: .leading, spacing: 2) { + Text(account.sidebarTitle) + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(AhakeySettingsTheme.primaryText) + .lineLimit(1) + Text(account.sidebarSubtitle) + .font(.system(size: 11)) + .foregroundStyle(AhakeySettingsTheme.secondaryText) + .lineLimit(1) + } + Spacer(minLength: 0) + Image(systemName: "chevron.right") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(AhakeySettingsTheme.tertiaryText) + } + .padding(8) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(AhakeySettingsTheme.controlFill) + ) + } + .buttonStyle(.plain) + .disabled(onTap == nil) + } +} + +// MARK: - Global top chrome(右侧功能入口,无品牌字) + +struct AhakeySettingsGlobalTopBar: View { + var onPluginMarketTap: (() -> Void)? = nil + + var body: some View { + HStack(spacing: 10) { + Spacer(minLength: 0) + + topBarLabeledButton( + title: "插件市场", + systemImage: "bag", + action: onPluginMarketTap + ) + } + .padding(.horizontal, AhakeySettingsTheme.contentPadding) + .padding(.vertical, 12) + .background(AhakeySettingsTheme.sidebarBackground) + .overlay(alignment: .bottom) { + Rectangle() + .fill(AhakeySettingsTheme.divider) + .frame(height: 1) + } + } + + @ViewBuilder + private func topBarLabeledButton( + title: String, + systemImage: String, + action: (() -> Void)? + ) -> some View { + let label = HStack(spacing: 6) { + Image(systemName: systemImage) + .font(.system(size: 14, weight: .semibold)) + Text(title) + .font(.system(size: 12, weight: .semibold)) + } + .foregroundStyle(AhakeySettingsTheme.primaryText) + .padding(.horizontal, 12) + .padding(.vertical, 7) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(AhakeySettingsTheme.controlFill) + ) + + if let action { + Button(action: action) { label } + .buttonStyle(.plain) + .help(title) + } else { + label + .opacity(0.55) + .help(title) + } + } +} diff --git a/ahakeyconfig-mac/Sources/Views/Settings/AhakeySettingsTheme.swift b/ahakeyconfig-mac/Sources/Views/Settings/AhakeySettingsTheme.swift new file mode 100644 index 00000000..940abc5b --- /dev/null +++ b/ahakeyconfig-mac/Sources/Views/Settings/AhakeySettingsTheme.swift @@ -0,0 +1,98 @@ +import AppKit +import SwiftUI + +/// Settings 壳色板:随窗口 `NSAppearance` / `preferredColorScheme` 在浅色与深色间切换。 +enum AhakeySettingsTheme { + static let windowBackground = adaptive( + "AhakeySettings.windowBackground", + light: NSColor(srgbRed: 0.957, green: 0.957, blue: 0.965, alpha: 1), // #F4F4F6 + dark: NSColor(srgbRed: 0.11, green: 0.11, blue: 0.12, alpha: 1) + ) + static let sidebarBackground = adaptive( + "AhakeySettings.sidebarBackground", + light: NSColor(srgbRed: 0.925, green: 0.925, blue: 0.937, alpha: 1), // #ECECEF + dark: NSColor(srgbRed: 0.09, green: 0.09, blue: 0.10, alpha: 1) + ) + static let contentBackground = adaptive( + "AhakeySettings.contentBackground", + light: NSColor(srgbRed: 0.957, green: 0.957, blue: 0.965, alpha: 1), + dark: NSColor(srgbRed: 0.11, green: 0.11, blue: 0.12, alpha: 1) + ) + static let cardBackground = adaptive( + "AhakeySettings.cardBackground", + light: NSColor.white, + dark: NSColor(srgbRed: 0.16, green: 0.16, blue: 0.17, alpha: 1) + ) + static let cardHover = adaptive( + "AhakeySettings.cardHover", + light: NSColor(srgbRed: 0.941, green: 0.941, blue: 0.949, alpha: 1), // #F0F0F2 + dark: NSColor(srgbRed: 0.19, green: 0.19, blue: 0.20, alpha: 1) + ) + + static let primaryText = adaptive( + "AhakeySettings.primaryText", + light: NSColor(white: 0, alpha: 0.88), + dark: NSColor(white: 1, alpha: 0.95) + ) + static let secondaryText = adaptive( + "AhakeySettings.secondaryText", + light: NSColor(white: 0, alpha: 0.48), + dark: NSColor(white: 1, alpha: 0.45) + ) + static let tertiaryText = adaptive( + "AhakeySettings.tertiaryText", + light: NSColor(white: 0, alpha: 0.32), + dark: NSColor(white: 1, alpha: 0.32) + ) + + static let accentBlue = adaptive( + "AhakeySettings.accentBlue", + light: NSColor(srgbRed: 0.0, green: 0.478, blue: 1.0, alpha: 1), // #007AFF + dark: NSColor(srgbRed: 0.04, green: 0.52, blue: 1.0, alpha: 1) + ) + static let proGold = adaptive( + "AhakeySettings.proGold", + light: NSColor(srgbRed: 0.72, green: 0.56, blue: 0.18, alpha: 1), + dark: NSColor(srgbRed: 0.85, green: 0.72, blue: 0.38, alpha: 1) + ) + static let dangerText = adaptive( + "AhakeySettings.dangerText", + light: NSColor(srgbRed: 0.86, green: 0.22, blue: 0.20, alpha: 1), + dark: NSColor(srgbRed: 1.0, green: 0.42, blue: 0.38, alpha: 1) + ) + + static let sidebarSelection = adaptive( + "AhakeySettings.sidebarSelection", + light: NSColor(white: 0, alpha: 0.06), + dark: NSColor(white: 1, alpha: 0.08) + ) + static let controlFill = adaptive( + "AhakeySettings.controlFill", + light: NSColor(white: 0, alpha: 0.06), + dark: NSColor(white: 1, alpha: 0.10) + ) + static let divider = adaptive( + "AhakeySettings.divider", + light: NSColor(white: 0, alpha: 0.08), + dark: NSColor(white: 1, alpha: 0.06) + ) + + static let sidebarWidth: CGFloat = 220 + static let contentPadding: CGFloat = 28 + static let cardCornerRadius: CGFloat = 14 + static let rowPaddingH: CGFloat = 18 + static let rowPaddingV: CGFloat = 14 + + static let pageTitleFont = Font.system(size: 26, weight: .bold) + static let sectionTitleFont = Font.system(size: 13, weight: .semibold) + static let rowTitleFont = Font.system(size: 14, weight: .medium) + static let rowSubtitleFont = Font.system(size: 12, weight: .regular) + static let sidebarItemFont = Font.system(size: 13, weight: .medium) + + private static func adaptive(_ name: String, light: NSColor, dark: NSColor) -> Color { + Color(nsColor: NSColor(name: name, dynamicProvider: { appearance in + let isDark = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua + return isDark ? dark : light + })) + } +} diff --git a/ahakeyconfig-mac/Sources/Views/Settings/VoiceClientWorkspaceSection.swift b/ahakeyconfig-mac/Sources/Views/Settings/VoiceClientWorkspaceSection.swift new file mode 100644 index 00000000..2f30035d --- /dev/null +++ b/ahakeyconfig-mac/Sources/Views/Settings/VoiceClientWorkspaceSection.swift @@ -0,0 +1,239 @@ +import SwiftUI + +/// Shared voice permissions, routing summary, and try-recording controls for Studio diagnostics and Settings voice tab. +struct VoiceClientWorkspaceSection: View { + struct Labels { + var permissionsTitle: String + var inputMonitoring: String + var accessibility: String + var microphone: String + var speechRecognition: String + var routingTitle: String + var trySectionTitle: String + var tryHint: String + var tryRecord: String + var recheckPermissions: String + var granted: String + var missing: String + var emptyTranscript: String + } + + enum Presentation { + case studioGroupBox + case settingsCards + } + + let labels: Labels + var presentation: Presentation = .studioGroupBox + + @ObservedObject private var voiceRelay = VoiceRelayService.shared + @ObservedObject private var nativeSpeech = NativeSpeechTranscriptionService.shared + + static var studioDefault: Labels { + Labels( + permissionsTitle: "系统权限", + inputMonitoring: "输入监控", + accessibility: "辅助功能", + microphone: "麦克风", + speechRecognition: "语音转写", + routingTitle: "语音路由", + trySectionTitle: "试录", + tryHint: "点击下方按钮试录,转写会同步到展开岛虚拟键盘。", + tryRecord: "试录", + recheckPermissions: "重新检查", + granted: "已授权", + missing: "未授权", + emptyTranscript: "暂无转写" + ) + } + + static func labels(localized key: (String) -> String) -> Labels { + Labels( + permissionsTitle: key("ahakey.settings.voice.permissions"), + inputMonitoring: key("ahakey.settings.voice.inputMonitoring"), + accessibility: key("ahakey.settings.voice.accessibility"), + microphone: key("ahakey.settings.voice.microphone"), + speechRecognition: key("ahakey.settings.voice.speechRecognition"), + routingTitle: key("ahakey.settings.voice.routing"), + trySectionTitle: key("ahakey.settings.voice.trySection"), + tryHint: key("ahakey.settings.voice.tryHint"), + tryRecord: key("ahakey.settings.voice.tryRecord"), + recheckPermissions: key("ahakey.settings.voice.recheckPermissions"), + granted: key("ahakey.settings.voice.granted"), + missing: key("ahakey.settings.voice.missing"), + emptyTranscript: key("virtualKeyboard.voiceHistoryEmpty") + ) + } + + var body: some View { + switch presentation { + case .studioGroupBox: + VStack(alignment: .leading, spacing: 16) { + permissionsContent + routingContent + tryRecordContent + } + case .settingsCards: + VStack(alignment: .leading, spacing: 20) { + settingsCard(sectionTitle: labels.permissionsTitle) { + settingsPermissionRow(labels.inputMonitoring, granted: voiceRelay.inputMonitoringGranted) + settingsDivider() + settingsPermissionRow(labels.accessibility, granted: voiceRelay.accessibilityGranted) + settingsDivider() + settingsPermissionRow(labels.microphone, granted: nativeSpeech.microphoneGranted) + settingsDivider() + settingsPermissionRow(labels.speechRecognition, granted: nativeSpeech.speechRecognitionGranted) + } + + settingsCard(sectionTitle: labels.routingTitle) { + VStack(alignment: .leading, spacing: 8) { + Text(voiceRelay.activeRouteSummary) + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(AhaKeyStudioEmbeddedTheme.primaryText) + Text(voiceRelay.statusMessage) + .font(.system(size: 12)) + .foregroundStyle(AhaKeyStudioEmbeddedTheme.secondaryText) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 18) + .padding(.vertical, 14) + } + + settingsCard(sectionTitle: labels.trySectionTitle) { + tryRecordInnerContent + .padding(.horizontal, 18) + .padding(.vertical, 14) + } + } + } + } + + @ViewBuilder + private var permissionsContent: some View { + GroupBox(labels.permissionsTitle) { + VStack(alignment: .leading, spacing: 8) { + permissionBadge(title: labels.inputMonitoring, granted: voiceRelay.inputMonitoringGranted) + permissionBadge(title: labels.accessibility, granted: voiceRelay.accessibilityGranted) + permissionBadge(title: labels.microphone, granted: nativeSpeech.microphoneGranted) + permissionBadge(title: labels.speechRecognition, granted: nativeSpeech.speechRecognitionGranted) + } + .padding(.top, 4) + } + } + + @ViewBuilder + private var routingContent: some View { + GroupBox(labels.routingTitle) { + VStack(alignment: .leading, spacing: 6) { + Text(voiceRelay.activeRouteSummary) + .font(.callout.weight(.medium)) + Text(voiceRelay.statusMessage) + .font(.caption) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, 4) + } + } + + @ViewBuilder + private var tryRecordContent: some View { + GroupBox(labels.trySectionTitle) { + tryRecordInnerContent + .padding(.top, 4) + } + } + + private var tryRecordInnerContent: some View { + VStack(alignment: .leading, spacing: 10) { + if nativeSpeech.isRecording { + Text(nativeSpeech.transcriptPreview.isEmpty ? labels.emptyTranscript : nativeSpeech.transcriptPreview) + .font(.system(size: 13)) + .foregroundStyle(presentation == .settingsCards ? AhaKeyStudioEmbeddedTheme.primaryText : .primary) + .frame(maxWidth: .infinity, alignment: .leading) + } else if !nativeSpeech.lastCommittedText.isEmpty { + Text(nativeSpeech.lastCommittedText) + .font(.system(size: 13)) + .foregroundStyle(presentation == .settingsCards ? AhaKeyStudioEmbeddedTheme.secondaryText : .secondary) + .frame(maxWidth: .infinity, alignment: .leading) + } else { + Text(labels.tryHint) + .font(.system(size: 12)) + .foregroundStyle(presentation == .settingsCards ? AhaKeyStudioEmbeddedTheme.secondaryText : .secondary) + } + + HStack(spacing: 10) { + Button(nativeSpeech.isRecording ? "结束并写入" : labels.tryRecord) { + nativeSpeech.toggleRecordingFromVoiceKey() + } + .buttonStyle(.borderedProminent) + + Button(labels.recheckPermissions) { + voiceRelay.refreshPermissions(deferredTCCRequery: true) + nativeSpeech.refreshPermissions(deferredTCCRequery: true) + } + .buttonStyle(.bordered) + } + } + } + + private func permissionBadge(title: String, granted: Bool) -> some View { + HStack(spacing: 6) { + Circle() + .fill(granted ? Color.green : Color.orange) + .frame(width: 8, height: 8) + Text(title) + .font(.caption.weight(.semibold)) + Text(granted ? labels.granted : labels.missing) + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background( + RoundedRectangle(cornerRadius: 999) + .fill(Color(nsColor: .controlBackgroundColor)) + ) + } + + @ViewBuilder + private func settingsCard(sectionTitle: String, @ViewBuilder content: () -> Content) -> some View { + VStack(alignment: .leading, spacing: 10) { + Text(sectionTitle) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(AhaKeyStudioEmbeddedTheme.secondaryText) + .padding(.leading, 4) + VStack(spacing: 0) { + content() + } + .background( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(AhaKeyStudioEmbeddedTheme.cardBackground) + ) + } + } + + private func settingsPermissionRow(_ title: String, granted: Bool) -> some View { + HStack { + Circle() + .fill(granted ? Color.green : Color.orange) + .frame(width: 8, height: 8) + Text(title) + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(AhaKeyStudioEmbeddedTheme.primaryText) + Spacer() + Text(granted ? labels.granted : labels.missing) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(AhaKeyStudioEmbeddedTheme.secondaryText) + } + .padding(.horizontal, 18) + .padding(.vertical, 12) + } + + private func settingsDivider() -> some View { + Rectangle() + .fill(AhaKeyStudioEmbeddedTheme.divider) + .frame(height: 1) + .padding(.leading, 18) + } +} diff --git a/ahakeyconfig-mac/Sources/Views/UnifiedAhaKeyOnboardingView.swift b/ahakeyconfig-mac/Sources/Views/UnifiedAhaKeyOnboardingView.swift old mode 100644 new mode 100755 index 619be4c0..d7ed764d --- a/ahakeyconfig-mac/Sources/Views/UnifiedAhaKeyOnboardingView.swift +++ b/ahakeyconfig-mac/Sources/Views/UnifiedAhaKeyOnboardingView.swift @@ -5,6 +5,15 @@ enum UnifiedOnboardingStorage { static let micGrantedKey = "AhaKey.UnifiedOnboarding.v2.micPreGranted" static let pasteGrantedKey = "AhaKey.UnifiedOnboarding.v2.pastePreGranted" static let currentStepKey = "AhaKey.UnifiedOnboarding.v2.currentStep" + + /// 从偏好设置等入口重新打开新手引导(回到欢迎页),并重置功能级气泡 tip。 + @MainActor + static func resetForReplay() { + UserDefaults.standard.set(false, forKey: completedKey) + UserDefaults.standard.removeObject(forKey: currentStepKey) + FeatureCoachTipStore.resetAllSeen() + FeatureCoachTipController.shared.clearPresented() + } } struct AhaKeyOnboardingPermissionState: Equatable { diff --git a/ahakeyconfig-mac/Sources/Views/VoiceInputFloatingHUD.swift b/ahakeyconfig-mac/Sources/Views/VoiceInputFloatingHUD.swift new file mode 100644 index 00000000..689348ce --- /dev/null +++ b/ahakeyconfig-mac/Sources/Views/VoiceInputFloatingHUD.swift @@ -0,0 +1,111 @@ +import SwiftUI + +struct VoiceInputFloatingHUD: View { + @ObservedObject var nativeSpeech: NativeSpeechTranscriptionService + var onCancel: () -> Void + var onConfirm: () -> Void + var onDragChanged: (CGSize) -> Void = { _ in } + var onDragEnded: () -> Void = {} + + @State private var recentlyFinished = false + @State private var dragStartTranslation: CGSize = .zero + + var body: some View { + Group { + if isVisible { + hudBody + .transition(.scale(scale: 0.92).combined(with: .opacity)) + .contentShape(Rectangle()) + .gesture( + DragGesture(minimumDistance: 1) + .onChanged { value in + let delta = CGSize( + width: value.translation.width - dragStartTranslation.width, + height: -value.translation.height + dragStartTranslation.height + ) + dragStartTranslation = value.translation + onDragChanged(delta) + } + .onEnded { _ in + dragStartTranslation = .zero + onDragEnded() + } + ) + } + } + .animation(.spring(response: 0.24, dampingFraction: 0.86), value: isVisible) + .onChange(of: nativeSpeech.lastCommittedText) { newValue in + guard !newValue.isEmpty else { return } + recentlyFinished = true + DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { + recentlyFinished = false + } + } + } + + private var isVisible: Bool { + nativeSpeech.isRecording || nativeSpeech.statusMessage.contains("整理") || recentlyFinished + } + + @ViewBuilder + private var hudBody: some View { + if nativeSpeech.isRecording { + HStack(spacing: 10) { + hudIconButton(systemImage: "xmark", action: onCancel) + MiniVoiceMeter() + .frame(width: 64, height: 24) + hudIconButton(systemImage: "checkmark", action: onConfirm, filled: true) + } + .padding(.horizontal, 8) + .padding(.vertical, 6) + .background(.black.opacity(0.92), in: Capsule()) + .overlay(Capsule().strokeBorder(.white.opacity(0.16), lineWidth: 1)) + .shadow(color: .black.opacity(0.28), radius: 16, y: 8) + } else { + Text(recentlyFinished ? "Done" : "Thinking") + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(.white.opacity(0.92)) + .padding(.horizontal, 18) + .padding(.vertical, 9) + .background(.black.opacity(0.74), in: Capsule()) + .overlay(Capsule().strokeBorder(.white.opacity(0.12), lineWidth: 1)) + .shadow(color: .black.opacity(0.24), radius: 14, y: 7) + } + } + + private func hudIconButton(systemImage: String, action: @escaping () -> Void, filled: Bool = false) -> some View { + Button(action: action) { + Image(systemName: systemImage) + .font(.system(size: 13, weight: .bold)) + .foregroundStyle(filled ? Color.black : Color.white) + .frame(width: 28, height: 28) + .background(filled ? Color.white : Color.white.opacity(0.16), in: Circle()) + } + .buttonStyle(.plain) + } +} + +private struct MiniVoiceMeter: View { + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + var body: some View { + TimelineView(.animation(minimumInterval: 1.0 / 24.0)) { context in + let t = reduceMotion ? 0 : context.date.timeIntervalSinceReferenceDate + HStack(alignment: .center, spacing: 3) { + ForEach(0..<13, id: \.self) { index in + Capsule() + .fill(index == 6 ? Color.white : Color.white.opacity(0.86)) + .frame(width: index == 6 ? 2 : 3, height: barHeight(index: index, phase: t)) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + + private func barHeight(index: Int, phase: TimeInterval) -> CGFloat { + if index == 6 { return 18 } + if reduceMotion { return 8 } + let wave = sin(phase * 8 + Double(index) * 0.7) * 0.5 + 0.5 + return 5 + CGFloat(wave) * 14 + } +} diff --git a/ahakeyconfig-mac/ahakeyicon.png b/ahakeyconfig-mac/ahakeyicon.png old mode 100644 new mode 100755 diff --git a/ahakeyconfig-mac/scripts/build-debug.sh b/ahakeyconfig-mac/scripts/build-debug.sh index cf61b3aa..59bb46df 100755 --- a/ahakeyconfig-mac/scripts/build-debug.sh +++ b/ahakeyconfig-mac/scripts/build-debug.sh @@ -43,11 +43,18 @@ fi echo "🐞 Debug building $APP_DISPLAY_NAME..." cd "$APP_ROOT" -swift build -c debug --arch arm64 --product AhaKeyConfig -swift build -c debug --arch arm64 --product ahakeyconfig-agent -BUILD_OUTPUT=".build/arm64-apple-macosx/debug/$EXECUTABLE_NAME" -AGENT_OUTPUT=".build/arm64-apple-macosx/debug/ahakeyconfig-agent" +BUILD_ARCH="${BUILD_ARCH:-$(uname -m)}" +if [[ "$BUILD_ARCH" != "arm64" && "$BUILD_ARCH" != "x86_64" ]]; then + echo "Unsupported BUILD_ARCH: $BUILD_ARCH" + exit 1 +fi + +swift build -c debug --arch "$BUILD_ARCH" --product AhaKeyConfig +swift build -c debug --arch "$BUILD_ARCH" --product ahakeyconfig-agent + +BUILD_OUTPUT=".build/$BUILD_ARCH-apple-macosx/debug/$EXECUTABLE_NAME" +AGENT_OUTPUT=".build/$BUILD_ARCH-apple-macosx/debug/ahakeyconfig-agent" if [[ ! -f "$BUILD_OUTPUT" ]]; then echo "Build output not found at $BUILD_OUTPUT" exit 1 @@ -120,7 +127,7 @@ if [[ "$NEED_PLIST" == "1" ]]; then CFBundleVersion ${BUILD_NUMBER} LSMinimumSystemVersion - 15.0 + 14.0 NSBluetoothAlwaysUsageDescription AhaKey 配置需要蓝牙连接你的 AhaKey 键盘。 NSMicrophoneUsageDescription diff --git a/ahakeyconfig-mac/scripts/generate_dmg_background.swift b/ahakeyconfig-mac/scripts/generate_dmg_background.swift old mode 100644 new mode 100755 diff --git a/ahakeyconfig-mac/scripts/generate_icons.swift b/ahakeyconfig-mac/scripts/generate_icons.swift old mode 100644 new mode 100755 diff --git a/docs/design/agent-assistant-settings.md b/docs/design/agent-assistant-settings.md new file mode 100644 index 00000000..8a57b75d --- /dev/null +++ b/docs/design/agent-assistant-settings.md @@ -0,0 +1,66 @@ +# Agent 助手设置(Pet 三分段) + +Settings「Agent」Tab = AI 工作流管家(状态、Hook、批准语义、对话管理),**不是**第二套改键台。改键唯一入口在「硬件设备」。侧栏暂不改名。 + +## IA + +```text +Agent +├ 助手(默认) 日常对话下达;顶栏「外观」直达 Agent · 设置(皮肤/图标) +├ 联动 启用主路径常开;高级设置折叠 +└ 设置 Pet 皮肤与图标;跳转 / 安全边界折叠 +``` + +| 分段 | title | subtitle | +|------|-------|----------| +| assistant | 助手 | 对话下达 | +| status | 联动 | 启用与蓝牙 | +| general | 设置 | 皮肤与图标 | + +视觉:顶部分段 + `AhakeySettingsTheme`;助手为 Chatbot(输入贴底)。 + +## 高频 / 低频 + +| 优先级 | 行为 | 入口 | +|--------|------|------| +| 日常 | 查联动、切蓝牙、对话跳转 | 助手 | +| 上手 | 一键启用联动、装 Cursor Hook、蓝牙给 Agent | 联动(常开) | +| 外观 | Pet 皮肤 / 状态图标 | Agent · 设置 | +| 岛侧 | 信息层、尺寸、动画、GIF | 灵动岛 · 组件库 · OLED Pet | +| 低频 | 卸载守护、其它 IDE Hook、Mode/批准、诊断、改键说明 | 联动/设置折叠 | + +## 联动页常开 + +1. **就绪摘要** +「一键启用联动」(未装 `install()`;已装则 `start()` + 蓝牙交给 Agent) +2. **蓝牙占用** +3. **Hook 概览** + Cursor 主按钮;其它 IDE 在高级 + +折叠「高级设置」:守护细节、Claude/Codex/Kimi Hook、Mode·批准、诊断入口。 + +## 助手对话(本版) + +- 本地自然语言 + 4 芯片:查联动状态、一键启用联动、蓝牙交给 Agent、打开联动页 +- 意图 `enableLinkage` 与联动页主按钮同语义 +- **不接云端 LLM** + +## 边界 + +| 区域 | 职责 | +|------|------| +| Agent · 助手 | 日常对话 | +| Agent · 联动 | 启用与蓝牙 | +| Agent · 设置 | Pet **皮肤与图标**(主入口);边界与跳转 | +| 灵动岛 · 组件库 · OLED Pet | 信息层、尺寸、动画、软件岛 GIF;模块显隐/排序 | +| 硬件设备 | 按键映射、Mode | +| 我的设备 | 改名、电量、诊断 | + +## 代码 + +| 概念 | 文件 | +|------|------| +| 分段枚举 | `AhaKeySettingsTypes.swift` | +| 根容器 | `AhaKeyAgentWorkspacePane.swift` | +| 助手 / 联动 / 设置 | `AhaKeyAgentAssistantPane` / `Status` / `General` | +| Pet 皮肤/图标 | `AhaKeyPetAppearanceEditor` | +| 岛侧 Pet | `AhaKeyOledPetIslandSettingsEditor` / `AhaKeyOledPetSettingsPane` | +| 意图与工具 | `AgentAssistantTools.swift` | diff --git a/docs/design/ahakey-ui-design-spec.md b/docs/design/ahakey-ui-design-spec.md new file mode 100644 index 00000000..7ff28150 --- /dev/null +++ b/docs/design/ahakey-ui-design-spec.md @@ -0,0 +1,198 @@ +# AhaKey Studio · 统一 UI 设计规范 + +> **版本:** 1.0(合并基准版) +> **适用范围:** AhaKey macOS 客户端全部 UI,含主 App 与 VibeBar 灵动岛 +> **权威来源:** +> - 主 App:`bukeke111/desktop` `my-branch` → `ahakey-design-spec.md` + `AhaKeyDesignSystem.swift` +> - VibeBar:`bukeke111/vibebar1.0` + `mac-vibebar-design-system.md` + +--- + +## 1. 产品界面架构 + +AhaKey macOS 由两个独立视觉层组成,不得混用 token: + +| 层级 | 角色 | 设计关键词 | 用户心智 | +|------|------|-----------|----------| +| **VibeBar** | 能力导航岛 | 黑色胶囊、低打扰、一键跳转 | 「我能从这里进 Voice / 审批 / OLED」 | +| **Studio** | 配置工作台 | 浅色卡片、侧栏导航、Inspector | 「我在这里深度配置和管理」 | + +**混合合并规则:** +- VibeBar:以 `vibebar1.0` 设计为准(能力入口命名与布局) +- Studio:保留协作者已有功能,视觉与布局以 `my-branch` 为准 +- 状态数据:`VibeBarBridge` 实时绑定保留,以角标/副标题呈现,不取代能力入口主文案 + +--- + +## 2. 全局设计原则 + +| 原则 | 说明 | +|------|------| +| 单一事实来源 | 产品名、试用状态 → 顶栏左侧;设备状态 → 顶栏中部;侧栏只做导航 | +| 层级用色不用阴影 | Studio 主壳用 `bg-card` + 1px `border` 分区 | +| 语义优先于实现 | 文案面向「场景 / 软件 / 批准方式」 | +| 深浅色一等公民 | 所有色来自 token;支持 `light / dark` | +| 能力入口优先于状态监控 | VibeBar 主文案是能力名,状态作次级角标 | +| 低打扰高可见 | 默认收起;悬停、热区、异常、录音时展开 | + +--- + +## 3. VibeBar 灵动岛规范 + +### 3.1 设计定位 + +``` +Capability Navigation Island(能力导航岛) +≠ Status Monitor HUD(纯状态监控) +``` + +### 3.2 收起态(Compact) + +| 位置 | 内容 | 图标 | 颜色 | +|------|------|------|------| +| 左侧 | `AhaKey`(未连接 `—`) | `keyboard` / `keyboard.fill` | cyan / secondary | +| 右侧 | `Voice` | `mic.fill` / `mic.slash` | green / red / secondary | + +- 字号 12pt Semibold,内边距水平 8pt / 垂直 3pt +- 热区:顶部居中 440×58pt + +### 3.3 展开态(Expanded) + +- 面板宽度 420pt +- 标题:`AhaKey Island`;副标题:动态设备名或 Connected/Disconnected +- 四格:**VoiceAgent / Device / Approve / OLED**(必须可点击跳转) + +| Tile | 图标 | 跳转 | 状态角标 | +|------|------|------|----------| +| VoiceAgent | waveform | Studio Voice Agent | Ready | +| Device | battery.75percent | 设备信息 | 电量 % / Off | +| Approve | checkmark.circle | 拨杆设置 | Auto / Ask | +| OLED | rectangle.inset.filled | OLED 管理 | Ready | + +### 3.4 VibeBar 颜色 Token + +| Token | 色值 | +|-------|------| +| island.black | #000000 | +| island.blackElevated | #050505 | +| text.primary | #FFFFFF | +| text.secondary | rgba(255,255,255,0.64) | +| fill.soft | rgba(255,255,255,0.08) | +| accent.voice | #BF5AF2 | +| accent.success | #30D158 | +| accent.warning | #FFD60A | +| accent.error | #FF453A | + +### 3.5 交互状态机 + +- 悬停紧凑条 → 展开 +- 鼠标离开展开区 → 450ms 后收起 +- 点击 chevron.up → 立即收起 + +--- + +## 4. AhaKey Studio 主 App 规范 + +详见 `my-branch` 的 `ahakey-design-spec.md` 与 `AhaKeyDesignSystem.swift`。 + +### 4.1 页面骨架 + +Settings 导航壳(侧栏:硬件 / 语音 / Agent / 灵动岛)+ 各 Tab 详情区;**深浅色一等公民**——偏好外观切换驱动整窗(侧栏、内容区、卡片、嵌入硬件 Studio),而非仅系统控件。默认 **深色**;可选浅色 / 跟随系统。硬件 Tab 内嵌 Gen2 嵌入式 Studio(画布 + Inspector)。左下角「我的设备」进入连接、改名与系统控制。 + +详情区顶栏右侧为功能入口(无品牌字):**仅插件市场**(购物袋)。插件市场使用独立商店主题(`AhakeyPluginMarketTheme`:橱窗画布、大圆角瓷砖、Hero 渐变),与 Settings 工具页色板刻意区分。信息架构仍以「我的插件」为主(Library 大图标列表 + 可折叠安装教程);「开源市场」为页头次级入口子页(Featured Hero + 货架瓷砖 + 页脚贡献);点进产品页再看路径/权限与主 CTA。下载、安装、卸载与社区上传即将开放。 + +原「偏好配置」已并入侧栏 **用户中心**(以独立弹窗呈现,不替换主详情区;弹窗内左导航:用户中心 / 设置 / 使用数据 / 关于我们;下方帮助中心 / 版本说明)。首页为社区成员主页:身份卡、本机插件/货架摘要、开源市场与贡献入口;订阅与额度降为次级。设置含外观与语言、权限与隐私、接入与服务、设备联动;使用数据含隐私说明与额度摘要;关于我们含品牌简介;帮助中心含新手引导与帮助文档占位。全屏新手引导完成后,主路径关键节点会弹出一次性功能 tip(连接设备、改键写入、Agent 模式 vs 联动、语音空历史等;灵动岛 / 插件市场等为防误判提示),点「知道了」后不再出现。 + +**不在本壳切换范围内:** VibeBar 灵动岛浮层仍保持黑色胶囊定位。 + +### 4.2 主窗口 Tab + +| Tab | 标识 | 用途 | +|------|------|------| +| 硬件设备 | hardware | 嵌入式 AhaKeyStudioView(Gen2 画布 + 磁吸/OceanLight);职责为按键/部件映射 | +| 语音输入 | voiceInput | 三分段:历史记录 / 词典 / 通用设置;系统权限在用户中心 · 设置 | +| Agent | agent | 三分段:助手 / 联动 / 设置(含 Pet 外观);改键仅在硬件;见 `agent-assistant-settings.md` | +| 灵动岛 | dynamicIsland | VibeBar 外观与行为设置 | + +侧栏底部另有 **我的设备**(非 Tab):设备卡上方标注「我的设备」,点击进入当前设备详情(改名、蓝牙占用、设备信息、Agent 系统)。 + +### 4.3 Settings 壳 Token(`AhakeySettingsTheme`) + +色值随窗口 `NSAppearance` / `preferredColorScheme` 自适应;嵌入 Studio 复用同一套(`AhaKeyStudioEmbeddedTheme`)。 + +| Token | Dark | Light | +|-------|------|-------| +| window / content | `#1C1C1E` | `#F4F4F6` | +| sidebar | `#171718` | `#ECECEF` | +| card | `#29292B` | `#FFFFFF` | +| cardHover | `#303032` | `#F0F0F2` | +| primaryText | white 95% | black 88% | +| secondaryText | white 45% | black 48% | +| tertiaryText | white 32% | black 32% | +| controlFill | white 10% | black 6% | +| divider / selection | white 6–8% | black 6–8% | +| accentBlue | `#0A85FF` | `#007AFF` | + +其它 Studio 尺寸:圆角 sm 8 / md 10 / lg 12 / pill 999;间距 shell 5 / page 24 / panel 14。 + +--- + +## 5. VibeBar ↔ Studio 导航映射 + +| VibeBar 入口 | Studio 目标 | 说明 | +|-------------|-------------|------| +| VoiceAgent | Agent · 助手分段 | Pet 对话管家(本地意图) | +| Device | 左下角「我的设备」→ 当前设备详情 | 我的设备 | +| Approve | 硬件设备 · Key 2(批准键) | 改键唯一入口在硬件 | +| OLED | 硬件设备 · OLED | Inspector LCD | +| Voice(紧凑右) | 硬件设备 · Key 1(语音键) | 改键唯一入口在硬件 | + +--- + +## 6. 代码映射 + +| 概念 | 文件 | +|------|------| +| VibeBar 视图 | `vibebar/Sources/VibeBar/VibeBarViews.swift` | +| VibeBar Token | `vibebar/Sources/VibeBar/VibeBarDesignTokens.swift` | +| VibeBar 状态 | `vibebar/Sources/VibeBar/VibeBarState.swift` | +| BLE 绑定 | `ahakeyconfig-mac/Sources/Utilities/VibeBarBridge.swift` | +| Studio 导航 | `ahakeyconfig-mac/Sources/Utilities/StudioNavigationRouter.swift` | +| 设计系统 | `ahakeyconfig-mac/Sources/Views/Settings/AhakeySettingsTheme.swift` | +| Settings 主壳 | `ahakeyconfig-mac/Sources/Views/Settings/AhaKeySettingsRootView.swift` | +| Agent 三分段壳 | `ahakeyconfig-mac/Sources/Views/Settings/AhaKeyAgentWorkspacePane.swift` | +| Agent 助手工具 | `ahakeyconfig-mac/Sources/Utilities/AgentAssistantTools.swift` | +| 旧工作台(独立窗口可留) | `ahakeyconfig-mac/Sources/Views/AhaKeyWorkbenchView.swift` | +| 按键配置页(硬件路径) | `ahakeyconfig-mac/Sources/Views/AhaKeyKeyConfigPageView.swift` | +| 浮动语音 HUD | `ahakeyconfig-mac/Sources/Views/VoiceInputFloatingHUD.swift` | +| 磁吸 Inspector | `ahakeyconfig-mac/Sources/Views/IslandKeyboard/MagneticModuleInspectorView.swift` | + +--- + +## 7. 合并验收清单 + +**VibeBar:** +- [x] 收起左:AhaKey(非电量) +- [x] 收起右:Voice(非 Auto/Ask) +- [x] 展开四格:VoiceAgent / Device / Approve / OLED +- [x] 每格可点击并跳转 Studio +- [x] Device/Approve 角标显示实时状态 + +**Studio:** +- [x] Settings 壳深浅色一等公民;默认深色;切换浅色整窗同步(侧栏/卡片/嵌入 Studio) +- [x] 侧栏 4 Tab 可切换 +- [x] 硬件 Tab:Gen2 画布 + 磁吸 Inspector + OceanLight +- [x] 语音 Tab:历史 / 词典 / 通用三分段;系统权限在用户中心 · 设置 +- [x] Agent Tab:助手 / 联动 / 设置三分段;Pet 外观在设置页;高频常开、高级折叠;无嵌入按键配置整页 +- [x] VibeBar 跳转自动切换对应 Settings Tab(Approve/Voice → 硬件元件) +- [ ] VibeBar 灵动岛浮层保持深色胶囊(有意不跟 Settings 外观) + +--- + +## 8. 相关文档 + +- `mac-vibebar-design-system.md` — VibeBar 细节补充 +- `ahakey-design-spec.md` — Studio 组件与 HTML 原型对照 +- `agent-assistant-settings.md` — Agent Pet 助手 IA 与工具边界 +- `voice-input-settings.md` — 语音输入三分段与高频/低频 +- `hardware-studio-layout.md` — 硬件设备主页布局 diff --git a/docs/design/hardware-studio-layout.md b/docs/design/hardware-studio-layout.md new file mode 100644 index 00000000..d0cd7ed1 --- /dev/null +++ b/docs/design/hardware-studio-layout.md @@ -0,0 +1,128 @@ +# 硬件设备主页 · 布局规范 + +更新时间:2026-07-11 +适用范围:Settings「硬件设备」Tab 内嵌的 `AhaKeyStudioView(.embeddedClient)`(独立 Studio 窗口对齐同一结构)。 + +**职责边界:**「硬件设备」只做按键/部件映射画布与 Inspector;连接、改名、蓝牙占用、设备信息与 Agent 系统控制在左下角「我的设备」(`AhaKeyDeviceManagementPane` / `DeviceInfoView`)。嵌入式硬件页内原「设备信息」入口跳转到我的设备详情,不再另开一套表单。 + +关联: + +- [UX Blueprint](../02-ux-blueprint.md) +- [统一 UI 设计规范](./ahakey-ui-design-spec.md) + +--- + +## 1. 页面结构(定稿) + +```text +┌─────────────────────────────────────────────────────────────┐ +│ 左栏顶:设备名称(主标题) · 连接状态 │ +├──────────────────────────────┬──────────────────────────────┤ +│ 左:键盘映射画布(顶对齐) │ 右:元件 Tab + 当前元件配置 │ +│ 左下:Agent 模式分段 + 指引 │ │ +│ 再下:硬件组装占位(待开发) │ │ +├──────────────────────────────┴──────────────────────────────┤ +│ 底栏:当前部件 · Mode · 脏计数 · 同步文案(可选,不抢标题) │ +└─────────────────────────────────────────────────────────────┘ +``` + +| 区域 | 内容 | 规则 | +|------|------|------| +| 左栏顶 | **设备名称**为主标题(`deviceName` 或「AhaKey Mini」);同行:连接状态 / 电量图标 | 嵌入 Settings 时也必须有;**不含** Mode 分段与连接按钮 | +| 主区左 · 中 | 键盘映射画布 | 宽度吃剩余空间;高度按键盘比例;**顶部对齐** | +| 主区左 · 下 | **Agent 模式**:Claude / Cursor / Codex / custom 分段 + 当前模式一句指引与问号 | 填画布下方留白;扫读顺序为设备 → 键盘 → 切 Agent → 右侧改部件 | +| 主区左 · 再下 | **硬件组装**占位:自定义硬件组装设计入口预留 | 仅占位与文案,无交互;后续接入组装设计能力 | +| 主区右 | 固定宽约 320–340:上元件 Tab,下配置 | Tab 始终可见;配置区滚动 | +| 底栏 | 简短同步状态 | 不抢主标题 | + +--- + +## 2. 元件发现与选择 + +### 2.1 右侧元件 Tab(主发现路径) + +Inspector 顶部列出当前代际可配置部件。用户**不点键盘也能**知道有哪些可配。 + +| 代际 | Tab 清单 | +|------|----------| +| 共用 | 灯条、LCD、Key1–4 | +| X1 | + 拨杆 | +| Gen2 | + 磁吸底座(拨杆等作为底座子配置,不单独占顶层 Tab) | + +脏状态:对应 Tab 可挂小圆点(与画布热区脏点同源)。 + +### 2.2 与画布双向同步 + +- 点 Tab → 更新 `selectedPart` → 画布热区高亮 +- 点画布热区 → 更新 `selectedPart` → Tab 高亮 +- 配置区始终展示当前 `selectedPart` 的可编辑内容(降低「先摘要再点修改」的学习成本) + +### 2.3 明确不做 + +- 不上左侧第三栏「部件目录」式设置页导航 +- 不把硬件主流程拆成「设备 / 键位 / 屏幕」多页 + +--- + +## 3. 留白与密度 + +- 画布容器:`maxWidth: .infinity` + **顶对齐**;Mode 区放在画布下方填留白,不要用「撑满高度 + aspectRatio 垂直居中」制造空带 +- Tab 已承担发现性后,可去掉或缩短「点按灯条、屏幕…」长提示 +- Inspector 内避免大块 `Spacer` 把主操作顶到角落 + +--- + +## 4. Inspector 信息层级 + +### 标题层级 + +```text +1 级 元件名(inspectorHeader:Key1 / LCD / 灯条…)+ 问号帮助 +2 级 模块名(InspectorSection / 折叠「更多…」) + └ 模块内控件行 +``` + +- **1 级**:元件标题行,含脏标记与 `InspectorHelpButton`(悬停展开、移开收起) +- **2 级**:`InspectorSection` 常开模块,或 `AhaKeyStudioDisclosureSection` 折叠模块(标题即 2 级名) +- 模块内可用小字标签做分区,**不升为 2 级** + +### 按键模块(定稿) + +| 2 级 | 语音键 | 非语音键 | +|------|--------|----------| +| 按键描述 | 描述输入 | 同左 | +| 按键配置 | 仅**语音输入方式**下拉 | 绑定方法控件 | +| 绑定方法 | 「按一下开始…」+ 控件;旁附问号(含当前输入方式注意事项) | (无,绑定在按键配置内) | +| 更多设置 | AhaType、长按阈值(折叠) | 长按固件预留(折叠) | + +### 问号帮助(原理 / 指引) + +- **收进问号:** 原理说明、操作指引、路线图、长 caption、纯文档文案 +- **留在正文:** 配置控件、实时状态值、脏标记、错误/约束提示 +- **不再用「更多…」承载纯说明;** 折叠区只保留仍有真实控件的内容 + +### 折叠(仅控件) + +与灵动岛「主区常开 + 更多…默认收起」同构,组件为 `AhaKeyStudioDisclosureSection`(嵌入色走 `AhaKeyStudioEmbeddedTheme`)。 + +| 元件 | 2 级常开 | 2 级折叠 | +|------|----------|----------| +| 按键 | 按键描述、按键配置;语音键另有绑定方法(在按键配置下方) | 更多设置 | +| LCD | 显示配置 | (无) | +| 灯条 X1 | 亮度、状态映射 | 更多灯效 | +| 灯条 Gen2 | 灯效配置 | 更多海洋灯 | +| 拨杆 | 实时档位、生效状态 | (无) | +| 磁吸 | 磁吸模块、模拟、未解锁套件 | (无) | + +展开态不持久化;切换元件 Tab 时折叠复位。 + +--- + +## 5. 实现入口 + +- `AhaKeyStudioView`:顶栏、画布、Inspector、Tab、`inspectorHelpLines` +- `InspectorSection`:二级模块标题 + 内容区 +- `InspectorHelpButton`:标题行问号 popover +- `AhaKeyStudioPart`:代际可见部件过滤 +- `AhaKeyStudioDisclosureSection` / `HardwarePartTabBar` +- Settings 嵌入:`HardwareStudioHost` → `AhaKeyStudioView(.embeddedClient)` diff --git a/docs/design/voice-input-settings.md b/docs/design/voice-input-settings.md new file mode 100644 index 00000000..0424eac6 --- /dev/null +++ b/docs/design/voice-input-settings.md @@ -0,0 +1,54 @@ +# 语音输入 Settings · 布局规范 + +更新时间:2026-07-11 +适用范围:Settings「语音输入」Tab(`VoiceInputWorkspacePane`)。 + +关联: + +- [统一 UI 设计规范](./ahakey-ui-design-spec.md) +- 灵动岛三分段与折叠:`AhaKeyDynamicIslandSettingsPane` / `AhaKeySettingsDisclosureSection` + +--- + +## 1. 信息架构 + +```text +语音输入 +├ 历史记录(默认) ← 全部本机转写列表(复制 / 删除 / 失败重试) +├ 词典 ← 词条增删改、启用开关 +└ 通用设置 ← 试录、路由、历史保存时长;权限提示去偏好配置 +``` + +系统权限与隐私说明在顶栏 **偏好配置 → 权限与隐私**。 + +顶部分段控件:`AhaKeyVoiceInputSectionPicker`(样式对齐灵动岛分段)。 + +--- + +## 2. 高频 / 低频 + +| 区域 | 常开 | 外置 | +|------|------|------| +| 历史 | 按日全部记录列表 | 保存时长 → 通用 | +| 词典 | 词条列表与编辑 | — | +| 通用 | 试录、语音路由、保存时长 | 系统权限 → 偏好配置 | +| 偏好配置 | — | 权限与隐私 | + +原则:历史页只浏览记录;保存策略与权限放在设置侧。 + +--- + +## 3. 数据 + +- `VoiceInputStore`:Application Support `AhaKeyStudio/voice-input-store.json` +- 历史:转写 finalize 成功 / 失败 / 空结果写入;`retention` 裁剪 +- 词典:finalize 前本地短语替换(长词优先) +- 「问任何问题」kind 预留,本版筛选多为空态 + +--- + +## 4. 实现入口 + +- `VoiceInputWorkspacePane` +- `AhaKeyVoiceHistoryPane` / `AhaKeyVoiceDictionaryPane` / `AhaKeyVoiceGeneralConfigPane` +- `VoiceInputStore` + `NativeSpeechTranscriptionService` finalize 挂钩 diff --git a/docs/repo-layout.md b/docs/repo-layout.md old mode 100644 new mode 100755 index 5e0892c0..83f469ba --- a/docs/repo-layout.md +++ b/docs/repo-layout.md @@ -16,6 +16,7 @@ - `BLE_tcp_bridge/` — BLE ↔ TCP 桥接(C#) - `Package.swift` — macOS targets 的根 SwiftPM 清单 - `docs/` — 仓库级文档(架构、BLE 协议、安装、发布) +- `docs/versioning.md` — 界面开发版本管理(小白版:看哪个分支、怎么打版本) - `assets/` — 共享品牌 / 构建资源 ## macOS 子目录(`ahakeyconfig-mac/`) diff --git a/docs/user-center.md b/docs/user-center.md new file mode 100644 index 00000000..f6201acb --- /dev/null +++ b/docs/user-center.md @@ -0,0 +1,22 @@ +# 用户中心 + +## 入口 + +- **主入口**:Studio 侧栏左下角用户卡 → 右侧「用户中心」整页。 +- **引导入口**:通用设置里 AhaType 未登录时的「去用户中心登录」;硬件画布未登录开启 AhaType 时引导注册登录(嵌入模式跳转用户中心,独立窗口仍可用 Sheet)。 + +## 能力 + +- 登录 / 注册 / 退出 / 切换账号 +- 资料与日/周/月额度 +- 订阅充值(微信扫码)与免费券兑换 +- AhaType 云端整理开关(需登录) + +## 与「登录时打开」的区别 + +| 文案 | 位置 | 含义 | +|------|------|------| +| 用户中心 · 登录 | 侧栏用户卡 | AhaKey / AhaType **云端账号** | +| 登录时打开 | 灵动岛 → 通用 | macOS **开机登录项**,与账号无关 | + +账号相关 UI **不**放在顶栏齿轮「通用设置」或灵动岛「通用」中。 diff --git a/docs/versioning.md b/docs/versioning.md new file mode 100644 index 00000000..fc6258cc --- /dev/null +++ b/docs/versioning.md @@ -0,0 +1,87 @@ +# AhaKey 界面开发 · 版本怎么管(小白版) + +用三句话记住: + +1. **写界面代码** → 在 `ui/develop` 分支 +2. **做完一小批能给人用的改动** → 更新 `CHANGELOG.md`,打一个版本号标签(如 `v0.2.1`) +3. **正式告诉别人有新版** → 在 GitHub 的 Releases 里写几句更新说明 + +--- + +## 我该看哪个分支? + +| 名字 | 是干什么的 | 你日常要不要动 | +|------|------------|----------------| +| `ui/develop` | **界面开发主线**(最新 UI 在这里) | ✅ 日常在这里改、推送 | +| `main` | 仓库默认主分支(偏整仓/上游历史) | ⚠️ 暂时先别往这里硬合 UI,避免搞乱 | +| `feat/xxx` | 临时功能分支(可选) | 做完合回 `ui/develop` 后可删 | + +以前的 `integrate/keke-vibebar-ui` 已经改名为 **`ui/develop`**,内容一样,只是名字更好记。 + +--- + +## 版本号怎么起? + +只使用这种格式: + +```text +v主版本.次版本.修订号 +例如:v0.2.0、v0.2.1、v0.3.0 +``` + +简单约定(界面相关): + +- **修订号 +1**(`v0.2.0` → `v0.2.1`):修 bug、小样式、小文案 +- **次版本 +1**(`v0.2.0` → `v0.3.0`):一整块新功能(比如新的设置页、大改布局) +- **主版本 +1**:很少用;产品形态大变时再考虑 + +**不要**再用这种标签:日期名、随便起的英文名、带 `#` 的名字。那些不算正式版本。 + +--- + +## 发一版时按这个清单做 + +1. 确认改动都在 `ui/develop`,并且已经 `git push` 到 GitHub +2. 打开根目录的 `CHANGELOG.md`,在最上面加一节「这次改了什么」 +3. 打标签(例子:下一版是 `v0.2.1`): + +```bash +git tag -a v0.2.1 -m "简短说明这次版本" +git push origin v0.2.1 +``` + +4. 到 GitHub → **Releases** → 选这个标签 → 写几条用户能看懂的更新说明 + +打 `v*` 标签后,仓库里的自动流程可能会帮你打 macOS 安装包;有的话会出现在 Release 附件里。 + +--- + +## 现在哪一版是「界面最新」? + +- 分支:`ui/develop` +- 版本标签:`v0.5.0`(功能级气泡 tip / LCD 恢复默认 / 卡片左右布局) +- Release 页:仓库的 Releases 里搜 `v0.5.0` + +更细的更新列表看:`CHANGELOG.md`。 + +--- + +## 两个远程是什么意思?(了解即可) + +| 远程名 | 地址 | 你怎么用 | +|--------|------|----------| +| `origin` | 你的库 `bukeke111/desktop` | ✅ 平时只推这里 | +| `upstream` | 组织库 `AhakeyAI/desktop` | 需要同步别人进度时再拉,不要随便推 | + +--- + +## 常见担心 + +**Q:我改错分支了怎么办?** +先别慌。看左下角/终端里当前分支是不是 `ui/develop`。不是的话先切回去再提交。 + +**Q:标签打错了怎么办?** +告诉会用 git 的人帮你改;小白阶段宁可多问一句,也不要连续强推删除。 + +**Q:旧分支 `my-branch`、`vibebar1.0` 还在?** +先留着当备份,不删也不继续往上堆新功能。新工作只走 `ui/develop`。 diff --git a/vibebar/Sources/VibeBar/VibeBarPetAppearanceSettings.swift b/vibebar/Sources/VibeBar/VibeBarPetAppearanceSettings.swift new file mode 100644 index 00000000..ad7bb09d --- /dev/null +++ b/vibebar/Sources/VibeBar/VibeBarPetAppearanceSettings.swift @@ -0,0 +1,204 @@ +import Foundation +import SwiftUI + +public enum VibeBarPetSkin: String, CaseIterable, Identifiable, Codable, Sendable { + /// 默认:色点 + 几何符号。 + case minimalDot + /// 可选:状态 emoji,支持按状态自定义图标。 + case classicEmoji + + public var id: String { rawValue } + + public var title: String { + switch self { + case .minimalDot: "极简圆点" + case .classicEmoji: "状态 emoji" + } + } + + public var detail: String { + switch self { + case .minimalDot: "默认 · 色点 + 符号" + case .classicEmoji: "可选 · 图标可自定义" + } + } + + public func glyph(for status: VibeBarAgentStatus) -> String { + switch self { + case .classicEmoji: + return status.petEmoji + case .minimalDot: + switch status { + case .idle: return "●" + case .listening: return "◉" + case .thinking: return "◎" + case .searching: return "◌" + case .coding: return "◆" + case .approval: return "▲" + case .completed: return "★" + case .error: return "✕" + } + } + } +} + +public enum VibeBarPetSize: String, CaseIterable, Identifiable, Codable, Sendable { + case small + case medium + case large + + public var id: String { rawValue } + + public var title: String { + switch self { + case .small: "小" + case .medium: "中" + case .large: "大" + } + } + + public var glyphFontSize: CGFloat { + switch self { + case .small: 26 + case .medium: 34 + case .large: 42 + } + } + + public var panelHeight: CGFloat { + switch self { + case .small: 58 + case .medium: 70 + case .large: 84 + } + } +} + +public enum VibeBarPetAnimationStyle: String, CaseIterable, Identifiable, Codable, Sendable { + case none + case breathe + case bounce + case blink + + public var id: String { rawValue } + + public var title: String { + switch self { + case .none: "无" + case .breathe: "呼吸" + case .bounce: "弹跳" + case .blink: "闪烁" + } + } +} + +public enum VibeBarPetAnimationSpeed: String, CaseIterable, Identifiable, Codable, Sendable { + case slow + case normal + case fast + + public var id: String { rawValue } + + public var title: String { + switch self { + case .slow: "慢" + case .normal: "标准" + case .fast: "快" + } + } + + public var period: TimeInterval { + switch self { + case .slow: 2.4 + case .normal: 1.4 + case .fast: 0.8 + } + } +} + +/// 单状态覆盖(高级)。 +public struct VibeBarPetStatusOverride: Codable, Equatable, Sendable { + public var glyph: String? + public var animationStyle: VibeBarPetAnimationStyle? + + public init(glyph: String? = nil, animationStyle: VibeBarPetAnimationStyle? = nil) { + self.glyph = glyph + self.animationStyle = animationStyle + } +} + +public struct VibeBarPetAppearance: Codable, Equatable, Sendable { + public var skinId: VibeBarPetSkin + public var showsTaskTitle: Bool + public var showsProgress: Bool + public var showsStatusLabel: Bool + public var petSize: VibeBarPetSize + public var animationStyle: VibeBarPetAnimationStyle + public var animationSpeed: VibeBarPetAnimationSpeed + public var statusOverrides: [String: VibeBarPetStatusOverride] + public var customAssetPath: String? + + public static let `default` = VibeBarPetAppearance( + skinId: .minimalDot, + showsTaskTitle: true, + showsProgress: true, + showsStatusLabel: true, + petSize: .medium, + animationStyle: .breathe, + animationSpeed: .normal, + statusOverrides: [:], + customAssetPath: nil + ) + + public func glyph(for status: VibeBarAgentStatus) -> String { + if let override = statusOverrides[status.rawValue]?.glyph, !override.isEmpty { + return override + } + return skinId.glyph(for: status) + } + + public func animationStyle(for status: VibeBarAgentStatus) -> VibeBarPetAnimationStyle { + statusOverrides[status.rawValue]?.animationStyle ?? animationStyle + } + + public var resolvedCustomAssetURL: URL? { + guard let path = customAssetPath, !path.isEmpty else { return nil } + let url = URL(fileURLWithPath: path) + return FileManager.default.fileExists(atPath: url.path) ? url : nil + } +} + +public enum VibeBarPetAppearanceSettings { + public static let storageKey = "vibebar.island.petAppearance" + public static let didChangeNotification = Notification.Name("vibebar.island.petAppearanceDidChange") + + public static var appearance: VibeBarPetAppearance { + get { + guard let data = UserDefaults.standard.data(forKey: storageKey), + let decoded = try? JSONDecoder().decode(VibeBarPetAppearance.self, from: data) else { + return .default + } + return decoded + } + set { + if let data = try? JSONEncoder().encode(newValue) { + UserDefaults.standard.set(data, forKey: storageKey) + } + NotificationCenter.default.post(name: didChangeNotification, object: nil) + NotificationCenter.default.post( + name: VibeBarIslandAppearanceSettings.appearanceDidChangeNotification, + object: nil + ) + } + } + + public static func resetToDefaults() { + appearance = .default + } + + public static func update(_ mutate: (inout VibeBarPetAppearance) -> Void) { + var value = appearance + mutate(&value) + appearance = value + } +}