From 4eabecc8112285aa0ea6b96e4729b78639773349 Mon Sep 17 00:00:00 2001 From: wgqqqqq Date: Mon, 7 Sep 2026 10:11:54 +0800 Subject: [PATCH 1/5] fix(ios): keep QR capture work off main thread --- .../Features/Pairing/PairingSheet.swift | 23 +++--- .../Platform/QRCodeScannerView.swift | 76 +++++++++++++++---- 2 files changed, 74 insertions(+), 25 deletions(-) diff --git a/src/apps/mobile/ios/OpenBitFun/Features/Pairing/PairingSheet.swift b/src/apps/mobile/ios/OpenBitFun/Features/Pairing/PairingSheet.swift index e8599390eb..ada87b61ee 100644 --- a/src/apps/mobile/ios/OpenBitFun/Features/Pairing/PairingSheet.swift +++ b/src/apps/mobile/ios/OpenBitFun/Features/Pairing/PairingSheet.swift @@ -37,16 +37,19 @@ struct PairingSheet: View { } } .fullScreenCover(isPresented: $scannerOpen) { - QRCodeScannerView { code in - pairingURL = code - scannerOpen = false - if PairingLinkHintsKt.inspectPairingLink(url: code).requiresAccount { - manualOpen = true - focused = true - } else { - model.submitPairing(url: code) - } - } + QRCodeScannerView( + onCode: { code in + pairingURL = code + scannerOpen = false + if PairingLinkHintsKt.inspectPairingLink(url: code).requiresAccount { + manualOpen = true + focused = true + } else { + model.submitPairing(url: code) + } + }, + onCancel: { scannerOpen = false } + ) .ignoresSafeArea() } } diff --git a/src/apps/mobile/ios/OpenBitFun/Infrastructure/Platform/QRCodeScannerView.swift b/src/apps/mobile/ios/OpenBitFun/Infrastructure/Platform/QRCodeScannerView.swift index 42d7c7f7a0..73fa92d0c8 100644 --- a/src/apps/mobile/ios/OpenBitFun/Infrastructure/Platform/QRCodeScannerView.swift +++ b/src/apps/mobile/ios/OpenBitFun/Infrastructure/Platform/QRCodeScannerView.swift @@ -4,10 +4,12 @@ import UIKit struct QRCodeScannerView: UIViewControllerRepresentable { let onCode: (String) -> Void + let onCancel: () -> Void func makeUIViewController(context: Context) -> QRScannerController { let controller = QRScannerController() controller.onCode = onCode + controller.onCancel = onCancel return controller } @@ -15,9 +17,13 @@ struct QRCodeScannerView: UIViewControllerRepresentable { } final class QRScannerController: UIViewController, AVCaptureMetadataOutputObjectsDelegate { - private let session = AVCaptureSession() + private let sessionQueue = DispatchQueue(label: "com.openbitfun.mobile.ios.qr-session") + private lazy var session = AVCaptureSession() private var previewLayer: AVCaptureVideoPreviewLayer? + private var captureConfigured = false + private var emittedCode = false var onCode: ((String) -> Void)? + var onCancel: (() -> Void)? override func viewDidLoad() { super.viewDidLoad() @@ -27,7 +33,10 @@ final class QRScannerController: UIViewController, AVCaptureMetadataOutputObject close.tintColor = UIColor(OpenBitFunTheme.contentOnAction) close.backgroundColor = UIColor(OpenBitFunTheme.mediaControlBackground) close.layer.cornerRadius = 22 - close.addAction(UIAction { [weak self] _ in self?.dismiss(animated: true) }, for: .touchUpInside) + close.addAction(UIAction { [weak self] _ in + self?.stopCapture() + self?.onCancel?() + }, for: .touchUpInside) close.translatesAutoresizingMaskIntoConstraints = false view.addSubview(close) NSLayoutConstraint.activate([ @@ -37,44 +46,81 @@ final class QRScannerController: UIViewController, AVCaptureMetadataOutputObject close.heightAnchor.constraint(equalToConstant: 44), ]) - guard AVCaptureDevice.authorizationStatus(for: .video) != .denied else { return } - AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in - guard granted else { return } - DispatchQueue.main.async { self?.configureCapture() } + switch AVCaptureDevice.authorizationStatus(for: .video) { + case .authorized: + configureCaptureAsync() + case .notDetermined: + AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in + guard granted else { return } + self?.configureCaptureAsync() + } + default: + break } } + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + stopCapture() + } + override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() previewLayer?.frame = view.bounds } + private func configureCaptureAsync() { + sessionQueue.async { [weak self] in + self?.configureCapture() + } + } + private func configureCapture() { + guard !captureConfigured else { return } guard let device = AVCaptureDevice.default(for: .video), let input = try? AVCaptureDeviceInput(device: device), session.canAddInput(input) else { return } let output = AVCaptureMetadataOutput() guard session.canAddOutput(output) else { return } + session.beginConfiguration() session.addInput(input) session.addOutput(output) - output.setMetadataObjectsDelegate(self, queue: .main) + output.setMetadataObjectsDelegate(self, queue: sessionQueue) output.metadataObjectTypes = [.qr] - let layer = AVCaptureVideoPreviewLayer(session: session) - layer.videoGravity = .resizeAspectFill - view.layer.insertSublayer(layer, at: 0) - previewLayer = layer + session.commitConfiguration() + captureConfigured = true + DispatchQueue.main.async { [weak self] in + guard let self, self.previewLayer == nil else { return } + let layer = AVCaptureVideoPreviewLayer(session: self.session) + layer.videoGravity = .resizeAspectFill + layer.frame = self.view.bounds + self.view.layer.insertSublayer(layer, at: 0) + self.previewLayer = layer + } session.startRunning() } + private func stopCapture() { + sessionQueue.async { [weak self] in + guard let self, self.session.isRunning else { return } + self.session.stopRunning() + } + } + func metadataOutput( _ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection, ) { guard let value = (metadataObjects.first as? AVMetadataMachineReadableCodeObject)?.stringValue, - !value.isEmpty else { return } - session.stopRunning() - onCode?(value) - dismiss(animated: true) + !value.isEmpty, + !emittedCode else { return } + emittedCode = true + if session.isRunning { + session.stopRunning() + } + DispatchQueue.main.async { [weak self] in + self?.onCode?(value) + } } } From bbbb667cd8c180bdcf572e6452950d4b1fe48869 Mon Sep 17 00:00:00 2001 From: wgqqqqq Date: Mon, 7 Sep 2026 11:04:47 +0800 Subject: [PATCH 2/5] feat(mobile): align iOS remote conversation UX with HarmonyOS --- .../App/MobileLaunchConfiguration.swift | 8 +- .../Features/Chat/ChatTimelineView.swift | 250 ++++++++++++++++-- .../Features/Pairing/PairingSheet.swift | 154 ++++++++--- .../MobileAppModel+GeneralChat.swift | 6 +- .../Platform/QRCodeScannerView.swift | 92 +++++-- .../Models/MobilePresentationModels.swift | 1 + .../Resources/Localizable.xcstrings | 10 + .../mobile/core/domain/ChatTimelineStore.kt | 1 + .../mobile/core/domain/ConversationModels.kt | 2 + .../core/domain/ChatSessionControllerTest.kt | 1 + .../core/domain/ChatTimelineProjectorTest.kt | 1 + .../core/domain/ChatTimelineStoreTest.kt | 2 + .../feature/generalchat/GeneralChatStore.kt | 2 + .../session/ConversationPresentation.kt | 3 + .../feature/session/RemoteSessionStore.kt | 18 +- .../session/ConversationPresentationTest.kt | 1 + .../session/MessageBlockPresentationTest.kt | 1 + .../session/RemoteResponseMapperTest.kt | 9 +- .../session/RemoteSessionPersistenceTest.kt | 8 +- .../mobile/core/protocol/MessageDtos.kt | 4 + .../mobile/core/protocol/RemoteCommandTest.kt | 19 ++ 21 files changed, 501 insertions(+), 92 deletions(-) diff --git a/src/apps/mobile/ios/OpenBitFun/App/MobileLaunchConfiguration.swift b/src/apps/mobile/ios/OpenBitFun/App/MobileLaunchConfiguration.swift index 0268889097..a8c187ce8c 100644 --- a/src/apps/mobile/ios/OpenBitFun/App/MobileLaunchConfiguration.swift +++ b/src/apps/mobile/ios/OpenBitFun/App/MobileLaunchConfiguration.swift @@ -315,7 +315,8 @@ private extension MobileAppModel { timelineRows = [ MobileConversationRow( id: userID, kind: "USER", text: "请检查移动端的消息、工具和文件交互。", thinking: nil, - images: [], tools: [], blocks: [], streaming: false, typing: false, pending: false, showRetry: false + images: [], tools: [], blocks: [], streaming: false, typing: false, pending: false, + showRetry: false, error: nil ), MobileConversationRow( id: assistantID, kind: "ASSISTANT", text: "", thinking: nil, images: [], tools: [], @@ -323,12 +324,13 @@ private extension MobileAppModel { .thinking(id: "preview-thinking", text: "先对照 HarmonyOS 的消息顺序与工具状态,再核对 Android 的交互策略。", streaming: false), .text( id: "preview-text", - text: "## 检查结果\n\n消息按共享投影顺序显示,文件可直接打开:[main.rs](computer://src/main.rs)。\n\n- Markdown 与代码块\n- 思考过程与子任务\n- 工具确认、提问和取消\n\n```swift\nlet parity = true\n```", + text: "## 检查结果\n\n消息按共享投影顺序显示,文件可直接打开:[main.rs](computer://src/main.rs)。\n\n- [x] Markdown 与代码块\n- [x] 思考过程与子任务\n- [ ] 完成真机回归\n\n| 平台 | 状态 |\n| :--- | ---: |\n| HarmonyOS | 已对照 |\n| iOS | 已对齐 |\n\n```swift\nlet parity = true\n```", streaming: false ), .tools(id: "preview-tools", tools: [readOne, readTwo, approval, question]), ], - streaming: false, typing: false, pending: false, showRetry: false + streaming: false, typing: false, pending: false, showRetry: true, + error: "桌面端进程意外退出。" ), ] messages = [ diff --git a/src/apps/mobile/ios/OpenBitFun/Features/Chat/ChatTimelineView.swift b/src/apps/mobile/ios/OpenBitFun/Features/Chat/ChatTimelineView.swift index c408772ffa..4aefc78889 100644 --- a/src/apps/mobile/ios/OpenBitFun/Features/Chat/ChatTimelineView.swift +++ b/src/apps/mobile/ios/OpenBitFun/Features/Chat/ChatTimelineView.swift @@ -139,7 +139,9 @@ private struct ConversationRowView: View { if !row.tools.isEmpty { ToolStatusList(tools: row.tools, model: model) } } if !row.images.isEmpty { TimelineImageGrid(images: row.images) } - if row.showRetry { + if let error = row.error, !error.isEmpty { + assistantFailure(error) + } else if row.showRetry { Button { model.retryMessage(row.text) } label: { Label(model.localized("重试"), systemImage: "arrow.clockwise") .font(MobileDesignTypography.labelSmall.font) @@ -150,6 +152,30 @@ private struct ConversationRowView: View { } .frame(maxWidth: .infinity, alignment: .leading) } + + private func assistantFailure(_ error: String) -> some View { + VStack(alignment: .leading, spacing: 6) { + Text(model.localized("本次回复失败")) + .font(MobileDesignTypography.labelSmall.font.weight(.medium)) + .foregroundStyle(OpenBitFunTheme.statusDanger) + Text(error) + .font(MobileDesignTypography.bodySmall.font) + .foregroundStyle(OpenBitFunTheme.ink) + .lineSpacing(MobileDesignTypography.bodySmall.lineSpacing) + .textSelection(.enabled) + if row.showRetry { + Button(model.localized("重试")) { model.retryMessage(row.text) } + .font(MobileDesignTypography.bodySmall.font.weight(.medium)) + .foregroundStyle(MobileDesignColors.fileLink) + .buttonStyle(.plain) + } + } + .padding(.leading, 12) + .overlay(alignment: .leading) { + Rectangle().fill(OpenBitFunTheme.statusDanger).frame(width: 2) + } + .frame(maxWidth: .infinity, alignment: .leading) + } } private struct EmptyConversationRow: View { @@ -318,9 +344,23 @@ private struct MarkdownBlockView: View { VStack(alignment: .leading, spacing: 5) { ForEach(block.items, id: \.id) { item in HStack(alignment: .firstTextBaseline, spacing: 7) { - Text(item.marker).foregroundStyle(OpenBitFunTheme.muted) - .frame(width: 20, alignment: .trailing) - Text(inlineString(item.inlines)).foregroundStyle(OpenBitFunTheme.ink) + if let checked = taskListState(item.text) { + ZStack { + RoundedRectangle(cornerRadius: 4) + .stroke(OpenBitFunTheme.line, lineWidth: 1) + if checked { + Image(systemName: "checkmark") + .font(.system(size: 9, weight: .bold)) + .foregroundStyle(OpenBitFunTheme.muted) + } + } + .frame(width: 16, height: 16) + .padding(.horizontal, 2) + } else { + Text(item.marker).foregroundStyle(OpenBitFunTheme.muted) + .frame(width: 20, alignment: .trailing) + } + Text(listItemInlineString(item)).foregroundStyle(OpenBitFunTheme.ink) .lineSpacing(MobileDesignTypography.bodyLarge.lineSpacing) .textSelection(.enabled) } @@ -328,12 +368,7 @@ private struct MarkdownBlockView: View { } } case "code": CodeBlock(language: block.language, code: block.text) - case "table": - ScrollView(.horizontal, showsIndicators: false) { - Text(block.text).font(.system(size: 12.5, design: .monospaced)) - .foregroundStyle(OpenBitFunTheme.ink).padding(12).textSelection(.enabled) - } - .background(OpenBitFunTheme.soft).clipShape(RoundedRectangle(cornerRadius: 12)) + case "table": MarkdownTableView(source: block.text) case "divider": Rectangle().fill(OpenBitFunTheme.line).frame(height: 1).padding(.vertical, 3) default: Text(inlineString(block.inlines)) @@ -351,25 +386,184 @@ private struct MarkdownBlockView: View { } private func inlineString(_ inlines: [MarkdownInline]) -> AttributedString { - var result = AttributedString() - for inline in inlines { - var part = AttributedString(inline.text) - switch inline.type { - case "strong": part.font = .system(size: 14, weight: .semibold) - case "emphasis": part.font = .system(size: 14).italic() - case "code": - part.font = .system(size: 13, design: .monospaced) - part.backgroundColor = OpenBitFunTheme.soft - case "link": - part.foregroundColor = MobileDesignColors.fileLink - part.underlineStyle = .single - part.link = URL(string: inline.url) - default: break - } - result.append(part) - } - return result.characters.isEmpty ? AttributedString(block.text) : result + markdownInlineString(inlines, fallback: block.text) + } + + private func taskListState(_ text: String) -> Bool? { + let prefix = text.prefix(3).lowercased() + if prefix == "[x]" { return true } + if prefix == "[ ]" { return false } + return nil + } + + private func listItemInlineString(_ item: MarkdownListItem) -> AttributedString { + guard taskListState(item.text) != nil else { + return markdownInlineString(item.inlines, fallback: item.text) + } + let text = String(item.text.dropFirst(3)).trimmingCharacters(in: .whitespaces) + return markdownInlineString( + MarkdownParser.shared.parseInlineText(value: text), + fallback: text + ) + } +} + +private struct MarkdownTableView: View { + let source: String + + private var table: MarkdownTableData { MarkdownTableData(source: source) } + + var body: some View { + ViewThatFits(in: .horizontal) { + tableGrid(flexibleColumns: true) + ScrollView(.horizontal, showsIndicators: false) { + tableGrid(flexibleColumns: false) + } + } + .background(OpenBitFunTheme.card) + .clipShape(RoundedRectangle(cornerRadius: 10)) + .overlay(RoundedRectangle(cornerRadius: 10).stroke(OpenBitFunTheme.line, lineWidth: 1)) + } + + private func tableGrid(flexibleColumns: Bool) -> some View { + Grid(alignment: .leading, horizontalSpacing: 0, verticalSpacing: 0) { + ForEach(Array(table.rows.enumerated()), id: \.offset) { rowIndex, row in + GridRow(alignment: .top) { + ForEach(Array(row.cells.enumerated()), id: \.offset) { columnIndex, cell in + Text(markdownInlineString( + MarkdownParser.shared.parseInlineText(value: cell.text), + fallback: cell.text + )) + .font(MobileDesignTypography.bodySmall.font) + .foregroundStyle(OpenBitFunTheme.ink) + .multilineTextAlignment(cell.textAlignment) + .textSelection(.enabled) + .frame( + minWidth: 132, + maxWidth: flexibleColumns ? .infinity : 180, + minHeight: 42, + alignment: cell.frameAlignment + ) + .padding(.horizontal, 12) + .padding(.vertical, 10) + .background(rowIndex == 0 || rowIndex.isMultiple(of: 2) + ? OpenBitFunTheme.soft + : OpenBitFunTheme.card) + .overlay(alignment: .trailing) { + if columnIndex < row.cells.count - 1 { + Rectangle().fill(OpenBitFunTheme.line).frame(width: 1) + } + } + } + } + .overlay(alignment: .bottom) { + if rowIndex < table.rows.count - 1 { + Rectangle().fill(OpenBitFunTheme.line).frame(height: 1) + } + } + } + } + .frame(maxWidth: flexibleColumns ? .infinity : nil) + } +} + +private struct MarkdownTableData { + struct Row { let cells: [Cell] } + struct Cell { + let text: String + let alignment: Alignment + + var textAlignment: TextAlignment { + if alignment == .center { return .center } + if alignment == .trailing { return .trailing } + return .leading + } + + var frameAlignment: Alignment { alignment } + } + + let rows: [Row] + + init(source: String) { + let lines = source.split(separator: "\n", omittingEmptySubsequences: true).map(String.init) + guard lines.count >= 2 else { + rows = [Row(cells: [Cell(text: source, alignment: .leading)])] + return + } + let header = Self.splitRow(lines[0]) + let separators = Self.splitRow(lines[1]) + let alignments = separators.map(Self.alignment) + let values = [header] + lines.dropFirst(2).map(Self.splitRow) + let columnCount = max(header.count, alignments.count) + rows = values.map { row in + Row(cells: (0.. [String] { + var source = line.trimmingCharacters(in: .whitespaces) + if source.first == "|" { source.removeFirst() } + if source.last == "|" { source.removeLast() } + var cells: [String] = [] + var current = "" + var inCode = false + var escaped = false + for character in source { + if escaped { + current.append(character) + escaped = false + } else if character == "\\" { + escaped = true + } else if character == "`" { + inCode.toggle() + current.append(character) + } else if character == "|" && !inCode { + cells.append(current.trimmingCharacters(in: .whitespaces)) + current = "" + } else { + current.append(character) + } + } + if escaped { current.append("\\") } + cells.append(current.trimmingCharacters(in: .whitespaces)) + return cells + } + + private static func alignment(_ separator: String) -> Alignment { + let value = separator.trimmingCharacters(in: .whitespaces) + if value.hasPrefix(":") && value.hasSuffix(":") { return .center } + if value.hasSuffix(":") { return .trailing } + return .leading + } +} + +private func markdownInlineString( + _ inlines: [MarkdownInline], + fallback: String +) -> AttributedString { + var result = AttributedString() + for inline in inlines { + var part = AttributedString(inline.text) + switch inline.type { + case "strong": part.font = .system(size: 14, weight: .semibold) + case "emphasis": part.font = .system(size: 14).italic() + case "code": + part.font = .system(size: 13, design: .monospaced) + part.backgroundColor = OpenBitFunTheme.soft + case "link": + part.foregroundColor = MobileDesignColors.fileLink + part.underlineStyle = .single + part.link = URL(string: inline.url) + default: break + } + result.append(part) } + return result.characters.isEmpty ? AttributedString(fallback) : result } private struct CodeBlock: View { diff --git a/src/apps/mobile/ios/OpenBitFun/Features/Pairing/PairingSheet.swift b/src/apps/mobile/ios/OpenBitFun/Features/Pairing/PairingSheet.swift index ada87b61ee..1e19ba4e88 100644 --- a/src/apps/mobile/ios/OpenBitFun/Features/Pairing/PairingSheet.swift +++ b/src/apps/mobile/ios/OpenBitFun/Features/Pairing/PairingSheet.swift @@ -13,8 +13,8 @@ struct PairingSheet: View { @State private var pairingUserID = "" // Intentionally transient: pairing passwords must never enter saved scene state. @State private var pairingPassword = "" - @State private var scannerOpen = false @State private var manualOpen = false + @State private var scanError: String? @FocusState private var focused: Bool var body: some View { @@ -27,7 +27,6 @@ struct PairingSheet: View { .onAppear { if model.pairingScanRequested { step = .scan - scannerOpen = true model.consumePairingScanRequest() } else if MobileLaunchConfiguration.pairingManualPreview || MobileLaunchConfiguration.pairingAccountPreview { @@ -36,22 +35,6 @@ struct PairingSheet: View { focused = !MobileLaunchConfiguration.pairingAccountPreview } } - .fullScreenCover(isPresented: $scannerOpen) { - QRCodeScannerView( - onCode: { code in - pairingURL = code - scannerOpen = false - if PairingLinkHintsKt.inspectPairingLink(url: code).requiresAccount { - manualOpen = true - focused = true - } else { - model.submitPairing(url: code) - } - }, - onCancel: { scannerOpen = false } - ) - .ignoresSafeArea() - } } private var introPage: some View { @@ -76,8 +59,8 @@ struct PairingSheet: View { scanTitle: model.localized("扫码连接"), accountTitle: model.localized("登录 OpenBitFun 账号"), onScan: { + scanError = nil step = .scan - scannerOpen = true }, onOpenAccount: model.openAccountFromPairing, enabled: !model.pairingBusy, @@ -92,30 +75,59 @@ struct PairingSheet: View { private var scanPage: some View { VStack(spacing: 0) { - hero(height: 252) - VStack(spacing: 22) { - Button { scannerOpen = true } label: { - Image(systemName: "qrcode.viewfinder") - .font(.system(size: 72, weight: .regular)) + HStack { + Spacer() + Button { dismiss() } label: { + Image(systemName: "xmark") + .font(.system(size: 17, weight: .medium)) .foregroundStyle(OpenBitFunTheme.ink) - .frame(width: 176, height: 176) - .background(MobileDesignColors.connectHeroSurface) - .overlay(RoundedRectangle(cornerRadius: 34).stroke(OpenBitFunTheme.line, lineWidth: 1.5)) - .clipShape(RoundedRectangle(cornerRadius: 34)) + .frame(width: 44, height: 44) } .buttonStyle(.plain) - Text(model.localized("扫描二维码")) - .font(.system(size: 24, weight: .bold)).foregroundStyle(OpenBitFunTheme.ink) - if let error = model.pairingError { - Text(error).font(.system(size: 13)).foregroundStyle(OpenBitFunTheme.statusDanger) + } + .padding(.horizontal, 18) + .padding(.top, 8) + + ScrollView(showsIndicators: false) { + VStack(spacing: 0) { + Text(model.localized("扫描桌面端二维码")) + .font(.system(size: 24, weight: .bold)) + .foregroundStyle(OpenBitFunTheme.ink) + .multilineTextAlignment(.center) + Text(model.localized("在 OpenBitFun 桌面端点击「连接移动端」\n扫描二维码完成连接")) + .font(MobileDesignTypography.bodyLarge.font) + .foregroundStyle(OpenBitFunTheme.muted) + .lineSpacing(MobileDesignTypography.bodyLarge.lineSpacing) .multilineTextAlignment(.center) + .padding(.top, 8) + .padding(.bottom, 24) + + inlineScanner + + if let error = scanError ?? model.pairingError { + Text(error) + .font(MobileDesignTypography.bodySmall.font) + .foregroundStyle(scanError == nil + ? OpenBitFunTheme.statusDanger + : OpenBitFunTheme.muted) + .multilineTextAlignment(.center) + .padding(.horizontal, 14) + .padding(.vertical, 12) + .frame(maxWidth: .infinity) + .background(OpenBitFunTheme.soft) + .clipShape(RoundedRectangle(cornerRadius: 16)) + .padding(.top, 16) + } } + .frame(maxWidth: 520) + .padding(.horizontal, 28) + .padding(.bottom, 20) + .frame(maxWidth: .infinity) } - .offset(y: -50) - Spacer(minLength: 12) + Button { manualOpen = true; focused = true } label: { - Text(model.localized("手动输入配对码")) - .font(.system(size: 20, weight: .bold)) + Text(model.localized("改为手动配对")) + .font(.system(size: 17, weight: .semibold)) .foregroundStyle(OpenBitFunTheme.ink) .frame(maxWidth: .infinity, minHeight: 58) .background(OpenBitFunTheme.card) @@ -126,6 +138,62 @@ struct PairingSheet: View { .padding(.horizontal, 44) .padding(.bottom, 34) } + .background(OpenBitFunTheme.page) + } + + private var inlineScanner: some View { + ZStack { + QRCodeScannerView( + paused: manualOpen, + showsCloseButton: false, + onCode: handleScannedCode, + onCancel: {}, + onPermissionDenied: { + scanError = model.localized( + "需要相机权限才能扫码,请在系统设置中允许 OpenBitFun 访问相机,或改为手动配对。" + ) + }, + onUnavailable: { + scanError = model.localized( + "无法打开相机,请检查权限后重试,或改为手动配对。" + ) + } + ) + .frame(width: 248, height: 248) + + ForEach(0..<4, id: \.self) { index in + PairingScanCorner() + .stroke(MobileDesignColors.connectScanAccent, style: StrokeStyle(lineWidth: 4, lineCap: .round)) + .frame(width: 52, height: 52) + .rotationEffect(.degrees(Double(index) * 90)) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: scanCornerAlignment(index)) + .padding(20) + } + } + .frame(width: 248, height: 248) + .background(OpenBitFunTheme.mediaBackground) + .clipShape(RoundedRectangle(cornerRadius: 28)) + .overlay(RoundedRectangle(cornerRadius: 28).stroke(OpenBitFunTheme.line, lineWidth: 1)) + } + + private func handleScannedCode(_ code: String) { + pairingURL = code + scanError = nil + if PairingLinkHintsKt.inspectPairingLink(url: code).requiresAccount { + manualOpen = true + focused = true + } else { + model.submitPairing(url: code) + } + } + + private func scanCornerAlignment(_ index: Int) -> Alignment { + switch index { + case 0: .topLeading + case 1: .topTrailing + case 2: .bottomTrailing + default: .bottomLeading + } } private func hero(height: CGFloat) -> some View { @@ -258,3 +326,17 @@ struct PairingSheet: View { .buttonStyle(.plain) } } + +private struct PairingScanCorner: Shape { + func path(in rect: CGRect) -> Path { + var path = Path() + path.move(to: CGPoint(x: 0, y: rect.height)) + path.addLine(to: CGPoint(x: 0, y: 12)) + path.addQuadCurve( + to: CGPoint(x: 12, y: 0), + control: CGPoint(x: 0, y: 0) + ) + path.addLine(to: CGPoint(x: rect.width, y: 0)) + return path + } +} diff --git a/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel+GeneralChat.swift b/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel+GeneralChat.swift index 6fe7c1fb7c..b610d091a8 100644 --- a/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel+GeneralChat.swift +++ b/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel+GeneralChat.swift @@ -221,7 +221,8 @@ extension MobileAppModel { streaming: false, typing: false, pending: false, - showRetry: false + showRetry: false, + error: nil ) } @@ -239,7 +240,8 @@ extension MobileAppModel { streaming: row.streaming, typing: row.typing, pending: row.pending, - showRetry: row.showRetry + showRetry: row.showRetry, + error: row.error ) } diff --git a/src/apps/mobile/ios/OpenBitFun/Infrastructure/Platform/QRCodeScannerView.swift b/src/apps/mobile/ios/OpenBitFun/Infrastructure/Platform/QRCodeScannerView.swift index 73fa92d0c8..ea35926829 100644 --- a/src/apps/mobile/ios/OpenBitFun/Infrastructure/Platform/QRCodeScannerView.swift +++ b/src/apps/mobile/ios/OpenBitFun/Infrastructure/Platform/QRCodeScannerView.swift @@ -3,17 +3,31 @@ import SwiftUI import UIKit struct QRCodeScannerView: UIViewControllerRepresentable { + var paused = false + var showsCloseButton = true let onCode: (String) -> Void let onCancel: () -> Void + var onPermissionDenied: () -> Void = {} + var onUnavailable: () -> Void = {} func makeUIViewController(context: Context) -> QRScannerController { let controller = QRScannerController() + controller.showsCloseButton = showsCloseButton controller.onCode = onCode controller.onCancel = onCancel + controller.onPermissionDenied = onPermissionDenied + controller.onUnavailable = onUnavailable + controller.setPaused(paused) return controller } - func updateUIViewController(_ uiViewController: QRScannerController, context: Context) {} + func updateUIViewController(_ uiViewController: QRScannerController, context: Context) { + uiViewController.onCode = onCode + uiViewController.onCancel = onCancel + uiViewController.onPermissionDenied = onPermissionDenied + uiViewController.onUnavailable = onUnavailable + uiViewController.setPaused(paused) + } } final class QRScannerController: UIViewController, AVCaptureMetadataOutputObjectsDelegate { @@ -22,12 +36,33 @@ final class QRScannerController: UIViewController, AVCaptureMetadataOutputObject private var previewLayer: AVCaptureVideoPreviewLayer? private var captureConfigured = false private var emittedCode = false + private var capturePaused = false + private var reportedFailure = false + var showsCloseButton = true var onCode: ((String) -> Void)? var onCancel: (() -> Void)? + var onPermissionDenied: (() -> Void)? + var onUnavailable: (() -> Void)? override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = UIColor(OpenBitFunTheme.mediaBackground) + if showsCloseButton { installCloseButton() } + + switch AVCaptureDevice.authorizationStatus(for: .video) { + case .authorized: + configureCaptureAsync() + case .notDetermined: + AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in + if granted { self?.configureCaptureAsync() } + else { self?.reportPermissionDenied() } + } + default: + reportPermissionDenied() + } + } + + private func installCloseButton() { let close = UIButton(type: .system) close.setImage(UIImage(systemName: "xmark"), for: .normal) close.tintColor = UIColor(OpenBitFunTheme.contentOnAction) @@ -45,18 +80,6 @@ final class QRScannerController: UIViewController, AVCaptureMetadataOutputObject close.widthAnchor.constraint(equalToConstant: 44), close.heightAnchor.constraint(equalToConstant: 44), ]) - - switch AVCaptureDevice.authorizationStatus(for: .video) { - case .authorized: - configureCaptureAsync() - case .notDetermined: - AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in - guard granted else { return } - self?.configureCaptureAsync() - } - default: - break - } } override func viewDidDisappear(_ animated: Bool) { @@ -79,9 +102,15 @@ final class QRScannerController: UIViewController, AVCaptureMetadataOutputObject guard !captureConfigured else { return } guard let device = AVCaptureDevice.default(for: .video), let input = try? AVCaptureDeviceInput(device: device), - session.canAddInput(input) else { return } + session.canAddInput(input) else { + reportUnavailable() + return + } let output = AVCaptureMetadataOutput() - guard session.canAddOutput(output) else { return } + guard session.canAddOutput(output) else { + reportUnavailable() + return + } session.beginConfiguration() session.addInput(input) session.addOutput(output) @@ -97,7 +126,22 @@ final class QRScannerController: UIViewController, AVCaptureMetadataOutputObject self.view.layer.insertSublayer(layer, at: 0) self.previewLayer = layer } - session.startRunning() + if !capturePaused { session.startRunning() } + } + + func setPaused(_ paused: Bool) { + sessionQueue.async { [weak self] in + guard let self else { return } + guard self.capturePaused != paused else { return } + self.capturePaused = paused + guard self.captureConfigured else { return } + if paused { + if self.session.isRunning { self.session.stopRunning() } + } else { + self.emittedCode = false + if !self.session.isRunning { self.session.startRunning() } + } + } } private func stopCapture() { @@ -123,4 +167,20 @@ final class QRScannerController: UIViewController, AVCaptureMetadataOutputObject self?.onCode?(value) } } + + private func reportPermissionDenied() { + reportFailure { [weak self] in self?.onPermissionDenied?() } + } + + private func reportUnavailable() { + reportFailure { [weak self] in self?.onUnavailable?() } + } + + private func reportFailure(_ callback: @escaping () -> Void) { + sessionQueue.async { [weak self] in + guard let self, !self.reportedFailure else { return } + self.reportedFailure = true + DispatchQueue.main.async(execute: callback) + } + } } diff --git a/src/apps/mobile/ios/OpenBitFun/Presentation/Models/MobilePresentationModels.swift b/src/apps/mobile/ios/OpenBitFun/Presentation/Models/MobilePresentationModels.swift index 860e8b3f47..a92c6a0066 100644 --- a/src/apps/mobile/ios/OpenBitFun/Presentation/Models/MobilePresentationModels.swift +++ b/src/apps/mobile/ios/OpenBitFun/Presentation/Models/MobilePresentationModels.swift @@ -89,6 +89,7 @@ struct MobileConversationRow: Identifiable, Equatable { let typing: Bool let pending: Bool let showRetry: Bool + let error: String? } enum MobileFilePreviewFailureKind: String { diff --git a/src/apps/mobile/ios/OpenBitFun/Resources/Localizable.xcstrings b/src/apps/mobile/ios/OpenBitFun/Resources/Localizable.xcstrings index ed40b3f42d..1c3068d4c9 100644 --- a/src/apps/mobile/ios/OpenBitFun/Resources/Localizable.xcstrings +++ b/src/apps/mobile/ios/OpenBitFun/Resources/Localizable.xcstrings @@ -3651,6 +3651,16 @@ } } }, + "本次回复失败": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This response failed" + } + } + } + }, "回复中断": { "localizations": { "en": { diff --git a/src/apps/mobile/shared/core-domain/src/commonMain/kotlin/com/openbitfun/mobile/core/domain/ChatTimelineStore.kt b/src/apps/mobile/shared/core-domain/src/commonMain/kotlin/com/openbitfun/mobile/core/domain/ChatTimelineStore.kt index b63c9fd9e1..f5cafa9679 100644 --- a/src/apps/mobile/shared/core-domain/src/commonMain/kotlin/com/openbitfun/mobile/core/domain/ChatTimelineStore.kt +++ b/src/apps/mobile/shared/core-domain/src/commonMain/kotlin/com/openbitfun/mobile/core/domain/ChatTimelineStore.kt @@ -372,6 +372,7 @@ public class ChatTimelineStore public constructor() { tools = null, items = null, images = null, + error = null, ) private fun realMessages(messages: List): List = diff --git a/src/apps/mobile/shared/core-domain/src/commonMain/kotlin/com/openbitfun/mobile/core/domain/ConversationModels.kt b/src/apps/mobile/shared/core-domain/src/commonMain/kotlin/com/openbitfun/mobile/core/domain/ConversationModels.kt index 41d0f85e49..092894186d 100644 --- a/src/apps/mobile/shared/core-domain/src/commonMain/kotlin/com/openbitfun/mobile/core/domain/ConversationModels.kt +++ b/src/apps/mobile/shared/core-domain/src/commonMain/kotlin/com/openbitfun/mobile/core/domain/ConversationModels.kt @@ -25,6 +25,8 @@ public data class ChatMessage public constructor( public val tools: List?, public val items: List?, public val images: List?, + /** Relay failure detail for an assistant message or active turn. */ + public val error: String?, ) public data class SessionSummary public constructor( diff --git a/src/apps/mobile/shared/core-domain/src/commonTest/kotlin/com/openbitfun/mobile/core/domain/ChatSessionControllerTest.kt b/src/apps/mobile/shared/core-domain/src/commonTest/kotlin/com/openbitfun/mobile/core/domain/ChatSessionControllerTest.kt index 6b328bd415..c97f22e094 100644 --- a/src/apps/mobile/shared/core-domain/src/commonTest/kotlin/com/openbitfun/mobile/core/domain/ChatSessionControllerTest.kt +++ b/src/apps/mobile/shared/core-domain/src/commonTest/kotlin/com/openbitfun/mobile/core/domain/ChatSessionControllerTest.kt @@ -222,5 +222,6 @@ class ChatSessionControllerTest { tools = null, items = null, images = null, + error = null, ) } diff --git a/src/apps/mobile/shared/core-domain/src/commonTest/kotlin/com/openbitfun/mobile/core/domain/ChatTimelineProjectorTest.kt b/src/apps/mobile/shared/core-domain/src/commonTest/kotlin/com/openbitfun/mobile/core/domain/ChatTimelineProjectorTest.kt index 67db58b004..80813816c6 100644 --- a/src/apps/mobile/shared/core-domain/src/commonTest/kotlin/com/openbitfun/mobile/core/domain/ChatTimelineProjectorTest.kt +++ b/src/apps/mobile/shared/core-domain/src/commonTest/kotlin/com/openbitfun/mobile/core/domain/ChatTimelineProjectorTest.kt @@ -314,6 +314,7 @@ class ChatTimelineProjectorTest { tools = null, items = null, images = images, + error = null, ) private fun activeMessage( diff --git a/src/apps/mobile/shared/core-domain/src/commonTest/kotlin/com/openbitfun/mobile/core/domain/ChatTimelineStoreTest.kt b/src/apps/mobile/shared/core-domain/src/commonTest/kotlin/com/openbitfun/mobile/core/domain/ChatTimelineStoreTest.kt index 153600c2b8..9be47a463f 100644 --- a/src/apps/mobile/shared/core-domain/src/commonTest/kotlin/com/openbitfun/mobile/core/domain/ChatTimelineStoreTest.kt +++ b/src/apps/mobile/shared/core-domain/src/commonTest/kotlin/com/openbitfun/mobile/core/domain/ChatTimelineStoreTest.kt @@ -671,6 +671,7 @@ class ChatTimelineStoreTest { tools = null, items = null, images = null, + error = null, ) private fun activeMessage( @@ -691,5 +692,6 @@ class ChatTimelineStoreTest { tools = null, items = null, images = null, + error = null, ) } diff --git a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/generalchat/GeneralChatStore.kt b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/generalchat/GeneralChatStore.kt index c3329e685f..d563d8d7d3 100644 --- a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/generalchat/GeneralChatStore.kt +++ b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/generalchat/GeneralChatStore.kt @@ -669,6 +669,7 @@ public class GeneralChatStore internal constructor( tools = null, items = null, images = payload.images, + error = null, ) } @@ -727,6 +728,7 @@ public class GeneralChatStore internal constructor( tools = null, items = null, images = images, + error = null, ) private const val AGENT_TYPE = "general_chat" diff --git a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/ConversationPresentation.kt b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/ConversationPresentation.kt index 60c7554944..4354c1766e 100644 --- a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/ConversationPresentation.kt +++ b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/ConversationPresentation.kt @@ -149,6 +149,8 @@ public data class ConversationRow public constructor( public val pending: Boolean, /** The send failed and this is the row a retry would repeat. */ public val showRetry: Boolean, + /** A user-visible assistant failure returned by the desktop. */ + public val error: String?, ) /** @@ -193,6 +195,7 @@ public fun ChatTimelineState.conversationRows(): List = typing = message?.let { isTyping(it, item.isStreaming) } == true, pending = item.type == ChatTimelineItemType.OPTIMISTIC_USER_MESSAGE, showRetry = item.showRetryAction, + error = message?.error?.trim()?.takeIf(String::isNotEmpty), ) } diff --git a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/RemoteSessionStore.kt b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/RemoteSessionStore.kt index eaf2f5de57..d9eb8ef2c9 100644 --- a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/RemoteSessionStore.kt +++ b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/RemoteSessionStore.kt @@ -1111,6 +1111,7 @@ public class RemoteSessionStore internal constructor( tools = null, items = null, images = wireImages, + error = null, ) timelineStore.appendOptimisticMessage(local) setBusy(current, true) @@ -1496,6 +1497,7 @@ private data class StoredRemoteMessagePayload( val tools: List? = null, val items: List? = null, val images: List? = null, + val error: String? = null, ) private val STORE_JSON = Json { ignoreUnknownKeys = true } @@ -1504,7 +1506,13 @@ private fun toPersisted(sessionId: String, m: ChatMessage): PersistedRemoteMessa messageId = m.id, sessionId = sessionId, role = m.role, text = m.text, status = m.status, timestamp = m.timestamp, thinking = m.thinking, payloadJson = STORE_JSON.encodeToString(StoredRemoteMessagePayload( - m.renderVersion, m.turnId, m.detail, m.tools, m.items, m.images, + renderVersion = m.renderVersion, + turnId = m.turnId, + detail = m.detail, + tools = m.tools, + items = m.items, + images = m.images, + error = m.error, )), ) @@ -1518,7 +1526,7 @@ private fun toChatMessage(m: PersistedRemoteMessage): ChatMessage { id = m.messageId, role = m.role, text = m.text, status = m.status, renderVersion = payload.renderVersion, turnId = payload.turnId, detail = payload.detail, timestamp = m.timestamp, thinking = m.thinking, tools = payload.tools, - items = payload.items, images = payload.images, + items = payload.items, images = payload.images, error = payload.error, ) } @@ -1545,15 +1553,16 @@ internal object RemoteResponseMapper { id = item.resolvedId ?: generatedId(item.role, item.timestamp.orEmpty(), text), role = item.role, text = text, - status = if (item.role == "assistant") "done" else "sent", + status = item.status ?: if (item.role == "assistant") "done" else "sent", renderVersion = null, - turnId = null, + turnId = item.turnId, detail = messageDetail(tools), timestamp = item.timestamp, thinking = item.thinking, tools = tools, items = item.items, images = item.images, + error = item.error, ) } @@ -1572,6 +1581,7 @@ internal object RemoteResponseMapper { tools = tools, items = turn.items, images = null, + error = turn.error, ) } diff --git a/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/session/ConversationPresentationTest.kt b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/session/ConversationPresentationTest.kt index cd1504180f..e1ea330cda 100644 --- a/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/session/ConversationPresentationTest.kt +++ b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/session/ConversationPresentationTest.kt @@ -250,6 +250,7 @@ private fun message( tools = tools, items = items, images = null, + error = null, ) private fun tool( diff --git a/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/session/MessageBlockPresentationTest.kt b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/session/MessageBlockPresentationTest.kt index b02628df03..b26bcfa676 100644 --- a/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/session/MessageBlockPresentationTest.kt +++ b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/session/MessageBlockPresentationTest.kt @@ -197,6 +197,7 @@ private fun message( tools = tools, items = items, images = null, + error = null, ) private fun item( diff --git a/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/session/RemoteResponseMapperTest.kt b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/session/RemoteResponseMapperTest.kt index b923be4213..56887d95b6 100644 --- a/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/session/RemoteResponseMapperTest.kt +++ b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/session/RemoteResponseMapperTest.kt @@ -74,8 +74,11 @@ class RemoteResponseMapperTest { val message = RemoteResponseMapper.chatMessage( ChatMessageResponse( id = "m1", + turnId = "turn-m1", role = "assistant", content = "", + status = "failed", + error = "Desktop process exited", thinking = "Thinking out loud", timestamp = "2026-06-10T12:00:00.000Z", items = listOf( @@ -96,7 +99,9 @@ class RemoteResponseMapperTest { assertEquals("m1", message.id) assertEquals("", message.text) assertEquals("Thinking out loud", message.thinking) - assertEquals("done", message.status) + assertEquals("failed", message.status) + assertEquals("turn-m1", message.turnId) + assertEquals("Desktop process exited", message.error) assertEquals("shell · pending\nedit · done", message.detail) assertEquals(2, message.tools?.size) } @@ -107,6 +112,7 @@ class RemoteResponseMapperTest { ActiveTurnSnapshotResponse( turnId = "turn-1", status = "active", + error = "Connection interrupted", items = listOf(ChatMessageItemResponse(type = "text", content = "Working on it")), tools = listOf(RemoteToolStatusResponse(id = "tool-3", name = "read_file", status = "running")), ), @@ -116,6 +122,7 @@ class RemoteResponseMapperTest { assertEquals("turn-1", message.turnId) assertEquals("Working on it", message.text) assertEquals("read_file · running", message.detail) + assertEquals("Connection interrupted", message.error) } @Test diff --git a/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/session/RemoteSessionPersistenceTest.kt b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/session/RemoteSessionPersistenceTest.kt index 9dfe54b864..8a160d2979 100644 --- a/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/session/RemoteSessionPersistenceTest.kt +++ b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/session/RemoteSessionPersistenceTest.kt @@ -137,7 +137,7 @@ class RemoteSessionPersistenceTest { fun activeTurnIsPersistedOnEndAndRestoredOnReopen() = runTest { val stores = MemoryPersistence() val transport = PersistenceTransport() - transport.messagesJson = """[{"id":"m-1","role":"assistant","content":"All done"}]""" + transport.messagesJson = """[{"id":"m-1","role":"assistant","content":"All done","status":"failed","error":"Desktop failed"}]""" transport.polls = listOf( """{"resp":"ok","version":1,"changed":true,"session_state":"running","active_turn":{"turn_id":"t-1","status":"active","text":"All "}}""", """{"resp":"ok","version":2,"changed":true,"session_state":"idle","active_turn":{"turn_id":"t-1","status":"completed","text":"All done"}}""", @@ -154,7 +154,11 @@ class RemoteSessionPersistenceTest { transport2.messagesJson = transport.messagesJson val store2 = RemoteSessionStore.create(this, transport2, "device-a", stores.stores) store2.dispatch(RemoteSessionIntent.Open("server")) - assertEquals("m-1", assertIs(store2.state.value).timeline?.persistedMessages?.single()?.id) + val restored = assertIs(store2.state.value) + .timeline?.persistedMessages?.single() + assertEquals("m-1", restored?.id) + assertEquals("failed", restored?.status) + assertEquals("Desktop failed", restored?.error) runCurrent(); store2.dispatch(RemoteSessionIntent.Stop) } diff --git a/src/apps/mobile/shared/core-protocol/src/commonMain/kotlin/com/openbitfun/mobile/core/protocol/MessageDtos.kt b/src/apps/mobile/shared/core-protocol/src/commonMain/kotlin/com/openbitfun/mobile/core/protocol/MessageDtos.kt index de9586c710..060ccdb749 100644 --- a/src/apps/mobile/shared/core-protocol/src/commonMain/kotlin/com/openbitfun/mobile/core/protocol/MessageDtos.kt +++ b/src/apps/mobile/shared/core-protocol/src/commonMain/kotlin/com/openbitfun/mobile/core/protocol/MessageDtos.kt @@ -50,8 +50,11 @@ public data class RemoteImageContext( public data class ChatMessageResponse( @SerialName("id") val id: String? = null, @SerialName("message_id") val messageId: String? = null, + @SerialName("turn_id") val turnId: String? = null, @SerialName("role") val role: String, @SerialName("content") val content: String, + @SerialName("status") val status: String? = null, + @SerialName("error") val error: String? = null, @SerialName("timestamp") val timestamp: String? = null, @SerialName("metadata") val metadata: JsonElement? = null, @SerialName("thinking") val thinking: String? = null, @@ -67,6 +70,7 @@ public data class ChatMessageResponse( public data class ActiveTurnSnapshotResponse( @SerialName("turn_id") val turnId: String, @SerialName("status") val status: String, + @SerialName("error") val error: String? = null, @SerialName("text") val text: String? = null, @SerialName("thinking") val thinking: String? = null, @SerialName("tools") val tools: List = emptyList(), diff --git a/src/apps/mobile/shared/core-protocol/src/commonTest/kotlin/com/openbitfun/mobile/core/protocol/RemoteCommandTest.kt b/src/apps/mobile/shared/core-protocol/src/commonTest/kotlin/com/openbitfun/mobile/core/protocol/RemoteCommandTest.kt index c79a0af3fc..ca2ec04404 100644 --- a/src/apps/mobile/shared/core-protocol/src/commonTest/kotlin/com/openbitfun/mobile/core/protocol/RemoteCommandTest.kt +++ b/src/apps/mobile/shared/core-protocol/src/commonTest/kotlin/com/openbitfun/mobile/core/protocol/RemoteCommandTest.kt @@ -114,6 +114,25 @@ class RemoteCommandTest { assertTrue(absentResponse.messages.isEmpty()) } + @Test + fun assistantFailureFieldsRemainOptionalAndDecodeWhenPresent() { + val current = RelayJson.decodeFromString( + SessionMessagesResponse.serializer(), + """{"resp":"ok","messages":[{"id":"m1","turn_id":"t1","role":"assistant","content":"","status":"failed","error":"process exited"}]}""", + ).messages.single() + val legacy = RelayJson.decodeFromString( + SessionMessagesResponse.serializer(), + """{"resp":"ok","messages":[{"role":"assistant","content":"done"}]}""", + ).messages.single() + + assertEquals("t1", current.turnId) + assertEquals("failed", current.status) + assertEquals("process exited", current.error) + assertNull(legacy.turnId) + assertNull(legacy.status) + assertNull(legacy.error) + } + @Test fun sendMessageImagesKeepMimeAndLegacyImageFields() { val encoded = RelayJson.encodeToString( From e3624ab06bfd5a934e523d7ff88203854b5449d6 Mon Sep 17 00:00:00 2001 From: wgqqqqq Date: Mon, 7 Sep 2026 11:32:23 +0800 Subject: [PATCH 3/5] fix(ios): match HarmonyOS user bubble sizing --- .../App/MobileLaunchConfiguration.swift | 4 +- .../Features/Chat/ChatTimelineView.swift | 61 +++++++++++++++---- .../DesignSystem/MobileDesignGallery.swift | 18 +++--- 3 files changed, 61 insertions(+), 22 deletions(-) diff --git a/src/apps/mobile/ios/OpenBitFun/App/MobileLaunchConfiguration.swift b/src/apps/mobile/ios/OpenBitFun/App/MobileLaunchConfiguration.swift index a8c187ce8c..255dd117d9 100644 --- a/src/apps/mobile/ios/OpenBitFun/App/MobileLaunchConfiguration.swift +++ b/src/apps/mobile/ios/OpenBitFun/App/MobileLaunchConfiguration.swift @@ -314,7 +314,7 @@ private extension MobileAppModel { ) timelineRows = [ MobileConversationRow( - id: userID, kind: "USER", text: "请检查移动端的消息、工具和文件交互。", thinking: nil, + id: userID, kind: "USER", text: "介绍本项目", thinking: nil, images: [], tools: [], blocks: [], streaming: false, typing: false, pending: false, showRetry: false, error: nil ), @@ -334,7 +334,7 @@ private extension MobileAppModel { ), ] messages = [ - ChatMessage(id: UUID(), role: .user, text: "请检查移动端的消息、工具和文件交互。"), + ChatMessage(id: UUID(), role: .user, text: "介绍本项目"), ChatMessage(id: UUID(), role: .assistant, text: "检查结果"), ] } diff --git a/src/apps/mobile/ios/OpenBitFun/Features/Chat/ChatTimelineView.swift b/src/apps/mobile/ios/OpenBitFun/Features/Chat/ChatTimelineView.swift index 4aefc78889..894ca7bfc2 100644 --- a/src/apps/mobile/ios/OpenBitFun/Features/Chat/ChatTimelineView.swift +++ b/src/apps/mobile/ios/OpenBitFun/Features/Chat/ChatTimelineView.swift @@ -94,14 +94,22 @@ private struct ConversationRowView: View { } private var userRow: some View { - VStack(alignment: .trailing, spacing: 7) { - if !row.images.isEmpty { TimelineImageGrid(images: row.images) } - if !row.text.isEmpty { - Text(row.text) - .font(MobileDesignTypography.bodyLarge.font) - .foregroundStyle(OpenBitFunTheme.ink) - .lineSpacing(MobileDesignTypography.bodyLarge.lineSpacing) - .textSelection(.enabled) + VStack(alignment: .trailing, spacing: 6) { + IntrinsicWidthCapLayout(maxWidth: MobileDesignGeometry.messageBubbleMaxWidth) { + VStack(alignment: .leading, spacing: 8) { + if !row.images.isEmpty { TimelineImageGrid(images: row.images) } + if !row.text.isEmpty { + Text(row.text) + .font(MobileDesignTypography.bodyLarge.font) + .foregroundStyle(OpenBitFunTheme.ink) + .lineSpacing(MobileDesignTypography.bodyLarge.lineSpacing) + .textSelection(.enabled) + } + } + .padding(.horizontal, MobileDesignGeometry.messageBubbleHorizontalPadding) + .padding(.vertical, MobileDesignGeometry.messageBubbleVerticalPadding) + .background(OpenBitFunTheme.soft) + .clipShape(RoundedRectangle(cornerRadius: MobileDesignGeometry.messageBubbleRadius)) } if row.pending { Text(model.localized("正在发送")) @@ -117,12 +125,8 @@ private struct ConversationRowView: View { .buttonStyle(.plain) } } - .padding(.horizontal, MobileDesignGeometry.messageBubbleHorizontalPadding) - .padding(.vertical, MobileDesignGeometry.messageBubbleVerticalPadding) - .frame(maxWidth: MobileDesignGeometry.messageBubbleMaxWidth, alignment: .trailing) - .background(OpenBitFunTheme.soft) - .clipShape(RoundedRectangle(cornerRadius: MobileDesignGeometry.messageBubbleRadius)) .frame(maxWidth: .infinity, alignment: .trailing) + .padding(.vertical, 2) } private var assistantRow: some View { @@ -178,6 +182,37 @@ private struct ConversationRowView: View { } } +private struct IntrinsicWidthCapLayout: Layout { + let maxWidth: CGFloat + + func sizeThatFits( + proposal: ProposedViewSize, + subviews: Subviews, + cache _: inout () + ) -> CGSize { + guard let subview = subviews.first else { return .zero } + let availableWidth = min(proposal.width ?? maxWidth, maxWidth) + let size = subview.sizeThatFits( + ProposedViewSize(width: availableWidth, height: proposal.height) + ) + return CGSize(width: min(size.width, availableWidth), height: size.height) + } + + func placeSubviews( + in bounds: CGRect, + proposal _: ProposedViewSize, + subviews: Subviews, + cache _: inout () + ) { + guard let subview = subviews.first else { return } + subview.place( + at: bounds.origin, + anchor: .topLeading, + proposal: ProposedViewSize(width: bounds.width, height: bounds.height) + ) + } +} + private struct EmptyConversationRow: View { var body: some View { VStack(spacing: 8) { diff --git a/src/apps/mobile/ios/OpenBitFun/Features/DesignSystem/MobileDesignGallery.swift b/src/apps/mobile/ios/OpenBitFun/Features/DesignSystem/MobileDesignGallery.swift index 1a1818660c..9cf7838b32 100644 --- a/src/apps/mobile/ios/OpenBitFun/Features/DesignSystem/MobileDesignGallery.swift +++ b/src/apps/mobile/ios/OpenBitFun/Features/DesignSystem/MobileDesignGallery.swift @@ -7,21 +7,25 @@ struct MobileDesignGallery: View { init(scenario: MobilePreviewScenario) { self.scenario = scenario let session = ChatSession(id: UUID().uuidString, title: scenario.headerTitle, updatedLabel: "刚刚") + let previewMessages = scenario.messages.map { message in + ChatMessage( + id: UUID(), + role: message.role == "user" ? .user : .assistant, + text: message.text + ) + } let previewModel = MobileAppModel( sessions: [session], selectedSessionID: session.id, - messages: scenario.messages.map { message in - ChatMessage( - id: UUID(), - role: message.role == "user" ? .user : .assistant, - text: message.text - ) - } + messages: previewMessages ) + previewModel.coreAdapter = nil previewModel.surface = .remote previewModel.remoteConnected = true previewModel.remoteSessionSelected = true previewModel.remoteSessions = [session] + previewModel.messages = previewMessages + previewModel.timelineRows = previewMessages.map(MobileAppModel.simpleTimelineRow) previewModel.designGalleryPreview = true previewModel.draft = scenario.composerDraft previewModel.isSending = scenario.streaming From d5a1c2e3d3087bebb59aac0cf35eed4d5236f35f Mon Sep 17 00:00:00 2001 From: wgqqqqq Date: Mon, 7 Sep 2026 11:35:49 +0800 Subject: [PATCH 4/5] fix(ios): honor shared user message line height --- .../mobile/ios/OpenBitFun/Features/Chat/ChatTimelineView.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/src/apps/mobile/ios/OpenBitFun/Features/Chat/ChatTimelineView.swift b/src/apps/mobile/ios/OpenBitFun/Features/Chat/ChatTimelineView.swift index 894ca7bfc2..3a7dcbe5ad 100644 --- a/src/apps/mobile/ios/OpenBitFun/Features/Chat/ChatTimelineView.swift +++ b/src/apps/mobile/ios/OpenBitFun/Features/Chat/ChatTimelineView.swift @@ -103,6 +103,7 @@ private struct ConversationRowView: View { .font(MobileDesignTypography.bodyLarge.font) .foregroundStyle(OpenBitFunTheme.ink) .lineSpacing(MobileDesignTypography.bodyLarge.lineSpacing) + .padding(.vertical, MobileDesignTypography.bodyLarge.lineSpacing / 2) .textSelection(.enabled) } } From 11aa8c2560ebd6d738e642b1f87eccb6898a3557 Mon Sep 17 00:00:00 2001 From: wgqqqqq Date: Mon, 7 Sep 2026 12:44:44 +0800 Subject: [PATCH 5/5] feat(ios): close HarmonyOS UI parity gaps --- .../Features/Chat/ComposerBar.swift | 133 ++++++-- .../Features/Chat/ConversationHeader.swift | 24 +- .../AdaptiveModalComponents.swift | 11 +- .../DesignSystem/MobileDesignGallery.swift | 3 +- .../Features/Settings/AppSettingsView.swift | 319 ++++++++++++++---- .../Features/Shell/OpenBitFunTheme.swift | 3 +- .../Shell/RemoteFilePreviewView.swift | 54 +-- .../Features/Shell/SidebarView.swift | 34 +- .../Resources/Localizable.xcstrings | 30 ++ 9 files changed, 467 insertions(+), 144 deletions(-) diff --git a/src/apps/mobile/ios/OpenBitFun/Features/Chat/ComposerBar.swift b/src/apps/mobile/ios/OpenBitFun/Features/Chat/ComposerBar.swift index 2bcb6a2d23..b347938aab 100644 --- a/src/apps/mobile/ios/OpenBitFun/Features/Chat/ComposerBar.swift +++ b/src/apps/mobile/ios/OpenBitFun/Features/Chat/ComposerBar.swift @@ -4,6 +4,15 @@ import Speech import SwiftUI import UniformTypeIdentifiers +private enum ComposerPrimaryAction { + case stopListening + case stopTurn + case send + case sendBlocked + case voice + case voiceBlocked +} + struct ComposerBar: View { @ObservedObject var model: MobileAppModel @Environment(\.horizontalSizeClass) private var horizontalSizeClass @@ -34,6 +43,18 @@ struct ComposerBar: View { (model.surface == .local || model.connectionPhase != .disconnected) } + private var primaryActionKind: ComposerPrimaryAction { + if speech.isListening { return .stopListening } + if model.isSending, model.surface == .local { return .stopTurn } + if hasContent { return canSend ? .send : .sendBlocked } + if model.isSending { return .stopTurn } + return model.busy ? .voiceBlocked : .voice + } + + private var showsSupplementalVoice: Bool { + hasContent && !speech.isListening + } + var body: some View { VStack(spacing: 2) { if !model.composerImages.isEmpty { @@ -54,6 +75,14 @@ struct ComposerBar: View { ? MobileDesignGeometry.composerExpandedHeight : MobileDesignGeometry.composerCollapsedHeight) .background(OpenBitFunTheme.card) + .overlay( + RoundedRectangle( + cornerRadius: expanded || !model.composerImages.isEmpty + ? MobileDesignGeometry.composerExpandedRadius + : MobileDesignGeometry.composerCollapsedRadius + ) + .stroke(OpenBitFunTheme.line, lineWidth: 0.5) + ) .clipShape( RoundedRectangle( cornerRadius: expanded || !model.composerImages.isEmpty @@ -172,6 +201,9 @@ struct ComposerBar: View { .anchorPreference(key: ComposerModelSelectorAnchorKey.self, value: .bounds) { $0 } } Spacer(minLength: 0) + if showsSupplementalVoice { + supplementalVoiceAction + } primaryAction } .frame(minHeight: MobileDesignGeometry.composerExpandedActionRowHeight) @@ -200,6 +232,9 @@ struct ComposerBar: View { if canSend { model.send() } } .onChange(of: model.draft) { _ in model.syncDraftToCore() } + if showsSupplementalVoice, !expanded { + supplementalVoiceAction + } } .padding(.leading, speech.isListening ? 12 : 4) .padding(.trailing, 4) @@ -240,34 +275,80 @@ struct ComposerBar: View { private var primaryAction: some View { Button(action: performPrimaryAction) { - Group { - if model.isSending { - Image(systemName: "stop.fill") - .font(.system(size: 16, weight: .bold)) - .foregroundStyle(OpenBitFunTheme.accent) - } else if hasContent { + ZStack { + switch primaryActionKind { + case .send, .sendBlocked: Image(systemName: "arrow.up") - .font(.system(size: 16, weight: .bold)) - .foregroundStyle(canSend ? OpenBitFunTheme.accent : OpenBitFunTheme.muted) - } else { - ReferenceGlyph(assetName: "ComposerMicGlyph", width: 16, height: 19) - .foregroundStyle(model.busy ? OpenBitFunTheme.muted.opacity(0.45) : OpenBitFunTheme.muted) + .font(.system(size: 17, weight: .bold)) + .foregroundStyle( + primaryActionKind == .send + ? OpenBitFunTheme.contentOnAction + : OpenBitFunTheme.muted + ) + .frame(width: 32, height: 32) + .background( + primaryActionKind == .send + ? MobileDesignColors.primaryAction + : OpenBitFunTheme.soft + ) + .clipShape(Circle()) + case .stopListening, .stopTurn: + RoundedRectangle(cornerRadius: 2.5) + .fill(OpenBitFunTheme.contentOnAction) + .frame(width: 10, height: 10) + .frame(width: 32, height: 32) + .background(MobileDesignColors.primaryAction) + .clipShape(Circle()) + case .voice, .voiceBlocked: + ReferenceGlyph( + assetName: "ComposerMicGlyph", + width: 16, + height: 19, + color: + primaryActionKind == .voice + ? OpenBitFunTheme.ink + : OpenBitFunTheme.muted.opacity(0.38) + ) } } .frame( width: MobileDesignGeometry.composerActionSize, height: MobileDesignGeometry.composerActionSize ) + .background(primaryActionKind == .voiceBlocked ? OpenBitFunTheme.soft : OpenBitFunTheme.transparent) + .clipShape(Circle()) } .buttonStyle(.plain) - .disabled(!model.isSending && hasContent && !canSend) + .disabled(primaryActionKind == .sendBlocked || primaryActionKind == .voiceBlocked) .accessibilityLabel(primaryActionLabel) } + private var supplementalVoiceAction: some View { + Button(action: startVoiceInput) { + ReferenceGlyph( + assetName: "ComposerMicGlyph", + width: 16, + height: 19, + color: OpenBitFunTheme.ink + ) + .frame( + width: MobileDesignGeometry.composerActionSize, + height: MobileDesignGeometry.composerActionSize + ) + .opacity(model.busy || model.isSending ? 0.32 : 0.72) + } + .buttonStyle(.plain) + .disabled(model.busy || model.isSending) + .accessibilityLabel(Text(model.localized("语音输入"))) + } + private var primaryActionLabel: String { - if model.isSending { return model.localized("停止") } - if hasContent { return model.localized("发送") } - return model.localized(speech.isListening ? "停止听写" : "语音输入") + switch primaryActionKind { + case .stopListening: return model.localized("停止听写") + case .stopTurn: return model.localized("停止") + case .send, .sendBlocked: return model.localized("发送") + case .voice, .voiceBlocked: return model.localized("语音输入") + } } private var attachmentStrip: some View { @@ -406,19 +487,21 @@ struct ComposerBar: View { } private func performPrimaryAction() { - if model.isSending { - model.stopSending() - return - } - if hasContent { - if canSend { model.send() } - return - } - if speech.isListening { + switch primaryActionKind { + case .stopListening: speech.stop() + case .stopTurn: + model.stopSending() + case .send: + model.send() + case .sendBlocked, .voiceBlocked: return + case .voice: + startVoiceInput() } - guard !model.busy else { return } + } + + private func startVoiceInput() { let existing = model.draft.trimmingCharacters(in: .whitespacesAndNewlines) speech.start( localeIdentifier: model.appLanguage == .simplifiedChinese ? "zh-CN" : "en-US", diff --git a/src/apps/mobile/ios/OpenBitFun/Features/Chat/ConversationHeader.swift b/src/apps/mobile/ios/OpenBitFun/Features/Chat/ConversationHeader.swift index 2639d22fc8..20c1c80eec 100644 --- a/src/apps/mobile/ios/OpenBitFun/Features/Chat/ConversationHeader.swift +++ b/src/apps/mobile/ios/OpenBitFun/Features/Chat/ConversationHeader.swift @@ -1,5 +1,7 @@ import SwiftUI +private let conversationHeaderActionSurfaceSize: CGFloat = 42 + struct ConversationHeader: View { @ObservedObject var model: MobileAppModel @Binding var actionsOpen: Bool @@ -25,13 +27,16 @@ struct ConversationHeader: View { Button(action: sidebarAction) { ReferenceGlyph(assetName: "MenuGlyph", width: 23, height: 18) .frame( - width: MobileDesignGeometry.controlTouchSize, - height: MobileDesignGeometry.controlTouchSize + width: conversationHeaderActionSurfaceSize, + height: conversationHeaderActionSurfaceSize ) .background(OpenBitFunTheme.card) - .overlay(Circle().stroke(OpenBitFunTheme.line, lineWidth: 1)) .clipShape(Circle()) - .shadow(color: OpenBitFunTheme.shadowMedium, radius: 8, y: 3) + .shadow(color: OpenBitFunTheme.shadowSubtle, radius: 15, y: 4) + .frame( + width: MobileDesignGeometry.controlTouchSize, + height: MobileDesignGeometry.controlTouchSize + ) } .buttonStyle(.plain) .accessibilityLabel(MobileLocalization.text(sidebarActionLabel)) @@ -103,13 +108,16 @@ struct ConversationHeader: View { Button { actionsOpen.toggle() } label: { ReferenceGlyph(assetName: "MoreGlyph", width: 23, height: 7) .frame( - width: MobileDesignGeometry.controlTouchSize, - height: MobileDesignGeometry.controlTouchSize + width: conversationHeaderActionSurfaceSize, + height: conversationHeaderActionSurfaceSize ) .background(OpenBitFunTheme.card) - .overlay(Circle().stroke(OpenBitFunTheme.line, lineWidth: 1)) .clipShape(Circle()) - .shadow(color: OpenBitFunTheme.shadowMedium, radius: 8, y: 3) + .shadow(color: OpenBitFunTheme.shadowSubtle, radius: 15, y: 4) + .frame( + width: MobileDesignGeometry.controlTouchSize, + height: MobileDesignGeometry.controlTouchSize + ) } .buttonStyle(.plain) .accessibilityLabel(model.localized("会话操作")) diff --git a/src/apps/mobile/ios/OpenBitFun/Features/DesignSystem/AdaptiveModalComponents.swift b/src/apps/mobile/ios/OpenBitFun/Features/DesignSystem/AdaptiveModalComponents.swift index 5a04ee5ec6..9e7b33cd97 100644 --- a/src/apps/mobile/ios/OpenBitFun/Features/DesignSystem/AdaptiveModalComponents.swift +++ b/src/apps/mobile/ios/OpenBitFun/Features/DesignSystem/AdaptiveModalComponents.swift @@ -12,7 +12,7 @@ struct OpenBitFunModalHeader: View { HStack(spacing: 12) { VStack(alignment: .leading, spacing: 2) { Text(MobileLocalization.text(title)) - .font(MobileDesignTypography.headlineSmall.font) + .font(MobileDesignTypography.conversationHeaderTitle.font) .foregroundStyle(OpenBitFunTheme.ink) .lineLimit(1) if let subtitle, !subtitle.isEmpty { @@ -25,11 +25,12 @@ struct OpenBitFunModalHeader: View { Spacer(minLength: 8) Button(action: onClose) { Image(systemName: "xmark") - .font(.system(size: 15, weight: .medium)) + .font(.system(size: 18, weight: .medium)) .foregroundStyle(OpenBitFunTheme.ink) - .frame(width: 40, height: 40) - .background(OpenBitFunTheme.soft) - .clipShape(Circle()) + .frame( + width: MobileDesignGeometry.controlTouchSize, + height: MobileDesignGeometry.controlTouchSize + ) } .buttonStyle(.plain) .accessibilityLabel(MobileLocalization.text("关闭")) diff --git a/src/apps/mobile/ios/OpenBitFun/Features/DesignSystem/MobileDesignGallery.swift b/src/apps/mobile/ios/OpenBitFun/Features/DesignSystem/MobileDesignGallery.swift index 9cf7838b32..508dc63c61 100644 --- a/src/apps/mobile/ios/OpenBitFun/Features/DesignSystem/MobileDesignGallery.swift +++ b/src/apps/mobile/ios/OpenBitFun/Features/DesignSystem/MobileDesignGallery.swift @@ -38,7 +38,8 @@ struct MobileDesignGallery: View { ConversationHeader( model: model, actionsOpen: .constant(false), - contextTitle: scenario.headerSubtitle + contextTitle: scenario.headerSubtitle, + sidebarAction: {} ) ChatTimelineView(model: model) ComposerBar(model: model) diff --git a/src/apps/mobile/ios/OpenBitFun/Features/Settings/AppSettingsView.swift b/src/apps/mobile/ios/OpenBitFun/Features/Settings/AppSettingsView.swift index 416854c9c7..ddb84bdc72 100644 --- a/src/apps/mobile/ios/OpenBitFun/Features/Settings/AppSettingsView.swift +++ b/src/apps/mobile/ios/OpenBitFun/Features/Settings/AppSettingsView.swift @@ -17,26 +17,28 @@ struct SettingsView: View { var body: some View { ZStack(alignment: .topTrailing) { - ScrollView(showsIndicators: false) { - VStack(alignment: .leading, spacing: 0) { - Text(model.localized("设置")) - .font(.system(size: 28, weight: .bold)) - .foregroundStyle(OpenBitFunTheme.ink) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.bottom, 30) + VStack(spacing: 0) { + OpenBitFunModalHeader(title: "设置", onClose: { dismiss() }) + .padding(.horizontal, MobileDesignGeometry.sheetHorizontalPadding) + Divider().overlay(OpenBitFunTheme.line) - Button { accountOpen = true } label: { - SettingsCard { - SettingsProfileRow( - subtitle: model.accountUser ?? model.localized("未登录") - ) + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 0) { + SettingsGroup(title: "账号") { + Button { accountOpen = true } label: { + SettingsProfileRow( + subtitle: model.accountUser ?? model.localized("未登录"), + authenticated: model.accountUser != nil + ) + } + .buttonStyle(.plain) } - } - .buttonStyle(.plain) - .padding(.bottom, 24) - SettingsGroup(title: "通用") { - VStack(spacing: 0) { + if showsCurrentConnection { + currentConnectionSection + } + + SettingsGroup(title: "通用") { Button { model.languagePickerOpen = true } label: { SettingsValueRow( icon: "textformat", @@ -46,47 +48,57 @@ struct SettingsView: View { ) } .buttonStyle(.plain) - Divider().overlay(OpenBitFunTheme.line).padding(.horizontal, 26) + } + SettingsGroup(title: "模型") { Button { model.generalConfigOpen = true } label: { SettingsValueRow( icon: "square.grid.2x2", - title: "模型", + title: "默认模型", value: selectedModelName, showsChevron: true ) } .buttonStyle(.plain) } - } - SettingsGroup(title: "关于") { - VStack(spacing: 0) { - SettingsValueRow( - icon: nil, - title: "产品", - value: "OpenBitFun iOS版" - ) - Divider().overlay(OpenBitFunTheme.line).padding(.horizontal, 26) - SettingsValueRow(icon: nil, title: "版本", value: appVersion) + accountDevicesSection + SettingsGroup(title: "关于") { + VStack(spacing: 0) { + SettingsValueRow( + icon: nil, + title: "产品", + value: "OpenBitFun iOS版" + ) + Divider().overlay(OpenBitFunTheme.line).padding(.horizontal, 26) + SettingsValueRow(icon: nil, title: "版本", value: appVersion) + } + } + if model.accountUser != nil { + Button(role: .destructive) { + model.logoutAccount() + } label: { + HStack(spacing: 14) { + Image(systemName: "rectangle.portrait.and.arrow.right") + .font(.system(size: 20, weight: .regular)) + .frame(width: 24, height: 24) + Text(model.localized("退出账号")) + .font(MobileDesignTypography.bodyLarge.font.weight(.medium)) + Spacer(minLength: 0) + } + .foregroundStyle(OpenBitFunTheme.statusDanger) + .padding(.horizontal, 20) + .frame(minHeight: 62) + } + .buttonStyle(.plain) + .overlay(alignment: .top) { + Divider().overlay(OpenBitFunTheme.line).padding(.horizontal, 8) + } } } + .padding(.horizontal, MobileDesignGeometry.sheetHorizontalPadding) + .padding(.top, 22) + .padding(.bottom, 34) } - .padding(.horizontal, 16) - .padding(.top, 64) - .padding(.bottom, 34) - } - - Button { dismiss() } label: { - Image(systemName: "xmark") - .font(.system(size: 18, weight: .regular)) - .foregroundStyle(OpenBitFunTheme.ink) - .frame(width: 40, height: 40) - .background(OpenBitFunTheme.card) - .clipShape(Circle()) } - .buttonStyle(.plain) - .accessibilityLabel(model.localized("关闭")) - .padding(.top, 22) - .padding(.trailing, 18) if model.languagePickerOpen { LanguagePickerSheet(model: model) @@ -104,6 +116,139 @@ struct SettingsView: View { .animation(.easeInOut(duration: 0.2), value: model.generalConfigOpen) .animation(.easeInOut(duration: 0.2), value: accountOpen) } + + private var showsCurrentConnection: Bool { + model.remoteConnected || model.accountDeviceName != nil || model.directPairingDeviceName != nil + } + + private var currentConnectionSection: some View { + SettingsGroup(title: "当前远程控制") { + VStack(spacing: 0) { + HStack(spacing: 14) { + Image(systemName: "desktopcomputer") + .font(.system(size: 20, weight: .regular)) + .foregroundStyle(OpenBitFunTheme.muted) + .frame(width: 28, height: 28) + VStack(alignment: .leading, spacing: 3) { + Text( + model.accountDeviceName + ?? model.directPairingDeviceName + ?? model.localized("尚未连接桌面端") + ) + .font(MobileDesignTypography.bodyLarge.font.weight(.medium)) + .foregroundStyle(OpenBitFunTheme.ink) + .lineLimit(1) + Text(connectionDetail) + .font(MobileDesignTypography.bodySmall.font) + .foregroundStyle(OpenBitFunTheme.muted) + } + Spacer(minLength: 0) + } + .padding(.horizontal, 18) + .frame(minHeight: 68) + + Divider().overlay(OpenBitFunTheme.line).padding(.horizontal, 18) + + HStack(spacing: 8) { + Text(model.localized(model.accountDeviceName == nil ? "扫码连接" : "账号设备")) + .font(MobileDesignTypography.bodySmall.font) + .foregroundStyle(OpenBitFunTheme.muted) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background(OpenBitFunTheme.soft) + .clipShape(Capsule()) + Spacer(minLength: 0) + if model.connectionPhase == .disconnected { + Button(model.localized("重新连接"), action: model.verifyRemoteConnection) + .font(MobileDesignTypography.bodyMedium.font.weight(.medium)) + .foregroundStyle(OpenBitFunTheme.ink) + .buttonStyle(.plain) + } + Button(model.localized("断开"), action: model.disconnectRemote) + .font(MobileDesignTypography.bodyMedium.font.weight(.medium)) + .foregroundStyle(OpenBitFunTheme.statusDanger) + .buttonStyle(.plain) + } + .padding(.horizontal, 18) + .frame(minHeight: 54) + } + } + } + + private var connectionDetail: String { + switch model.connectionPhase { + case .connected: model.localized("已连接") + case .reconnecting: model.localized("正在重连") + case .disconnected: model.localized("连接已断开") + } + } + + private var accountDevicesSection: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text(model.localized("设备")) + .font(MobileDesignTypography.bodySmall.font.weight(.medium)) + .foregroundStyle(OpenBitFunTheme.muted) + Spacer(minLength: 0) + Button(action: model.refreshRemoteDevices) { + Group { + if model.accountRefreshing { + ProgressView().controlSize(.small) + } else { + Image(systemName: "arrow.clockwise") + .font(.system(size: 17, weight: .regular)) + } + } + .foregroundStyle(OpenBitFunTheme.ink) + .frame(width: 44, height: 44) + .contentShape(Circle()) + } + .buttonStyle(.plain) + .disabled(model.accountUser == nil || model.accountRefreshing) + .opacity(model.accountUser == nil || model.accountRefreshing ? 0.55 : 1) + .accessibilityLabel(model.localized("刷新")) + } + .padding(.leading, 8) + .padding(.trailing, 4) + .frame(minHeight: 48) + + VStack(spacing: 4) { + if model.accountUser == nil { + emptyDeviceMessage("登录云账号后可查看该账号下的所有设备。") + } else if model.accountRefreshing && model.accountDevices.isEmpty { + emptyDeviceMessage("正在加载设备…") + } else if model.accountDevices.isEmpty { + emptyDeviceMessage("账号下还没有其他已注册设备。") + } else { + ForEach(model.accountDevices) { device in + Button { + guard device.online, + !(device.selected && model.connectionPhase == .connected) else { return } + model.selectRemoteDevice(device) + } label: { + EmbeddedSettingsDeviceRow( + device: device, + connected: device.selected && model.connectionPhase == .connected + ) + } + .buttonStyle(.plain) + } + } + } + .padding(8) + .background(OpenBitFunTheme.card) + .clipShape(RoundedRectangle(cornerRadius: MobileDesignGeometry.settingsCardRadius)) + } + .padding(.bottom, 24) + } + + private func emptyDeviceMessage(_ text: String) -> some View { + Text(model.localized(text)) + .font(MobileDesignTypography.bodySmall.font) + .foregroundStyle(OpenBitFunTheme.muted) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(8) + } } private struct LanguagePickerSheet: View { @@ -155,9 +300,10 @@ private struct SettingsGroup: View { var body: some View { VStack(alignment: .leading, spacing: 8) { Text(MobileLocalization.text(title)) - .font(.system(size: 18, weight: .bold)) + .font(MobileDesignTypography.bodySmall.font.weight(.medium)) .foregroundStyle(OpenBitFunTheme.muted) - .padding(.leading, 12) + .padding(.leading, 8) + .padding(.bottom, 2) SettingsCard(content: content) } .padding(.bottom, 24) @@ -169,7 +315,7 @@ struct SettingsCard: View { var body: some View { OpenBitFunModalCard( - radius: MobileDesignGeometry.settingsCompactCardRadius, + radius: MobileDesignGeometry.settingsCardRadius, bordered: false, content: content ) @@ -178,6 +324,7 @@ struct SettingsCard: View { private struct SettingsProfileRow: View { let subtitle: String + let authenticated: Bool var body: some View { HStack(spacing: 12) { @@ -186,7 +333,7 @@ private struct SettingsProfileRow: View { .foregroundStyle(OpenBitFunTheme.muted) .frame(width: 34, height: 34) VStack(alignment: .leading, spacing: 2) { - Text(MobileLocalization.text("个人资料")) + Text(MobileLocalization.text(authenticated ? "当前账号" : "当前身份")) .font(.system(size: 16, weight: .medium)) .foregroundStyle(OpenBitFunTheme.ink) Text(MobileLocalization.text(subtitle)) @@ -195,9 +342,15 @@ private struct SettingsProfileRow: View { .lineLimit(1) } Spacer(minLength: 8) - Image(systemName: "chevron.right") - .font(.system(size: 14, weight: .medium)) - .foregroundStyle(OpenBitFunTheme.muted.opacity(0.72)) + if authenticated { + Text(MobileLocalization.text("已登录")) + .font(MobileDesignTypography.bodySmall.font.weight(.medium)) + .foregroundStyle(OpenBitFunTheme.statusSuccess) + } else { + Image(systemName: "chevron.right") + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(OpenBitFunTheme.muted.opacity(0.72)) + } } .padding(.horizontal, 18) .frame(height: 64) @@ -213,10 +366,17 @@ private struct SettingsValueRow: View { var body: some View { HStack(spacing: 14) { if let icon { - Image(systemName: icon) - .font(.system(size: 20, weight: .regular)) - .foregroundStyle(OpenBitFunTheme.muted) - .frame(width: 23, height: 23) + if icon == "textformat" { + Text("Aa") + .font(.system(size: 18, weight: .regular)) + .foregroundStyle(OpenBitFunTheme.muted) + .frame(width: 23, height: 23) + } else { + Image(systemName: icon) + .font(.system(size: 20, weight: .regular)) + .foregroundStyle(OpenBitFunTheme.muted) + .frame(width: 23, height: 23) + } } Text(MobileLocalization.text(title)) .font(.system(size: 16, weight: .medium)) @@ -233,6 +393,51 @@ private struct SettingsValueRow: View { } } .padding(.horizontal, 18) - .frame(height: 52) + .frame(minHeight: 56) + } +} + +private struct EmbeddedSettingsDeviceRow: View { + let device: MobileAccountDevice + let connected: Bool + + private var status: String { + if connected { + return "\(MobileLocalization.text("当前控制")) · \(MobileLocalization.text("在线"))" + } + return MobileLocalization.text(device.online ? "在线" : "离线") + } + + var body: some View { + HStack(spacing: 12) { + Image(systemName: "desktopcomputer") + .font(.system(size: 20, weight: .regular)) + .foregroundStyle(connected ? OpenBitFunTheme.ink : OpenBitFunTheme.muted) + .frame(width: 28, height: 28) + VStack(alignment: .leading, spacing: 2) { + Text(device.name.isEmpty ? device.id : device.name) + .font(MobileDesignTypography.titleSmall.font.weight(.medium)) + .foregroundStyle(OpenBitFunTheme.ink) + .lineLimit(1) + Text(status) + .font(MobileDesignTypography.bodySmall.font) + .foregroundStyle(device.online ? OpenBitFunTheme.statusSuccess : OpenBitFunTheme.muted) + } + Spacer(minLength: 8) + if device.online && !connected { + Text(MobileLocalization.text("连接")) + .font(MobileDesignTypography.bodyMedium.font) + .foregroundStyle(OpenBitFunTheme.ink) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(OpenBitFunTheme.soft) + .clipShape(Capsule()) + } + } + .padding(.horizontal, 10) + .frame(minHeight: 62) + .background(connected ? OpenBitFunTheme.soft : OpenBitFunTheme.card) + .clipShape(RoundedRectangle(cornerRadius: MobileDesignGeometry.settingsCompactCardRadius)) + .opacity(device.online ? 1 : 0.48) } } diff --git a/src/apps/mobile/ios/OpenBitFun/Features/Shell/OpenBitFunTheme.swift b/src/apps/mobile/ios/OpenBitFun/Features/Shell/OpenBitFunTheme.swift index 3b6ef7a5ae..aafea6fc61 100644 --- a/src/apps/mobile/ios/OpenBitFun/Features/Shell/OpenBitFunTheme.swift +++ b/src/apps/mobile/ios/OpenBitFun/Features/Shell/OpenBitFunTheme.swift @@ -51,12 +51,13 @@ struct ReferenceGlyph: View { let assetName: String let width: CGFloat let height: CGFloat + var color: Color = OpenBitFunTheme.ink var body: some View { Image(assetName) .resizable() .renderingMode(.template) - .foregroundStyle(OpenBitFunTheme.ink) + .foregroundStyle(color) .aspectRatio(contentMode: .fit) .frame(width: width, height: height) } diff --git a/src/apps/mobile/ios/OpenBitFun/Features/Shell/RemoteFilePreviewView.swift b/src/apps/mobile/ios/OpenBitFun/Features/Shell/RemoteFilePreviewView.swift index 51716a7369..d285bce9f1 100644 --- a/src/apps/mobile/ios/OpenBitFun/Features/Shell/RemoteFilePreviewView.swift +++ b/src/apps/mobile/ios/OpenBitFun/Features/Shell/RemoteFilePreviewView.swift @@ -87,15 +87,17 @@ struct RemoteFilePreviewSheet: View { var body: some View { VStack(spacing: 0) { HStack(spacing: 12) { - Image(systemName: preview.imageData == nil ? "doc.text" : "photo") - .font(.system(size: 16, weight: .medium)) - .foregroundStyle(MobileDesignColors.fileLink) - .frame(width: 34, height: 34) - .background(MobileDesignColors.fileLink.opacity(0.1)) - .clipShape(RoundedRectangle(cornerRadius: 9)) + Button(action: closePreview) { + Image(systemName: "chevron.left") + .font(.system(size: 18, weight: .medium)) + .foregroundStyle(OpenBitFunTheme.ink) + .frame(width: 44, height: 44) + } + .buttonStyle(.plain) + .accessibilityLabel(Text(model.localized("返回"))) VStack(alignment: .leading, spacing: 2) { Text(preview.name) - .font(MobileDesignTypography.titleSmall.font) + .font(MobileDesignTypography.bodyLarge.font.weight(.medium)) .foregroundStyle(OpenBitFunTheme.ink) .lineLimit(1) if !preview.mimeType.isEmpty || preview.sizeBytes > 0 { @@ -106,37 +108,36 @@ struct RemoteFilePreviewSheet: View { } } Spacer() + Button { + model.openRemoteFile(reference: preview.id, label: preview.name) + } label: { + Image(systemName: "arrow.clockwise") + .font(.system(size: 18, weight: .medium)) + .foregroundStyle(OpenBitFunTheme.ink) + .frame(width: 44, height: 44) + } + .buttonStyle(.plain) + .disabled(model.filePreviewLoading || model.connectionPhase == .disconnected) + .opacity(model.filePreviewLoading || model.connectionPhase == .disconnected ? 0.45 : 1) + .accessibilityLabel(Text(model.localized("刷新"))) Button { model.downloadRemoteFile( reference: "computer://\(preview.id)", label: preview.name ) } label: { - Image(systemName: "arrow.down.circle") + Image(systemName: "arrow.down.to.line") .font(.system(size: 18, weight: .medium)) .foregroundStyle(OpenBitFunTheme.ink) - .frame(width: 36, height: 36) + .frame(width: 44, height: 44) } .buttonStyle(.plain) .disabled([.preparing, .downloading, .saving].contains(model.downloadPhase)) .opacity([.preparing, .downloading, .saving].contains(model.downloadPhase) ? 0.45 : 1) .accessibilityLabel(Text(model.localizedFormat("下载 %@", preview.name))) - Button { - model.dismissFilePreview() - if !embedded { dismiss() } - } label: { - Image(systemName: "xmark") - .font(.system(size: 14, weight: .medium)) - .foregroundStyle(OpenBitFunTheme.ink) - .frame(width: 36, height: 36) - .background(OpenBitFunTheme.soft) - .clipShape(Circle()) - } - .buttonStyle(.plain) - .accessibilityLabel(Text(model.localized("关闭文件预览"))) } - .padding(.horizontal, 18) - .padding(.vertical, 12) + .frame(height: 68) + .padding(.horizontal, 8) Rectangle().fill(OpenBitFunTheme.line).frame(height: 1) @@ -300,4 +301,9 @@ struct RemoteFilePreviewSheet: View { .presentationDetents([.large]) .presentationDragIndicator(.visible) } + + private func closePreview() { + model.dismissFilePreview() + if !embedded { dismiss() } + } } diff --git a/src/apps/mobile/ios/OpenBitFun/Features/Shell/SidebarView.swift b/src/apps/mobile/ios/OpenBitFun/Features/Shell/SidebarView.swift index 6deedbbd03..4ec7a498f1 100644 --- a/src/apps/mobile/ios/OpenBitFun/Features/Shell/SidebarView.swift +++ b/src/apps/mobile/ios/OpenBitFun/Features/Shell/SidebarView.swift @@ -76,8 +76,8 @@ struct SidebarView: View { } ScrollView(showsIndicators: false) { VStack(alignment: .leading, spacing: 0) { - recentSection workspaceSection + recentSection } .padding(.bottom, model.accountUser == nil && !model.remoteConnected ? 142 : 84) } @@ -161,7 +161,7 @@ struct SidebarView: View { private var authenticatedHeader: some View { HStack(spacing: 6) { Text(verbatim: "OpenBitFun") - .font(.system(size: 20, weight: .bold)) + .font(.system(size: 20, weight: .medium)) .foregroundStyle(OpenBitFunTheme.ink) Spacer(minLength: 0) if let onCollapse { @@ -177,29 +177,16 @@ struct SidebarView: View { .buttonStyle(.plain) .accessibilityLabel(Text(model.localized("收起侧栏"))) } - if model.remoteConnected { - Button { model.remoteViewSettingsOpen = true } label: { - Image(systemName: "ellipsis") - .font(.system(size: 17, weight: .medium)) - .foregroundStyle(OpenBitFunTheme.muted) - .frame(width: 38, height: 38) - .background(OpenBitFunTheme.card) - .overlay(Circle().stroke(OpenBitFunTheme.line, lineWidth: 1)) - .clipShape(Circle()) - } - .buttonStyle(.plain) - .accessibilityLabel(Text(model.localized("视图设置"))) - } Button { withAnimation(.easeOut(duration: 0.18)) { searchVisible.toggle() } if !searchVisible { search = "" } } label: { ReferenceImage(assetName: "SidebarSearchGlyph", width: 22, height: 22) - .frame(width: 38, height: 38) + .frame(width: 44, height: 44) .background(OpenBitFunTheme.card) - .overlay(Circle().stroke(OpenBitFunTheme.line, lineWidth: 1)) + .overlay(Circle().stroke(OpenBitFunTheme.line, lineWidth: 0.5)) .clipShape(Circle()) - .shadow(color: OpenBitFunTheme.shadowMedium, radius: 10, y: 4) + .shadow(color: MobileDesignColors.shadowFaint, radius: 9, y: 3) } .buttonStyle(.plain) .accessibilityLabel(Text(model.localized("搜索"))) @@ -783,25 +770,26 @@ struct SidebarView: View { .font(.system(size: 15, weight: .medium)) .foregroundStyle(OpenBitFunTheme.ink) } - .frame(width: 116, height: 46) + .frame(width: 98, height: 44) .background(OpenBitFunTheme.card) - .overlay(RoundedRectangle(cornerRadius: 23).stroke(OpenBitFunTheme.line, lineWidth: 1)) + .overlay(RoundedRectangle(cornerRadius: 22).stroke(OpenBitFunTheme.line, lineWidth: 0.5)) .clipShape(Capsule()) - .shadow(color: OpenBitFunTheme.shadowMedium, radius: 10, y: 4) + .shadow(color: OpenBitFunTheme.shadowSubtle, radius: 12, y: 4) } .buttonStyle(.plain) Spacer(minLength: 0) Button { model.settingsOpen = true; model.drawerOpen = false } label: { ReferenceImage(assetName: "SidebarSettingsGlyph", width: 24, height: 24) - .frame(width: 46, height: 46) + .frame(width: 44, height: 44) .background(OpenBitFunTheme.card) .clipShape(Circle()) - .shadow(color: OpenBitFunTheme.shadowMedium, radius: 10, y: 4) + .shadow(color: OpenBitFunTheme.shadowSubtle, radius: 12, y: 4) } .buttonStyle(.plain) .accessibilityLabel(Text(model.localized("设置"))) } .frame(height: 56) + .padding(.leading, 12) } } diff --git a/src/apps/mobile/ios/OpenBitFun/Resources/Localizable.xcstrings b/src/apps/mobile/ios/OpenBitFun/Resources/Localizable.xcstrings index 1c3068d4c9..8b82596190 100644 --- a/src/apps/mobile/ios/OpenBitFun/Resources/Localizable.xcstrings +++ b/src/apps/mobile/ios/OpenBitFun/Resources/Localizable.xcstrings @@ -381,6 +381,26 @@ } } }, + "当前账号": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Current account" + } + } + } + }, + "当前身份": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Current identity" + } + } + } + }, "产品": { "localizations": { "en": { @@ -1481,6 +1501,16 @@ } } }, + "默认模型": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Default model" + } + } + } + }, "正在": { "localizations": { "en": {