diff --git a/src/apps/mobile/ios/OpenBitFun/Features/Account/AccountSettingsView.swift b/src/apps/mobile/ios/OpenBitFun/Features/Account/AccountSettingsView.swift index 9eec0c3591..2e74fa0661 100644 --- a/src/apps/mobile/ios/OpenBitFun/Features/Account/AccountSettingsView.swift +++ b/src/apps/mobile/ios/OpenBitFun/Features/Account/AccountSettingsView.swift @@ -7,6 +7,7 @@ struct AccountSettingsView: View { @State private var relayURL = AccountDefaults.shared.CLOUD_RELAY_URL @State private var username = "" @State private var password = "" + @State private var advancedOpen = false var body: some View { Group { @@ -23,68 +24,168 @@ struct AccountSettingsView: View { } private var loginPage: some View { - ScrollView(showsIndicators: false) { - VStack(alignment: .leading, spacing: 0) { - Button { close() } label: { - Image(systemName: "chevron.left") - .font(.system(size: 19, weight: .medium)) + VStack(spacing: 0) { + OpenBitFunModalHeader(title: "", onClose: close) + .padding(.horizontal, MobileDesignGeometry.sheetHorizontalPadding) + + ScrollView(showsIndicators: false) { + VStack(spacing: 0) { + Text(model.localized("登录 OpenBitFun 账号")) + .font(MobileDesignTypography.displayMedium.font) .foregroundStyle(OpenBitFunTheme.ink) - .frame(width: 44, height: 44) - } - .buttonStyle(.plain) - .accessibilityLabel(model.localized("返回")) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + Text(model.localized("登录后可查看并连接账号下的桌面设备。")) + .font(MobileDesignTypography.bodyMedium.font) + .foregroundStyle(OpenBitFunTheme.muted) + .lineSpacing(MobileDesignTypography.bodyMedium.lineSpacing) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + .padding(.top, 8) + .padding(.bottom, 24) - Text(model.localized("登录 OpenBitFun 账号")) - .font(.system(size: 32, weight: .bold)) - .foregroundStyle(OpenBitFunTheme.ink) - Text(model.localized("登录后可查看并连接账号下的桌面设备。")) - .font(.system(size: 15)) - .foregroundStyle(OpenBitFunTheme.muted) - .lineSpacing(4) - .padding(.top, 12) - .padding(.bottom, 42) + VStack(spacing: 0) { + accountCredentialRow( + icon: "person", + placeholder: model.localized("用户名"), + text: $username, + secure: false + ) + Divider() + .overlay(OpenBitFunTheme.line) + .padding(.leading, 54) + .padding(.trailing, 16) + accountCredentialRow( + icon: "lock", + placeholder: model.localized("密码"), + text: $password, + secure: true + ) + } + .background(OpenBitFunTheme.card) + .clipShape(RoundedRectangle(cornerRadius: 24)) + .overlay(RoundedRectangle(cornerRadius: 24).stroke(OpenBitFunTheme.line, lineWidth: 1)) + .shadow(color: MobileDesignColors.shadowFaint, radius: 16, y: 5) + + VStack(spacing: 0) { + Button { advancedOpen.toggle() } label: { + HStack(spacing: 14) { + Image(systemName: "gearshape") + .font(.system(size: 21, weight: .regular)) + .foregroundStyle(OpenBitFunTheme.muted) + .frame(width: 24, height: 24) + Text(model.localized("高级选项")) + .font(MobileDesignTypography.titleSmall.font) + .foregroundStyle(OpenBitFunTheme.ink) + Spacer(minLength: 8) + Image(systemName: advancedOpen ? "chevron.up" : "chevron.down") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(OpenBitFunTheme.muted) + } + .padding(.horizontal, 18) + .frame(height: 58) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) - accountField(model.localized("用户名"), text: $username, secure: false, height: 58) - accountField(model.localized("密码"), text: $password, secure: true, height: 58) + if advancedOpen { + Divider().overlay(OpenBitFunTheme.line).padding(.horizontal, 18) + VStack(alignment: .leading, spacing: 8) { + Text(model.localized("登录服务器")) + .font(MobileDesignTypography.labelSmall.font) + .foregroundStyle(OpenBitFunTheme.muted) + accountRelayField + } + .padding(.horizontal, 18) + .padding(.top, 14) + .padding(.bottom, 18) + } + } + .background(OpenBitFunTheme.card) + .clipShape(RoundedRectangle(cornerRadius: 20)) + .overlay(RoundedRectangle(cornerRadius: 20).stroke(OpenBitFunTheme.line, lineWidth: 1)) .padding(.top, 14) - Text(model.localized("登录服务器")) - .font(.system(size: 13)) - .foregroundStyle(OpenBitFunTheme.muted) - .padding(.leading, 4) - .padding(.top, 26) - .padding(.bottom, 8) - accountField(model.localized("Relay 地址"), text: $relayURL, secure: false, height: 52) + if let error = model.coreErrorMessage, !error.isEmpty { + Text(error) + .font(MobileDesignTypography.bodySmall.font) + .foregroundStyle(OpenBitFunTheme.statusDanger) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 4) + .padding(.top, 12) + } + } + .frame(maxWidth: 520) + .padding(.horizontal, MobileDesignGeometry.sheetHorizontalPadding) + .padding(.bottom, 34) + .frame(maxWidth: .infinity) + } - if let error = model.coreErrorMessage, !error.isEmpty { - Text(error) - .font(.system(size: 13)) - .foregroundStyle(OpenBitFunTheme.statusDanger) - .padding(.top, 12) + Button { + model.loginAccount(relayURL: relayURL, username: username, password: password) + password = "" + } label: { + HStack(spacing: 8) { + if model.accountBusy { ProgressView().tint(OpenBitFunTheme.contentOnAction) } + Text(model.localized(model.accountBusy ? "正在登录" : "登录")) } + .font(.system(size: 17, weight: .bold)) + .foregroundStyle(OpenBitFunTheme.contentOnAction) + .frame(maxWidth: 520, minHeight: MobileDesignGeometry.sheetActionHeight) + .background(canLogin ? OpenBitFunTheme.accent : OpenBitFunTheme.muted.opacity(0.35)) + .clipShape(Capsule()) + } + .buttonStyle(.plain) + .disabled(!canLogin) + .padding(.horizontal, MobileDesignGeometry.sheetHorizontalPadding) + .padding(.top, 10) + .padding(.bottom, 24) + } + } - Button { - model.loginAccount(relayURL: relayURL, username: username, password: password) - password = "" - } label: { - HStack(spacing: 8) { - if model.accountBusy { ProgressView().tint(OpenBitFunTheme.contentOnAction) } - Text(model.localized(model.accountBusy ? "正在登录" : "登录")) - } - .font(.system(size: 17, weight: .bold)) - .foregroundStyle(OpenBitFunTheme.contentOnAction) - .frame(maxWidth: .infinity, minHeight: 56) - .background(canLogin ? OpenBitFunTheme.accent : OpenBitFunTheme.muted.opacity(0.35)) - .clipShape(RoundedRectangle(cornerRadius: 18)) + private var accountRelayField: some View { + TextField(model.localized("Relay 地址"), text: $relayURL) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .keyboardType(.URL) + .font(MobileDesignTypography.bodyMedium.font) + .foregroundStyle(OpenBitFunTheme.ink) + .padding(.horizontal, 14) + .frame(height: 48) + .background(OpenBitFunTheme.soft) + .clipShape(RoundedRectangle(cornerRadius: 14)) + .overlay(RoundedRectangle(cornerRadius: 14).stroke(OpenBitFunTheme.line, lineWidth: 1)) + } + + @ViewBuilder + private func accountCredentialRow( + icon: String, + placeholder: String, + text: Binding, + secure: Bool + ) -> some View { + HStack(spacing: 12) { + Image(systemName: icon) + .font(.system(size: 20, weight: .regular)) + .foregroundStyle(OpenBitFunTheme.muted) + .frame(width: 24, height: 24) + Group { + if secure { + SecureField(placeholder, text: text) + .textContentType(.password) + } else { + TextField(placeholder, text: text) + .textContentType(.username) } - .buttonStyle(.plain) - .disabled(!canLogin) - .padding(.top, model.coreErrorMessage == nil ? 30 : 22) } - .padding(.horizontal, 28) - .padding(.top, 22) - .padding(.bottom, 44) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .font(MobileDesignTypography.bodyLarge.font) + .foregroundStyle(OpenBitFunTheme.ink) } + .padding(.leading, 18) + .padding(.trailing, 12) + .frame(height: 60) } private var deviceListRetryPage: some View { @@ -173,8 +274,8 @@ struct AccountSettingsView: View { .frame(maxWidth: .infinity) .padding(.vertical, 24) .background(OpenBitFunTheme.card) - .clipShape(RoundedRectangle(cornerRadius: 28)) - .padding(.bottom, 24) + .clipShape(RoundedRectangle(cornerRadius: MobileDesignGeometry.settingsProminentCardRadius)) + .padding(.bottom, 24) VStack(alignment: .leading, spacing: 10) { HStack { @@ -194,33 +295,41 @@ struct AccountSettingsView: View { .padding(.horizontal, 18) .padding(.vertical, 16) .background(OpenBitFunTheme.card) - .clipShape(RoundedRectangle(cornerRadius: 24)) + .clipShape(RoundedRectangle(cornerRadius: MobileDesignGeometry.settingsCardRadius)) .padding(.bottom, 24) VStack(alignment: .leading, spacing: 8) { HStack { Text(model.localized("设备管理")) - .font(.system(size: 17, weight: .bold)) - .foregroundStyle(OpenBitFunTheme.ink) + .font(MobileDesignTypography.titleSmall.font) + .foregroundStyle(OpenBitFunTheme.muted) Spacer() - Button { model.refreshRemoteDevices() } label: { - Text(model.localized(model.accountRefreshing ? "正在刷新" : "刷新")) - .font(.system(size: 13)) - .foregroundStyle(model.accountRefreshing ? OpenBitFunTheme.muted : OpenBitFunTheme.ink) + Button(action: model.refreshRemoteDevices) { + Group { + if model.accountRefreshing { + ProgressView().controlSize(.small) + } else { + Image(systemName: "arrow.clockwise") + .font(.system(size: 18, weight: .regular)) + } + } + .foregroundStyle(model.accountRefreshing ? OpenBitFunTheme.muted : OpenBitFunTheme.ink) + .frame(width: MobileDesignGeometry.controlTouchSize, height: MobileDesignGeometry.controlTouchSize) } .buttonStyle(.plain) .disabled(model.accountRefreshing) + .accessibilityLabel(model.localized("刷新")) } - VStack(spacing: 0) { - ForEach(Array(model.accountDevices.enumerated()), id: \.offset) { index, device in + VStack(spacing: 4) { + ForEach(model.accountDevices) { device in Button { model.selectRemoteDevice(device) } label: { - SettingsDeviceRow(device: device) + SettingsDeviceRow( + device: device, + connected: device.selected && model.connectionPhase == .connected + ) } .buttonStyle(.plain) .disabled(!device.online && !device.selected) - if index < model.accountDevices.count - 1 { - Divider().overlay(OpenBitFunTheme.line).padding(.horizontal, 20) - } } if model.accountDevices.isEmpty { Text(model.localized("暂无可连接的桌面设备")) @@ -230,15 +339,14 @@ struct AccountSettingsView: View { .padding(.vertical, 12) } } + .padding(8) + .background(OpenBitFunTheme.card) + .clipShape(RoundedRectangle(cornerRadius: MobileDesignGeometry.settingsCardRadius)) } - .padding(.horizontal, 18) - .padding(.vertical, 16) - .background(OpenBitFunTheme.card) - .clipShape(RoundedRectangle(cornerRadius: 24)) .padding(.bottom, 24) Text(model.localized("个人资料详情")) - .font(.system(size: 18, weight: .bold)) + .font(MobileDesignTypography.titleSmall.font.weight(.bold)) .foregroundStyle(OpenBitFunTheme.muted) .padding(.leading, 18) .padding(.bottom, 8) @@ -252,20 +360,28 @@ struct AccountSettingsView: View { ) } .background(OpenBitFunTheme.card) - .clipShape(RoundedRectangle(cornerRadius: 28)) + .clipShape(RoundedRectangle(cornerRadius: MobileDesignGeometry.settingsCardRadius)) Button(role: .destructive) { model.logoutAccount() } label: { - Text(model.localized("退出账号")) - .font(.system(size: 16, weight: .medium)) - .foregroundStyle(OpenBitFunTheme.statusDanger) - .frame(maxWidth: .infinity, minHeight: 54) - .background(OpenBitFunTheme.card) - .clipShape(RoundedRectangle(cornerRadius: 16)) + HStack(spacing: 14) { + Image(systemName: "rectangle.portrait.and.arrow.right") + .font(.system(size: 22, weight: .regular)) + .frame(width: 24, height: 24) + Text(model.localized("退出账号")) + .font(MobileDesignTypography.titleMedium.font) + Spacer(minLength: 0) + } + .foregroundStyle(OpenBitFunTheme.statusDanger) + .padding(.horizontal, 20) + .frame(maxWidth: .infinity, minHeight: 62) + .background(OpenBitFunTheme.card) + .clipShape(RoundedRectangle(cornerRadius: MobileDesignGeometry.settingsCardRadius)) } .buttonStyle(.plain) - .padding(.top, 18) + .padding(.top, 32) + .padding(.bottom, 8) } .padding(.horizontal, MobileDesignGeometry.sheetHorizontalPadding) .padding(.top, 20) @@ -279,20 +395,21 @@ struct AccountSettingsView: View { } private func profileDetailRow(label: String, value: String) -> some View { - HStack(spacing: 12) { + HStack(spacing: 16) { Text(label) - .font(.system(size: 16)) + .font(MobileDesignTypography.bodyLarge.font) .foregroundStyle(OpenBitFunTheme.ink) Spacer(minLength: 8) Text(value) - .font(.system(size: 16)) + .font(MobileDesignTypography.bodyMedium.font) .foregroundStyle(OpenBitFunTheme.muted) .lineLimit(1) .truncationMode(.middle) .multilineTextAlignment(.trailing) } - .frame(minHeight: 56) .padding(.horizontal, 18) + .padding(.vertical, 12) + .frame(minHeight: 58) } private var canLogin: Bool { @@ -306,58 +423,48 @@ struct AccountSettingsView: View { if let onClose { onClose() } else { model.accountSheetOpen = false } } - @ViewBuilder - private func accountField( - _ placeholder: String, - text: Binding, - secure: Bool, - height: CGFloat - ) -> some View { - Group { - if secure { SecureField(placeholder, text: text) } - else { TextField(placeholder, text: text) } - } - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - .font(.system(size: height == 58 ? 17 : 14)) - .foregroundStyle(OpenBitFunTheme.ink) - .padding(.horizontal, 20) - .frame(height: height) - .background(OpenBitFunTheme.card) - .clipShape(RoundedRectangle(cornerRadius: height == 58 ? 18 : 16)) - } } struct SettingsDeviceRow: View { let device: MobileAccountDevice + let connected: Bool var body: some View { - HStack(spacing: 14) { + HStack(spacing: 12) { Image(systemName: "desktopcomputer") - .font(.system(size: 21, weight: .regular)) - .foregroundStyle(OpenBitFunTheme.muted) - .frame(width: 42, height: 42) + .font(.system(size: 20, weight: .regular)) + .foregroundStyle(connected ? OpenBitFunTheme.ink : OpenBitFunTheme.muted) + .frame(width: 28, height: 28) VStack(alignment: .leading, spacing: 3) { Text(device.name) - .font(.system(size: 16, weight: .medium)) + .font(MobileDesignTypography.titleSmall.font) .foregroundStyle(OpenBitFunTheme.ink) .lineLimit(1) - Text(MobileLocalization.text(device.online ? "在线" : "离线")) - .font(.system(size: 12)) + Text(deviceStatus) + .font(MobileDesignTypography.bodySmall.font) .foregroundStyle(device.online ? OpenBitFunTheme.statusSuccess : OpenBitFunTheme.muted) } - Spacer(minLength: 12) - if device.selected { - Image(systemName: "checkmark.circle.fill") - .font(.system(size: 18)) - .foregroundStyle(OpenBitFunTheme.statusSuccess) - } else { - Image(systemName: "chevron.right") - .font(.system(size: 14, weight: .medium)) - .foregroundStyle(OpenBitFunTheme.muted) + Spacer(minLength: 0) + 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, 20) - .frame(minHeight: 76) + .padding(.horizontal, 10) + .padding(.vertical, 10) + .frame(minHeight: 62) + .background(connected ? OpenBitFunTheme.soft : OpenBitFunTheme.card) + .clipShape(RoundedRectangle(cornerRadius: MobileDesignGeometry.settingsCompactCardRadius)) + .opacity(device.online ? 1 : 0.48) + } + + private var deviceStatus: String { + let presence = MobileLocalization.text(device.online ? "在线" : "离线") + return connected ? "\(MobileLocalization.text("当前控制")) · \(presence)" : presence } } diff --git a/src/apps/mobile/ios/OpenBitFun/Features/Chat/ConversationHeader.swift b/src/apps/mobile/ios/OpenBitFun/Features/Chat/ConversationHeader.swift index 20c1c80eec..a32c8c6667 100644 --- a/src/apps/mobile/ios/OpenBitFun/Features/Chat/ConversationHeader.swift +++ b/src/apps/mobile/ios/OpenBitFun/Features/Chat/ConversationHeader.swift @@ -11,11 +11,19 @@ struct ConversationHeader: View { @State private var editing = false @State private var renameDraft = "" + private var resolvedTitle: String { + if let title = model.selectedSession?.title, !title.isEmpty { return title } + if model.surface == .remote { return model.localized("远程") } + return "OpenBitFun" + } + private var resolvedSubtitle: String? { if let contextTitle, !contextTitle.isEmpty { return contextTitle } if model.surface == .local && model.localSessionSelected { return model.localized("本地会话") } - if model.remoteConnected && model.remoteSessionSelected { - return model.accountDeviceName ?? model.localized("已连接桌面端") + if model.remoteConnected { + return model.accountDeviceName + ?? model.directPairingDeviceName + ?? model.localized("已连接桌面端") } return nil } @@ -49,7 +57,7 @@ struct ConversationHeader: View { } VStack(spacing: 3) { - Text(model.selectedSession?.title ?? "OpenBitFun") + Text(resolvedTitle) .font( (resolvedSubtitle == nil ? MobileDesignTypography.titleMedium @@ -196,7 +204,7 @@ struct ConversationActionsPopover: View { .padding(.leading, 8) if model.surface == .local { action( - model.selectedSession?.pinned == true ? "取消置顶" : "置顶会话", + model.selectedSession?.pinned == true ? "取消置顶" : "置顶", icon: "checkmark.circle", selected: model.selectedSession?.pinned == true, perform: model.togglePinSelectedSession @@ -205,7 +213,7 @@ struct ConversationActionsPopover: View { action("已上传文件", icon: "cloud", perform: model.showUploadedFiles) if model.surface == .local { Divider().overlay(OpenBitFunTheme.line).padding(.vertical, 8) - action("归档会话", icon: "folder", perform: model.archiveSelectedSession) + action("归档", icon: "folder", perform: model.archiveSelectedSession) action("删除", icon: "gearshape", perform: model.deleteSelectedSession) } else if model.isSending { Divider().overlay(OpenBitFunTheme.line).padding(.vertical, 8) diff --git a/src/apps/mobile/ios/OpenBitFun/Features/Pairing/PairingSheet.swift b/src/apps/mobile/ios/OpenBitFun/Features/Pairing/PairingSheet.swift index 1e19ba4e88..43e18facf2 100644 --- a/src/apps/mobile/ios/OpenBitFun/Features/Pairing/PairingSheet.swift +++ b/src/apps/mobile/ios/OpenBitFun/Features/Pairing/PairingSheet.swift @@ -2,7 +2,7 @@ import OpenBitFunMobileCore import SwiftUI struct PairingSheet: View { - private enum Step { case intro, scan } + private enum Step { case intro, account, scan } @ObservedObject var model: MobileAppModel @Environment(\.dismiss) private var dismiss @@ -15,11 +15,16 @@ struct PairingSheet: View { @State private var pairingPassword = "" @State private var manualOpen = false @State private var scanError: String? + @State private var switchingDeviceID: String? @FocusState private var focused: Bool var body: some View { return ZStack { - if step == .intro { introPage } else { scanPage } + switch step { + case .intro: introPage + case .account: accountDevicePage + case .scan: scanPage + } if manualOpen { manualPairingOverlay } } .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -33,8 +38,211 @@ struct PairingSheet: View { step = .scan manualOpen = true focused = !MobileLaunchConfiguration.pairingAccountPreview + } else if model.accountUser != nil { + step = .account + model.refreshRemoteDevices() + } + } + .onChange(of: model.accountSelectedDeviceID) { selectedDeviceID in + guard step == .account, selectedDeviceID == switchingDeviceID else { return } + switchingDeviceID = nil + dismiss() + } + .onChange(of: model.coreErrorMessage) { error in + if step == .account, error != nil { switchingDeviceID = nil } + } + } + + private var accountDevicePage: some View { + VStack(spacing: 0) { + HStack(spacing: 16) { + Button { dismiss() } label: { + Image(systemName: "chevron.left") + .font(.system(size: 20, weight: .medium)) + .foregroundStyle(OpenBitFunTheme.ink) + .frame(width: 48, height: 48) + .background(OpenBitFunTheme.card) + .clipShape(Circle()) + } + .buttonStyle(.plain) + .accessibilityLabel(model.localized("返回")) + + VStack(alignment: .leading, spacing: 4) { + Text(model.localized("选择桌面设备")) + .font(MobileDesignTypography.headlineLarge.font) + .foregroundStyle(OpenBitFunTheme.ink) + Text(model.localized("远程")) + .font(MobileDesignTypography.bodySmall.font) + .foregroundStyle(OpenBitFunTheme.muted) + } + Spacer(minLength: 0) + } + .padding(.horizontal, 28) + .padding(.top, 18) + .frame(height: 92, alignment: .top) + + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 18) { + Text(model.localized("选择一台在线桌面继续工作。")) + .font(MobileDesignTypography.bodyMedium.font) + .foregroundStyle(OpenBitFunTheme.muted) + .lineSpacing(MobileDesignTypography.bodyMedium.lineSpacing) + + accountDeviceList + + Button { + scanError = nil + step = .scan + } label: { + HStack(spacing: 12) { + Image(systemName: "link") + .font(.system(size: 20, weight: .regular)) + .foregroundStyle(OpenBitFunTheme.muted.opacity(0.66)) + .frame(width: 22, height: 22) + Text(model.localized("扫描二维码连接")) + .font(MobileDesignTypography.bodyLarge.font.weight(.medium)) + .foregroundStyle(OpenBitFunTheme.ink) + Spacer(minLength: 0) + Image(systemName: "chevron.right") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(OpenBitFunTheme.muted.opacity(0.44)) + } + .padding(.horizontal, 16) + .frame(height: 58) + .background(OpenBitFunTheme.card) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .overlay(RoundedRectangle(cornerRadius: 8).stroke(OpenBitFunTheme.line, lineWidth: 1)) + } + .buttonStyle(.plain) + } + .frame(maxWidth: 520) + .padding(.horizontal, 28) + .padding(.top, 10) + .padding(.bottom, 34) + .frame(maxWidth: .infinity) + } + } + .background(OpenBitFunTheme.page) + } + + private var accountDeviceList: some View { + VStack(spacing: 4) { + HStack { + Text(model.localized("账号设备")) + .font(MobileDesignTypography.bodyLarge.font.weight(.bold)) + .foregroundStyle(OpenBitFunTheme.ink) + Spacer(minLength: 0) + Button(action: model.refreshRemoteDevices) { + Text(model.localized(model.accountRefreshing ? "正在加载" : "刷新")) + .font(MobileDesignTypography.bodyMedium.font) + .foregroundStyle(model.accountRefreshing ? OpenBitFunTheme.muted : OpenBitFunTheme.accent) + .frame(minWidth: 44, minHeight: 38, alignment: .trailing) + } + .buttonStyle(.plain) + .disabled(model.accountRefreshing) } + .frame(height: 38) + + Group { + if model.accountRefreshing && accountDesktopDevices.isEmpty { + VStack(spacing: 0) { + accountDeviceSkeleton + accountDeviceSkeleton + } + } else if accountDesktopDevices.isEmpty { + Text(model.localized("暂无可连接的桌面设备")) + .font(MobileDesignTypography.bodyMedium.font) + .foregroundStyle(OpenBitFunTheme.muted) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + } else { + ScrollView(showsIndicators: false) { + VStack(spacing: 0) { + ForEach(accountDesktopDevices) { device in + accountDeviceRow(device) + } + } + } + } + } + .frame(height: 120) } + .padding(.horizontal, 16) + .padding(.vertical, 8) + .frame(height: 174) + .background(OpenBitFunTheme.card) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .overlay(RoundedRectangle(cornerRadius: 8).stroke(OpenBitFunTheme.line, lineWidth: 1)) + } + + private var accountDesktopDevices: [MobileAccountDevice] { + model.accountDevices.filter { model.localDeviceID.isEmpty || $0.id != model.localDeviceID } + } + + private var accountDeviceSkeleton: some View { + HStack(spacing: 12) { + RoundedRectangle(cornerRadius: 5).fill(OpenBitFunTheme.soft).frame(width: 26, height: 22) + VStack(alignment: .leading, spacing: 7) { + RoundedRectangle(cornerRadius: 4).fill(OpenBitFunTheme.soft).frame(width: 142, height: 12) + RoundedRectangle(cornerRadius: 4).fill(OpenBitFunTheme.soft).frame(width: 52, height: 9) + } + Spacer(minLength: 0) + } + .padding(.horizontal, 4) + .frame(height: 60) + } + + private func accountDeviceRow(_ device: MobileAccountDevice) -> some View { + Button { + guard device.online, switchingDeviceID == nil else { return } + switchingDeviceID = device.id + model.selectRemoteDevice(device) + } label: { + HStack(spacing: 12) { + Image(systemName: "desktopcomputer") + .font(.system(size: 22, weight: .regular)) + .foregroundStyle(OpenBitFunTheme.muted.opacity(device.online ? 0.68 : 0.38)) + .frame(width: 26, height: 24) + VStack(alignment: .leading, spacing: 3) { + Text(device.name.isEmpty ? device.id : device.name) + .font(MobileDesignTypography.titleSmall.font) + .foregroundStyle(OpenBitFunTheme.ink) + .lineLimit(1) + Text(accountDeviceStatus(device)) + .font(MobileDesignTypography.bodySmall.font) + .foregroundStyle(device.online ? OpenBitFunTheme.statusSuccess : OpenBitFunTheme.muted) + } + Spacer(minLength: 0) + if device.online && !(device.selected && model.connectionPhase == .connected) { + Text(model.localized(switchingDeviceID == device.id ? "正在连接" : "连接")) + .font(MobileDesignTypography.bodyMedium.font) + .foregroundStyle(OpenBitFunTheme.ink) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(OpenBitFunTheme.soft) + .clipShape(Capsule()) + } else if device.online { + Image(systemName: "chevron.right") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(OpenBitFunTheme.muted.opacity(0.44)) + } + } + .padding(.horizontal, 4) + .frame(height: 60) + .contentShape(Rectangle()) + .opacity(device.online ? 1 : 0.64) + } + .buttonStyle(.plain) + .disabled(!device.online || switchingDeviceID != nil) + } + + private func accountDeviceStatus(_ device: MobileAccountDevice) -> String { + if switchingDeviceID == device.id { return model.localized("正在连接") } + let presence = model.localized(device.online ? "在线" : "离线") + if device.selected && model.connectionPhase == .connected { + return "\(model.localized("当前控制")) · \(presence)" + } + if device.selected { return "\(model.localized("上次连接")) · \(presence)" } + return presence } private var introPage: some View { @@ -204,7 +412,11 @@ struct PairingSheet: View { endPoint: .bottomTrailing ) Button { - if step == .scan { step = .intro } else { dismiss() } + if step == .scan { + step = model.accountUser == nil ? .intro : .account + } else { + dismiss() + } } label: { Image(systemName: "chevron.left") .font(.system(size: 20, weight: .medium)) diff --git a/src/apps/mobile/ios/OpenBitFun/Features/Remote/RemoteHomeViews.swift b/src/apps/mobile/ios/OpenBitFun/Features/Remote/RemoteHomeViews.swift index 68e9cb13f5..a0d54cf101 100644 --- a/src/apps/mobile/ios/OpenBitFun/Features/Remote/RemoteHomeViews.swift +++ b/src/apps/mobile/ios/OpenBitFun/Features/Remote/RemoteHomeViews.swift @@ -5,8 +5,7 @@ struct RemoteHomeView: View { @ObservedObject var model: MobileAppModel var body: some View { - ZStack(alignment: .topTrailing) { - VStack(spacing: 12) { + VStack(spacing: 12) { Spacer() ZStack { Image(systemName: "desktopcomputer") @@ -33,67 +32,54 @@ struct RemoteHomeView: View { .background(OpenBitFunTheme.accent) .clipShape(Capsule()) Spacer() - } - remoteSettingsButton } .frame(maxWidth: .infinity, maxHeight: .infinity) .padding(.horizontal, 20) .padding(.bottom, 48) .background(OpenBitFunTheme.page) } - - private var remoteSettingsButton: some View { - Button { model.remoteControlSettingsOpen = true } label: { - Image(systemName: "gearshape") - .font(.system(size: 18, weight: .medium)) - .foregroundStyle(OpenBitFunTheme.ink) - .frame(width: 44, height: 44) - .background(OpenBitFunTheme.card) - .overlay(Circle().stroke(OpenBitFunTheme.line, lineWidth: 1)) - .clipShape(Circle()) - } - .buttonStyle(.plain) - .accessibilityLabel(model.localized("远程控制设置")) - .padding(.top, 16).padding(.trailing, 16) - } } struct RemoteConnectedHomeView: View { @ObservedObject var model: MobileAppModel var body: some View { - ZStack(alignment: .topTrailing) { - VStack(spacing: 14) { + VStack(spacing: 10) { Spacer() - Image(systemName: "desktopcomputer.and.macbook") - .font(.system(size: 34, weight: .medium)).foregroundStyle(OpenBitFunTheme.muted) - Text(model.localized("桌面端已连接")) - .font(MobileDesignTypography.titleMedium.font).foregroundStyle(OpenBitFunTheme.ink) - Text(model.localized("选择已有会话,或在当前工作区创建一个新会话。")) - .font(MobileDesignTypography.bodySmall.font).foregroundStyle(OpenBitFunTheme.muted) + Text(model.localized(model.remoteSessions.isEmpty ? "还没有远程对话" : "选择一个会话")) + .font(MobileDesignTypography.headlineMedium.font) + .foregroundStyle(OpenBitFunTheme.ink) + Text(model.localized( + model.remoteSessions.isEmpty + ? "新建聊天后,可以从手机继续处理桌面端任务。" + : "从侧边栏打开会话,或新建一个。" + )) + .font(MobileDesignTypography.bodyMedium.font) + .foregroundStyle(OpenBitFunTheme.muted) .multilineTextAlignment(.center) - Button { model.remoteCreateOpen = true } label: { - Label(model.localized("新建远程会话"), systemImage: "plus") - .font(MobileDesignTypography.labelMedium.font).foregroundStyle(OpenBitFunTheme.contentOnAction) - .frame(minWidth: 176, minHeight: 44).background(OpenBitFunTheme.accent).clipShape(Capsule()) + .lineLimit(2) + .frame(maxWidth: 280) + Button(action: model.createRemoteSessionFromHome) { + HStack(spacing: 8) { + if model.remoteCreateSubmitting { + ProgressView().controlSize(.small).tint(OpenBitFunTheme.contentOnAction) + } + Text(model.localized(model.remoteCreateSubmitting ? "正在加载" : "新建会话")) + } + .font(MobileDesignTypography.titleSmall.font) + .foregroundStyle(OpenBitFunTheme.contentOnAction) + .frame(width: 148, height: 46) + .background(OpenBitFunTheme.accent) + .clipShape(Capsule()) } .buttonStyle(.plain) + .disabled(model.remoteCreateSubmitting || !model.remoteCreateInteraction.canSubmit) + .padding(.top, 12) Spacer() - } - Button { model.remoteControlSettingsOpen = true } label: { - Image(systemName: "gearshape") - .font(.system(size: 18, weight: .medium)) - .foregroundStyle(OpenBitFunTheme.ink) - .frame(width: 44, height: 44) - .background(OpenBitFunTheme.card) - .overlay(Circle().stroke(OpenBitFunTheme.line, lineWidth: 1)) - .clipShape(Circle()) - } - .buttonStyle(.plain) - .accessibilityLabel(model.localized("远程控制设置")) - .padding(.top, 16).padding(.trailing, 16) } .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(.horizontal, 24) + .padding(.bottom, 56) .background(OpenBitFunTheme.page) } } diff --git a/src/apps/mobile/ios/OpenBitFun/Features/Shell/MobileShellView.swift b/src/apps/mobile/ios/OpenBitFun/Features/Shell/MobileShellView.swift index c5547e14fa..fcf75483e4 100644 --- a/src/apps/mobile/ios/OpenBitFun/Features/Shell/MobileShellView.swift +++ b/src/apps/mobile/ios/OpenBitFun/Features/Shell/MobileShellView.swift @@ -335,7 +335,9 @@ struct MobileShellView: View { sidebarAction: sidebarAction, sidebarActionLabel: sidebarActionLabel ) - if model.connectionPhase != .connected { + if model.surface == .remote, + model.remoteSessionSelected, + model.connectionPhase != .connected { ConnectionStatusBar( phase: model.connectionPhase, detail: model.coreErrorMessage, diff --git a/src/apps/mobile/ios/OpenBitFun/Features/Shell/RemoteSettingsViews.swift b/src/apps/mobile/ios/OpenBitFun/Features/Shell/RemoteSettingsViews.swift index ce2a892a37..cc2566ff9b 100644 --- a/src/apps/mobile/ios/OpenBitFun/Features/Shell/RemoteSettingsViews.swift +++ b/src/apps/mobile/ios/OpenBitFun/Features/Shell/RemoteSettingsViews.swift @@ -26,79 +26,61 @@ struct RemoteViewSettingsView: View { Divider().overlay(OpenBitFunTheme.line) ScrollView(showsIndicators: false) { - VStack(alignment: .leading, spacing: 8) { + VStack(alignment: .leading, spacing: 0) { sectionTitle("分组方式") - SettingsCard { - choiceRow("按项目", value: "PROJECT", selected: model.remoteGroupMode) - settingsDivider - choiceRow("按时间倒序排列", value: "TIME", selected: model.remoteGroupMode) - settingsDivider - choiceRow("聊天优先", value: "CHAT", selected: model.remoteGroupMode) - } + choiceRow("按项目", value: "PROJECT", selected: model.remoteGroupMode) + choiceRow("按时间倒序排列", value: "TIME", selected: model.remoteGroupMode) + choiceRow("聊天优先", value: "CHAT", selected: model.remoteGroupMode) sectionTitle("筛选") filterLabel("工作区") - SettingsCard { + filterRow( + "所有工作区", + selected: model.remoteWorkspaceFilter.isEmpty, + action: { model.remoteWorkspaceFilter = "" } + ) + ForEach(workspaces) { workspace in filterRow( - "所有工作区", - selected: model.remoteWorkspaceFilter.isEmpty, - action: { model.remoteWorkspaceFilter = "" } + workspace.name, + selected: normalizedPath(model.remoteWorkspaceFilter) == normalizedPath(workspace.path), + action: { model.remoteWorkspaceFilter = workspace.path } ) - ForEach(workspaces) { workspace in - settingsDivider - filterRow( - workspace.name, - selected: normalizedPath(model.remoteWorkspaceFilter) == normalizedPath(workspace.path), - action: { model.remoteWorkspaceFilter = workspace.path } - ) - } } filterLabel("Agent 类型") - SettingsCard { + filterRow( + "所有 Agent 类型", + selected: model.remoteViewAgentFilter.isEmpty, + action: { model.remoteViewAgentFilter = "" } + ) + ForEach(agentGroups, id: \.self) { group in filterRow( - "所有 Agent 类型", - selected: model.remoteViewAgentFilter.isEmpty, - action: { model.remoteViewAgentFilter = "" } + agentLabel(group), + selected: model.remoteViewAgentFilter == group, + action: { model.remoteViewAgentFilter = group } ) - ForEach(agentGroups, id: \.self) { group in - settingsDivider - filterRow( - agentLabel(group), - selected: model.remoteViewAgentFilter == group, - action: { model.remoteViewAgentFilter = group } - ) - } } filterLabel("状态") - SettingsCard { + filterRow( + "所有状态", + selected: model.remoteStatusFilter.isEmpty, + action: { model.remoteStatusFilter = "" } + ) + ForEach(statuses, id: \.self) { status in filterRow( - "所有状态", - selected: model.remoteStatusFilter.isEmpty, - action: { model.remoteStatusFilter = "" } + statusLabel(status), + selected: model.remoteStatusFilter == status, + action: { model.remoteStatusFilter = status } ) - ForEach(statuses, id: \.self) { status in - settingsDivider - filterRow( - statusLabel(status), - selected: model.remoteStatusFilter == status, - action: { model.remoteStatusFilter = status } - ) - } } sectionTitle("显示信息") - SettingsCard { - metadataToggle("工作区", isOn: $model.remoteShowWorkspaceMetadata) - settingsDivider - metadataToggle("更新时间", isOn: $model.remoteShowUpdatedMetadata) - settingsDivider - metadataToggle("状态", isOn: $model.remoteShowStatusMetadata) - } + metadataToggle("工作区", isOn: $model.remoteShowWorkspaceMetadata) + metadataToggle("更新时间", isOn: $model.remoteShowUpdatedMetadata) + metadataToggle("状态", isOn: $model.remoteShowStatusMetadata) } .padding(.horizontal, 20) - .padding(.top, 8) .padding(.bottom, 34) } } @@ -107,22 +89,22 @@ struct RemoteViewSettingsView: View { private func sectionTitle(_ title: String) -> some View { Text(model.localized(title)) - .font(MobileDesignTypography.labelLarge.font) + .font(MobileDesignTypography.labelSmall.font.weight(.medium)) .foregroundStyle(OpenBitFunTheme.muted) - .padding(.top, 8) + .frame(maxWidth: .infinity, alignment: .leading) .padding(.leading, 4) + .padding(.top, 12) + .frame(height: 38, alignment: .topLeading) } private func filterLabel(_ title: String) -> some View { Text(model.localized(title)) .font(MobileDesignTypography.labelSmall.font) .foregroundStyle(OpenBitFunTheme.muted) - .padding(.top, 2) + .frame(maxWidth: .infinity, alignment: .leading) .padding(.leading, 10) - } - - private var settingsDivider: some View { - Divider().overlay(OpenBitFunTheme.line).padding(.horizontal, 20) + .padding(.top, 10) + .frame(height: 34, alignment: .topLeading) } private func choiceRow(_ title: String, value: String, selected: String) -> some View { @@ -132,33 +114,42 @@ struct RemoteViewSettingsView: View { private func filterRow(_ title: String, selected: Bool, action: @escaping () -> Void) -> some View { Button(action: action) { HStack(spacing: 12) { + Image(systemName: selected ? "checkmark.circle.fill" : "circle") + .font(.system(size: 20, weight: .regular)) + .foregroundStyle(selected ? OpenBitFunTheme.ink : OpenBitFunTheme.muted) + .frame(width: 22) Text(model.localized(title)) - .font(.system(size: 16, weight: .medium)) + .font(MobileDesignTypography.titleSmall.font) .foregroundStyle(OpenBitFunTheme.ink) .lineLimit(1) Spacer(minLength: 0) - if selected { - Image(systemName: "checkmark") - .font(.system(size: 15, weight: .semibold)) - .foregroundStyle(OpenBitFunTheme.accent) - } } - .padding(.horizontal, 20) - .frame(minHeight: 52) + .padding(.horizontal, 10) + .frame(height: 46) + .frame(maxWidth: .infinity) + .background(selected ? OpenBitFunTheme.card : OpenBitFunTheme.transparent) + .overlay(alignment: .bottom) { + Rectangle().fill(OpenBitFunTheme.line).frame(height: 1) + } .contentShape(Rectangle()) } .buttonStyle(.plain) + .frame(maxWidth: .infinity) } private func metadataToggle(_ title: String, isOn: Binding) -> some View { Toggle(isOn: isOn) { Text(model.localized(title)) - .font(.system(size: 16, weight: .medium)) + .font(MobileDesignTypography.titleSmall.font) .foregroundStyle(OpenBitFunTheme.ink) } - .tint(OpenBitFunTheme.accent) - .padding(.horizontal, 20) - .frame(minHeight: 56) + .tint(OpenBitFunTheme.ink) + .padding(.leading, 10) + .padding(.trailing, 6) + .frame(height: 52) + .overlay(alignment: .bottom) { + Rectangle().fill(OpenBitFunTheme.line).frame(height: 1) + } } private func agentLabel(_ group: String) -> String { @@ -214,17 +205,18 @@ struct RemoteControlSettingsView: View { } private var controlPage: some View { - ZStack(alignment: .topTrailing) { + VStack(spacing: 0) { + OpenBitFunModalHeader( + title: "远程控制", + onClose: { model.remoteControlSettingsOpen = false } + ) + .padding(.horizontal, MobileDesignGeometry.sheetHorizontalPadding) + Divider().overlay(OpenBitFunTheme.line) + ScrollView(showsIndicators: false) { VStack(alignment: .leading, spacing: 0) { - Text(model.localized("远程控制")) - .font(.system(size: 20, weight: .bold)) - .foregroundStyle(OpenBitFunTheme.ink) - .frame(maxWidth: .infinity, minHeight: 56, alignment: .center) - .padding(.bottom, 30) - Button { page = .account } label: { - SettingsCard { + remoteCard(radius: 28) { HStack(spacing: 12) { Image(systemName: "person.crop.circle") .font(.system(size: 28, weight: .regular)) @@ -248,64 +240,54 @@ struct RemoteControlSettingsView: View { remoteSectionTitle("当前远程控制") currentControlCard - remoteSectionTitle("其他连接方式") - .padding(.top, 16) - Button { - model.remoteControlSettingsOpen = false - DispatchQueue.main.asyncAfter(deadline: .now() + 0.22) { - model.connectRemote() - } - } label: { - SettingsCard { - HStack(spacing: 12) { - Image(systemName: "link") - .font(.system(size: 20, weight: .regular)) - .foregroundStyle(OpenBitFunTheme.muted) - .frame(width: 24, height: 24) - VStack(alignment: .leading, spacing: 2) { - Text(model.localized("扫描二维码连接")) - .font(.system(size: 16, weight: .medium)) - .foregroundStyle(OpenBitFunTheme.ink) - Text(model.localized("适用于临时配对或未登录账号的桌面端。")) - .font(.system(size: 13)) + VStack(alignment: .leading, spacing: 10) { + remoteSectionTitle("其他连接方式") + Button { + model.remoteControlSettingsOpen = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.22) { + model.connectRemote() + } + } label: { + remoteCard(radius: 24) { + HStack(spacing: 12) { + Image(systemName: "link") + .font(.system(size: 20, weight: .regular)) .foregroundStyle(OpenBitFunTheme.muted) - .lineLimit(2) + .frame(width: 24, height: 24) + VStack(alignment: .leading, spacing: 2) { + Text(model.localized("扫描二维码连接")) + .font(.system(size: 16, weight: .medium)) + .foregroundStyle(OpenBitFunTheme.ink) + Text(model.localized("适用于临时配对或未登录账号的桌面端。")) + .font(.system(size: 13)) + .foregroundStyle(OpenBitFunTheme.muted) + .lineLimit(2) + } + Spacer(minLength: 8) + Image(systemName: "chevron.right") + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(OpenBitFunTheme.muted.opacity(0.72)) } - Spacer(minLength: 8) - Image(systemName: "chevron.right") - .font(.system(size: 14, weight: .medium)) - .foregroundStyle(OpenBitFunTheme.muted.opacity(0.72)) + .padding(.horizontal, 18) + .frame(minHeight: 78) } - .padding(.horizontal, 18) - .frame(minHeight: 78) } + .buttonStyle(.plain) } - .buttonStyle(.plain) + .padding(.top, 16) + .padding(.bottom, 8) permissionSection - .padding(.top, 20) } - .padding(.horizontal, 18) - .padding(.top, 20) + .padding(.horizontal, MobileDesignGeometry.sheetHorizontalPadding) + .padding(.top, 22) .padding(.bottom, 42) } - - Button { model.remoteControlSettingsOpen = false } label: { - Image(systemName: "xmark") - .font(.system(size: 17, weight: .regular)) - .foregroundStyle(OpenBitFunTheme.ink) - .frame(width: 40, height: 40) - .background(OpenBitFunTheme.card) - .clipShape(Circle()) - } - .buttonStyle(.plain) - .accessibilityLabel(model.localized("关闭")) - .padding(.top, 16).padding(.trailing, 16) } } private var currentControlCard: some View { - SettingsCard { + remoteCard(radius: 28) { HStack(spacing: 14) { Image(systemName: "desktopcomputer") .font(.system(size: 23, weight: .regular)) @@ -362,7 +344,7 @@ struct RemoteControlSettingsView: View { .disabled(model.busy) } } - SettingsCard { + remoteCard(radius: 28) { Text(model.localized("控制桌面端执行工具时采用的确认方式。")) .font(.system(size: 13)).foregroundStyle(OpenBitFunTheme.muted) .frame(maxWidth: .infinity, alignment: .leading) @@ -441,12 +423,19 @@ struct RemoteControlSettingsView: View { private func remoteSectionTitle(_ title: String) -> some View { Text(model.localized(title)) - .font(.system(size: 18, weight: .bold)) + .font(MobileDesignTypography.titleSmall.font) .foregroundStyle(OpenBitFunTheme.muted) .frame(maxWidth: .infinity, minHeight: 42, alignment: .leading) .padding(.horizontal, 18) } + private func remoteCard( + radius: CGFloat, + @ViewBuilder content: @escaping () -> Content + ) -> some View { + OpenBitFunModalCard(radius: radius, bordered: false, content: content) + } + private func remoteChip(_ title: String, action: @escaping () -> Void) -> some View { Button(action: action) { Text(model.localized(title)) diff --git a/src/apps/mobile/ios/OpenBitFun/Features/Shell/SessionActionComponents.swift b/src/apps/mobile/ios/OpenBitFun/Features/Shell/SessionActionComponents.swift index e9f544b4ab..57f0f43c3b 100644 --- a/src/apps/mobile/ios/OpenBitFun/Features/Shell/SessionActionComponents.swift +++ b/src/apps/mobile/ios/OpenBitFun/Features/Shell/SessionActionComponents.swift @@ -92,7 +92,7 @@ struct SessionActionSurface: View { } if canArchive { actionRow( - session.status.lowercased() == "archived" ? "取消归档" : "归档会话", + session.status.lowercased() == "archived" ? "取消归档" : "归档", icon: "archivebox" ) { onArchive() @@ -100,7 +100,7 @@ struct SessionActionSurface: View { } } if canExport { - actionRow("导出会话", icon: "cloud") { + actionRow("复制 Markdown", icon: "cloud") { onExport() onClose() } @@ -272,7 +272,9 @@ struct SessionDetailsView: View { } .padding(.vertical, 8) .frame(minHeight: 52) - .overlay(alignment: .bottom) { Divider().overlay(OpenBitFunTheme.line) } + .overlay(alignment: .bottom) { + Rectangle().fill(OpenBitFunTheme.line).frame(height: 1) + } } private func pathRow(_ path: String) -> some View { @@ -292,6 +294,8 @@ struct SessionDetailsView: View { .textSelection(.enabled) } .padding(.vertical, 12) - .overlay(alignment: .bottom) { Divider().overlay(OpenBitFunTheme.line) } + .overlay(alignment: .bottom) { + Rectangle().fill(OpenBitFunTheme.line).frame(height: 1) + } } } diff --git a/src/apps/mobile/ios/OpenBitFun/Features/Shell/SidebarView.swift b/src/apps/mobile/ios/OpenBitFun/Features/Shell/SidebarView.swift index 4ec7a498f1..e24f3ae02b 100644 --- a/src/apps/mobile/ios/OpenBitFun/Features/Shell/SidebarView.swift +++ b/src/apps/mobile/ios/OpenBitFun/Features/Shell/SidebarView.swift @@ -39,6 +39,7 @@ struct SidebarView: View { @State private var expandedWorkspacePaths: Set = [] @State private var expandedDeviceWorkspaceLists: Set = [] @State private var compactActionSession: ChatSession? + @State private var workspacePickerDevice: MobileDeviceDirectoryEntry? @State private var workspaceCreatePath: String? @State private var remoteChatsCollapsed = false @@ -67,6 +68,18 @@ struct SidebarView: View { return entries } + private var selectedDirectoryEntry: MobileDeviceDirectoryEntry? { + if model.directPairingConnected, + let direct = directoryEntries.first(where: { $0.id == model.directPairingSidebarDeviceID }) { + return direct + } + if let selectedID = model.accountSelectedDeviceID, + let selected = directoryEntries.first(where: { $0.id == selectedID }) { + return selected + } + return directoryEntries.first(where: \.online) ?? directoryEntries.first + } + var body: some View { GeometryReader { proxy in VStack(alignment: .leading, spacing: 0) { @@ -119,6 +132,18 @@ struct SidebarView: View { surface } } + .sheet(item: $workspacePickerDevice) { device in + SidebarWorkspacePickerSheet( + device: device, + onClose: { workspacePickerDevice = nil }, + onSelect: { workspace in + workspacePickerDevice = nil + model.selectDirectoryWorkspace(workspace) + } + ) + .presentationDetents([.large]) + .presentationDragIndicator(.hidden) + } .overlayPreferenceValue(SidebarWorkspaceCreateAnchorKey.self) { anchors in GeometryReader { proxy in if let path = workspaceCreatePath, @@ -143,7 +168,12 @@ struct SidebarView: View { } } .task { - if ProcessInfo.processInfo.arguments.contains("--project-create-menu"), + if ProcessInfo.processInfo.arguments.contains("--workspace-picker"), + workspacePickerDevice == nil, + let device = selectedDirectoryEntry { + try? await Task.sleep(nanoseconds: 450_000_000) + workspacePickerDevice = device + } else if ProcessInfo.processInfo.arguments.contains("--project-create-menu"), workspaceCreatePath == nil, let workspace = model.remoteWorkspaces.first { try? await Task.sleep(nanoseconds: 450_000_000) @@ -339,36 +369,75 @@ struct SidebarView: View { } ForEach(directoryEntries) { device in - directoryDevice(device) + directoryDeviceSelector(device) } + if let selectedDirectoryEntry { + HStack { + Text(model.localized("工作区")) + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(OpenBitFunTheme.muted) + Spacer(minLength: 0) + Button { workspacePickerDevice = selectedDirectoryEntry } label: { + Image(systemName: "plus") + .font(.system(size: 18, weight: .regular)) + .foregroundStyle(selectedDirectoryEntry.online ? OpenBitFunTheme.ink : OpenBitFunTheme.muted) + .frame(width: 34, height: 34) + } + .buttonStyle(.plain) + .disabled(!selectedDirectoryEntry.online) + .opacity(selectedDirectoryEntry.online ? 1 : 0.38) + .accessibilityLabel(Text(model.localized("添加工作区"))) + } + .frame(height: 48) + .padding(.top, 16) + + directoryDeviceBody(selectedDirectoryEntry) + } } } @ViewBuilder - private func directoryDevice(_ device: MobileDeviceDirectoryEntry) -> some View { - let current = model.accountSelectedDeviceID == device.id || device.id == model.directPairingSidebarDeviceID - VStack(alignment: .leading, spacing: 0) { - Button { model.toggleDeviceDirectory(device) } label: { - HStack(spacing: 10) { - ReferenceImage(assetName: "SidebarDeviceGlyph", width: 22, height: 18) - Text(device.name).font(.system(size: 15, weight: current ? .medium : .regular)) - .foregroundStyle(OpenBitFunTheme.ink).lineLimit(1) - Spacer(minLength: 0) - Circle().fill(device.online ? OpenBitFunTheme.statusSuccess : OpenBitFunTheme.muted).frame(width: 7, height: 7) - if current { Text(model.localized("当前控制")).font(.system(size: 11)).foregroundStyle(OpenBitFunTheme.statusSuccess) } - if device.status == "LOADING" { ProgressView().controlSize(.small) } - Image(systemName: device.expanded ? "chevron.down" : "chevron.right") - .font(.system(size: 12, weight: .medium)).foregroundStyle(OpenBitFunTheme.muted) + private func directoryDeviceSelector(_ device: MobileDeviceDirectoryEntry) -> some View { + let selected = selectedDirectoryEntry?.id == device.id + Button { selectDirectoryDevice(device) } label: { + HStack(spacing: 8) { + ReferenceImage(assetName: "SidebarDeviceGlyph", width: 24, height: 20) + Text(device.name) + .font(.system(size: 15, weight: selected ? .medium : .regular)) + .foregroundStyle(device.online ? OpenBitFunTheme.ink : OpenBitFunTheme.muted) + .lineLimit(1) + Spacer(minLength: 0) + if device.status == "LOADING" { + ProgressView().controlSize(.small) + } else { + Circle() + .fill(device.online ? OpenBitFunTheme.statusSuccess : OpenBitFunTheme.muted) + .frame(width: 8, height: 8) } - .padding(.horizontal, 10).frame(minHeight: 46).contentShape(Rectangle()) + Image(systemName: selected ? "chevron.down" : "chevron.right") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(device.online ? OpenBitFunTheme.ink : OpenBitFunTheme.muted) + .frame(width: 20, height: 24) } - .buttonStyle(.plain) - .accessibilityIdentifier("sidebar.device.\(device.id)") - .accessibilityLabel(Text(device.name)) - .accessibilityValue(Text(device.online ? model.localized("在线") : model.localized("离线"))) - if device.expanded { directoryDeviceBody(device) } + .padding(.leading, 8) + .padding(.trailing, 4) + .frame(height: 52) + .contentShape(Rectangle()) } + .buttonStyle(.plain) + .disabled(!device.online || selected) + .opacity(device.online ? 1 : 0.58) + .accessibilityIdentifier("sidebar.device.\(device.id)") + .accessibilityLabel(Text(device.name)) + .accessibilityValue(Text(device.online ? model.localized("在线") : model.localized("离线"))) + } + + private func selectDirectoryDevice(_ device: MobileDeviceDirectoryEntry) { + guard device.online, selectedDirectoryEntry?.id != device.id else { return } + guard device.id != model.directPairingSidebarDeviceID, + let accountDevice = model.accountDevices.first(where: { $0.id == device.id }) else { return } + model.selectRemoteDevice(accountDevice) } @ViewBuilder @@ -882,8 +951,22 @@ private struct SidebarWorkspaceRow: View { var body: some View { VStack(alignment: .leading, spacing: 0) { - HStack(spacing: 10) { - Button(action: onOpenWorkspace) { + HStack(spacing: 6) { + Button(action: onToggle) { + ReferenceImage( + assetName: expanded ? "SidebarDownGlyph" : "SidebarChevronGlyph", + width: 14, + height: 14 + ) + .opacity(0.62) + .frame(width: 24, height: 46) + } + .buttonStyle(.plain) + .accessibilityLabel( + MobileLocalization.text(expanded ? "收起工作区" : "展开工作区") + ) + + Button(action: onToggle) { HStack(spacing: 10) { ReferenceImage(assetName: "SidebarFolderGlyph", width: 24, height: 20) Text(workspace.name) @@ -894,14 +977,15 @@ private struct SidebarWorkspaceRow: View { .contentShape(Rectangle()) } .buttonStyle(.plain) + .onLongPressGesture(perform: onOpenWorkspace) .accessibilityIdentifier("sidebar.workspace.\(workspace.deviceKey ?? "unknown").\(workspace.path)") .accessibilityValue(Text(workspace.path)) Spacer(minLength: 0) Button(action: onToggleCreate) { - Image(systemName: "square.and.pencil") - .font(.system(size: 16, weight: .medium)) - .foregroundStyle(OpenBitFunTheme.muted) - .frame(width: 32, height: 40) + Image(systemName: "plus") + .font(.system(size: 17, weight: .regular)) + .foregroundStyle(OpenBitFunTheme.ink) + .frame(width: 30, height: 40) } .buttonStyle(.plain) .accessibilityLabel(MobileLocalization.text("新建远程会话")) @@ -911,23 +995,10 @@ private struct SidebarWorkspaceRow: View { value: .bounds, transform: { [workspace.path: $0] } ) - Button(action: onToggle) { - ReferenceImage( - assetName: expanded ? "SidebarDownGlyph" : "SidebarChevronGlyph", - width: 14, - height: 14 - ) - .opacity(0.62) - .frame(width: 32, height: 40) - } - .buttonStyle(.plain) - .accessibilityLabel( - MobileLocalization.text(expanded ? "收起工作区" : "展开工作区") - ) } - .padding(.horizontal, 10) + .padding(.leading, 6) + .padding(.trailing, 6) .frame(height: 46) - .background(workspace.selected ? OpenBitFunTheme.soft.opacity(0.75) : OpenBitFunTheme.transparent) .clipShape(RoundedRectangle(cornerRadius: 10)) if expanded { @@ -1010,3 +1081,105 @@ private struct SidebarWorkspaceRow: View { } } } + +private struct SidebarWorkspacePickerSheet: View { + let device: MobileDeviceDirectoryEntry + let onClose: () -> Void + let onSelect: (MobileWorkspaceGroup) -> Void + + var body: some View { + VStack(spacing: 0) { + HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 2) { + Text(MobileLocalization.text("选择工作区")) + .font(MobileDesignTypography.headlineMedium.font.weight(.bold)) + .foregroundStyle(OpenBitFunTheme.ink) + .lineLimit(1) + Text(device.name) + .font(MobileDesignTypography.bodySmall.font) + .foregroundStyle(OpenBitFunTheme.muted) + .lineLimit(1) + } + Spacer(minLength: 8) + Button(action: onClose) { + Image(systemName: "xmark") + .font(.system(size: 17, weight: .regular)) + .foregroundStyle(OpenBitFunTheme.muted) + .frame(width: 40, height: 40) + .background(OpenBitFunTheme.soft) + .clipShape(Circle()) + } + .buttonStyle(.plain) + .accessibilityLabel(Text(MobileLocalization.text("关闭"))) + } + .frame(height: 66) + + Divider().overlay(OpenBitFunTheme.line) + + if device.status == "LOADING" && device.workspaces.isEmpty { + VStack(spacing: 12) { + ProgressView().controlSize(.regular) + Text(MobileLocalization.text("正在加载")) + .font(MobileDesignTypography.bodyMedium.font) + .foregroundStyle(OpenBitFunTheme.muted) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if device.workspaces.isEmpty { + VStack(spacing: 8) { + Image(systemName: "folder") + .font(.system(size: 30, weight: .regular)) + .foregroundStyle(OpenBitFunTheme.muted) + Text(MobileLocalization.text("这台电脑还没有工作区")) + .font(MobileDesignTypography.bodyMedium.font) + .foregroundStyle(OpenBitFunTheme.muted) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + ScrollView(showsIndicators: false) { + LazyVStack(spacing: 4) { + ForEach(device.workspaces) { workspace in + Button { onSelect(workspace) } label: { + HStack(spacing: 12) { + Image(systemName: "folder") + .font(.system(size: 20, weight: .regular)) + .foregroundStyle(OpenBitFunTheme.ink) + .frame(width: 34, height: 34) + .background(OpenBitFunTheme.card) + .clipShape(RoundedRectangle(cornerRadius: 10)) + VStack(alignment: .leading, spacing: 3) { + Text(workspace.name) + .font(MobileDesignTypography.bodyLarge.font.weight(workspace.selected ? .medium : .regular)) + .foregroundStyle(OpenBitFunTheme.ink) + .lineLimit(1) + Text(workspace.path) + .font(MobileDesignTypography.labelSmall.font) + .foregroundStyle(OpenBitFunTheme.muted) + .lineLimit(1) + } + Spacer(minLength: 8) + Image(systemName: workspace.selected ? "checkmark" : "chevron.right") + .font(.system(size: workspace.selected ? 17 : 15, weight: .regular)) + .foregroundStyle(workspace.selected ? OpenBitFunTheme.ink : OpenBitFunTheme.muted) + .frame(width: 40, height: 40) + } + .padding(.leading, 10) + .padding(.trailing, 8) + .frame(height: 68) + .background(workspace.selected ? OpenBitFunTheme.soft : OpenBitFunTheme.page) + .clipShape(RoundedRectangle(cornerRadius: 14)) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + } + .padding(.top, 10) + .padding(.bottom, 18) + } + } + } + .padding(.horizontal, 18) + .padding(.top, 10) + .background(OpenBitFunTheme.page) + } +} diff --git a/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel+RemoteSession.swift b/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel+RemoteSession.swift index 3d233a4529..f049f0ac5f 100644 --- a/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel+RemoteSession.swift +++ b/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel+RemoteSession.swift @@ -407,6 +407,20 @@ extension MobileAppModel { coreAdapter?.selectRemoteAssistant(path: assistant.path) } + /// Compact Remote Home follows HarmonyOS by creating an empty conversation + /// immediately, then letting the normal composer own the first message. + func createRemoteSessionFromHome() { + guard remoteCreateInteraction.canSubmit else { + showToast(localized("远程会话当前不可创建,请重试")) + return + } + if let workspace = remoteWorkspaces.first(where: \.selected) ?? remoteWorkspaces.first { + createRemoteSession(in: workspace, agentType: "code") + } else { + createRemoteAssistantSession() + } + } + func selectRemoteAssistant(_ assistant: MobileAssistantOption) { guard remoteConnected, remoteCreateInteraction.canSelectWorkspace else { return } workspaceSelectionBusy = true