diff --git a/src/apps/mobile/ios/BitFun.xcodeproj/project.pbxproj b/src/apps/mobile/ios/BitFun.xcodeproj/project.pbxproj new file mode 100644 index 0000000000..7aeef2f7cd --- /dev/null +++ b/src/apps/mobile/ios/BitFun.xcodeproj/project.pbxproj @@ -0,0 +1,84 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 56; + objects = { + +/* Begin PBXBuildFile section */ + A10000000000000000000001 /* BitFunApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000001 /* BitFunApp.swift */; }; + A10000000000000000000002 /* MobileAppModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000002 /* MobileAppModel.swift */; }; + A10000000000000000000003 /* BitFunTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000003 /* BitFunTheme.swift */; }; + A10000000000000000000004 /* SidebarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000004 /* SidebarView.swift */; }; + A10000000000000000000005 /* ConversationHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000005 /* ConversationHeader.swift */; }; + A10000000000000000000006 /* ChatTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000006 /* ChatTimelineView.swift */; }; + A10000000000000000000007 /* ComposerBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000007 /* ComposerBar.swift */; }; + A10000000000000000000008 /* MobileShellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000008 /* MobileShellView.swift */; }; + A10000000000000000000010 /* MobileCoreAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000010 /* MobileCoreAdapter.swift */; }; + A10000000000000000000011 /* BitFunMobileCore.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = B10000000000000000000011 /* BitFunMobileCore.xcframework */; }; + A10000000000000000000012 /* libsqlite3.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = B10000000000000000000012 /* libsqlite3.tbd */; }; + A10000000000000000000009 /* Resources.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000009 /* Resources.xcassets */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + B10000000000000000000000 /* BitFun.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = BitFun.app; sourceTree = BUILT_PRODUCTS_DIR; }; + B10000000000000000000001 /* BitFunApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BitFunApp.swift; sourceTree = ""; }; + B10000000000000000000002 /* MobileAppModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MobileAppModel.swift; sourceTree = ""; }; + B10000000000000000000003 /* BitFunTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BitFunTheme.swift; sourceTree = ""; }; + B10000000000000000000004 /* SidebarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarView.swift; sourceTree = ""; }; + B10000000000000000000005 /* ConversationHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationHeader.swift; sourceTree = ""; }; + B10000000000000000000006 /* ChatTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatTimelineView.swift; sourceTree = ""; }; + B10000000000000000000007 /* ComposerBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposerBar.swift; sourceTree = ""; }; + B10000000000000000000008 /* MobileShellView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MobileShellView.swift; sourceTree = ""; }; + B10000000000000000000010 /* MobileCoreAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MobileCoreAdapter.swift; sourceTree = ""; }; + B10000000000000000000011 /* BitFunMobileCore.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = "../../shared/core-feature/build/XCFrameworks/debug/BitFunMobileCore.xcframework"; sourceTree = ""; }; + B10000000000000000000012 /* libsqlite3.tbd */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.text-based-dylib-definition; name = libsqlite3.tbd; path = "$(SDKROOT)/usr/lib/libsqlite3.tbd"; sourceTree = SDKROOT; }; + B10000000000000000000009 /* Resources.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Resources.xcassets; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + C10000000000000000000001 /* Frameworks */ = {isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( A10000000000000000000011 /* BitFunMobileCore.xcframework in Frameworks */, A10000000000000000000012 /* libsqlite3.tbd in Frameworks */ ); runOnlyForDeploymentPostprocessing = 0; }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + D10000000000000000000000 = {isa = PBXGroup; children = (D10000000000000000000001 /* BitFun */, D10000000000000000000009 /* Products */); sourceTree = ""; }; + D10000000000000000000001 /* BitFun */ = {isa = PBXGroup; children = (D10000000000000000000002 /* App */, D10000000000000000000003 /* Features */, D10000000000000000000008 /* Infrastructure */, B10000000000000000000009 /* Resources.xcassets */, B10000000000000000000011 /* BitFunMobileCore.xcframework */, B10000000000000000000012 /* libsqlite3.tbd */); path = BitFun; sourceTree = ""; }; + D10000000000000000000002 /* App */ = {isa = PBXGroup; children = (B10000000000000000000001 /* BitFunApp.swift */); path = App; sourceTree = ""; }; + D10000000000000000000003 /* Features */ = {isa = PBXGroup; children = (D10000000000000000000004 /* Chat */, D10000000000000000000005 /* Shell */); path = Features; sourceTree = ""; }; + D10000000000000000000004 /* Chat */ = {isa = PBXGroup; children = (B10000000000000000000005 /* ConversationHeader.swift */, B10000000000000000000006 /* ChatTimelineView.swift */, B10000000000000000000007 /* ComposerBar.swift */); path = Chat; sourceTree = ""; }; + D10000000000000000000005 /* Shell */ = {isa = PBXGroup; children = (B10000000000000000000003 /* BitFunTheme.swift */, B10000000000000000000004 /* SidebarView.swift */, B10000000000000000000008 /* MobileShellView.swift */); path = Shell; sourceTree = ""; }; + D10000000000000000000008 /* Infrastructure */ = {isa = PBXGroup; children = (B10000000000000000000002 /* MobileAppModel.swift */, B10000000000000000000010 /* MobileCoreAdapter.swift */); path = Infrastructure; sourceTree = ""; }; + D10000000000000000000009 /* Products */ = {isa = PBXGroup; children = (B10000000000000000000000 /* BitFun.app */); name = Products; sourceTree = ""; }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + E10000000000000000000001 /* BitFun */ = {isa = PBXNativeTarget; buildConfigurationList = F10000000000000000000002 /* Build configuration list for PBXNativeTarget "BitFun" */; buildPhases = (C10000000000000000000002 /* Sources */, C10000000000000000000001 /* Frameworks */, C10000000000000000000003 /* Resources */); buildRules = ( ); dependencies = ( ); name = BitFun; productName = BitFun; productReference = B10000000000000000000000 /* BitFun.app */; productType = "com.apple.product-type.application"; }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + E10000000000000000000000 /* Project object */ = {isa = PBXProject; attributes = {LastUpgradeCheck = 1640; TargetAttributes = {E10000000000000000000001 = {CreatedOnToolsVersion = 16.4; }; }; }; buildConfigurationList = F10000000000000000000001 /* Build configuration list for PBXProject "BitFun" */; compatibilityVersion = "Xcode 14.0"; developmentRegion = en; hasScannedForEncodings = 0; knownRegions = (en, Base); mainGroup = D10000000000000000000000; productRefGroup = D10000000000000000000009 /* Products */; projectDirPath = ""; projectRoot = ""; targets = (E10000000000000000000001 /* BitFun */); }; +/* End PBXProject section */ + +/* Begin PBXSourcesBuildPhase section */ + C10000000000000000000002 /* Sources */ = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = (A10000000000000000000001, A10000000000000000000002, A10000000000000000000003, A10000000000000000000004, A10000000000000000000005, A10000000000000000000006, A10000000000000000000007, A10000000000000000000008, A10000000000000000000010); runOnlyForDeploymentPostprocessing = 0; }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXResourcesBuildPhase section */ + C10000000000000000000003 /* Resources */ = {isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = (A10000000000000000000009); runOnlyForDeploymentPostprocessing = 0; }; +/* End PBXResourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + F10000000000000000000003 /* Debug */ = {isa = XCBuildConfiguration; buildSettings = {ALWAYS_SEARCH_USER_PATHS = NO; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGNING_ALLOWED = NO; CODE_SIGNING_REQUIRED = NO; DEVELOPMENT_TEAM = ""; FRAMEWORK_SEARCH_PATHS = ( "$(SRCROOT)/../shared/core-feature/build/XCFrameworks/debug" ); INFOPLIST_FILE = BitFun/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 16.0; PRODUCT_BUNDLE_IDENTIFIER = com.bitfun.mobile.ios; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; }; + F10000000000000000000004 /* Release */ = {isa = XCBuildConfiguration; buildSettings = {ALWAYS_SEARCH_USER_PATHS = NO; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGNING_ALLOWED = NO; CODE_SIGNING_REQUIRED = NO; DEVELOPMENT_TEAM = ""; FRAMEWORK_SEARCH_PATHS = ( "$(SRCROOT)/../shared/core-feature/build/XCFrameworks/debug" ); INFOPLIST_FILE = BitFun/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 16.0; PRODUCT_BUNDLE_IDENTIFIER = com.bitfun.mobile.ios; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Release; }; + F10000000000000000000005 /* Target Debug */ = {isa = XCBuildConfiguration; buildSettings = {SDKROOT = iphoneos; }; name = Debug; }; + F10000000000000000000006 /* Target Release */ = {isa = XCBuildConfiguration; buildSettings = {SDKROOT = iphoneos; }; name = Release; }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + F10000000000000000000001 /* Build configuration list for PBXProject "BitFun" */ = {isa = XCConfigurationList; buildConfigurations = (F10000000000000000000003 /* Debug */, F10000000000000000000004 /* Release */); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + F10000000000000000000002 /* Build configuration list for PBXNativeTarget "BitFun" */ = {isa = XCConfigurationList; buildConfigurations = (F10000000000000000000005 /* Target Debug */, F10000000000000000000006 /* Target Release */); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; +/* End XCConfigurationList section */ + }; + rootObject = E10000000000000000000000 /* Project object */; +} diff --git a/src/apps/mobile/ios/BitFun/App/BitFunApp.swift b/src/apps/mobile/ios/BitFun/App/BitFunApp.swift new file mode 100644 index 0000000000..bdec50e830 --- /dev/null +++ b/src/apps/mobile/ios/BitFun/App/BitFunApp.swift @@ -0,0 +1,13 @@ +import SwiftUI + +@main +struct BitFunApp: App { + @StateObject private var model = MobileAppModel.launchConfigured + + var body: some Scene { + WindowGroup { + MobileShellView(model: model) + .preferredColorScheme(.light) + } + } +} diff --git a/src/apps/mobile/ios/BitFun/Features/Chat/ChatTimelineView.swift b/src/apps/mobile/ios/BitFun/Features/Chat/ChatTimelineView.swift new file mode 100644 index 0000000000..189a10aae4 --- /dev/null +++ b/src/apps/mobile/ios/BitFun/Features/Chat/ChatTimelineView.swift @@ -0,0 +1,59 @@ +import SwiftUI + +struct ChatTimelineView: View { + @ObservedObject var model: MobileAppModel + + var body: some View { + ScrollViewReader { proxy in + ScrollView(showsIndicators: false) { + LazyVStack(spacing: 0) { + ForEach(model.messages) { message in + ChatMessageBubble(message: message) + .id(message.id) + } + if model.isSending { + HStack(spacing: 5) { + Circle().fill(BitFunTheme.muted).frame(width: 5, height: 5) + Circle().fill(BitFunTheme.muted).frame(width: 5, height: 5) + Circle().fill(BitFunTheme.muted).frame(width: 5, height: 5) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 20) + .padding(.vertical, 15) + } + } + .padding(.horizontal, 16) + .padding(.top, 8) + .padding(.bottom, 14) + } + .onChange(of: model.messages.count) { _ in + if let id = model.messages.last?.id { + withAnimation(.easeOut(duration: 0.18)) { proxy.scrollTo(id, anchor: .bottom) } + } + } + } + .background(BitFunTheme.page) + } +} + +private struct ChatMessageBubble: View { + let message: ChatMessage + + var body: some View { + VStack(alignment: message.role == .user ? .trailing : .leading, spacing: 0) { + Text(message.text) + .font(.system(size: 15, weight: .regular)) + .foregroundStyle(BitFunTheme.ink) + .lineSpacing(4) + .padding(.horizontal, 14) + .padding(.vertical, 11) + .background(message.role == .user ? BitFunTheme.soft : BitFunTheme.card) + .clipShape(RoundedRectangle(cornerRadius: 17)) + .overlay(RoundedRectangle(cornerRadius: 17).stroke(BitFunTheme.line, lineWidth: 1)) + .frame(maxWidth: 320, alignment: message.role == .user ? .trailing : .leading) + } + .frame(maxWidth: .infinity, alignment: message.role == .user ? .trailing : .leading) + .padding(.top, message.role == .user ? 8 : 2) + .padding(.bottom, message.role == .user ? 12 : 10) + } +} diff --git a/src/apps/mobile/ios/BitFun/Features/Chat/ComposerBar.swift b/src/apps/mobile/ios/BitFun/Features/Chat/ComposerBar.swift new file mode 100644 index 0000000000..adbc70ada5 --- /dev/null +++ b/src/apps/mobile/ios/BitFun/Features/Chat/ComposerBar.swift @@ -0,0 +1,56 @@ +import SwiftUI + +struct ComposerBar: View { + @ObservedObject var model: MobileAppModel + @FocusState private var focused: Bool + + var body: some View { + HStack(spacing: 5) { + Button { } label: { + ReferenceGlyph(assetName: "ComposerPlusGlyph", width: 18, height: 18) + .frame(width: 40, height: 40) + } + .buttonStyle(.plain) + TextField( + model.surface == .remote ? "向 BitFun 提问" : (model.localSessionSelected ? "输入消息" : "问问 BitFun"), + text: $model.draft, + axis: .vertical + ) + .font(.system(size: 15)) + .foregroundStyle(BitFunTheme.ink) + .lineLimit(1...4) + .focused($focused) + .submitLabel(.send) + .onSubmit { model.send() } + .onChange(of: model.draft) { _ in model.syncDraftToCore() } + Button { model.send() } label: { + if model.isSending { + Image(systemName: "stop.fill") + .font(.system(size: 16, weight: .bold)) + .foregroundStyle(BitFunTheme.accent) + .frame(width: 40, height: 40) + } else if model.draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + ReferenceGlyph(assetName: "ComposerMicGlyph", width: 16, height: 19) + .foregroundStyle(BitFunTheme.muted) + .frame(width: 40, height: 40) + } else { + Image(systemName: "arrow.up") + .font(.system(size: 16, weight: .bold)) + .foregroundStyle(BitFunTheme.accent) + .frame(width: 40, height: 40) + } + } + .buttonStyle(.plain) + } + .padding(.horizontal, 8) + .frame(minHeight: 52) + .background(BitFunTheme.card) + .clipShape(RoundedRectangle(cornerRadius: 20)) + .overlay(RoundedRectangle(cornerRadius: 20).stroke(BitFunTheme.line, lineWidth: 1)) + .shadow(color: .black.opacity(0.07), radius: 10, y: 2) + .padding(.horizontal, 16) + .padding(.top, 8) + .padding(.bottom, 14) + .background(BitFunTheme.page) + } +} diff --git a/src/apps/mobile/ios/BitFun/Features/Chat/ConversationHeader.swift b/src/apps/mobile/ios/BitFun/Features/Chat/ConversationHeader.swift new file mode 100644 index 0000000000..c4f4a86928 --- /dev/null +++ b/src/apps/mobile/ios/BitFun/Features/Chat/ConversationHeader.swift @@ -0,0 +1,55 @@ +import SwiftUI + +struct ConversationHeader: View { + @ObservedObject var model: MobileAppModel + @State private var menuOpen = false + + var body: some View { + HStack(spacing: 8) { + Button { model.drawerOpen = true } label: { + ReferenceGlyph(assetName: "MenuGlyph", width: 23, height: 18) + .frame(width: 44, height: 44) + .background(BitFunTheme.card) + .overlay(Circle().stroke(BitFunTheme.line, lineWidth: 1)) + .clipShape(Circle()) + .shadow(color: .black.opacity(0.07), radius: 8, y: 3) + } + .buttonStyle(.plain) + VStack(spacing: 3) { + Text(model.selectedSession?.title ?? "BitFun") + .font(.system(size: 17, weight: .medium)) + .foregroundStyle(BitFunTheme.ink) + .lineLimit(1) + if model.surface == .local && model.localSessionSelected { + Text("本地会话") + .font(.system(size: 14)) + .foregroundStyle(BitFunTheme.muted) + } else if model.remoteConnected && model.remoteSessionSelected { + Text("DESKTOP-KM3L4UI") + .font(.system(size: 14)) + .foregroundStyle(BitFunTheme.muted) + } + } + .frame(maxWidth: .infinity) + .contentShape(Rectangle()) + .onTapGesture { } + + Menu { + Button("置顶会话") { } + Button("导出会话") { } + Button("归档会话") { } + } label: { + ReferenceGlyph(assetName: "MoreGlyph", width: 23, height: 7) + .frame(width: 44, height: 44) + .background(BitFunTheme.card) + .overlay(Circle().stroke(BitFunTheme.line, lineWidth: 1)) + .clipShape(Circle()) + .shadow(color: .black.opacity(0.07), radius: 8, y: 3) + } + } + .frame(height: 76) + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(BitFunTheme.page) + } +} diff --git a/src/apps/mobile/ios/BitFun/Features/Shell/BitFunTheme.swift b/src/apps/mobile/ios/BitFun/Features/Shell/BitFunTheme.swift new file mode 100644 index 0000000000..9d4e604338 --- /dev/null +++ b/src/apps/mobile/ios/BitFun/Features/Shell/BitFunTheme.swift @@ -0,0 +1,64 @@ +import SwiftUI + +enum BitFunTheme { + // These values mirror harmonyos/entry/src/main/resources/base/element/color.json. + static let page = Color(red: 253 / 255, green: 253 / 255, blue: 251 / 255) + static let card = Color.white + static let soft = Color(red: 244 / 255, green: 243 / 255, blue: 240 / 255) + static let ink = Color(red: 23 / 255, green: 23 / 255, blue: 23 / 255) + static let muted = Color(red: 112 / 255, green: 111 / 255, blue: 106 / 255) + static let line = Color(red: 233 / 255, green: 231 / 255, blue: 226 / 255) + static let accent = Color(red: 17 / 255, green: 17 / 255, blue: 17 / 255) + static let green = Color(red: 39 / 255, green: 196 / 255, blue: 106 / 255) + static let red = Color(red: 224 / 255, green: 79 / 255, blue: 79 / 255) +} + +struct CircleControl: View { + let systemName: String + var size: CGFloat = 44 + var glyphSize: CGFloat = 18 + var action: () -> Void + + var body: some View { + Button(action: action) { + Image(systemName: systemName) + .font(.system(size: glyphSize, weight: .medium)) + .foregroundStyle(BitFunTheme.ink) + .frame(width: size, height: size) + .background(BitFunTheme.card) + .overlay(Circle().stroke(BitFunTheme.line, lineWidth: 1)) + .clipShape(Circle()) + .shadow(color: .black.opacity(0.07), radius: 8, y: 3) + } + .buttonStyle(.plain) + .accessibilityLabel(systemName) + } +} + +struct ReferenceGlyph: View { + let assetName: String + let width: CGFloat + let height: CGFloat + + var body: some View { + Image(assetName) + .resizable() + .renderingMode(.template) + .foregroundStyle(BitFunTheme.ink) + .aspectRatio(contentMode: .fit) + .frame(width: width, height: height) + } +} + +struct ReferenceImage: View { + let assetName: String + let width: CGFloat + let height: CGFloat + + var body: some View { + Image(assetName) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: width, height: height) + } +} diff --git a/src/apps/mobile/ios/BitFun/Features/Shell/MobileShellView.swift b/src/apps/mobile/ios/BitFun/Features/Shell/MobileShellView.swift new file mode 100644 index 0000000000..26eeebd203 --- /dev/null +++ b/src/apps/mobile/ios/BitFun/Features/Shell/MobileShellView.swift @@ -0,0 +1,458 @@ +import AVFoundation +import SwiftUI + +struct MobileShellView: View { + @ObservedObject var model: MobileAppModel + + var body: some View { + ZStack(alignment: .leading) { + VStack(spacing: 0) { + ConversationHeader(model: model) + if model.connectionPhase != .connected { + ConnectionStatusBar(phase: model.connectionPhase, detail: model.coreErrorMessage) + } + if model.surface == .remote && !model.remoteConnected { + RemoteHomeView(model: model) + ComposerBar(model: model) + } else if model.surface == .remote && !model.remoteSessionSelected { + RemoteConnectedHomeView() + ComposerBar(model: model) + } else if model.surface == .local && !model.localSessionSelected { + LocalHomeView(model: model) + ComposerBar(model: model) + } else { + ChatTimelineView(model: model) + ComposerBar(model: model) + } + } + .background(BitFunTheme.page) + .ignoresSafeArea(.keyboard, edges: .bottom) + + if model.drawerOpen { + Color.black.opacity(0.24) + .ignoresSafeArea() + .onTapGesture { model.drawerOpen = false } + SidebarView(model: model) + .transition(.move(edge: .leading).combined(with: .opacity)) + .shadow(color: .black.opacity(0.18), radius: 26, x: 10, y: 0) + } + } + .animation(.easeOut(duration: 0.24), value: model.drawerOpen) + .sheet(isPresented: $model.settingsOpen) { SettingsView(model: model) } + .sheet(isPresented: $model.pairingSheetOpen) { PairingSheet(model: model) } + } +} + +private struct PairingSheet: View { + @ObservedObject var model: MobileAppModel + @Environment(\.dismiss) private var dismiss + @State private var pairingURL = "" + @State private var scannerOpen = false + @FocusState private var focused: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + HStack { + Text("连接桌面端") + .font(.system(size: 24, weight: .bold)) + .foregroundStyle(BitFunTheme.ink) + Spacer() + Button { dismiss() } label: { + Image(systemName: "xmark") + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(BitFunTheme.ink) + .frame(width: 36, height: 36) + .background(BitFunTheme.soft) + .clipShape(Circle()) + } + .buttonStyle(.plain) + } + .padding(.bottom, 24) + + Text("扫描桌面端显示的二维码,或粘贴连接链接。") + .font(.system(size: 15)) + .foregroundStyle(BitFunTheme.muted) + .lineSpacing(4) + .padding(.bottom, 18) + + Button { + scannerOpen = true + } label: { + Label("扫描二维码", systemImage: "qrcode.viewfinder") + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(BitFunTheme.accent) + .frame(maxWidth: .infinity, minHeight: 44) + .background(BitFunTheme.soft) + .clipShape(Capsule()) + } + .buttonStyle(.plain) + .padding(.bottom, 12) + + HStack(spacing: 8) { + TextField("粘贴桌面端连接链接", text: $pairingURL, axis: .vertical) + .font(.system(size: 14)) + .foregroundStyle(BitFunTheme.ink) + .lineLimit(2...4) + .focused($focused) + Button { + pairingURL = UIPasteboard.general.string ?? "" + focused = false + } label: { + Image(systemName: "doc.on.clipboard") + .font(.system(size: 17, weight: .medium)) + .foregroundStyle(BitFunTheme.accent) + .frame(width: 40, height: 40) + } + .buttonStyle(.plain) + .accessibilityLabel("从剪贴板粘贴") + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background(BitFunTheme.card) + .overlay(RoundedRectangle(cornerRadius: 12).stroke(BitFunTheme.line, lineWidth: 1)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + + if let error = model.pairingError { + Text(error) + .font(.system(size: 13)) + .foregroundStyle(BitFunTheme.red) + .lineSpacing(3) + .padding(.top, 12) + } + + Button { + model.submitPairing(url: pairingURL) + focused = false + } label: { + HStack(spacing: 8) { + if model.pairingBusy { ProgressView().tint(.white) } + Text(model.pairingBusy ? "正在连接" : "连接") + .font(.system(size: 16, weight: .semibold)) + } + .foregroundStyle(.white) + .frame(maxWidth: .infinity, minHeight: 48) + .background(pairingURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? BitFunTheme.muted : BitFunTheme.accent) + .clipShape(Capsule()) + } + .buttonStyle(.plain) + .disabled(model.pairingBusy || pairingURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + .padding(.top, 22) + + Spacer() + } + .padding(.horizontal, 20) + .padding(.top, 22) + .background(BitFunTheme.page) + .presentationDetents([.medium]) + .presentationDragIndicator(.visible) + .fullScreenCover(isPresented: $scannerOpen) { + QRCodeScannerView { code in + pairingURL = code + scannerOpen = false + } + .ignoresSafeArea() + } + } +} + +private struct QRCodeScannerView: UIViewControllerRepresentable { + let onCode: (String) -> Void + + func makeUIViewController(context: Context) -> QRScannerController { + let controller = QRScannerController() + controller.onCode = onCode + return controller + } + + func updateUIViewController(_ uiViewController: QRScannerController, context: Context) {} +} + +private final class QRScannerController: UIViewController, AVCaptureMetadataOutputObjectsDelegate { + private let session = AVCaptureSession() + private var previewLayer: AVCaptureVideoPreviewLayer? + var onCode: ((String) -> Void)? + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .black + let close = UIButton(type: .system) + close.setImage(UIImage(systemName: "xmark"), for: .normal) + close.tintColor = .white + close.backgroundColor = UIColor.black.withAlphaComponent(0.55) + close.layer.cornerRadius = 22 + close.addAction(UIAction { [weak self] _ in self?.dismiss(animated: true) }, for: .touchUpInside) + close.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(close) + NSLayoutConstraint.activate([ + close.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 16), + close.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20), + close.widthAnchor.constraint(equalToConstant: 44), + 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() } + } + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + previewLayer?.frame = view.bounds + } + + private func configureCapture() { + 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.addInput(input) + session.addOutput(output) + output.setMetadataObjectsDelegate(self, queue: .main) + output.metadataObjectTypes = [.qr] + let layer = AVCaptureVideoPreviewLayer(session: session) + layer.videoGravity = .resizeAspectFill + view.layer.insertSublayer(layer, at: 0) + previewLayer = layer + session.startRunning() + } + + 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) + } +} + +private struct LocalHomeView: View { + @ObservedObject var model: MobileAppModel + + private let prompts: [(String, String)] = [ + ("Aa", "帮我写点内容"), + ("≡", "梳理一个问题"), + ("✓", "制定行动计划") + ] + + var body: some View { + VStack(spacing: 0) { + Spacer(minLength: 0) + VStack(spacing: 12) { + ForEach(prompts, id: \.1) { icon, title in + Button { + model.draft = title + model.send() + } label: { + HStack(spacing: 20) { + Text(icon) + .font(.system(size: 29, weight: .regular)) + .foregroundStyle(BitFunTheme.muted) + .frame(width: 32) + .fixedSize() + Text(title) + .font(.system(size: 20, weight: .medium)) + .foregroundStyle(BitFunTheme.muted) + Spacer(minLength: 0) + } + .frame(height: 48) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 20) + .padding(.bottom, 12) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(BitFunTheme.page) + } +} + +private struct RemoteHomeView: View { + @ObservedObject var model: MobileAppModel + + var body: some View { + VStack(spacing: 12) { + Spacer() + ZStack { + Image(systemName: "desktopcomputer") + .font(.system(size: 42, weight: .medium)) + .foregroundStyle(BitFunTheme.ink) + } + .frame(width: 74, height: 74) + .background(BitFunTheme.card) + .overlay(RoundedRectangle(cornerRadius: 24).stroke(BitFunTheme.line, lineWidth: 1)) + .clipShape(RoundedRectangle(cornerRadius: 24)) + Text("连接桌面端") + .font(.system(size: 18, weight: .bold)) + .foregroundStyle(BitFunTheme.ink) + Text("扫描桌面端显示的二维码,开始远程处理任务。") + .font(.system(size: 13)) + .foregroundStyle(BitFunTheme.muted) + .multilineTextAlignment(.center) + .lineSpacing(7) + .padding(.horizontal, 20) + Button("连接") { model.connectRemote() } + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(.white) + .frame(width: 136, height: 44) + .background(BitFunTheme.accent) + .clipShape(Capsule()) + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(.horizontal, 20) + .padding(.bottom, 48) + .background(BitFunTheme.page) + } +} + +private struct RemoteConnectedHomeView: View { + var body: some View { + VStack { + Spacer() + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(BitFunTheme.page) + } +} + +private struct ConnectionStatusBar: View { + let phase: ConnectionPhase + var detail: String? + var body: some View { + HStack(spacing: 8) { + Circle().fill(phase == .reconnecting ? BitFunTheme.muted : BitFunTheme.red).frame(width: 8, height: 8) + Text(phase == .reconnecting ? "正在恢复连接" : "连接不可用") + .font(.system(size: 13, weight: .medium)) + Text(detail ?? (phase == .reconnecting ? "正在重新连接桌面端" : "请重新连接")) + .font(.system(size: 12)) + .foregroundStyle(BitFunTheme.muted) + Spacer() + } + .foregroundStyle(BitFunTheme.ink) + .padding(.horizontal, 18) + .frame(height: 48) + .background(BitFunTheme.soft) + } +} + +private struct SettingsView: View { + @ObservedObject var model: MobileAppModel + @Environment(\.dismiss) private var dismiss + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + HStack { + Spacer() + Button { dismiss() } label: { + Image(systemName: "xmark") + .font(.system(size: 17, weight: .regular)) + .foregroundStyle(BitFunTheme.ink) + .frame(width: 48, height: 48) + .background(BitFunTheme.card) + .clipShape(Circle()) + .shadow(color: .black.opacity(0.07), radius: 10, y: 4) + } + .buttonStyle(.plain) + } + .padding(.top, 8) + + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 0) { + Text("设置") + .font(.system(size: 32, weight: .bold)) + .foregroundStyle(BitFunTheme.ink) + .padding(.top, 72) + .padding(.bottom, 34) + + SettingsCard { + SettingsValueRow(icon: "person", title: "个人资料", value: "6a7c7282-b185-4ae7-…") + } + SettingsGroup(title: "语言") { + SettingsValueRow(icon: "textformat", title: "语言", value: "简体中文") + } + SettingsGroup(title: "普通对话") { + SettingsValueRow(icon: "square.grid.2x2", title: "模型", value: "deepseek-v4-pro") + } + SettingsGroup(title: "关于") { + VStack(spacing: 0) { + SettingsValueRow(icon: nil, title: "产品", value: "BitFun HarmonyOS") + Divider().overlay(BitFunTheme.line).padding(.horizontal, 20) + SettingsValueRow(icon: nil, title: "版本", value: "1.0.0") + } + } + } + .padding(.horizontal, 20) + .padding(.bottom, 28) + } + } + .background(BitFunTheme.page) + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.hidden) + } +} + +private struct SettingsGroup: View { + let title: String + @ViewBuilder let content: () -> Content + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + Text(title) + .font(.system(size: 20, weight: .bold)) + .foregroundStyle(BitFunTheme.muted) + .padding(.leading, 18) + .padding(.top, 34) + SettingsCard(content: content) + } + } +} + +private struct SettingsCard: View { + @ViewBuilder let content: () -> Content + + var body: some View { + VStack(spacing: 0, content: content) + .background(BitFunTheme.card) + .clipShape(RoundedRectangle(cornerRadius: 14)) + } +} + +private struct SettingsValueRow: View { + let icon: String? + let title: String + let value: String + + var body: some View { + HStack(spacing: 14) { + if let icon { + Image(systemName: icon) + .font(.system(size: 23, weight: .regular)) + .foregroundStyle(BitFunTheme.muted) + .frame(width: 42, height: 42) + } + Text(title) + .font(.system(size: 17, weight: .medium)) + .foregroundStyle(BitFunTheme.ink) + Spacer(minLength: 12) + Text(value) + .font(.system(size: 16)) + .foregroundStyle(BitFunTheme.muted) + .lineLimit(1) + Image(systemName: "chevron.right") + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(BitFunTheme.muted) + } + .padding(.horizontal, 20) + .frame(minHeight: 76) + } +} diff --git a/src/apps/mobile/ios/BitFun/Features/Shell/SidebarView.swift b/src/apps/mobile/ios/BitFun/Features/Shell/SidebarView.swift new file mode 100644 index 0000000000..7d2a0bd9aa --- /dev/null +++ b/src/apps/mobile/ios/BitFun/Features/Shell/SidebarView.swift @@ -0,0 +1,267 @@ +import SwiftUI + +struct SidebarView: View { + @ObservedObject var model: MobileAppModel + @State private var search = "" + @State private var searchVisible = false + + private var devices: [SidebarDevice] { + if let accountDeviceName = model.accountDeviceName, !accountDeviceName.isEmpty { + return [SidebarDevice(name: accountDeviceName, online: true)] + } + return [ + SidebarDevice(name: "Mac-userdeMacBook-Pro.local", online: true), + SidebarDevice(name: "DESKTOP-KM3L4UI", online: true), + ] + } + + private let workspaces = [ + SidebarWorkspace(name: "arkanaly...", sessions: ["本项目是啥", "Remote Code ..."]), + SidebarWorkspace(name: "BitFun", sessions: ["你在哪个分支", "你去拉一下Deep...", "你去看看issue里..."]) + ] + + private var recentSessions: [ChatSession] { + let source = model.sessions + guard !search.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return source } + return source.filter { $0.title.localizedCaseInsensitiveContains(search) } + } + + var body: some View { + GeometryReader { proxy in + VStack(alignment: .leading, spacing: 0) { + authenticatedHeader + if searchVisible { + searchField + } + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 0) { + recentSection + workspaceSection + } + .padding(.bottom, 84) + } + footer + } + .padding(.horizontal, 20) + .padding(.top, 4) + .padding(.bottom, 16) + .frame(width: min(320, proxy.size.width * 0.68), height: proxy.size.height, alignment: .topLeading) + .background(BitFunTheme.page) + } + } + + private var authenticatedHeader: some View { + HStack(spacing: 6) { + Text("BitFun") + .font(.system(size: 20, weight: .bold)) + .foregroundStyle(BitFunTheme.ink) + Spacer(minLength: 0) + Button { + withAnimation(.easeOut(duration: 0.18)) { searchVisible.toggle() } + if !searchVisible { search = "" } + } label: { + ReferenceImage(assetName: "SidebarSearchGlyph", width: 22, height: 22) + .frame(width: 38, height: 38) + .background(BitFunTheme.card) + .overlay(Circle().stroke(BitFunTheme.line, lineWidth: 1)) + .clipShape(Circle()) + .shadow(color: .black.opacity(0.08), radius: 10, y: 4) + } + .buttonStyle(.plain) + .accessibilityLabel("搜索") + } + .frame(height: 50) + } + + private var searchField: some View { + TextField("搜索对话", text: $search) + .font(.system(size: 14)) + .foregroundStyle(BitFunTheme.ink) + .padding(.horizontal, 14) + .frame(height: 42) + .background(BitFunTheme.soft) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .padding(.top, 12) + } + + private var recentSection: some View { + VStack(alignment: .leading, spacing: 0) { + Text("最近对话") + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(BitFunTheme.muted) + .padding(.top, 16) + .padding(.bottom, 6) + ForEach(recentSessions) { session in + SidebarRecentRow(session: session, selected: session.id == model.selectedSessionID) { + model.surface = .local + model.select(session) + } + } + if recentSessions.count > 6 { + HStack(spacing: 8) { + Text("···") + Text("还有 \(recentSessions.count - 6) 个会话") + } + .font(.system(size: 13)) + .foregroundStyle(BitFunTheme.muted) + .frame(height: 40, alignment: .leading) + .padding(.leading, 12) + } + } + } + + private var workspaceSection: some View { + VStack(alignment: .leading, spacing: 0) { + HStack { + Text("设备") + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(BitFunTheme.muted) + Spacer() + Button { model.connectRemote() } label: { + ReferenceImage(assetName: "SidebarPlusGlyph", width: 17, height: 20) + .frame(width: 32, height: 32) + } + .buttonStyle(.plain) + .accessibilityLabel("添加连接") + } + .frame(height: 38) + .padding(.top, 18) + + ForEach(devices) { device in + Button { model.connectRemote() } label: { + HStack(spacing: 10) { + ReferenceImage(assetName: "SidebarDeviceGlyph", width: 22, height: 18) + Text(device.name) + .font(.system(size: 15)) + .foregroundStyle(BitFunTheme.ink) + .lineLimit(1) + Spacer(minLength: 0) + ReferenceImage( + assetName: device.online ? "SidebarChevronGlyph" : "SidebarDownGlyph", + width: 14, + height: 14 + ) + } + .padding(.horizontal, 10) + .frame(height: 46) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + + ForEach(workspaces) { workspace in + SidebarWorkspaceRow(workspace: workspace) + } + } + } + + private var footer: some View { + HStack(spacing: 0) { + Button { model.surface = .local; model.drawerOpen = false } label: { + HStack(spacing: 9) { + ReferenceImage(assetName: "SidebarEditGlyph", width: 24, height: 24) + Text("聊天") + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(BitFunTheme.ink) + } + .frame(width: 116, height: 46) + .background(BitFunTheme.card) + .overlay(RoundedRectangle(cornerRadius: 23).stroke(BitFunTheme.line, lineWidth: 1)) + .clipShape(Capsule()) + .shadow(color: .black.opacity(0.08), radius: 10, 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) + .background(BitFunTheme.card) + .clipShape(Circle()) + .shadow(color: .black.opacity(0.08), radius: 10, y: 4) + } + .buttonStyle(.plain) + .accessibilityLabel("设置") + } + .frame(height: 56) + } +} + +private struct SidebarRecentRow: View { + let session: ChatSession + let selected: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(spacing: 8) { + Text(session.title) + .font(.system(size: 15)) + .foregroundStyle(BitFunTheme.ink) + .lineLimit(1) + Spacer(minLength: 0) + HStack(spacing: 3) { + Circle().fill(BitFunTheme.muted).frame(width: 3.5, height: 3.5) + Circle().fill(BitFunTheme.muted).frame(width: 3.5, height: 3.5) + Circle().fill(BitFunTheme.muted).frame(width: 3.5, height: 3.5) + } + .frame(width: 34, height: 40) + .opacity(0.62) + } + .padding(.horizontal, 12) + .frame(height: 44) + .background(selected ? BitFunTheme.soft : .clear) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } + .buttonStyle(.plain) + } +} + +private struct SidebarWorkspaceRow: View { + let workspace: SidebarWorkspace + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 10) { + ReferenceImage(assetName: "SidebarFolderGlyph", width: 24, height: 20) + Text(workspace.name) + .font(.system(size: 15)) + .foregroundStyle(BitFunTheme.ink) + .lineLimit(1) + Spacer(minLength: 0) + ReferenceImage(assetName: "SidebarEditGlyph", width: 22, height: 22) + .opacity(0.62) + ReferenceImage(assetName: "SidebarDownGlyph", width: 14, height: 14) + .opacity(0.62) + } + .padding(.horizontal, 10) + .frame(height: 46) + ForEach(Array(workspace.sessions.enumerated()), id: \.offset) { _, title in + HStack(spacing: 10) { + Image(systemName: "doc") + .font(.system(size: 21, weight: .regular)) + .foregroundStyle(BitFunTheme.muted) + .frame(width: 22) + Text(title) + .font(.system(size: 15)) + .foregroundStyle(BitFunTheme.ink) + .lineLimit(1) + Spacer(minLength: 0) + } + .padding(.leading, 32) + .frame(height: 44) + } + } + } +} + +private struct SidebarDevice: Identifiable { + let id = UUID() + let name: String + let online: Bool +} + +private struct SidebarWorkspace: Identifiable { + let id = UUID() + let name: String + let sessions: [String] +} diff --git a/src/apps/mobile/ios/BitFun/Info.plist b/src/apps/mobile/ios/BitFun/Info.plist new file mode 100644 index 0000000000..c47bda8ad0 --- /dev/null +++ b/src/apps/mobile/ios/BitFun/Info.plist @@ -0,0 +1,24 @@ + + + + + CFBundleDisplayName + BitFun + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleName + BitFun + CFBundlePackageType + APPL + CFBundleShortVersionString + 0.1 + CFBundleVersion + 1 + NSCameraUsageDescription + 扫描桌面端二维码以连接 BitFun + UILaunchScreen + + + diff --git a/src/apps/mobile/ios/BitFun/Infrastructure/MobileAppModel.swift b/src/apps/mobile/ios/BitFun/Infrastructure/MobileAppModel.swift new file mode 100644 index 0000000000..349d0f5ec1 --- /dev/null +++ b/src/apps/mobile/ios/BitFun/Infrastructure/MobileAppModel.swift @@ -0,0 +1,342 @@ +import Foundation +import SwiftUI +import BitFunMobileCore + +enum ConnectionPhase { + case connected + case reconnecting + case disconnected +} + +enum MobileSurface: String { + case local + case remote +} + +struct ChatMessage: Identifiable, Equatable { + let id: UUID + let role: Role + let text: String + + enum Role { case user, assistant } +} + +struct ChatSession: Identifiable, Equatable { + let id: UUID + var title: String + var updatedLabel: String + var pinned: Bool = false +} + +@MainActor +final class MobileAppModel: ObservableObject { + @Published var surface: MobileSurface = .local + @Published var sessions: [ChatSession] + @Published var remoteSessions: [ChatSession] = [] + @Published var selectedSessionID: UUID + @Published var messages: [ChatMessage] + @Published var draft = "" + @Published var drawerOpen = false + @Published var settingsOpen = false + @Published var connectionPhase: ConnectionPhase = .connected + @Published var isSending = false + @Published var remoteConnected = false + @Published var remoteSessionSelected = false + @Published var localSessionSelected = false + @Published var pairingSheetOpen = false + @Published var pairingBusy = false + @Published var pairingError: String? + @Published var coreErrorMessage: String? + @Published var accountUser: String? + @Published var accountBusy = false + @Published var accountDeviceName: String? + @Published var accountDeviceCount = 0 + + private var coreAdapter: MobileCoreAdapter? + + init(sessions: [ChatSession], selectedSessionID: UUID, messages: [ChatMessage]) { + self.sessions = sessions + self.selectedSessionID = selectedSessionID + self.messages = messages + self.coreAdapter = nil + self.coreAdapter = MobileCoreAdapter( + onState: { [weak self] state in self?.apply(coreState: state) }, + onPairingState: { [weak self] state in self?.apply(pairingState: state) }, + onAccountState: { [weak self] state in self?.apply(accountState: state) }, + onRemoteState: { [weak self] state in self?.apply(remoteState: state) }, + ) + } + + static let preview: MobileAppModel = { + let first = ChatSession(id: UUID(), title: "你好", updatedLabel: "刚刚") + return MobileAppModel( + sessions: [first], + selectedSessionID: first.id, + messages: [ + ChatMessage(id: UUID(), role: .user, text: "你好"), + ChatMessage(id: UUID(), role: .assistant, text: "这是 BitFun 的移动端会话界面。你可以从手机连接桌面端,查看工作区、会话和 Agent 的执行状态。") + ] + ) + }() + + static var launchConfigured: MobileAppModel { + let model = preview + let arguments = ProcessInfo.processInfo.arguments + if arguments.contains("--remote") { + model.surface = .remote + } + if arguments.contains("--connected") { + model.configureConnectedPreview() + } + if let relay = arguments.value(after: "--relay-url"), + let username = arguments.value(after: "--username"), + let password = arguments.value(after: "--password") { + model.loginAccount(relayURL: relay, username: username, password: password) + } + if arguments.contains("--drawer") { + model.drawerOpen = true + } + return model + } + + var selectedSession: ChatSession? { + guard (surface == .local && localSessionSelected) || (surface == .remote && remoteSessionSelected) else { + return nil + } + return visibleSessions.first { $0.id == selectedSessionID } + } + + var visibleSessions: [ChatSession] { + surface == .local ? sessions : remoteSessions + } + + func send() { + if surface == .remote { + sendRemote() + return + } + let value = draft.trimmingCharacters(in: .whitespacesAndNewlines) + guard !value.isEmpty, !isSending else { return } + if surface == .local { + localSessionSelected = true + if selectedSession == nil, let first = sessions.first { + selectedSessionID = first.id + } + } + messages.append(ChatMessage(id: UUID(), role: .user, text: value)) + draft = "" + isSending = true + coreAdapter?.updateDraft(value) + coreAdapter?.send() + } + + func select(_ session: ChatSession) { + selectedSessionID = session.id + if surface == .remote { + remoteSessionSelected = true + } else { + localSessionSelected = true + } + drawerOpen = false + } + + func switchSurface(_ next: MobileSurface) { + surface = next + drawerOpen = false + } + + func connectRemote() { + pairingError = nil + pairingSheetOpen = true + } + + private func configureConnectedPreview() { + surface = .remote + remoteConnected = true + connectionPhase = .connected + remoteSessionSelected = true + accountDeviceName = "DESKTOP-KM3L4UI" + let session = ChatSession(id: UUID(), title: "你好", updatedLabel: "刚刚") + remoteSessions = [session] + selectedSessionID = session.id + messages = [ + ChatMessage(id: UUID(), role: .user, text: "你好"), + ChatMessage(id: UUID(), role: .assistant, text: "这是 BitFun 的远程会话预览。"), + ] + } + + func submitPairing(url: String) { + pairingError = nil + pairingBusy = true + coreAdapter?.submitPairing(url: url) + } + + func loginAccount(relayURL: String, username: String, password: String) { + accountBusy = true + coreErrorMessage = nil + coreAdapter?.loginAccount(relayURL: relayURL, username: username, password: password) + } + + func sendRemote() { + let value = draft.trimmingCharacters(in: .whitespacesAndNewlines) + guard !value.isEmpty, let sessionID = visibleSessions.first(where: { $0.id == selectedSessionID })?.id else { return } + draft = "" + isSending = true + coreAdapter?.sendRemote(sessionID: sessionID.uuidString, content: value) + } + + func syncDraftToCore() { + coreAdapter?.updateDraft(draft) + } + + private func apply(coreState state: GeneralChatUiState) { + if !state.sessions.isEmpty { + sessions = state.sessions.map { session in + ChatSession( + id: UUID(uuidString: session.id) ?? UUID(), + title: session.title.isEmpty ? "未命名会话" : session.title, + updatedLabel: session.updatedAt, + pinned: session.pinned, + ) + } + } + if !state.messages.isEmpty { + messages = state.messages.map { message in + let text = message.blocks.map(\.text).joined(separator: "\n") + return ChatMessage( + id: UUID(uuidString: message.id) ?? UUID(), + role: message.role.lowercased() == "user" ? .user : .assistant, + text: text, + ) + } + } + if draft != state.draft { draft = state.draft } + isSending = state.busy + if let failure = state.failure { + coreErrorMessage = failure.name + } else { + coreErrorMessage = nil + } + } + + private func apply(pairingState state: PairingUiState) { + pairingBusy = state is PairingUiStateConnecting + if let failed = state as? PairingUiStateFailed { + pairingBusy = false + pairingError = pairingErrorMessage(failed.failure) + } else if state is PairingUiStatePaired { + pairingBusy = false + pairingError = nil + remoteConnected = true + surface = .remote + connectionPhase = .connected + pairingSheetOpen = false + } + } + + private func apply(accountState state: AccountUiState) { + accountBusy = state is AccountUiStateSigningIn + if let ready = state as? AccountUiStateReady { + accountBusy = false + accountUser = ready.username + accountDeviceName = ready.selectedDeviceName + accountDeviceCount = ready.devices.count + if ready.selectedDeviceId == nil, + let target = ready.devices.first(where: { $0.online }) { + accountBusy = true + coreAdapter?.selectAccountDevice(id: target.id) + return + } + remoteConnected = true + surface = .remote + connectionPhase = .connected + } else if let failed = state as? AccountUiStateFailed { + accountBusy = false + coreErrorMessage = accountErrorMessage(failed.reason.name) + connectionPhase = .disconnected + } + } + + private func apply(remoteState state: RemoteSessionUiState) { + guard let ready = state as? RemoteSessionUiStateReady else { + if let failed = state as? RemoteSessionUiStateFailed { + connectionPhase = .disconnected + coreErrorMessage = failed.remoteMessage ?? failed.reason.name + } + return + } + remoteConnected = true + surface = .remote + connectionPhase = .connected + remoteSessions = ready.sessions.map { session in + ChatSession( + id: UUID(uuidString: session.id) ?? UUID(), + title: session.title.isEmpty ? "未命名会话" : session.title, + updatedLabel: session.updatedAt, + ) + } + if let selected = ready.selectedSessionId, let id = UUID(uuidString: selected) { + selectedSessionID = id + } + remoteSessionSelected = ready.selectedSessionId != nil + isSending = ready.busy + if let timeline = ready.timeline { + let allMessages = timeline.persistedMessages + timeline.optimisticMessages + messages = allMessages.map { message in + ChatMessage( + id: UUID(uuidString: message.id) ?? UUID(), + role: message.role.lowercased() == "user" ? .user : .assistant, + text: message.text, + ) + } + } + } + + private func pairingErrorMessage(_ failure: PairingFailure) -> String { + if let remote = failure.remoteMessage?.trimmingCharacters(in: .whitespacesAndNewlines), !remote.isEmpty { + return remote + } + switch failure.reason.name { + case "PAIRING_LINK_EMPTY", "PAIRING_LINK_INCOMPLETE", "PAIRING_LINK_UNDECODABLE", "PAIRING_LINK_KEY_UNUSABLE": + return "连接链接无效,请重新扫描或粘贴桌面端链接" + case "ACCOUNT_USERNAME_REQUIRED": + return "请输入桌面端账号" + case "ACCOUNT_PASSWORD_REQUIRED": + return "请输入桌面端密码" + case "REJECTED", "DESKTOP_REJECTED": + return "桌面端拒绝了这次连接" + case "ROOM_NOT_FOUND": + return "找不到桌面端房间,请确认桌面端仍在等待连接" + case "RATE_LIMITED", "TOO_MANY_ATTEMPTS": + return "尝试次数过多,请稍后再试" + case "RELAY_UNAVAILABLE", "NETWORK_UNREACHABLE": + return "网络不可用,请检查手机与桌面端的网络" + case "TIMEOUT": + return "连接超时,请重新尝试" + case "PROTOCOL_MISMATCH": + return "桌面端版本不兼容,请升级后重试" + default: + return "连接失败,请检查桌面端链接" + } + } + + private func accountErrorMessage(_ reason: String) -> String { + switch reason { + case "INVALID_CREDENTIALS", "UNAUTHORIZED": + return "账号或密码错误" + case "NETWORK": + return "网络不可用,请检查 relay 地址" + case "TIMEOUT": + return "登录超时,请稍后重试" + default: + return "登录失败,请检查账号、密码和 relay 地址" + } + } +} + +private extension Array where Element == String { + func value(after flag: String) -> String? { + guard let position = firstIndex(of: flag), position < self.index(before: endIndex) else { return nil } + return self[self.index(after: position)] + } +} diff --git a/src/apps/mobile/ios/BitFun/Infrastructure/MobileCoreAdapter.swift b/src/apps/mobile/ios/BitFun/Infrastructure/MobileCoreAdapter.swift new file mode 100644 index 0000000000..125f6586ff --- /dev/null +++ b/src/apps/mobile/ios/BitFun/Infrastructure/MobileCoreAdapter.swift @@ -0,0 +1,153 @@ +import BitFunMobileCore +import Foundation + +/// Swift owns presentation state; this adapter owns the shared KMP feature seam. +@MainActor +final class MobileCoreAdapter { + private let scope: any CoroutineScope + private let generalChat: GeneralChatStore + private let pairing: PairingStore + private let account: AccountStore + private var remoteSession: RemoteSessionStore? + private var observations: [Task] = [] + + var onState: ((GeneralChatUiState) -> Void)? + var onPairingState: ((PairingUiState) -> Void)? + var onAccountState: ((AccountUiState) -> Void)? + var onRemoteState: ((RemoteSessionUiState) -> Void)? + + init( + onState: ((GeneralChatUiState) -> Void)? = nil, + onPairingState: ((PairingUiState) -> Void)? = nil, + onAccountState: ((AccountUiState) -> Void)? = nil, + onRemoteState: ((RemoteSessionUiState) -> Void)? = nil, + ) { + self.scope = MainScope() + self.generalChat = GeneralChatStore.companion.create(scope: scope) + let defaults = UserDefaults.standard + let installID: String + if let stored = defaults.string(forKey: "bitfun.mobile.install_id") { + installID = stored + } else { + installID = UUID().uuidString + defaults.set(installID, forKey: "bitfun.mobile.install_id") + } + self.pairing = PairingStore.companion.create( + scope: scope, + device: DeviceIdentity(installId: installID, displayName: "BitFun iPhone"), + log: CoreLogNone.shared, + ) + self.account = AccountStore.companion.create( + scope: scope, + service: "com.bitfun.mobile.account", + deviceId: installID, + deviceName: "BitFun iPhone", + log: CoreLogNone.shared, + ) + self.onState = onState + self.onPairingState = onPairingState + self.onAccountState = onAccountState + self.onRemoteState = onRemoteState + + let flow = SkieSwiftStateFlow(generalChat.state) + onState?(flow.value) + observations.append(Task { [weak self] in + for await state in flow { + guard !Task.isCancelled else { return } + self?.onState?(state) + } + }) + + let accountFlow = SkieSwiftStateFlow(account.state) + onAccountState?(accountFlow.value) + observations.append(Task { [weak self] in + for await state in accountFlow { + guard !Task.isCancelled else { return } + self?.onAccountState?(state) + if let ready = state as? AccountUiStateReady { + self?.startAccountRemoteSessionIfNeeded(ready: ready) + } + } + }) + + let pairingFlow = SkieSwiftStateFlow(pairing.state) + onPairingState?(pairingFlow.value) + observations.append(Task { [weak self] in + for await state in pairingFlow { + guard !Task.isCancelled else { return } + self?.onPairingState?(state) + if let paired = state as? PairingUiStatePaired { + self?.startRemoteSessionStoreIfNeeded(paired: paired) + } + } + }) + } + + func updateDraft(_ text: String) { + generalChat.dispatch(intent: GeneralChatIntentUpdateDraft(text: text)) + } + + func send() { + generalChat.dispatch(intent: GeneralChatIntentSend.shared) + } + + func submitPairing(url: String) { + pairing.dispatch(intent: PairingIntentSubmit(pairingUrl: url)) + } + + func loginAccount(relayURL: String, username: String, password: String) { + account.dispatch(intent: AccountIntentLogin(relayUrl: relayURL, username: username, password: password)) + } + + func selectAccountDevice(id: String) { + account.dispatch(intent: AccountIntentSelectDevice(deviceId: id)) + } + + func disconnect() { + pairing.dispatch(intent: PairingIntentDisconnect.shared) + remoteSession?.dispatch(intent: RemoteSessionIntentStop.shared) + remoteSession = nil + } + + func sendRemote(sessionID: String, content: String) { + remoteSession?.dispatch(intent: RemoteSessionIntentSendMessage(sessionId: sessionID, content: content)) + } + + private func startRemoteSessionStoreIfNeeded(paired: PairingUiStatePaired) { + guard remoteSession == nil, let store = pairing.createSessionStore(scope: scope) else { return } + remoteSession = store + let flow = SkieSwiftStateFlow(store.state) + onRemoteState?(flow.value) + store.dispatch(intent: RemoteSessionIntentLoad.shared) + observations.append(Task { [weak self] in + for await state in flow { + guard !Task.isCancelled else { return } + self?.onRemoteState?(state) + } + }) + } + + private func startAccountRemoteSessionIfNeeded(ready: AccountUiStateReady) { + guard remoteSession == nil, let store = account.createSessionStore(scope: scope) else { return } + remoteSession = store + let flow = SkieSwiftStateFlow(store.state) + onRemoteState?(flow.value) + store.dispatch(intent: RemoteSessionIntentLoad.shared) + observations.append(Task { [weak self] in + for await state in flow { + guard !Task.isCancelled else { return } + self?.onRemoteState?(state) + } + }) + } + + func stop() { + observations.forEach { $0.cancel() } + observations.removeAll() + remoteSession?.stop() + remoteSession = nil + pairing.dispatch(intent: PairingIntentDisconnect.shared) + account.stop() + generalChat.stop() + } +} diff --git a/src/apps/mobile/ios/BitFun/Resources.xcassets/AppIcon.appiconset/Contents.json b/src/apps/mobile/ios/BitFun/Resources.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000000..091ba2d326 --- /dev/null +++ b/src/apps/mobile/ios/BitFun/Resources.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "bitfun_icon.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/src/apps/mobile/ios/BitFun/Resources.xcassets/AppIcon.appiconset/bitfun_icon.png b/src/apps/mobile/ios/BitFun/Resources.xcassets/AppIcon.appiconset/bitfun_icon.png new file mode 100644 index 0000000000..2d84e9480a Binary files /dev/null and b/src/apps/mobile/ios/BitFun/Resources.xcassets/AppIcon.appiconset/bitfun_icon.png differ diff --git a/src/apps/mobile/ios/BitFun/Resources.xcassets/BitFunLogo.imageset/Contents.json b/src/apps/mobile/ios/BitFun/Resources.xcassets/BitFunLogo.imageset/Contents.json new file mode 100644 index 0000000000..d5e9d847a8 --- /dev/null +++ b/src/apps/mobile/ios/BitFun/Resources.xcassets/BitFunLogo.imageset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "filename" : "bitfun_icon.png", + "idiom" : "universal", + "scale" : "1x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/src/apps/mobile/ios/BitFun/Resources.xcassets/BitFunLogo.imageset/bitfun_icon.png b/src/apps/mobile/ios/BitFun/Resources.xcassets/BitFunLogo.imageset/bitfun_icon.png new file mode 100644 index 0000000000..2d84e9480a Binary files /dev/null and b/src/apps/mobile/ios/BitFun/Resources.xcassets/BitFunLogo.imageset/bitfun_icon.png differ diff --git a/src/apps/mobile/ios/BitFun/Resources.xcassets/ComposerMicGlyph.imageset/ComposerMicGlyph.png b/src/apps/mobile/ios/BitFun/Resources.xcassets/ComposerMicGlyph.imageset/ComposerMicGlyph.png new file mode 100644 index 0000000000..ff4591718f Binary files /dev/null and b/src/apps/mobile/ios/BitFun/Resources.xcassets/ComposerMicGlyph.imageset/ComposerMicGlyph.png differ diff --git a/src/apps/mobile/ios/BitFun/Resources.xcassets/ComposerMicGlyph.imageset/Contents.json b/src/apps/mobile/ios/BitFun/Resources.xcassets/ComposerMicGlyph.imageset/Contents.json new file mode 100644 index 0000000000..5c8efc9b6b --- /dev/null +++ b/src/apps/mobile/ios/BitFun/Resources.xcassets/ComposerMicGlyph.imageset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "filename" : "ComposerMicGlyph.png", + "idiom" : "universal", + "scale" : "1x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/src/apps/mobile/ios/BitFun/Resources.xcassets/ComposerPlusGlyph.imageset/ComposerPlusGlyph.png b/src/apps/mobile/ios/BitFun/Resources.xcassets/ComposerPlusGlyph.imageset/ComposerPlusGlyph.png new file mode 100644 index 0000000000..d2562fa1e4 Binary files /dev/null and b/src/apps/mobile/ios/BitFun/Resources.xcassets/ComposerPlusGlyph.imageset/ComposerPlusGlyph.png differ diff --git a/src/apps/mobile/ios/BitFun/Resources.xcassets/ComposerPlusGlyph.imageset/Contents.json b/src/apps/mobile/ios/BitFun/Resources.xcassets/ComposerPlusGlyph.imageset/Contents.json new file mode 100644 index 0000000000..05281b50ca --- /dev/null +++ b/src/apps/mobile/ios/BitFun/Resources.xcassets/ComposerPlusGlyph.imageset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "filename" : "ComposerPlusGlyph.png", + "idiom" : "universal", + "scale" : "1x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/src/apps/mobile/ios/BitFun/Resources.xcassets/Contents.json b/src/apps/mobile/ios/BitFun/Resources.xcassets/Contents.json new file mode 100644 index 0000000000..73c00596a7 --- /dev/null +++ b/src/apps/mobile/ios/BitFun/Resources.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/src/apps/mobile/ios/BitFun/Resources.xcassets/MenuGlyph.imageset/Contents.json b/src/apps/mobile/ios/BitFun/Resources.xcassets/MenuGlyph.imageset/Contents.json new file mode 100644 index 0000000000..89378b0ac2 --- /dev/null +++ b/src/apps/mobile/ios/BitFun/Resources.xcassets/MenuGlyph.imageset/Contents.json @@ -0,0 +1,10 @@ +{ + "images" : [ + { + "filename" : "gpt_home_menu_glyph.png", + "idiom" : "universal", + "scale" : "1x" + } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/src/apps/mobile/ios/BitFun/Resources.xcassets/MenuGlyph.imageset/gpt_home_menu_glyph.png b/src/apps/mobile/ios/BitFun/Resources.xcassets/MenuGlyph.imageset/gpt_home_menu_glyph.png new file mode 100644 index 0000000000..e579ca4117 Binary files /dev/null and b/src/apps/mobile/ios/BitFun/Resources.xcassets/MenuGlyph.imageset/gpt_home_menu_glyph.png differ diff --git a/src/apps/mobile/ios/BitFun/Resources.xcassets/MoreGlyph.imageset/Contents.json b/src/apps/mobile/ios/BitFun/Resources.xcassets/MoreGlyph.imageset/Contents.json new file mode 100644 index 0000000000..e7b4959e05 --- /dev/null +++ b/src/apps/mobile/ios/BitFun/Resources.xcassets/MoreGlyph.imageset/Contents.json @@ -0,0 +1,10 @@ +{ + "images" : [ + { + "filename" : "remote_ref_more.png", + "idiom" : "universal", + "scale" : "1x" + } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/src/apps/mobile/ios/BitFun/Resources.xcassets/MoreGlyph.imageset/remote_ref_more.png b/src/apps/mobile/ios/BitFun/Resources.xcassets/MoreGlyph.imageset/remote_ref_more.png new file mode 100644 index 0000000000..ef9c3f77ad Binary files /dev/null and b/src/apps/mobile/ios/BitFun/Resources.xcassets/MoreGlyph.imageset/remote_ref_more.png differ diff --git a/src/apps/mobile/ios/BitFun/Resources.xcassets/SidebarChevronGlyph.imageset/Contents.json b/src/apps/mobile/ios/BitFun/Resources.xcassets/SidebarChevronGlyph.imageset/Contents.json new file mode 100644 index 0000000000..ad31d15ac6 --- /dev/null +++ b/src/apps/mobile/ios/BitFun/Resources.xcassets/SidebarChevronGlyph.imageset/Contents.json @@ -0,0 +1,4 @@ +{ + "images" : [{ "filename" : "SidebarChevronGlyph.png", "idiom" : "universal", "scale" : "1x" }], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/src/apps/mobile/ios/BitFun/Resources.xcassets/SidebarChevronGlyph.imageset/SidebarChevronGlyph.png b/src/apps/mobile/ios/BitFun/Resources.xcassets/SidebarChevronGlyph.imageset/SidebarChevronGlyph.png new file mode 100644 index 0000000000..2250a47f22 Binary files /dev/null and b/src/apps/mobile/ios/BitFun/Resources.xcassets/SidebarChevronGlyph.imageset/SidebarChevronGlyph.png differ diff --git a/src/apps/mobile/ios/BitFun/Resources.xcassets/SidebarDeviceGlyph.imageset/Contents.json b/src/apps/mobile/ios/BitFun/Resources.xcassets/SidebarDeviceGlyph.imageset/Contents.json new file mode 100644 index 0000000000..4577e809c8 --- /dev/null +++ b/src/apps/mobile/ios/BitFun/Resources.xcassets/SidebarDeviceGlyph.imageset/Contents.json @@ -0,0 +1,4 @@ +{ + "images" : [{ "filename" : "SidebarDeviceGlyph.png", "idiom" : "universal", "scale" : "1x" }], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/src/apps/mobile/ios/BitFun/Resources.xcassets/SidebarDeviceGlyph.imageset/SidebarDeviceGlyph.png b/src/apps/mobile/ios/BitFun/Resources.xcassets/SidebarDeviceGlyph.imageset/SidebarDeviceGlyph.png new file mode 100644 index 0000000000..19e8b6b604 Binary files /dev/null and b/src/apps/mobile/ios/BitFun/Resources.xcassets/SidebarDeviceGlyph.imageset/SidebarDeviceGlyph.png differ diff --git a/src/apps/mobile/ios/BitFun/Resources.xcassets/SidebarDownGlyph.imageset/Contents.json b/src/apps/mobile/ios/BitFun/Resources.xcassets/SidebarDownGlyph.imageset/Contents.json new file mode 100644 index 0000000000..1e182d63a4 --- /dev/null +++ b/src/apps/mobile/ios/BitFun/Resources.xcassets/SidebarDownGlyph.imageset/Contents.json @@ -0,0 +1,4 @@ +{ + "images" : [{ "filename" : "SidebarDownGlyph.png", "idiom" : "universal", "scale" : "1x" }], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/src/apps/mobile/ios/BitFun/Resources.xcassets/SidebarDownGlyph.imageset/SidebarDownGlyph.png b/src/apps/mobile/ios/BitFun/Resources.xcassets/SidebarDownGlyph.imageset/SidebarDownGlyph.png new file mode 100644 index 0000000000..e14255be59 Binary files /dev/null and b/src/apps/mobile/ios/BitFun/Resources.xcassets/SidebarDownGlyph.imageset/SidebarDownGlyph.png differ diff --git a/src/apps/mobile/ios/BitFun/Resources.xcassets/SidebarEditGlyph.imageset/Contents.json b/src/apps/mobile/ios/BitFun/Resources.xcassets/SidebarEditGlyph.imageset/Contents.json new file mode 100644 index 0000000000..1426a590fd --- /dev/null +++ b/src/apps/mobile/ios/BitFun/Resources.xcassets/SidebarEditGlyph.imageset/Contents.json @@ -0,0 +1,4 @@ +{ + "images" : [{ "filename" : "SidebarEditGlyph.png", "idiom" : "universal", "scale" : "1x" }], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/src/apps/mobile/ios/BitFun/Resources.xcassets/SidebarEditGlyph.imageset/SidebarEditGlyph.png b/src/apps/mobile/ios/BitFun/Resources.xcassets/SidebarEditGlyph.imageset/SidebarEditGlyph.png new file mode 100644 index 0000000000..b3be584f30 Binary files /dev/null and b/src/apps/mobile/ios/BitFun/Resources.xcassets/SidebarEditGlyph.imageset/SidebarEditGlyph.png differ diff --git a/src/apps/mobile/ios/BitFun/Resources.xcassets/SidebarFolderGlyph.imageset/Contents.json b/src/apps/mobile/ios/BitFun/Resources.xcassets/SidebarFolderGlyph.imageset/Contents.json new file mode 100644 index 0000000000..945673dc4d --- /dev/null +++ b/src/apps/mobile/ios/BitFun/Resources.xcassets/SidebarFolderGlyph.imageset/Contents.json @@ -0,0 +1,4 @@ +{ + "images" : [{ "filename" : "SidebarFolderGlyph.png", "idiom" : "universal", "scale" : "1x" }], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/src/apps/mobile/ios/BitFun/Resources.xcassets/SidebarFolderGlyph.imageset/SidebarFolderGlyph.png b/src/apps/mobile/ios/BitFun/Resources.xcassets/SidebarFolderGlyph.imageset/SidebarFolderGlyph.png new file mode 100644 index 0000000000..9c113bad42 Binary files /dev/null and b/src/apps/mobile/ios/BitFun/Resources.xcassets/SidebarFolderGlyph.imageset/SidebarFolderGlyph.png differ diff --git a/src/apps/mobile/ios/BitFun/Resources.xcassets/SidebarMenuGlyph.imageset/Contents.json b/src/apps/mobile/ios/BitFun/Resources.xcassets/SidebarMenuGlyph.imageset/Contents.json new file mode 100644 index 0000000000..c4586dcc9f --- /dev/null +++ b/src/apps/mobile/ios/BitFun/Resources.xcassets/SidebarMenuGlyph.imageset/Contents.json @@ -0,0 +1,4 @@ +{ + "images" : [{ "filename" : "SidebarMenuGlyph.png", "idiom" : "universal", "scale" : "1x" }], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/src/apps/mobile/ios/BitFun/Resources.xcassets/SidebarMenuGlyph.imageset/SidebarMenuGlyph.png b/src/apps/mobile/ios/BitFun/Resources.xcassets/SidebarMenuGlyph.imageset/SidebarMenuGlyph.png new file mode 100644 index 0000000000..6df2bfbfb4 Binary files /dev/null and b/src/apps/mobile/ios/BitFun/Resources.xcassets/SidebarMenuGlyph.imageset/SidebarMenuGlyph.png differ diff --git a/src/apps/mobile/ios/BitFun/Resources.xcassets/SidebarNewChatGlyph.imageset/Contents.json b/src/apps/mobile/ios/BitFun/Resources.xcassets/SidebarNewChatGlyph.imageset/Contents.json new file mode 100644 index 0000000000..075610851e --- /dev/null +++ b/src/apps/mobile/ios/BitFun/Resources.xcassets/SidebarNewChatGlyph.imageset/Contents.json @@ -0,0 +1,4 @@ +{ + "images" : [{ "filename" : "SidebarNewChatGlyph.png", "idiom" : "universal", "scale" : "1x" }], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/src/apps/mobile/ios/BitFun/Resources.xcassets/SidebarNewChatGlyph.imageset/SidebarNewChatGlyph.png b/src/apps/mobile/ios/BitFun/Resources.xcassets/SidebarNewChatGlyph.imageset/SidebarNewChatGlyph.png new file mode 100644 index 0000000000..1d440b90f5 Binary files /dev/null and b/src/apps/mobile/ios/BitFun/Resources.xcassets/SidebarNewChatGlyph.imageset/SidebarNewChatGlyph.png differ diff --git a/src/apps/mobile/ios/BitFun/Resources.xcassets/SidebarPlusGlyph.imageset/Contents.json b/src/apps/mobile/ios/BitFun/Resources.xcassets/SidebarPlusGlyph.imageset/Contents.json new file mode 100644 index 0000000000..0fcdff0f6c --- /dev/null +++ b/src/apps/mobile/ios/BitFun/Resources.xcassets/SidebarPlusGlyph.imageset/Contents.json @@ -0,0 +1,4 @@ +{ + "images" : [{ "filename" : "SidebarPlusGlyph.png", "idiom" : "universal", "scale" : "1x" }], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/src/apps/mobile/ios/BitFun/Resources.xcassets/SidebarPlusGlyph.imageset/SidebarPlusGlyph.png b/src/apps/mobile/ios/BitFun/Resources.xcassets/SidebarPlusGlyph.imageset/SidebarPlusGlyph.png new file mode 100644 index 0000000000..c14ed7da77 Binary files /dev/null and b/src/apps/mobile/ios/BitFun/Resources.xcassets/SidebarPlusGlyph.imageset/SidebarPlusGlyph.png differ diff --git a/src/apps/mobile/ios/BitFun/Resources.xcassets/SidebarSearchGlyph.imageset/Contents.json b/src/apps/mobile/ios/BitFun/Resources.xcassets/SidebarSearchGlyph.imageset/Contents.json new file mode 100644 index 0000000000..d4cffa909f --- /dev/null +++ b/src/apps/mobile/ios/BitFun/Resources.xcassets/SidebarSearchGlyph.imageset/Contents.json @@ -0,0 +1,4 @@ +{ + "images" : [{ "filename" : "SidebarSearchGlyph.png", "idiom" : "universal", "scale" : "1x" }], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/src/apps/mobile/ios/BitFun/Resources.xcassets/SidebarSearchGlyph.imageset/SidebarSearchGlyph.png b/src/apps/mobile/ios/BitFun/Resources.xcassets/SidebarSearchGlyph.imageset/SidebarSearchGlyph.png new file mode 100644 index 0000000000..74b4e36220 Binary files /dev/null and b/src/apps/mobile/ios/BitFun/Resources.xcassets/SidebarSearchGlyph.imageset/SidebarSearchGlyph.png differ diff --git a/src/apps/mobile/ios/BitFun/Resources.xcassets/SidebarSettingsGlyph.imageset/Contents.json b/src/apps/mobile/ios/BitFun/Resources.xcassets/SidebarSettingsGlyph.imageset/Contents.json new file mode 100644 index 0000000000..675720b863 --- /dev/null +++ b/src/apps/mobile/ios/BitFun/Resources.xcassets/SidebarSettingsGlyph.imageset/Contents.json @@ -0,0 +1,4 @@ +{ + "images" : [{ "filename" : "SidebarSettingsGlyph.png", "idiom" : "universal", "scale" : "1x" }], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/src/apps/mobile/ios/BitFun/Resources.xcassets/SidebarSettingsGlyph.imageset/SidebarSettingsGlyph.png b/src/apps/mobile/ios/BitFun/Resources.xcassets/SidebarSettingsGlyph.imageset/SidebarSettingsGlyph.png new file mode 100644 index 0000000000..f807e6f28e Binary files /dev/null and b/src/apps/mobile/ios/BitFun/Resources.xcassets/SidebarSettingsGlyph.imageset/SidebarSettingsGlyph.png differ diff --git a/src/apps/mobile/ios/README.md b/src/apps/mobile/ios/README.md index 5dfcbb7786..314c2dc06b 100644 --- a/src/apps/mobile/ios/README.md +++ b/src/apps/mobile/ios/README.md @@ -1,13 +1,57 @@ # BitFun iOS -iOS application entrypoint. +Native SwiftUI client for the mobile conversation surface. The initial iOS +entrypoint is intentionally small, but it is a real Xcode application and uses +the same geometry as the HarmonyOS reference: 76pt conversation header, 44pt +circle controls, 16pt content margins, the 48pt connection strip, and the +floating composer with 52pt collapsed height. -Provisional source layout: +## Project layout -- `BitFun/App/`: application lifecycle and composition root. -- `BitFun/Features/`: iOS product features and native UI. -- `BitFun/Infrastructure/`: iOS adapters and platform services. -- `BitFun/Resources/`: assets and localized resources. +- `BitFun/App/`: lifecycle and composition root. +- `BitFun/Features/Chat/`: header, timeline bubbles, and composer. +- `BitFun/Features/Shell/`: theme tokens, drawer, settings, and shell layout. +- `BitFun/Infrastructure/`: observable state and the platform seam. +- `BitFun/Resources.xcassets/`: app icon and future native assets. -Add the Xcode project after the UI stack, bundle identifier, deployment target, -and shared-core integration strategy are selected. +## Build and run + +The repository does not change the machine-wide developer directory. Use the +Xcode copy in `~/Downloads` when it is the compatible version: + +```bash +export DEVELOPER_DIR="$HOME/Downloads/Xcode.app/Contents/Developer" +"$DEVELOPER_DIR/usr/bin/xcodebuild" \ + -project BitFun.xcodeproj -scheme BitFun \ + -destination 'platform=iOS Simulator,id=1D7E5AA6-1AE9-4CAB-966B-A83B5F113B4A' \ + -derivedDataPath /tmp/BitFun-iOS-Derived \ + CODE_SIGNING_ALLOWED=YES CODE_SIGNING_IDENTITY=- build +``` + +`MobileAppModel` is kept in `Infrastructure` so the SwiftUI views do not know +about transport or persistence. Local chat, pairing, and remote session state +are supplied by the generated `BitFunMobileCore` framework from +`src/apps/mobile/shared/core-feature`; SwiftUI only maps the typed state to its +presentation model. Pairing accepts the desktop connection URL through the +connection sheet (the camera scanner remains a native adapter concern). + +When the framework has not been built yet, generate it with the same compatible +toolchain before opening the Xcode project: + +```bash +export JAVA_HOME="/Applications/DevEco-Studio.app/Contents/jbr/Contents/Home" +export PATH="$JAVA_HOME/bin:$PATH" +export DEVELOPER_DIR="$HOME/Downloads/Xcode.app/Contents/Developer" +../../../../gradlew :core-feature:assembleBitFunMobileCoreDebugXCFramework +``` + +The shell includes both product surfaces already: Local opens the HarmonyOS +welcome prompts, Remote has the disconnected desktop state and connection +action, and the unified drawer mirrors the HarmonyOS recent-chat, device, +workspace, chat, and settings sections. A connected preview exposes the +remote empty home through the same conversation chrome. + +For repeatable simulator captures, pass `--remote`, `--connected`, and/or +`--drawer` after the bundle identifier in `simctl launch`. These launch flags +select deterministic inspection states; normal launches use the live KMP +pairing/session stores. diff --git a/src/apps/mobile/shared/core-crypto/src/iosMain/kotlin/com/bitfun/mobile/core/crypto/CloudAccountKdf.ios.kt b/src/apps/mobile/shared/core-crypto/src/iosMain/kotlin/com/bitfun/mobile/core/crypto/CloudAccountKdf.ios.kt index 0376e25eba..072de36cbb 100644 --- a/src/apps/mobile/shared/core-crypto/src/iosMain/kotlin/com/bitfun/mobile/core/crypto/CloudAccountKdf.ios.kt +++ b/src/apps/mobile/shared/core-crypto/src/iosMain/kotlin/com/bitfun/mobile/core/crypto/CloudAccountKdf.ios.kt @@ -9,6 +9,7 @@ import kotlinx.cinterop.usePinned import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +@OptIn(kotlinx.cinterop.ExperimentalForeignApi::class) public actual object PlatformArgon2id { public actual suspend fun derive( password: String,