diff --git a/.gitignore b/.gitignore index 496ee2c..00741cb 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ -.DS_Store \ No newline at end of file +.DS_Store +.idea/ \ No newline at end of file diff --git a/app/README.md b/app/README.md index e642ac5..18b61e5 100644 --- a/app/README.md +++ b/app/README.md @@ -1 +1,62 @@ -# StackChan App \ No newline at end of file +# Project Setup and Running Instructions + +## 1. Clone the repository +```bash +git clone https://github.com/m5stack/StackChan +cd StackChan/app +``` + +## 2. Open the project in Xcode +Open the project in Xcode: + +Double‑click the `.xcodeproj` file, or open Xcode → File → Open, then select the project. + +1. Select your target device or simulator. + +### Connect an iPhone (Optional but Recommended) +- Connect your iPhone to the Mac using a USB cable. +- Unlock the iPhone and tap **Trust This Computer** if prompted. +- In Xcode, select your iPhone as the run destination at the top. + +### Enable Developer Mode on iPhone (iOS 16+) +> **Important:** Developer Mode will only appear after the iPhone has been connected to Xcode at least once. +If you do not see this option, make sure your iPhone is connected to the Mac, unlocked, trusted, and recognized by Xcode. +- On the iPhone, go to **Settings → Privacy & Security → Developer Mode**. +- Turn on Developer Mode and restart the iPhone. +- After restart, confirm enabling Developer Mode. + +## 3. Configure Signing & Capabilities +This step allows Xcode to install the app on your iPhone. + +1. In Xcode, select the project in the left sidebar. +2. Select the app target. +3. Open the **Signing & Capabilities** tab. +4. Sign in with your Apple ID (Xcode → Settings → Accounts → Add Apple ID). +5. Set **Team** to your Apple ID. +6. Change **Bundle Identifier** to a unique value, for example: +`com.yourname.stackchan` +7. Ensure no red error messages remain. + +> **Note:** A free Apple ID is sufficient for testing on your own iPhone. + +## 4. Modify network configuration +Before running the app, you need to set the correct server IP: + +1. Open the file `Network/Urls.swift`. +2. Find the line defining the base URL, for example: +```swift +// Base URL configured according to the server's IP +static let url = "192.168.51.24:12800/" +``` +3. Replace the IP address (`192.168.51.24`) with the IP of the computer where the server is running. +4. Save the file. + +## 5. Run the project +Press `Cmd + R` to build and run the app. + +> **Note:** The first build may take several minutes as Xcode prepares the environment. + +If running on an iPhone for the first time, you may need to trust yourself as a developer: +- On your iPhone, go to **Settings → General → VPN & Device Management → Trust Developer** and trust the developer profile that appears. + +The app will now connect to the server at the IP you configured. diff --git a/app/StackChan.xcodeproj/project.pbxproj b/app/StackChan.xcodeproj/project.pbxproj new file mode 100644 index 0000000..309af2c --- /dev/null +++ b/app/StackChan.xcodeproj/project.pbxproj @@ -0,0 +1,404 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + 0E4478B92F0A538600010197 /* README.MD in Resources */ = {isa = PBXBuildFile; fileRef = 0E4478B82F0A538600010197 /* README.MD */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 0E4478B82F0A538600010197 /* README.MD */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.MD; sourceTree = ""; }; + 0EBD7D382ECDA27C0001A9D1 /* StackChan.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = StackChan.app; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + 0EBD7E222ECDC9510001A9D1 /* Exceptions for "StackChan" folder in "StackChan" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 0EBD7D372ECDA27C0001A9D1 /* StackChan */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 0EBD7D3A2ECDA27C0001A9D1 /* StackChan */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 0EBD7E222ECDC9510001A9D1 /* Exceptions for "StackChan" folder in "StackChan" target */, + ); + explicitFileTypes = { + Info.plist = text.xml; + }; + path = StackChan; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + 0EBD7D352ECDA27C0001A9D1 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 0EBD7D2F2ECDA27C0001A9D1 = { + isa = PBXGroup; + children = ( + 0EBD7D3A2ECDA27C0001A9D1 /* StackChan */, + 0EBD7D392ECDA27C0001A9D1 /* Products */, + 0E4478B82F0A538600010197 /* README.MD */, + ); + sourceTree = ""; + }; + 0EBD7D392ECDA27C0001A9D1 /* Products */ = { + isa = PBXGroup; + children = ( + 0EBD7D382ECDA27C0001A9D1 /* StackChan.app */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 0EBD7D372ECDA27C0001A9D1 /* StackChan */ = { + isa = PBXNativeTarget; + buildConfigurationList = 0EBD7D432ECDA27D0001A9D1 /* Build configuration list for PBXNativeTarget "StackChan" */; + buildPhases = ( + 0EBD7D342ECDA27C0001A9D1 /* Sources */, + 0EBD7D352ECDA27C0001A9D1 /* Frameworks */, + 0EBD7D362ECDA27C0001A9D1 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 0EBD7D3A2ECDA27C0001A9D1 /* StackChan */, + ); + name = StackChan; + packageProductDependencies = ( + ); + productName = StackChan; + productReference = 0EBD7D382ECDA27C0001A9D1 /* StackChan.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 0EBD7D302ECDA27C0001A9D1 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 2610; + LastUpgradeCheck = 2620; + TargetAttributes = { + 0EBD7D372ECDA27C0001A9D1 = { + CreatedOnToolsVersion = 26.1.1; + }; + }; + }; + buildConfigurationList = 0EBD7D332ECDA27C0001A9D1 /* Build configuration list for PBXProject "StackChan" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 0EBD7D2F2ECDA27C0001A9D1; + minimizedProjectReferenceProxies = 1; + packageReferences = ( + ); + preferredProjectObjectVersion = 77; + productRefGroup = 0EBD7D392ECDA27C0001A9D1 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 0EBD7D372ECDA27C0001A9D1 /* StackChan */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 0EBD7D362ECDA27C0001A9D1 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 0E4478B92F0A538600010197 /* README.MD in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 0EBD7D342ECDA27C0001A9D1 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 0EBD7D412ECDA27D0001A9D1 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = NG678HLKHZ; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.1; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 0EBD7D422ECDA27D0001A9D1 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = NG678HLKHZ; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.1; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 0EBD7D442ECDA27D0001A9D1 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReferenceAnchor = 0EBD7D3A2ECDA27C0001A9D1 /* StackChan */; + baseConfigurationReferenceRelativePath = App.xcconfig; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = StackChan/StackChan.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 3; + DEVELOPMENT_TEAM = NG678HLKHZ; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + HEADER_SEARCH_PATHS = ""; + INFOPLIST_FILE = StackChan/Info.plist; + INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Bluetooth permission is required to connect to nearby devices"; + INFOPLIST_KEY_NSCameraUsageDescription = "Camera permission is required to scan the code"; + INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Local network access is required to discover devices"; + INFOPLIST_KEY_NSLocationAlwaysAndWhenInUseUsageDescription = "Location permission is required to access Wi-Fi information"; + INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "Location permission is required to access Wi-Fi information"; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; + IPHONEOS_DEPLOYMENT_TARGET = 16.6; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + LIBRARY_SEARCH_PATHS = "$(inherited)"; + MARKETING_VERSION = 1.0.3; + OTHER_LDFLAGS = ( + "$(inherited)", + "-ObjC", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.m5stack.StackChan; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OBJC_BRIDGING_HEADER = ""; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 0EBD7D452ECDA27D0001A9D1 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReferenceAnchor = 0EBD7D3A2ECDA27C0001A9D1 /* StackChan */; + baseConfigurationReferenceRelativePath = App.xcconfig; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = StackChan/StackChan.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 3; + DEVELOPMENT_TEAM = NG678HLKHZ; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + HEADER_SEARCH_PATHS = ""; + INFOPLIST_FILE = StackChan/Info.plist; + INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Bluetooth permission is required to connect to nearby devices"; + INFOPLIST_KEY_NSCameraUsageDescription = "Camera permission is required to scan the code"; + INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Local network access is required to discover devices"; + INFOPLIST_KEY_NSLocationAlwaysAndWhenInUseUsageDescription = "Location permission is required to access Wi-Fi information"; + INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "Location permission is required to access Wi-Fi information"; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; + IPHONEOS_DEPLOYMENT_TARGET = 16.6; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + LIBRARY_SEARCH_PATHS = "$(inherited)"; + MARKETING_VERSION = 1.0.3; + OTHER_LDFLAGS = ( + "$(inherited)", + "-ObjC", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.m5stack.StackChan; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OBJC_BRIDGING_HEADER = ""; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 0EBD7D332ECDA27C0001A9D1 /* Build configuration list for PBXProject "StackChan" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 0EBD7D412ECDA27D0001A9D1 /* Debug */, + 0EBD7D422ECDA27D0001A9D1 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 0EBD7D432ECDA27D0001A9D1 /* Build configuration list for PBXNativeTarget "StackChan" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 0EBD7D442ECDA27D0001A9D1 /* Debug */, + 0EBD7D452ECDA27D0001A9D1 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 0EBD7D302ECDA27C0001A9D1 /* Project object */; +} diff --git a/app/StackChan.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/app/StackChan.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/app/StackChan.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/app/StackChan.xcodeproj/project.xcworkspace/xcuserdata/yuanzhihong.xcuserdatad/UserInterfaceState.xcuserstate b/app/StackChan.xcodeproj/project.xcworkspace/xcuserdata/yuanzhihong.xcuserdatad/UserInterfaceState.xcuserstate new file mode 100644 index 0000000..93919c4 Binary files /dev/null and b/app/StackChan.xcodeproj/project.xcworkspace/xcuserdata/yuanzhihong.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/app/StackChan.xcodeproj/xcshareddata/xcschemes/StackChan.xcscheme b/app/StackChan.xcodeproj/xcshareddata/xcschemes/StackChan.xcscheme new file mode 100644 index 0000000..a21a351 --- /dev/null +++ b/app/StackChan.xcodeproj/xcshareddata/xcschemes/StackChan.xcscheme @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/StackChan.xcodeproj/xcuserdata/yuanzhihong.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist b/app/StackChan.xcodeproj/xcuserdata/yuanzhihong.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist new file mode 100644 index 0000000..6c47dd4 --- /dev/null +++ b/app/StackChan.xcodeproj/xcuserdata/yuanzhihong.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist @@ -0,0 +1,6 @@ + + + diff --git a/app/StackChan.xcodeproj/xcuserdata/yuanzhihong.xcuserdatad/xcschemes/xcschememanagement.plist b/app/StackChan.xcodeproj/xcuserdata/yuanzhihong.xcuserdatad/xcschemes/xcschememanagement.plist new file mode 100644 index 0000000..d0e43d9 --- /dev/null +++ b/app/StackChan.xcodeproj/xcuserdata/yuanzhihong.xcuserdatad/xcschemes/xcschememanagement.plist @@ -0,0 +1,22 @@ + + + + + SchemeUserState + + StackChan.xcscheme_^#shared#^_ + + orderHint + 0 + + + SuppressBuildableAutocreation + + 0EBD7D372ECDA27C0001A9D1 + + primary + + + + + diff --git a/app/StackChan/3DModel/stackChanModel.scn b/app/StackChan/3DModel/stackChanModel.scn new file mode 100644 index 0000000..65dd27c Binary files /dev/null and b/app/StackChan/3DModel/stackChanModel.scn differ diff --git a/app/StackChan/AppState.swift b/app/StackChan/AppState.swift new file mode 100644 index 0000000..974990b --- /dev/null +++ b/app/StackChan/AppState.swift @@ -0,0 +1,268 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +import Combine +import SwiftUI +import CoreBluetooth +import ARKit +import Combine + +enum PageType: Hashable { + case minicryEmotion + case cameraPage + case dance +} + +class AppState: ObservableObject { + static let shared = AppState() + private init() {} + + static let deviceId = UIDevice.current.identifierForVendor?.uuidString ?? UUID().uuidString + + static var isRelease : Bool = true + @Published var showAlert: Bool = false + @Published var alertTitle: String = "" + var alertAction: (() -> Void)? = nil + + func presentAlert(title: String,action: (() -> Void)? = nil) { + self.alertTitle = title + self.alertAction = action + self.showAlert = true + } + + @AppStorage("deviceMac") var deviceMac: String = "" + + @Published var stackChanPath: [PageType] = [] + @Published var nearbyPath: [PageType] = [] + @Published var settingsPath: [PageType] = [] + + @Published var showBindingDevice = false + @Published var forcedDisplayBindingDevice = true + + @Published var showCjamgeNameAlert: Bool = false + + @Published var showBindingDeviceAlert: Bool = false + + @Published var newName: String = "" + @Published var deviceInfo: Device = Device() + + let detector = DistanceDetector() + @Published var showSwitchFace: Bool = false + + @Published var blufDeviceList: [BlufiDeviceInfo] = [] + + /// Whether currently pairing a device + @Published var showDeviceWifiSet = false + + // Manual shutdown time, if just manually shut down, temporarily do not configure + var manualShutdownTime: Date? = nil + + @Published var deviceIsOnline: Bool = false + + func connectBulDevice(macAddress: String) { + if BlufiUtil.shared.blueSwitch { + BlufiUtil.shared.startScan() + } else { + BlufiUtil.shared.centralManagerDidUpdateState = { state in + switch state { + case .poweredOn: + BlufiUtil.shared.startScan() + default: break + } + } + } + BlufiUtil.shared.characteristicCallback = { characteristic in + if characteristic.properties.contains(.write) || characteristic.properties.contains(.writeWithoutResponse) { + + if characteristic.uuid.uuidString == "E2E5E5E2-1234-5678-1234-56789ABCDEF0" { + BlufiUtil.shared.writeExpressionCharacteristic = characteristic + print("✏️ Expression writable characteristic assigned: \(characteristic.uuid)") + } + + if characteristic.uuid.uuidString == "E2E5E5E1-1234-5678-1234-56789ABCDEF0" { + BlufiUtil.shared.writeHeadCharacteristic = characteristic + print("✏️ Head writable characteristic assigned: \(characteristic.uuid)") + } + } + } + } + + func connectWebSocket() { + let webSocketUrl = Urls.getWebSocketUrl() + "?mac=" + deviceMac + "&deviceType=App&deviceId=" + AppState.deviceId + WebSocketUtil.shared.connect(urlString: webSocketUrl) + } + + func sendWebSocketMessage(_ msgType: MsgType,_ data: Data? = nil) { + var buffer = Data([msgType.rawValue]) + + let payload = data ?? Data() + + // payload length + let dataLen = UInt32(payload.count) + buffer.append(UInt8((dataLen >> 24) & 0xFF)) + buffer.append(UInt8((dataLen >> 16) & 0xFF)) + buffer.append(UInt8((dataLen >> 8) & 0xFF)) + buffer.append(UInt8(dataLen & 0xFF)) + + // data + buffer.append(payload) + + WebSocketUtil.shared.send(data: buffer) + } + + /// Parse message data + func parseMessage(message: Data) -> (MsgType?,Data?) { + guard message.count >= 5 else { + return (nil,nil) + } + + let typeByte = message[0] + guard let msgType = MsgType(rawValue: typeByte) else { + return (nil,nil) + } + + let lengthData = message[1...4] + let dataLength = lengthData.reduce(0) { (result, byte) -> UInt32 in + return (result << 8) | UInt32(byte) + } + + if message.count < 5 + Int(dataLength) { + return (nil,nil) + } + + let payload = message[5..<(5 + Int(dataLength))] + + return (msgType, Data(payload)) + } + + func updateDeviceInfo() { + let map = [ + ValueConstant.mac: deviceMac, + ValueConstant.name: deviceInfo.name, + ] + Networking.shared.put(pathUrl: Urls.deviceInfo, parameters: map) { result in + switch result { + case .success(let success): + do { + let response = try Response.decode(from: success) + if response.isSuccess { + print("Update successful") + } + } catch { + print("Failed to parse data") + } + case .failure(let failure): + print("Request failed:", failure) + } + } + } + + /// Distance detection, callback when close + func startDistanceDetection() { + detector.startDistanceDetection( + distanceUpdate: { distance in + let distanceInCm = distance * 100 + if distanceInCm < 5 { + if self.showSwitchFace == false { + self.showSwitchFace = true + } + } + }, + belowThreshold: { + // Execute your business logic + // For example: stop machine, send notification, etc. + } + ) + } + + func stopDistanceDetection() { + detector.stopDistanceDetection() + } + + // Wrapper for ARSessionDelegate + class ARSessionDelegateWrapper: NSObject, ARSessionDelegate { + var onFrameUpdate: (ARFrame) -> Void + + init(onFrameUpdate: @escaping (ARFrame) -> Void) { + self.onFrameUpdate = onFrameUpdate + } + + func session(_ session: ARSession, didUpdate frame: ARFrame) { + onFrameUpdate(frame) + } + } + + func getDeviceInfo() { + let map = [ + ValueConstant.mac: deviceMac + ] + Networking.shared.get(pathUrl: Urls.deviceInfo,parameters: map) { result in + switch result { + case .success(let success): + do { + let response = try Response.decode(from: success) + if response.isSuccess, let deviceInfo = response.data { + withAnimation { + self.deviceInfo = deviceInfo + self.newName = self.deviceInfo.name ?? "" + } + if deviceInfo.name == "" { + self.showCjamgeNameAlert = true + } + } + } catch { + print("Failed to parse data") + } + case .failure(let failure): + print("Request failed:", failure) + } + } + } + + /// Enable Bluetooth functionality + func openBlufi() { + BlufiUtil.shared.blufDevicesMonitoring = { discovereDevices in + self.blufDeviceList = discovereDevices + + // Check manualShutdownTime, if exists and not exceeding 5 seconds, temporarily do not show popup + if let shutdownTime = self.manualShutdownTime { + let timeInterval = Date().timeIntervalSince(shutdownTime) + if timeInterval < 5 { + return + } + } + if !self.showDeviceWifiSet { + if !self.blufDeviceList.isEmpty { + self.showDeviceWifiSet = true + } + } + } + } + + // webSocket Message Monitoring + func webSocketMessageMonitoring() { + WebSocketUtil.shared.addObserver(for: "App") { (message: URLSessionWebSocketTask.Message) in + switch message { + case .data(let data): + let result = self.parseMessage(message: data) + if let msgType = result.0 { + switch msgType { + case MsgType.deviceOnline: + self.deviceIsOnline = false + case MsgType.deviceOffline: + self.deviceIsOnline = true + default: + break + } + } + case .string(let text): + print("Received a regular message: \(text)") + @unknown default: + break + } + } + } +} diff --git a/app/StackChan/Assets.xcassets/AccentColor.colorset/Contents.json b/app/StackChan/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..2f961ca --- /dev/null +++ b/app/StackChan/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,23 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.057", + "green" : "0.689", + "red" : "0.936" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "localizable" : true + } +} diff --git a/app/StackChan/Assets.xcassets/AppIcon.appiconset/Contents.json b/app/StackChan/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..0380c9b --- /dev/null +++ b/app/StackChan/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,36 @@ +{ + "images" : [ + { + "filename" : "app_logo.jpg", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/app/StackChan/Assets.xcassets/AppIcon.appiconset/app_logo.jpg b/app/StackChan/Assets.xcassets/AppIcon.appiconset/app_logo.jpg new file mode 100644 index 0000000..6a919fb Binary files /dev/null and b/app/StackChan/Assets.xcassets/AppIcon.appiconset/app_logo.jpg differ diff --git a/app/StackChan/Assets.xcassets/Contents.json b/app/StackChan/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/app/StackChan/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/app/StackChan/Assets.xcassets/lateral_image.imageset/7.595.png b/app/StackChan/Assets.xcassets/lateral_image.imageset/7.595.png new file mode 100644 index 0000000..e0d36e2 Binary files /dev/null and b/app/StackChan/Assets.xcassets/lateral_image.imageset/7.595.png differ diff --git a/app/StackChan/Assets.xcassets/lateral_image.imageset/Contents.json b/app/StackChan/Assets.xcassets/lateral_image.imageset/Contents.json new file mode 100644 index 0000000..5fab30d --- /dev/null +++ b/app/StackChan/Assets.xcassets/lateral_image.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "7.595.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/app/StackChan/Assets.xcassets/logo_icon.imageset/Contents.json b/app/StackChan/Assets.xcassets/logo_icon.imageset/Contents.json new file mode 100644 index 0000000..12483b1 --- /dev/null +++ b/app/StackChan/Assets.xcassets/logo_icon.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "stackChanLogo.jpg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/app/StackChan/Assets.xcassets/logo_icon.imageset/stackChanLogo.jpg b/app/StackChan/Assets.xcassets/logo_icon.imageset/stackChanLogo.jpg new file mode 100644 index 0000000..6a919fb Binary files /dev/null and b/app/StackChan/Assets.xcassets/logo_icon.imageset/stackChanLogo.jpg differ diff --git a/app/StackChan/Info.plist b/app/StackChan/Info.plist new file mode 100644 index 0000000..c1c146d --- /dev/null +++ b/app/StackChan/Info.plist @@ -0,0 +1,29 @@ + + + + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + NSBonjourServices + + _stackchan-mpc._tcp + + com.apple.developer.networking.wifi-info + + + NSLocalNetworkUsageDescription + This app requires access to the local network to communicate with devices. + NSMicrophoneUsageDescription + We need access to the microphone to capture audio data. + ITSAppUsesNonExemptEncryption + + CFBundleDisplayName + StackChan World + + NSPhotoLibraryAddUsageDescription + Save the photo to the album + + diff --git a/app/StackChan/Model/BlufiModel.swift b/app/StackChan/Model/BlufiModel.swift new file mode 100644 index 0000000..0412dab --- /dev/null +++ b/app/StackChan/Model/BlufiModel.swift @@ -0,0 +1,62 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +import Foundation + +struct BlufiModel: Codable { + + var cmd: String? = nil + var data: T? = nil + + func toJson() -> String? { + let encoder = JSONEncoder() + encoder.outputFormatting = .prettyPrinted + guard let jsonData = try? encoder.encode(self) else { return nil } + return String(data: jsonData, encoding: .utf8) + } + + static func fromJson(_ json: String) -> BlufiModel? { + guard let jsonData = json.data(using: .utf8) else { return nil } + let decoder = JSONDecoder() + return try? decoder.decode(BlufiModel.self, from: jsonData) + } +} + +struct BlufiWifi : Codable { + var ssid: String? + var password: String? + + func toJson() -> String? { + let encoder = JSONEncoder() + encoder.outputFormatting = .prettyPrinted + guard let jsonData = try? encoder.encode(self) else { return nil } + return String(data: jsonData, encoding: .utf8) + } + + static func fromJson(_ json: String) -> BlufiWifi? { + guard let jsonData = json.data(using: .utf8) else { return nil } + let decoder = JSONDecoder() + return try? decoder.decode(BlufiWifi.self, from: jsonData) + } +} + +struct BlufiNotifyState : Codable { + var type: Int? + var state: String? + + func toJson() -> String? { + let encoder = JSONEncoder() + encoder.outputFormatting = .prettyPrinted + guard let jsonData = try? encoder.encode(self) else { return nil } + return String(data: jsonData, encoding: .utf8) + } + + static func fromJson(_ json: String) -> BlufiNotifyState? { + guard let jsonData = json.data(using: .utf8) else { return nil } + let decoder = JSONDecoder() + return try? decoder.decode(BlufiNotifyState.self, from: jsonData) + } +} diff --git a/app/StackChan/Model/Device.swift b/app/StackChan/Model/Device.swift new file mode 100644 index 0000000..fb042ee --- /dev/null +++ b/app/StackChan/Model/Device.swift @@ -0,0 +1,12 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +import Foundation + +struct Device : Codable { + var mac: String = UUID().uuidString + var name: String? = nil +} diff --git a/app/StackChan/Model/ExpressionData.swift b/app/StackChan/Model/ExpressionData.swift new file mode 100644 index 0000000..021694a --- /dev/null +++ b/app/StackChan/Model/ExpressionData.swift @@ -0,0 +1,100 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +import Foundation + +struct ExpressionData : Codable { + var type: String = "bleAvatar" + var leftEye: ExpressionItem + var rightEye: ExpressionItem + var mouth: ExpressionItem +} + +struct ExpressionItem : Codable { + var x: Int = 0 + var y: Int = 0 + var rotation: Int = 0 + var weight: Int = 0 + var size: Int = 0 + + func copy() -> ExpressionItem { + ExpressionItem( + x: self.x, + y: self.y, + rotation: self.rotation, + weight: self.weight, + size: self.size + ) + } +} + +struct MotionData : Codable { + var type: String = "bleMotion" + var pitchServo: MotionDataItem + var yawServo: MotionDataItem + + func toJsonString() -> String { + let encoder = JSONEncoder() + if let jsonData = try? encoder.encode(self), + let jsonString = String(data: jsonData, encoding: .utf8) { + return jsonString + } + return "{}" + } +} + +struct MotionDataItem: Codable { + var angle: Int = 0 + var speed: Int = 500 + var rotate: Int = 0 + + init() { + self.angle = 0 + self.speed = 500 + self.rotate = 0 + } + + init(angle: Int, speed: Int = 500) { + self.angle = angle + self.speed = speed + self.rotate = 0 + } + + enum CodingKeys: String, CodingKey { + case angle + case speed + case rotate + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + if angle != 0 { + try container.encode(angle, forKey: .angle) + try container.encode(speed, forKey: .speed) + } else if rotate != 0 { + try container.encode(rotate, forKey: .rotate) + try container.encode(speed, forKey: .speed) + } else { + try container.encode(angle, forKey: .angle) + try container.encode(speed, forKey: .speed) + } + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.angle = try container.decodeIfPresent(Int.self, forKey: .angle) ?? 0 + self.speed = try container.decodeIfPresent(Int.self, forKey: .speed) ?? 500 + self.rotate = try container.decodeIfPresent(Int.self, forKey: .rotate) ?? 0 + } + + func copy() -> MotionDataItem { + MotionDataItem( + angle: self.angle, + speed: self.speed, + ) + } +} diff --git a/app/StackChan/Model/MessageModel.swift b/app/StackChan/Model/MessageModel.swift new file mode 100644 index 0000000..458b114 --- /dev/null +++ b/app/StackChan/Model/MessageModel.swift @@ -0,0 +1,39 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +import Foundation + +enum MsgType: UInt8, Codable { + case opus = 0x01 + case jpeg = 0x02 + case controlAvatar = 0x03 + case controlMotion = 0x04 + + case onCamera = 0x05 + case offCamera = 0x06 + + case textMessage = 0x07 + case requestCall = 0x09 + case refuseCall = 0x0A + case agreeCall = 0x0B + case hangupCall = 0x0C + + case updateDeviceName = 0x0D + case getDeviceName = 0x0E + + case ping = 0x10 + case pong = 0x11 + + case onPhoneScreen = 0x12 + case offPhoneScreen = 0x13 + + case dance = 0x14 + + case getAvatarPosture = 0x15 + + case deviceOffline = 0x16 + case deviceOnline = 0x17 +} diff --git a/app/StackChan/Model/Post.swift b/app/StackChan/Model/Post.swift new file mode 100644 index 0000000..698702c --- /dev/null +++ b/app/StackChan/Model/Post.swift @@ -0,0 +1,32 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +import Foundation + +struct Post : Codable{ + var id: Int + var mac: String? = nil + var name: String? = nil + var contentText: String? = nil + var contentImage: String? = nil + var createdAt: String? = nil + var postCommentList: [PostComment]? = nil +} + +struct PostComment: Codable { + var id: Int? = nil + var postId: Int? = nil + var mac: String? = nil + var name: String? = nil + var content: String? = nil + var createAt: String? = nil +} + + +struct GetPostComment: Codable { + var list: [PostComment]? = nil + var total: Int? = nil +} diff --git a/app/StackChan/Model/Response.swift b/app/StackChan/Model/Response.swift new file mode 100644 index 0000000..68b1d49 --- /dev/null +++ b/app/StackChan/Model/Response.swift @@ -0,0 +1,79 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +import Foundation + +struct Response: Codable { + let code: Int? + let message: String? + let data: T? + + var isSuccess: Bool { + return code == 0 + } + + func unwrap(or defaultValue: T) -> T { + return data ?? defaultValue + } + + static func decode(from jsonData: Data) throws -> Response { + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + do { + return try decoder.decode(Response.self, from: jsonData) + } catch let DecodingError.dataCorrupted(context) { + print("🔴 Data corrupted: \(context.debugDescription)") + printCodingPath(context.codingPath) + printJSON(jsonData) + throw DecodingError.dataCorrupted(context) + } catch let DecodingError.keyNotFound(key, context) { + print("🔴 Key '\(key.stringValue)' not found: \(context.debugDescription)") + printCodingPath(context.codingPath) + printJSON(jsonData) + throw DecodingError.keyNotFound(key, context) + } catch let DecodingError.typeMismatch(type, context) { + print("🔴 Type '\(type)' mismatch: \(context.debugDescription)") + printCodingPath(context.codingPath) + printJSON(jsonData) + throw DecodingError.typeMismatch(type, context) + } catch let DecodingError.valueNotFound(value, context) { + print("🔴 Value '\(value)' not found: \(context.debugDescription)") + printCodingPath(context.codingPath) + printJSON(jsonData) + throw DecodingError.valueNotFound(value, context) + } catch { + print("🔴 Other errors in the analysis: \(error)") + printJSON(jsonData) + throw error + } + } + + static func decode(from json: [String: Any]) throws -> Response { + let data = try JSONSerialization.data(withJSONObject: json, options: []) + return try decode(from: data) + } + + func debugDescription() -> String { + return "Response(code: \(code ?? 0), message: \(message ?? ""), data: \(String(describing: data)))" + } +} + +fileprivate func printCodingPath(_ codingPath: [CodingKey]) { + let path = codingPath.map { $0.stringValue }.joined(separator: ".") + print("📍 Error path: \(path)") +} + +fileprivate func printJSON(_ data: Data) { + if let obj = try? JSONSerialization.jsonObject(with: data, options: []), + let prettyData = try? JSONSerialization.data(withJSONObject: obj, options: [.prettyPrinted]), + let str = String(data: prettyData, encoding: .utf8) { + print("📄 Original JSON:\n\(str)") + } else if let str = String(data: data, encoding: .utf8) { + print("📄 Original JSON:\n\(str)") + } else { + print("⚠️ Unable to parse the original JSON") + } +} diff --git a/app/StackChan/Model/UploadFile.swift b/app/StackChan/Model/UploadFile.swift new file mode 100644 index 0000000..bc63fa5 --- /dev/null +++ b/app/StackChan/Model/UploadFile.swift @@ -0,0 +1,9 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +struct UploadFile : Codable { + var path: String? = nil +} diff --git a/app/StackChan/Network/Networking.swift b/app/StackChan/Network/Networking.swift new file mode 100644 index 0000000..b8576b0 --- /dev/null +++ b/app/StackChan/Network/Networking.swift @@ -0,0 +1,328 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +import Foundation + +class Networking { + + static let shared = Networking() + private init() {} + + enum HTTPMethod: String { + case GET,POST,PUT,DELETE + } + + private func request( + urlString: String, + method: HTTPMethod, + parameters: Any? = nil, + headers: [String: String] = [:], + completion: @escaping (Result) -> Void + ) { + var finalURLString = urlString + var httpBody: Data? = nil + + if method == .GET { + if let params = parameters as? [String: Any], !params.isEmpty { + var components = URLComponents(string: urlString) + components?.queryItems = params.map { URLQueryItem(name: $0.key, value: "\($0.value)") } + if let urlWithQuery = components?.url?.absoluteString { + finalURLString = urlWithQuery + } + } + } else { + if let params = parameters { + requestSetContentType: do { + requestSetBody: do { + do { + if let dict = params as? [String: Any] { + httpBody = try JSONSerialization.data(withJSONObject: dict, options: []) + } else if let array = params as? [Any] { + httpBody = try JSONSerialization.data(withJSONObject: array, options: []) + } else { + httpBody = try JSONSerialization.data(withJSONObject: params, options: []) + } + } catch { + completion(.failure(error)) + return + } + } + } + } + } + + guard let url = URL(string: finalURLString) else { + completion(.failure(NSError(domain: "Invalid URL", code: -1))) + return + } + + var request = URLRequest(url: url) + request.httpMethod = method.rawValue + + if method != .GET, httpBody != nil { + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = httpBody + } + + setHandler(request: &request, headers: headers) + logRequest(request) + + URLSession.shared.dataTask(with: request) { data, response, error in + DispatchQueue.main.async { + if let error = error { + completion(.failure(error)) + return + } + guard let data = data else { + completion(.failure(NSError(domain: "No data returned", code: -2))) + return + } + self.logResponse(data: data) + completion(.success(data)) + } + }.resume() + } + + func get(pathUrl: String, parameters: [String: Any] = [:], headers: [String: String] = [:], baseUrlString: String? = nil, completion: @escaping (Result) -> Void) { + let finalUrl = (baseUrlString ?? Urls.getBaseUrl()) + pathUrl + request(urlString: finalUrl, method: .GET, parameters: parameters, headers: headers, completion: completion) + } + + func post(pathUrl: String, parameters: Any? = nil, headers: [String: String] = [:], baseUrlString: String? = nil, completion: @escaping (Result) -> Void) { + let finalUrl = (baseUrlString ?? Urls.getBaseUrl()) + pathUrl + request(urlString: finalUrl, method: .POST, parameters: parameters, headers: headers, completion: completion) + } + + private func setHandler(request: inout URLRequest, headers: [String: String]) { + for (key, value) in headers { + request.setValue(value, forHTTPHeaderField: key) + } + if let token = UserDefaults.standard.string(forKey: ValueConstant.token), !token.isEmpty { + request.setValue(token, forHTTPHeaderField: ValueConstant.Authorization) + } + } + + func postFromData(pathUrl: String, + parameters: [String: Any?] = [:], + headers: [String: String] = [:], + baseUrlString: String? = nil, + suffix:String? = nil, + completion: @escaping (Result) -> Void) { + let finalUrl = (baseUrlString ?? Urls.getBaseUrl()) + pathUrl + guard let url = URL(string: finalUrl) else { + completion(.failure(NSError(domain: "Invalid URL", code: -1))) + return + } + + var request = URLRequest(url: url) + request.httpMethod = HTTPMethod.POST.rawValue + + let boundary = "Boundary-\(UUID().uuidString)" + request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") + + setHandler(request: &request, headers: headers) + + var requestBody = Data() + + for (key, value) in parameters { + if let value = value { + if let fileData = value as? Data { + let type = mimeType(for: fileData) + let fileName = UUID().uuidString + (suffix ?? "") + requestBody.append("--\(boundary)\r\n".data(using: .utf8)!) + requestBody.append("Content-Disposition: form-data; name=\"\(key)\"; filename=\"\(fileName)\"\r\n".data(using: .utf8)!) + requestBody.append("Content-Type: \(type)\r\n\r\n".data(using: .utf8)!) + requestBody.append(fileData) + requestBody.append("\r\n".data(using: .utf8)!) + } else if let array = value as? [Any] { + if let jsonData = try? JSONSerialization.data(withJSONObject: array, options: []) { + let jsonString = String(data: jsonData, encoding: .utf8) + requestBody.append("--\(boundary)\r\n".data(using: .utf8)!) + requestBody.append("Content-Disposition: form-data; name=\"\(key)\"\r\n\r\n".data(using: .utf8)!) + requestBody.append("\(jsonString ?? "[]")\r\n".data(using: .utf8)!) + } + } else if let dict = value as? [String:Any] { + if let jsonData = try? JSONSerialization.data(withJSONObject: dict, options: []) { + let jsonString = String(data: jsonData, encoding: .utf8) + requestBody.append("--\(boundary)\r\n".data(using: .utf8)!) + requestBody.append("Content-Disposition: form-data; name=\"\(key)\"\r\n\r\n".data(using: .utf8)!) + requestBody.append("\(jsonString ?? "{}")\r\n".data(using: .utf8)!) + } + } else { + let str = "\(value)" + requestBody.append("--\(boundary)\r\n".data(using: .utf8)!) + requestBody.append("Content-Disposition: form-data; name=\"\(key)\"\r\n\r\n".data(using: .utf8)!) + requestBody.append("\(str)\r\n".data(using: .utf8)!) + } + } + } + requestBody.append("--\(boundary)--\r\n".data(using: .utf8)!) + request.httpBody = requestBody + + logRequest(request) + + URLSession.shared.dataTask(with: request) { data, response, error in + DispatchQueue.main.async { + if let error = error { + completion(.failure(error)) + return + } + guard let data = data else { + completion(.failure(NSError(domain: "No data returned", code: -2))) + return + } + self.logResponse(data: data) + completion(.success(data)) + } + }.resume() + } + + func anyToJson(data: Any) -> String { + func convert(_ value: Any) -> Any { + if let dict = value as? [String: Any] { + var newDict: [String: Any] = [:] + for (k, v) in dict { + newDict[k] = convert(v) + } + return newDict + } else if let dict = value as? [String: Any?] { + var newDict: [String: Any] = [:] + for (k, v) in dict { + if let unwrapped = v { + newDict[k] = convert(unwrapped) + } else { + newDict[k] = NSNull() + } + } + return newDict + } else if let array = value as? [Any] { + return array.map { convert($0) } + } else if let array = value as? [Any?] { + return array.map { $0 == nil ? NSNull() : convert($0!) } + } else if value is Int || value is Double || value is Bool || value is String { + return value + } else { + return "\(value)" + } + } + + let converted = convert(data) + + if JSONSerialization.isValidJSONObject(converted) { + do { + let jsonData = try JSONSerialization.data(withJSONObject: converted, options: []) + return String(data: jsonData, encoding: .utf8) ?? "[]" + } catch { + print("JSON Serialization error: \(error)") + return "[]" + } + } else { + if let str = converted as? String { + return "\"\(str)\"" + } else { + return "\(converted)" + } + } + } + + func put(pathUrl: String, parameters: Any? = nil, headers: [String: String] = [:], baseUrlString: String? = nil, completion: @escaping (Result) -> Void) { + let finalUrl = (baseUrlString ?? Urls.getBaseUrl()) + pathUrl + request(urlString: finalUrl, method: .PUT, parameters: parameters, headers: headers, completion: completion) + } + + func delete(pathUrl: String, parameters: Any? = nil, headers: [String: String] = [:], baseUrlString: String? = nil, completion: @escaping (Result) -> Void) { + let finalUrl = (baseUrlString ?? Urls.getBaseUrl()) + pathUrl + request(urlString: finalUrl, method: .DELETE, parameters: parameters, headers: headers, completion: completion) + } + + func download(pathUrl: String, + parameters: [String: Any] = [:], + headers: [String: String] = [:], + baseUrlString: String? = nil, + completion: @escaping (Result) -> Void) { + + let finalUrl = (baseUrlString ?? Urls.getBaseUrl()) + pathUrl + let key = FileUtils.shared.hashedKey(for: finalUrl) + let cacheURL = FileUtils.shared.cacheDirectory().appendingPathComponent(key) + + if FileManager.default.fileExists(atPath: cacheURL.path) { + completion(.success(cacheURL.path)) + return + } + + var request = URLRequest(url: URL(string: finalUrl)!) + request.httpMethod = "GET" + + setHandler(request: &request, headers: headers) + + URLSession.shared.downloadTask(with: request) { tempURL, response, error in + DispatchQueue.main.async { + if let error = error { + completion(.failure(error)) + return + } + + guard let tempURL = tempURL else { + completion(.failure(NSError(domain: "No file downloaded", code: -3))) + return + } + + do { + let directory = cacheURL.deletingLastPathComponent() + if !FileManager.default.fileExists(atPath: directory.path) { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + } + + if FileManager.default.fileExists(atPath: cacheURL.path) { + try FileManager.default.removeItem(at: cacheURL) + } + try FileManager.default.moveItem(at: tempURL, to: cacheURL) + + completion(.success(cacheURL.path)) + } catch { + completion(.failure(error)) + } + } + }.resume() + } + + private func logRequest(_ request: URLRequest) { + print("➡️ Request URL: \(request.url?.absoluteString ?? "")") + print("➡️ Method: \(request.httpMethod ?? "")") + print("➡️ Headers: \(request.allHTTPHeaderFields ?? [:])") + if let body = request.httpBody { + if let bodyString = String(data: body, encoding: .utf8) { + print("➡️ Body:") + bodyString.jsonPrint() + } else { + let sizeInMB = Double(body.count) / (1024 * 1024) + print(String(format: "➡️ Body (binary data, size: %.2f MB)", sizeInMB)) + } + } + } + + private func logResponse(data: Data) { + if let responseString = String(data: data, encoding: .utf8) { + print("⬅️ Response:") + responseString.jsonPrint() + } else { + print("⬅️ Response (binary data, length: \(data.count) bytes)") + } + } + + private func mimeType(for data: Data) -> String { + var bytes = [UInt8](repeating: 0, count: 1) + data.copyBytes(to: &bytes, count: 1) + switch bytes[0] { + case 0xFF: return "image/jpeg" + case 0x89: return "image/png" + case 0x47: return "image/gif" + case 0x25: return "application/pdf" + case 0x49, 0x4D: return "image/tiff" + default: return "application/octet-stream" + } + } +} diff --git a/app/StackChan/Network/Urls.swift b/app/StackChan/Network/Urls.swift new file mode 100644 index 0000000..c15ff0a --- /dev/null +++ b/app/StackChan/Network/Urls.swift @@ -0,0 +1,45 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +struct Urls { + + // Base URL configured according to the server's IP + static let url = "192.168.51.43:12800/" + + static func getBaseUrl() -> String { + return "http://" + url + "stackChan/" + } + + static func getFileUrl() -> String { + return "http://" + url + } + + static func getWebSocketUrl() -> String { + return "ws://" + url + "stackChan/ws" + } + + static let registerMac = "api/v2/device/registerMac" + + static let dance = "dance" + + static let deviceRandomList = "device/randomList" + + static let uploadFile = "uploadFile" + + static let postAdd = "post/add" + + static let postGet = "post/get" + + static let postDelete = "post/delete" + + static let deviceInfo = "device/info" + + static let postCommentCreate = "post/comment/create" + + static let postCommentDelete = "post/comment/delete" + + static let postCommentGet = "post/comment/get" +} diff --git a/app/StackChan/Network/WebSocketUtil.swift b/app/StackChan/Network/WebSocketUtil.swift new file mode 100644 index 0000000..3569fed --- /dev/null +++ b/app/StackChan/Network/WebSocketUtil.swift @@ -0,0 +1,154 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +import Foundation + +class WebSocketUtil { + static let shared = WebSocketUtil() + private init() {} + + private var webSocketTask: URLSessionWebSocketTask? + + private var observers = [String: (URLSessionWebSocketTask.Message) -> Void]() + + private var reconnectDelay: TimeInterval = 3.0 + private var reconnectingNow: Bool = true + private var urlString: String = "" + + func connect(urlString: String) { + if let task = webSocketTask { + switch task.state { + case .running, .suspended : + disconnect() + default: + break + } + } + + self.urlString = urlString + + print("Start connecting to WebSocket:" + urlString) + + guard let url = URL(string: urlString) else { + print("Invalid URL") + return + } + + let request = URLRequest(url: url) + let session = URLSession(configuration: .default) + + webSocketTask = session.webSocketTask(with: request) + + webSocketTask?.resume() + + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + if self.webSocketTask?.state == .running { + print("webSocket Connection successful") + self.reconnectingNow = false + self.webSocketTask?.receive { [weak self] result in + self?.handleReceive(result: result) + } + } else { + print("webSocket Connection failed. Reconnecting is being prepared.") + self.reconnectingNow = true + DispatchQueue.main.asyncAfter(deadline: .now() + self.reconnectDelay) { + self.connect(urlString: urlString) + } + } + } + } + + private func handleReceive(result: Result) { + switch result { + case .success(let message): + let isPing = replyPong(message: message) + if !isPing { + self.notifyObservers(message: message) + } + self.webSocketTask?.receive { [weak self] next in + self?.handleReceive(result: next) + } + + case .failure(let error): + print("❌ Failed to receive the message: \(error.localizedDescription)") + print("⚠️ WebSocket Connection lost. Attempting to reconnect.…") + if !self.reconnectingNow && !self.urlString.isEmpty { + self.connect(urlString: self.urlString) + } + } + } + + func replyPong(message: URLSessionWebSocketTask.Message) -> Bool { + switch message { + case .data(let data): + let result = AppState.shared.parseMessage(message: data) + if let msgType = result.0, let _ = result.1 { + switch msgType { + case MsgType.ping: + AppState.shared.sendWebSocketMessage(.pong) + return true + default: + return false + } + } + case .string(_): + return false + @unknown default: + return false + } + return false + } + + + func send(message: String) { + let wsMessage = URLSessionWebSocketTask.Message.string(message) + print("This is the message being sent.: \(message)") + webSocketTask?.send(wsMessage) { error in + if let error = error { + print("❌ Message sending failed: \(error.localizedDescription)") + if !self.reconnectingNow && !self.urlString.isEmpty { + self.connect(urlString: self.urlString) + } + } + } + } + + func send(data: Data) { + let wsMessage = URLSessionWebSocketTask.Message.data(data) + webSocketTask?.send(wsMessage) { error in + if let error = error { + print("❌ Failed to send binary message: \(error.localizedDescription)") + if !self.reconnectingNow && !self.urlString.isEmpty { + self.connect(urlString: self.urlString) + } + } + } + } + + func disconnect() { + webSocketTask?.cancel(with: .goingAway, reason: nil) + self.reconnectingNow = false + print("webSocket Disconnected") + } + + func addObserver(for key: String, observer: @escaping (URLSessionWebSocketTask.Message) -> Void) { + observers[key] = observer + } + + func removeObserver(for key: String) { + observers.removeValue(forKey: key) + } + + func removeAllObservers() { + observers.removeAll() + } + + func notifyObservers(message: URLSessionWebSocketTask.Message) { + for observer in observers.values { + observer(message) + } + } +} diff --git a/app/StackChan/StackChan.entitlements b/app/StackChan/StackChan.entitlements new file mode 100644 index 0000000..ba21fbd --- /dev/null +++ b/app/StackChan/StackChan.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.developer.networking.wifi-info + + + diff --git a/app/StackChan/StackChanApp.swift b/app/StackChan/StackChanApp.swift new file mode 100644 index 0000000..afb0f46 --- /dev/null +++ b/app/StackChan/StackChanApp.swift @@ -0,0 +1,20 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +import SwiftUI + +@main +struct StackChanApp: App { + + @StateObject private var appState = AppState.shared + + var body: some Scene { + WindowGroup { + ContentView() + .environmentObject(appState) + } + } +} diff --git a/app/StackChan/Utils/AudioAcquisitionUtil.swift b/app/StackChan/Utils/AudioAcquisitionUtil.swift new file mode 100644 index 0000000..e7d0d4c --- /dev/null +++ b/app/StackChan/Utils/AudioAcquisitionUtil.swift @@ -0,0 +1,67 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +import SwiftUI +import AVFoundation +import Accelerate + +// Audio acquisition utility class, singleton pattern +class AudioAcquisitionUtil { + + static let shared = AudioAcquisitionUtil() + private init() {} + + private let engine = AVAudioEngine() + private let bus = 0 + var onAudioData: ((Data) -> Void)? + var onDecibel: ((Float) -> Void)? + + func start() { + let inputNode = engine.inputNode + let hwFormat = inputNode.inputFormat(forBus: bus) + inputNode.installTap(onBus: bus, bufferSize: 1024, format: hwFormat) { buffer, time in + let audioBuffer = buffer.audioBufferList.pointee.mBuffers + let data = Data(bytes: audioBuffer.mData!, count: Int(audioBuffer.mDataByteSize)) + + // Callback with raw audio data + DispatchQueue.main.async { + self.onAudioData?(data) + } + + // Calculate decibels and normalize + if let floatChannelData = buffer.floatChannelData { + let channelData = floatChannelData[0] + let frameLength = vDSP_Length(buffer.frameLength) + + var rms: Float = 0 + vDSP_rmsqv(channelData, 1, &rms, frameLength) // Calculate RMS + + // Convert RMS to a 0-1 range, normal environment ~0 + // Here we assume the maximum RMS value could be around 0.1; adjust based on actual environment + let normalizedDb = min(max(rms / 0.1, 0), 1) + + DispatchQueue.main.async { + self.onDecibel?(normalizedDb) + } + } + } + + do { + try engine.start() + } catch { + print("Audio engine start error: \(error)") + } + } + + func stop() { + engine.inputNode.removeTap(onBus: bus) + engine.stop() + } + + deinit { + stop() + } +} diff --git a/app/StackChan/Utils/BlufiUtil.swift b/app/StackChan/Utils/BlufiUtil.swift new file mode 100644 index 0000000..a51fd56 --- /dev/null +++ b/app/StackChan/Utils/BlufiUtil.swift @@ -0,0 +1,431 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +import ObjectiveC +import CoreBluetooth + +class BlufiUtil: NSObject,CBCentralManagerDelegate,CBPeripheralDelegate { + + static let shared = BlufiUtil() + + private var centralManager: CBCentralManager! + var discovereDevices: [BlufiDeviceInfo] = [] + + var blufDevicesMonitoring: (([BlufiDeviceInfo]) -> Void)? + var centralManagerDidUpdateState: ((CBManagerState) -> Void)? + var characteristicCallback: ((CBCharacteristic) -> Void)? + + var connectionStateChanged: ((CBPeripheral, Bool) -> Void)? + + var blueSwitch: Bool = false + private let autoReconnect = true + var currentPeripheral: CBPeripheral? = nil + var writeCharacteristic: CBCharacteristic? = nil + var automaticScanning: Bool = true + + var writeExpressionCharacteristic: CBCharacteristic? = nil + var writeHeadCharacteristic: CBCharacteristic? = nil + var writeWifiSetCharacteristic: CBCharacteristic? = nil + + var wifiSetCharacteristicCall: ((Data) -> Void)? = nil + + /// Service UUID + private let targetServiceUUIDs: [CBUUID] = [CBUUID(string: "e2e5e5e0-1234-5678-1234-56789abcdef0")] + + private let scanOptions: [String: Any] = [ + CBCentralManagerScanOptionAllowDuplicatesKey: true + ] + /// Timer to clean up devices that are not discovered + private var cleanupTimer: Timer? + private let deviceTimeout: TimeInterval = 3 + + private override init() { + super.init() + centralManager = CBCentralManager(delegate: self, queue: .main) + } + + // Print logs according to Bluetooth state + func centralManagerDidUpdateState(_ central: CBCentralManager) { + centralManagerDidUpdateState?(central.state) + switch central.state { + case .unknown: + print("Bluetooth state unknown") + case .resetting: + print("Bluetooth is resetting") + case .unsupported: + print("This device does not support Bluetooth") + case .unauthorized: + print("No permission to use Bluetooth, please check settings") + case .poweredOff: + print("Bluetooth is off, please turn on Bluetooth") + blueSwitch = false + case .poweredOn: + print("Bluetooth is on, ready to start scanning devices") + blueSwitch = true + if automaticScanning { + startScan() + } + if autoReconnect { + reconnect() + } + @unknown default: + print("Encountered unknown Bluetooth state") + } + } + + // Start scanning BLE devices + func startScan() { + guard centralManager.state == .poweredOn else { + print("Bluetooth is not ready when scanning") + return + } + discovereDevices.removeAll() + print("Started scanning nearby BLE devices") + centralManager.scanForPeripherals(withServices: targetServiceUUIDs, options: scanOptions) + startCleanupTimer() + } + + // Periodically clean up non-existing devices + private func startCleanupTimer() { + cleanupTimer?.invalidate() + cleanupTimer = Timer.scheduledTimer(withTimeInterval: 2, repeats: true, block: { timer in + let now = Date() + let originalCount = self.discovereDevices.count + + self.discovereDevices.removeAll { + now.timeIntervalSince($0.lastSeen) > self.deviceTimeout + } + + // If indeed some equipment has been removed, then notify the external party. + if self.discovereDevices.count != originalCount { + self.blufDevicesMonitoring?(self.discovereDevices) + } + }) + } + + func stopScan() { + print("Stopped scanning BLE devices") + centralManager.stopScan() + } + + func connect(peripheral: CBPeripheral) { + print("Started connecting to the specified BLE device") + centralManager.connect(peripheral) + } + + /// Actively disconnect the current peripheral + func disconnectCurrentPeripheral() { + guard let peripheral = currentPeripheral else { + print("⚠️ No connected peripheral to disconnect") + return + } + centralManager.cancelPeripheralConnection(peripheral) + print("⬅️ Disconnecting peripheral: \(peripheral.name ?? "Unknown device")") + writeWifiSetCharacteristic = nil + writeHeadCharacteristic = nil + writeExpressionCharacteristic = nil + } + + + /// Send head data + func sendHeadData(_ data: String) { + guard let currentPeripheral = currentPeripheral else { + print("⚠️ No connected peripheral") + return + } + + guard let writeHeadCharacteristic = self.writeHeadCharacteristic else { + print("⚠️ No writable characteristic on current peripheral") + return + } + + guard let dataToSend = data.data(using: .utf8) else { + print("⚠️ Failed to convert string to Data") + return + } + + currentPeripheral.writeValue(dataToSend, for: writeHeadCharacteristic, type: .withResponse) + print("➡️ Head data sent: \(data)") + } + + /// Send Wi-Fi configuration data + func sendWifiSetData(_ data: String) { + guard let currentPeripheral = currentPeripheral else { + print("⚠️ No connected peripheral") + return + } + guard let writeWifiSetCharacteristic = self.writeWifiSetCharacteristic else { + print("⚠️ No writable characteristic on current peripheral") + return + } + guard let dataToSend = data.data(using: .utf8) else { + print("⚠️ Failed to convert string to Data") + return + } + currentPeripheral.writeValue(dataToSend, for: writeWifiSetCharacteristic, type: .withResponse) + print("➡️ Head data sent: \(data)") + } + + /// Send expression data + func sendExpressionData(_ data: String) { + guard let currentPeripheral = currentPeripheral else { + print("⚠️ No connected peripheral") + return + } + + guard let writeExpressionCharacteristic = self.writeExpressionCharacteristic else { + print("⚠️ No writable characteristic on current peripheral") + return + } + + guard let dataToSend = data.data(using: .utf8) else { + print("⚠️ Failed to convert string to Data") + return + } + + currentPeripheral.writeValue(dataToSend, for: writeExpressionCharacteristic, type: .withResponse) + print("➡️ Expression data sent: \(data)") + } + + func sendData(_ data: String) { + guard let currentPeripheral = currentPeripheral else { + print("⚠️ No connected peripheral") + return + } + + guard let writeCharacteristic = self.writeCharacteristic else { + print("⚠️ No writable characteristic on current peripheral") + return + } + + guard let dataToSend = data.data(using: .utf8) else { + print("⚠️ Failed to convert string to Data") + return + } + + currentPeripheral.writeValue(dataToSend, for: writeCharacteristic, type: .withResponse) + print("➡️ Data sent: \(data)") + } + + func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { + // Called when successfully connected to a peripheral + print("✅ Successfully connected to device: \(peripheral.name ?? "Unknown device")") + self.currentPeripheral = peripheral + peripheral.delegate = self + peripheral.discoverServices(nil) + connectionStateChanged?(peripheral,true) + } + + func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: (any Error)?) { + // Called when connecting to a peripheral fails + if let error = error { + print("❌ Failed to connect to device: \(peripheral.name ?? "Unknown device"), error: \(error.localizedDescription)") + } else { + print("❌ Failed to connect to device: \(peripheral.name ?? "Unknown device"), unknown error") + } + + connectionStateChanged?(peripheral,false) + } + + func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: (any Error)?) { + // Called when a peripheral disconnects + if let error = error { + print("⚠️ Device disconnected: \(peripheral.name ?? "Unknown device"), error: \(error.localizedDescription)") + } else { + print("⚠️ Device disconnected: \(peripheral.name ?? "Unknown device"), no error") + } + currentPeripheral = nil + + connectionStateChanged?(peripheral,false) + + if autoReconnect { + reconnect() + } + } + + func centralManager(_ central: CBCentralManager, didUpdateANCSAuthorizationFor peripheral: CBPeripheral) { + // Called when ANCS (Apple Notification Center Service) authorization status changes + print("ℹ️ ANCS authorization status updated, device: \(peripheral.name ?? "Unknown device")") + } + + func centralManager(_ central: CBCentralManager, connectionEventDidOccur event: CBConnectionEvent, for peripheral: CBPeripheral) { + // Called when a connection event occurs (e.g., peripheral connected or disconnected) + print("🔔 Connection event occurred: \(event.rawValue), device: \(peripheral.name ?? "Unknown device")") + } + + func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String : Any], rssi RSSI: NSNumber) { + // New device added to the list + let deviceInfo = BlufiDeviceInfo(peripheral: peripheral, advertisementData: advertisementData, rssi: RSSI, lastSeen: Date()) + + if !discovereDevices.contains(where: { $0.peripheral.identifier.uuidString == peripheral.identifier.uuidString }) { + discovereDevices.append(deviceInfo) + } else { + if let index = discovereDevices.firstIndex(where: { $0.peripheral.identifier.uuidString == peripheral.identifier.uuidString }) { + discovereDevices[index] = deviceInfo + } + } + blufDevicesMonitoring?(discovereDevices) + } + + func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, timestamp: CFAbsoluteTime, isReconnecting: Bool, error: (any Error)?) { + if let error = error { + print("⚠️ Device disconnected: \(peripheral.name ?? "Unknown device"), timestamp: \(timestamp), reconnecting: \(isReconnecting), error: \(error.localizedDescription)") + } else { + print("⚠️ Device disconnected: \(peripheral.name ?? "Unknown device"), timestamp: \(timestamp), reconnecting: \(isReconnecting), no error") + } + } + + func peripheralDidUpdateName(_ peripheral: CBPeripheral) { + // Called when peripheral name updates + print("ℹ️ Peripheral name updated: \(peripheral.name ?? "Unknown device")") + } + + func peripheralDidUpdateRSSI(_ peripheral: CBPeripheral, error: (any Error)?) { + // Called when peripheral RSSI (signal strength) updates + if let error = error { + print("❌ Failed to update peripheral RSSI: \(error.localizedDescription)") + } else { + print("ℹ️ Peripheral RSSI updated") + } + } + + func peripheral(_ peripheral: CBPeripheral, didOpen channel: CBL2CAPChannel?, error: (any Error)?) { + // Called when L2CAP channel opens + if let error = error { + print("❌ Failed to open L2CAP channel: \(error.localizedDescription)") + } else { + print("✅ L2CAP channel opened: \(channel?.psm ?? 0)") + } + } + + func peripheral(_ peripheral: CBPeripheral, didModifyServices invalidatedServices: [CBService]) { + // Called when peripheral services are modified + print("⚠️ Peripheral services modified, number of invalidated services: \(invalidatedServices.count)") + } + + func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: (any Error)?) { + // Called after discovering services + if let error = error { + print("❌ Failed to discover services: \(error.localizedDescription)") + return + } + print("✅ Discovered peripheral services, service count: \(peripheral.services?.count ?? 0)") + peripheral.services?.forEach { service in + print("📦 Service UUID: \(service.uuid)") + peripheral.discoverCharacteristics(nil, for: service) + } + } + + func peripheral(_ peripheral: CBPeripheral, didReadRSSI RSSI: NSNumber, error: (any Error)?) { + // Called when RSSI value is read + if let error = error { + print("❌ Failed to read RSSI: \(error.localizedDescription)") + } else { + print("ℹ️ Read RSSI: \(RSSI)") + } + } + + func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: (any Error)?) { + } + + func peripheral(_ peripheral: CBPeripheral, didWriteValueFor descriptor: CBDescriptor, error: (any Error)?) { + // Called after writing a descriptor value + if let error = error { + print("❌ Failed to write descriptor value: \(descriptor.uuid), error: \(error.localizedDescription)") + } else { + print("✅ Successfully wrote descriptor value: \(descriptor.uuid)") + } + } + + func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: (any Error)?) { + // Called when characteristic value updates (message received) + if let error = error { + print("❌ Failed to update characteristic value: \(characteristic.uuid), error: \(error.localizedDescription)") + } else { + print("Bluetooth message received") + if characteristic.uuid.uuidString == writeWifiSetCharacteristic?.uuid.uuidString, let data = characteristic.value { + /// Callback for Wi-Fi configuration message + self.wifiSetCharacteristicCall?(data) + } + // print("ℹ️ Characteristic value updated: \(characteristic.uuid), value: \(characteristic.value?.hexEncodedString() ?? "nil")") + } + } + + func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor descriptor: CBDescriptor, error: (any Error)?) { + // Called when descriptor value updates + if let error = error { + print("❌ Failed to update descriptor value: \(descriptor.uuid), error: \(error.localizedDescription)") + } else { + print("ℹ️ Descriptor value updated: \(descriptor.uuid), value: \(descriptor.value ?? "nil")") + } + } + + func peripheral(_ peripheral: CBPeripheral, didDiscoverDescriptorsFor characteristic: CBCharacteristic, error: (any Error)?) { + // Called when descriptors of a characteristic are discovered + if let error = error { + print("❌ Failed to discover descriptors: \(characteristic.uuid), error: \(error.localizedDescription)") + } else { + print("✅ Discovered characteristic descriptors: \(characteristic.uuid), descriptor count: \(characteristic.descriptors?.count ?? 0)") + } + } + + func peripheralIsReady(toSendWriteWithoutResponse peripheral: CBPeripheral) { + // Called when peripheral is ready to send writes without response + print("ℹ️ Peripheral is ready to send write without response: \(peripheral.name ?? "Unknown device")") + } + + func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: (any Error)?) { + // Called when characteristics of a service are discovered + if let error = error { + print("❌ Failed to discover characteristics: \(service.uuid), error: \(error.localizedDescription)") + return + } + print("✅ Discovered characteristics count: \(service.characteristics?.count ?? 0) for service: \(service.uuid)") + service.characteristics?.forEach { characteristic in + print("🔹 Characteristic UUID: \(characteristic.uuid), properties: \(characteristic.properties)") + characteristicCallback?(characteristic) + } + } + + func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: (any Error)?) { + // Called when characteristic notification state updates + if let error = error { + print("❌ Failed to update characteristic notification state: \(characteristic.uuid), error: \(error.localizedDescription)") + } else { + print("ℹ️ Characteristic notification state updated: \(characteristic.uuid), isNotifying: \(characteristic.isNotifying)") + } + } + + func peripheral(_ peripheral: CBPeripheral, didDiscoverIncludedServicesFor service: CBService, error: (any Error)?) { + // Called when included services are discovered + if let error = error { + print("❌ Failed to discover included services: \(service.uuid), error: \(error.localizedDescription)") + } else { + print("✅ Discovered included services count: \(service.includedServices?.count ?? 0) for service: \(service.uuid)") + } + } + + // Method to reconnect + func reconnect() { + + } + +} + + +struct BlufiDeviceInfo { + let peripheral: CBPeripheral + let advertisementData: [String : Any] + let rssi: NSNumber + var lastSeen: Date +} + + +extension Data { + func hexEncodedString() -> String { + return map { String(format: "%02hhx", $0) }.joined() + } +} diff --git a/app/StackChan/Utils/DazzlingBackground.swift b/app/StackChan/Utils/DazzlingBackground.swift new file mode 100644 index 0000000..3a8151d --- /dev/null +++ b/app/StackChan/Utils/DazzlingBackground.swift @@ -0,0 +1,102 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +import SwiftUI + +struct DazzlingBackground : View { + + let backColors: [Color] + let background: Color + let dotCount: Int + let speed: CGFloat + + @State private var dots: [Dot] = [] + + init(backColors: [Color], background: Color, dotCount: Int = 5, speed: CGFloat = 2.2) { + self.backColors = backColors + self.background = background + self.dotCount = dotCount + self.speed = speed + } + + var body: some View { + GeometryReader { proxy in + ZStack { + LinearGradient( + colors: backColors, + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + TimelineView(.animation) { timeline in + Canvas { context, size in + for dot in dots { + let circle = Path(ellipseIn: CGRect(x: dot.position.x, y: dot.position.y, width: dot.dotSize, height: dot.dotSize)) + context.fill(circle, with: .color(.purple.opacity(0.4))) + } + } + .blur(radius: 50) + .drawingGroup() + .onAppear { + DispatchQueue.main.async { + if dots.isEmpty, proxy.size.width > 0, proxy.size.height > 0 { + startDots(size: proxy.size) + } + } + } + .onChange(of: timeline.date) { _ in + DispatchQueue.main.async { + updateDots(size: proxy.size) + } + } + } + } + .background(background) + } + + } + + private func startDots(size: CGSize) { + guard size.width > 0, size.height > 0 else { return } + for _ in 0.. 0, size.height > 0 else { return } + for i in dots.indices { + var dot = dots[i] + let dx = dot.target.x - dot.position.x + let dy = dot.target.y - dot.position.y + let distance = sqrt(dx*dx + dy*dy) + if distance < speed { + dot.target = CGPoint(x: CGFloat.random(in: 0.. Void)? + private var thresholdCallback: (() -> Void)? + private let thresholdDistance: Float = 0.05 // 5cm in meters + private var timer: Timer? + + func startDistanceDetection( + distanceUpdate: ((Float) -> Void)? = nil, + belowThreshold: (() -> Void)? = nil + ) { + guard ARWorldTrackingConfiguration.isSupported else { + return + } + if #available(iOS 13.0, *) { + guard ARWorldTrackingConfiguration.supportsFrameSemantics(.sceneDepth) else { + return + } + } else { + return + } + + checkCameraPermission { [weak self] granted in + guard granted else { + return + } + + self?.setupARSession() + self?.setupCallbacks(distanceUpdate: distanceUpdate, belowThreshold: belowThreshold) + self?.startDetectionTimer() + } + } + + /// 停止距离检测 + func stopDistanceDetection() { + isDetectionActive = false + timer?.invalidate() + timer = nil + arSession?.pause() + arSession = nil + } + + // MARK: - Private Methods + + private func checkCameraPermission(completion: @escaping (Bool) -> Void) { + let status = AVCaptureDevice.authorizationStatus(for: .video) + + switch status { + case .authorized: + completion(true) + case .notDetermined: + AVCaptureDevice.requestAccess(for: .video) { granted in + DispatchQueue.main.async { + completion(granted) + } + } + default: + completion(false) + } + } + + private func setupARSession() { + arSession = ARSession() + let configuration = ARWorldTrackingConfiguration() + + if #available(iOS 13.0, *) { + configuration.frameSemantics.insert(.sceneDepth) + } + + arSession?.run(configuration) + isDetectionActive = true + } + + private func setupCallbacks( + distanceUpdate: ((Float) -> Void)?, + belowThreshold: (() -> Void)? + ) { + self.distanceCallback = distanceUpdate + self.thresholdCallback = belowThreshold + } + + private func startDetectionTimer() { + timer?.invalidate() + + timer = Timer.scheduledTimer( + timeInterval: 0.1, + target: self, + selector: #selector(performDistanceCheck), + userInfo: nil, + repeats: true + ) + } + + @objc private func performDistanceCheck() { + guard isDetectionActive, + let frame = arSession?.currentFrame else { + return + } + + let distance = getCurrentDistance(from: frame) + + if let distance = distance { + distanceCallback?(distance) + + if distance < thresholdDistance { + handleBelowThreshold() + } + } + } + + private func getCurrentDistance(from frame: ARFrame) -> Float? { + if #available(iOS 13.0, *) { + return getDistanceUsingSceneDepth(from: frame) + } else { + return getDistanceUsingHitTest(from: frame) + } + } + + @available(iOS 13.0, *) + private func getDistanceUsingSceneDepth(from frame: ARFrame) -> Float? { + guard let depthData = frame.sceneDepth else { + return nil + } + + let depthPixelBuffer = depthData.depthMap + let width = CVPixelBufferGetWidth(depthPixelBuffer) + let height = CVPixelBufferGetHeight(depthPixelBuffer) + + let centerX = width / 2 + let centerY = height / 2 + + CVPixelBufferLockBaseAddress(depthPixelBuffer, .readOnly) + + guard let baseAddress = CVPixelBufferGetBaseAddress(depthPixelBuffer) else { + CVPixelBufferUnlockBaseAddress(depthPixelBuffer, .readOnly) + return nil + } + + let floatBuffer = baseAddress.assumingMemoryBound(to: Float32.self) + + var totalDistance: Float = 0 + var validSamples = 0 + + let sampleRadius = 5 + for x in max(0, centerX - sampleRadius)...min(width - 1, centerX + sampleRadius) { + for y in max(0, centerY - sampleRadius)...min(height - 1, centerY + sampleRadius) { + let distance = floatBuffer[y * width + x] + if distance.isFinite && distance > 0 { + totalDistance += distance + validSamples += 1 + } + } + } + + CVPixelBufferUnlockBaseAddress(depthPixelBuffer, .readOnly) + + guard validSamples > 0 else { + return nil + } + + return totalDistance / Float(validSamples) + } + + private func getDistanceUsingHitTest(from frame: ARFrame) -> Float? { + return nil + } + + private func handleBelowThreshold() { + NSObject.cancelPreviousPerformRequests(withTarget: self, selector: #selector(executeThresholdCallback), object: nil) + } + + @objc private func executeThresholdCallback() { + let generator = UIImpactFeedbackGenerator(style: .heavy) + generator.impactOccurred() + AudioServicesPlaySystemSound(1013) + thresholdCallback?() + } + + deinit { + stopDistanceDetection() + } +} + + +func exampleBasicUsage() { + let detector = DistanceDetector() + + detector.startDistanceDetection( + distanceUpdate: { distance in + let distanceInCm = distance * 100 + print(String(distanceInCm)) + }, + belowThreshold: { + } + ) +} + +class ProximityMonitor { + private let detector = DistanceDetector() + private var isMonitoring = false + + func startMonitoring() { + detector.startDistanceDetection( + distanceUpdate: { [weak self] distance in + self?.handleDistanceUpdate(distance) + }, + belowThreshold: { [weak self] in + self?.handleProximityAlert() + } + ) + isMonitoring = true + } + + func stopMonitoring() { + detector.stopDistanceDetection() + isMonitoring = false + } + + private func handleDistanceUpdate(_ distance: Float) { + let distanceInCm = distance * 100 + if distanceInCm < 10 { + } else if distanceInCm < 30 { + } + } + + private func handleProximityAlert() { + NotificationCenter.default.post( + name: NSNotification.Name("ProximityAlert"), + object: nil, + userInfo: ["alert": "object_too_close"] + ) + } +} diff --git a/app/StackChan/Utils/Extension.swift b/app/StackChan/Utils/Extension.swift new file mode 100644 index 0000000..7274859 --- /dev/null +++ b/app/StackChan/Utils/Extension.swift @@ -0,0 +1,305 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +import SwiftUI +import simd + +extension UIApplication { + func endEditing() { + sendAction(#selector(UIResponder.resignFirstResponder), + to: nil, from: nil, for: nil) + } +} + +extension Color { + init?(hex: String) { + var hexString = hex.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() + if hexString.hasPrefix("#") { + hexString.removeFirst() + } + guard hexString.count == 6 || hexString.count == 8 else { return nil } + + var rgbValue: UInt64 = 0 + Scanner(string: hexString).scanHexInt64(&rgbValue) + + let r, g, b, a: Double + if hexString.count == 6 { + r = Double((rgbValue & 0xFF0000) >> 16) / 255 + g = Double((rgbValue & 0x00FF00) >> 8) / 255 + b = Double(rgbValue & 0x0000FF) / 255 + a = 1.0 + } else { + a = Double((rgbValue & 0xFF000000) >> 24) / 255 + r = Double((rgbValue & 0x00FF0000) >> 16) / 255 + g = Double((rgbValue & 0x0000FF00) >> 8) / 255 + b = Double(rgbValue & 0x000000FF) / 255 + } + self.init(red: r, green: g, blue: b, opacity: a) + } + + func toHex() -> String? { + let uiColor = UIColor(self) + var r: CGFloat = 0 + var g: CGFloat = 0 + var b: CGFloat = 0 + var a: CGFloat = 0 + guard uiColor.getRed(&r, green: &g, blue: &b, alpha: &a) else { return nil } + return String(format: "#%02X%02X%02X", + Int(r * 255), + Int(g * 255), + Int(b * 255)) + } +} + +extension UIImage { + func scaledToFill(_ targetSize: CGSize) -> UIImage { + let renderer = UIGraphicsImageRenderer(size: targetSize) + return renderer.image { _ in + self.draw(in: CGRect(origin: .zero, size: targetSize)) + } + } + /// Optional image compression method + /// - Parameters: + /// - resolutionSize: Target resolution (optional). If nil, no cropping or scaling is applied + /// - memorySize: Target memory size in MB (optional). If nil, no memory compression is applied + /// - cropCenter: Whether to crop from center. Default false = aspect-fit scaling + func compress(to resolutionSize: CGSize? = nil, memorySize: Float? = nil, cropCenter: Bool = false) -> Data? { + var scaledImage = self + + // 1. Crop or scale based on target resolution + if let resolutionSize = resolutionSize { + if cropCenter { + // 1. Calculate scale to ensure the image fully covers the target resolution + let scale = max(resolutionSize.width / size.width, resolutionSize.height / size.height) + let scaledSize = CGSize(width: size.width * scale, height: size.height * scale) + + // 2. Calculate offset to ensure center cropping + let originX = (scaledSize.width - resolutionSize.width) / 2 + let originY = (scaledSize.height - resolutionSize.height) / 2 + + // 3. Start drawing + UIGraphicsBeginImageContextWithOptions(resolutionSize, false, 1.0) + self.draw(in: CGRect(x: -originX, y: -originY, width: scaledSize.width, height: scaledSize.height)) + scaledImage = UIGraphicsGetImageFromCurrentImageContext() ?? self + UIGraphicsEndImageContext() + } else { + // Aspect-fit scaling + let scale = min(resolutionSize.width / size.width, resolutionSize.height / size.height) + let newSize = CGSize(width: size.width * scale, height: size.height * scale) + UIGraphicsBeginImageContextWithOptions(resolutionSize, false, 1.0) + self.draw(in: CGRect(origin: .zero, size: newSize)) + scaledImage = UIGraphicsGetImageFromCurrentImageContext() ?? self + UIGraphicsEndImageContext() + } + } + + // 2. Compress based on target memory size + guard let memorySize = memorySize else { + return scaledImage.jpegData(compressionQuality: 1) + } + + let maxBytes = Int(memorySize * 1024 * 1024) // MB -> Bytes + var compression: CGFloat = 1.0 + var imageData = scaledImage.jpegData(compressionQuality: compression) + + // Keep compressing until size requirement is met or compression limit is reached + while let data = imageData, data.count > maxBytes, compression > 0.01 { + compression *= 0.7 + imageData = scaledImage.jpegData(compressionQuality: compression) + } + + return imageData + } + /// Compress image only based on target memory size, keeping resolution and aspect ratio unchanged + /// - Parameter memorySize: Target memory size in MB + /// - Returns: Compressed JPEG data + func compress(toMemorySize memorySize: Float) -> Data? { + let maxBytes = Int(memorySize * 1024 * 1024) + var compression: CGFloat = 1.0 + var imageData = self.jpegData(compressionQuality: compression) + + while let data = imageData, data.count > maxBytes, compression > 0.01 { + compression *= 0.7 + imageData = self.jpegData(compressionQuality: compression) + } + return imageData + } +} + +extension String { + func jsonPrint() { + guard let data = self.data(using: .utf8) else { + print(self) + return + } + do { + let jsonObject = try JSONSerialization.jsonObject(with: data, options: []) + let prettyData = try JSONSerialization.data(withJSONObject: jsonObject, options: [.prettyPrinted, .sortedKeys]) + if let prettyString = String(data: prettyData, encoding: .utf8) { + print("✅ JSON formatted output:\n\(prettyString)") + } else { + print(self) + } + } catch { + print("❌ Invalid JSON format: \(error.localizedDescription)") + print(self) + } + } + + func leftPadding(toLength: Int, withPad character: Character) -> String { + if count < toLength { + return String(repeatElement(character, count: toLength - count)) + self + } else { + return self + } + } + + /// Convert a MAC address string into 6-byte Data + func macToData() -> Data? { + // Remove separators such as ":" or "-" + let cleaned = self.replacingOccurrences(of: "[:\\-]", with: "", options: .regularExpression) + + // Must be exactly 12 hexadecimal characters + guard cleaned.count == 12 else { return nil } + + var data = Data() + var index = cleaned.startIndex + for _ in 0..<6 { + let nextIndex = cleaned.index(index, offsetBy: 2) + let byteString = String(cleaned[index.. Data? { + return self.data(using: .utf8) + } + +} + +extension Encodable { + func toDictionary() -> [String: Any]? { + guard let data = try? JSONEncoder().encode(self), + let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return nil + } + return dict + } + + func toListDictionary() -> [[String: Any]]? { + if let singleDict = self.toDictionary() { + return [singleDict] + } + if let arrayData = try? JSONEncoder().encode(self), + let jsonArray = try? JSONSerialization.jsonObject(with: arrayData) as? [[String: Any]] { + return jsonArray + } + + return nil + } + + func toJsonString(prettyPrinted: Bool = false) -> String { + let encoder = JSONEncoder() + if prettyPrinted { + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + } + do { + let data = try encoder.encode(self) + return String(data: data, encoding: .utf8) ?? "{}" + } catch { + print("❌ JSON serialization failed: \(error)") + return "{}" + } + } + + func toData() -> Data? { + let encoder = JSONEncoder() + do { + return try encoder.encode(self) + } catch { + print("❌ Failed to convert JSON to Data: \(error.localizedDescription)") + return nil + } + } +} + +extension CGRect { + var minDimension: CGFloat { + min(width, height) + } +} + + +extension View { + + @ViewBuilder + func rippleDiffusion() -> some View { + TimelineView(.animation) { timeline in + // Calculate time interval + let time = timeline.date.timeIntervalSinceReferenceDate + ZStack { + // Multiple ripple cycles + ForEach(0..<3) { i in + let progress = (time + Double(i) * 0.5).truncatingRemainder(dividingBy: 1.5) / 1.5 + Circle() + .stroke(Color.blue.opacity(1 - progress), lineWidth: 2) + .scaleEffect(0.5 + progress * 2) + } + } + .drawingGroup() + } + } + + @ViewBuilder + func glassEffectCircle() -> some View { + if #available(iOS 26.0, *) { + self.glassEffect() + } else { + self.background( + Circle() + .fill(.ultraThinMaterial) + ) + } + } + + @ViewBuilder + func glassEffectRegular(cornerRadius : CGFloat) -> some View { + if #available(iOS 26.0, *) { + self.glassEffect(.regular,in: RoundedRectangle(cornerRadius: cornerRadius)) + } else { + self + .background( + RoundedRectangle(cornerRadius: cornerRadius) + .fill(.thinMaterial) + ) + } + } + + @ViewBuilder + func presentationBackgroundClear() -> some View { + if #available(iOS 26.0, *) { + self.presentationBackground(.clear) + } else { + self.presentationBackground(.ultraThinMaterial) + } + } + + @ViewBuilder + func glassButtonStyle() -> some View { + if #available(iOS 26.0, *) { + self.buttonStyle(.glass) + } else { + self.buttonStyle(.bordered) + } + } + + func hideKeyboard() { + UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) + } +} diff --git a/app/StackChan/Utils/FileUtils.swift b/app/StackChan/Utils/FileUtils.swift new file mode 100644 index 0000000..8b740be --- /dev/null +++ b/app/StackChan/Utils/FileUtils.swift @@ -0,0 +1,43 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +import Foundation +import CryptoKit + +class FileUtils { + + static let shared = FileUtils() + private init() {} + + func cacheDirectory() -> URL { + let paths = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask) + return paths[0] + } + + func hashedKey(for url: String) -> String { + let data = Data(url.utf8) + let hash = SHA256.hash(data: data) + return hash.compactMap { String(format: "%02x", $0) }.joined() + } + + //File download, with built-in cache. First, check if the file exists locally. If it does, directly return the path; if not, download the file and then return the path. + func download(url: String) async throws -> String { + let key = hashedKey(for: url) + let cacheURL = cacheDirectory().appendingPathComponent(key) + + if FileManager.default.fileExists(atPath: cacheURL.path) { + return cacheURL.path + } + + guard let requestURL = URL(string: url) else { + throw URLError(.badURL) + } + + let (data, _) = try await URLSession.shared.data(from: requestURL) + try data.write(to: cacheURL) + return cacheURL.path + } +} diff --git a/app/StackChan/Utils/ImageUtils.swift b/app/StackChan/Utils/ImageUtils.swift new file mode 100644 index 0000000..daca842 --- /dev/null +++ b/app/StackChan/Utils/ImageUtils.swift @@ -0,0 +1,49 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +import UIKit + +final class ImageUtils { + + // MARK: - Singleton + static let shared = ImageUtils() + private init() {} + + // MARK: - Public Methods + + /// Resize the image to the specified resolution and export it /// - Parameters: + /// - image: Input the original image + /// - targetSize: Target resolution (e.g. CGSize(width: 1080, height: 1920)) + /// - format: Export format (default JPEG) + /// - quality: JPEG compression quality (0-1, default 1) + /// - Returns: Converted Data (JPEG or PNG), nil if failure + func exportScaledImageData( + from image: UIImage, + targetSize: CGSize, + format: ImageFormat = .jpeg, + quality: CGFloat = 1.0 + ) -> Data? { + + // Use UIGraphicsImageRenderer for high-quality scaling + let renderer = UIGraphicsImageRenderer(size: targetSize) + let scaledImage = renderer.image { _ in + image.draw(in: CGRect(origin: .zero, size: targetSize)) + } + + switch format { + case .jpeg: + return scaledImage.jpegData(compressionQuality: quality) + case .png: + return scaledImage.pngData() + } + } + + // MARK: - Image Format Enum + enum ImageFormat { + case jpeg + case png + } +} diff --git a/app/StackChan/Utils/MultipeerUtil.swift b/app/StackChan/Utils/MultipeerUtil.swift new file mode 100644 index 0000000..f28e2e9 --- /dev/null +++ b/app/StackChan/Utils/MultipeerUtil.swift @@ -0,0 +1,130 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +import MultipeerConnectivity + +class MultipeerUtil: NSObject, MCSessionDelegate, MCNearbyServiceAdvertiserDelegate, MCNearbyServiceBrowserDelegate { + + static let shared = MultipeerUtil() + + private let serviceType = "stackchan-mpc" + + private let myPeerID = MCPeerID(displayName: UIDevice.current.name) + private var session: MCSession! + + private var advertiser: MCNearbyServiceAdvertiser! + private var browser: MCNearbyServiceBrowser! + + var appendScanPeer: ((MCPeerID,[String : String]?) -> Void)? + var removeScanPeer: ((MCPeerID) -> Void)? + var onMessageReceived: ((Data, MCPeerID) -> Void)? + + private override init() { + super.init() + session = MCSession(peer: myPeerID, securityIdentity: nil, encryptionPreference: .required) + session.delegate = self + + advertiser = MCNearbyServiceAdvertiser(peer: myPeerID, discoveryInfo: [ + "deviceId": UIDevice.current.identifierForVendor?.uuidString ?? UUID().uuidString + ], serviceType: serviceType) + advertiser.delegate = self + + browser = MCNearbyServiceBrowser(peer: myPeerID, serviceType: serviceType) + browser.delegate = self + + } + + func startAdvertising() { + advertiser.stopAdvertisingPeer() + advertiser.startAdvertisingPeer() + print("Started advertising Multipeer service") + } + + func stopAdvertising() { + advertiser.stopAdvertisingPeer() + print("Stopped advertising Multipeer service") + } + + func startBrowsing() { + browser.stopBrowsingForPeers() + browser.startBrowsingForPeers() + print("Started browsing nearby devices") + } + + func stopBrowsing() { + browser.stopBrowsingForPeers() + print("Stopped browsing nearby devices") + } + + func sendMessage(_ data: Data, to peer: MCPeerID) { + if !session.connectedPeers.contains(peer) { + print("Not connected to \(peer.displayName), attempting to connect...") + Task { + await connectAndSend(data: data, to: peer) + } + return + } + + do { + try session.send(data, toPeers: [peer], with: .reliable) + print("Message sent successfully") + } catch { + print("Failed to send message: \(error)") + } + } + + private func connectAndSend(data: Data, to peer: MCPeerID) async { + browser.invitePeer(peer, to: session, withContext: nil, timeout: 30) + for _ in 0..<30 { + try? await Task.sleep(nanoseconds: 500_000_000) // 0.5 seconds + if session.connectedPeers.contains(peer) { + sendMessage(data, to: peer) + return + } + } + + print("Connection timed out, unable to send message to \(peer.displayName)") + } + + func browser(_ browser: MCNearbyServiceBrowser, foundPeer peerID: MCPeerID, withDiscoveryInfo info: [String : String]?) { + print("Discovered peer: " + peerID.displayName) + appendScanPeer?(peerID, info) + } + + func browser(_ browser: MCNearbyServiceBrowser, lostPeer peerID: MCPeerID) { + removeScanPeer?(peerID) + } + + func advertiser(_ advertiser: MCNearbyServiceAdvertiser, didReceiveInvitationFromPeer peerID: MCPeerID, + withContext context: Data?, invitationHandler: @escaping (Bool, MCSession?) -> Void) { + invitationHandler(true, session) + } + + func session(_ session: MCSession, peer peerID: MCPeerID, didChange state: MCSessionState) { + print("Peer \(peerID.displayName) state changed: \(state.rawValue)") + } + + func session(_ session: MCSession, didReceive data: Data, fromPeer peerID: MCPeerID) { + onMessageReceived?(data, peerID) + } + + + func session(_ session: MCSession, didReceive stream: InputStream, withName streamName: String, fromPeer peerID: MCPeerID) { + + } + + func session(_ session: MCSession, didReceiveCertificate certificate: [Any]?, fromPeer peerID: MCPeerID, certificateHandler: @escaping (Bool) -> Void) { + + } + + func session(_ session: MCSession, didStartReceivingResourceWithName resourceName: String, fromPeer peerID: MCPeerID, with progress: Progress) { + + } + + func session(_ session: MCSession, didFinishReceivingResourceWithName resourceName: String, fromPeer peerID: MCPeerID, at localURL: URL?, withError error: (any Error)?) { + + } +} diff --git a/app/StackChan/Utils/RippleDiffusion.swift b/app/StackChan/Utils/RippleDiffusion.swift new file mode 100644 index 0000000..75a8aae --- /dev/null +++ b/app/StackChan/Utils/RippleDiffusion.swift @@ -0,0 +1,51 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +import SwiftUI + +struct RippleDiffusion : View { + + let content: () -> Content + + init(@ViewBuilder content: @escaping () -> Content) { + self.content = content + } + + @State private var animate = false + + var body: some View { + ZStack(alignment: .center) { + ForEach(0..<3) { index in + Circle() + .stroke(Color.accentColor.opacity(0.7), lineWidth: 2) + .frame(width: CGFloat(index + 1) * 100, height: CGFloat(index + 1) * 100) + .scaleEffect(animate ? 2.0 : 0.1) + .opacity(animate ? 0 : 1) + .animation( + Animation.easeOut(duration: 1.8) + .repeatForever(autoreverses: false) + .delay(Double(index) * 0.3), + value: animate + ) + } + content() + } + .onAppear { + DispatchQueue.main.async { + animate = true + } + } + } +} + + +struct RippleDiffusionPreview : PreviewProvider { + static var previews: some View { + RippleDiffusion { + + } + } +} diff --git a/app/StackChan/Utils/Style.swift b/app/StackChan/Utils/Style.swift new file mode 100644 index 0000000..eab9f2b --- /dev/null +++ b/app/StackChan/Utils/Style.swift @@ -0,0 +1,52 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +import SwiftUI + + +struct WideOrangeButton: ButtonStyle { + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .padding(.all,15) + .frame(maxWidth: .infinity,minHeight: 44) + .foregroundColor(.white) + .background(RoundedRectangle(cornerRadius: 20.0) + .fill(.blue) + ) + .shadow(color: .black.opacity(0.2), radius: 5, x: 0, y: 0) + .opacity(configuration.isPressed ? 0.6 : 1.0) + } +} + +struct MyTextFieldStyle : TextFieldStyle { + func _body(configuration: TextField) -> some View { + configuration + .padding(.all,15) + .frame(maxWidth: .infinity,minHeight: 44) + .background( + RoundedRectangle(cornerRadius: 20.0) + .fill(.background) + ) + .shadow(color: .black.opacity(0.2), radius: 5, x: 0, y: 0) + } +} + +struct SopDirectoryButtonStyle: ButtonStyle { + let select: Bool + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .padding(.all, 20) + .frame(maxWidth: .infinity, minHeight: 44) + .foregroundColor(select ? .accentColor : .primary) + .background( + RoundedRectangle(cornerRadius: 20) + .fill(select ? Color.blue.opacity(0.2) : Color.clear) + ) + .opacity(configuration.isPressed ? 0.6 : 1.0) + } +} diff --git a/app/StackChan/Utils/ValueConstant.swift b/app/StackChan/Utils/ValueConstant.swift new file mode 100644 index 0000000..5286683 --- /dev/null +++ b/app/StackChan/Utils/ValueConstant.swift @@ -0,0 +1,28 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +struct ValueConstant { + static let token = "token" + static let deviceToken = "deviceToken" + static let Authorization = "Authorization" + static let deviceType = "deviceType" + static let mac = "mac" + static let index = "index" + static let data = "data" + static let list = "list" + static let file = "file" + static let directory = "directory" + static let moments = "moments" + static let name = "name" + static let content_text = "content_text" + static let content_image = "content_image" + static let page = "page" + static let pageSize = "pageSize" + static let id = "id" + static let postId = "postId" + static let content = "content" +} + diff --git a/app/StackChan/View/ARCameraView.swift b/app/StackChan/View/ARCameraView.swift new file mode 100644 index 0000000..7dffdf4 --- /dev/null +++ b/app/StackChan/View/ARCameraView.swift @@ -0,0 +1,227 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +import SwiftUI +import RealityKit +import ARKit + +struct ARCameraView : UIViewRepresentable { + + @Binding var expressionData: ExpressionData + + @Binding var decorate: Int + + @Binding var captureScreen: Bool + + let onCallback : ((ARSession,[ARAnchor]) -> Void)? + + let onFrameCallback : ((UIImage) -> Void)? + + func makeUIView(context: Context) -> ARSCNView { + let arView = ARSCNView(frame: .zero) + arView.contentMode = .scaleAspectFit + arView.autoresizingMask = [.flexibleWidth,.flexibleHeight] + + let configuration = ARFaceTrackingConfiguration() + configuration.isLightEstimationEnabled = true + //4K + if let format = ARFaceTrackingConfiguration.supportedVideoFormats.last { + configuration.videoFormat = format + } + //HDR + configuration.videoHDRAllowed = true + + arView.automaticallyUpdatesLighting = true + arView.session.delegate = context.coordinator + arView.delegate = context.coordinator + arView.session.run(configuration, options: [.resetTracking,.removeExistingAnchors]) + + return arView + } + + func updateUIView(_ uiView: ARSCNView, context: Context) { + context.coordinator.updateDecoration(decorate: decorate, uiView, context: context, expressionData: expressionData) + } + + func makeCoordinator() -> Corrdinator { + Corrdinator(parent: self) + } + + //robot data + var robot: ExpressionData = ExpressionData(leftEye: ExpressionItem(), rightEye: ExpressionItem(), mouth: ExpressionItem()) + + static func dismantleUIView(_ uiView: ARSCNView, coordinator: Corrdinator) { + uiView.session.pause() + uiView.delegate = nil + uiView.session.delegate = nil + uiView.isPlaying = false + uiView.scene.rootNode.enumerateChildNodes { node, _ in + node.removeFromParentNode() + } + uiView.scene = SCNScene() + } + + class Corrdinator: NSObject, ARSessionDelegate , ARSCNViewDelegate { + var parent: ARCameraView + + var decorate: Int = 0 + + var expressionLayer = ExpressionLayer(data: ExpressionData(leftEye: ExpressionItem(), rightEye: ExpressionItem(), mouth: ExpressionItem()),reverse: true) + + var faceAnchorNode: SCNNode? + var currentDecorationNode: SCNNode? + + private var lastCaptureTime: TimeInterval = 0 + + init(parent: ARCameraView) { + self.parent = parent + } + + func session(_ session: ARSession, didUpdate anchors: [ARAnchor]) { + parent.onCallback?(session,anchors) + } + + func renderer(_ renderer: any SCNSceneRenderer, updateAtTime time: TimeInterval) { + if parent.captureScreen { + if time - lastCaptureTime >= 0.5 { + lastCaptureTime = time + guard let scnView = renderer as? ARSCNView else { return } + let renderedImage = scnView.snapshot() + parent.onFrameCallback?(renderedImage) + } + } + } + + private lazy var expressionRenderer: UIGraphicsImageRenderer = { + let format = UIGraphicsImageRendererFormat.default() + format.scale = UIScreen.main.scale + format.opaque = false + return UIGraphicsImageRenderer( + size: expressionLayer.bounds.size, + format: format + ) + }() + + func createEmojiNoseNode(emoji: String) -> SCNNode { + let size = CGSize(width: 300, height: 300) + UIGraphicsBeginImageContextWithOptions(size, false, 0) + (emoji as NSString).draw(in: CGRect(origin: .zero, size: size), + withAttributes: [.font: UIFont.systemFont(ofSize: size.width - 20)]) + let image = UIGraphicsGetImageFromCurrentImageContext() + UIGraphicsEndImageContext() + + let nosePlane = SCNPlane(width: 0.05, height: 0.05) + nosePlane.firstMaterial?.diffuse.contents = image + nosePlane.firstMaterial?.isDoubleSided = true + + let noseNode = SCNNode(geometry: nosePlane) + noseNode.name = "noseNode" + noseNode.position = SCNVector3(0, 0, 0.07) + return noseNode + } + + func createStackChanModel() -> SCNNode { + guard let scene = SCNScene(named: "stackChanModel.scn"), + let modelNode = scene.rootNode.childNodes.first else { + print("no model") + return SCNNode() + } + modelNode.name = "stackChanModel" + modelNode.scale = SCNVector3(0.004, 0.004, 0.004) + modelNode.opacity = 0.4 + modelNode.position = SCNVector3(0, 0.03, 0) + modelNode.eulerAngles = SCNVector3Zero + modelNode.eulerAngles.x = -Float.pi / 2 + + return modelNode + } + + func renderer(_ renderer: SCNSceneRenderer, nodeFor anchor: ARAnchor) -> SCNNode? { + guard anchor is ARFaceAnchor else { return nil } + + let node = SCNNode() + self.faceAnchorNode = node + + updateDecorationOnNode(node: node, decorate: decorate) + + return node + } + + func createPlane() -> SCNNode { + let plane = SCNPlane(width: 0.16, height: 0.12) + + let layerWidth = plane.width * 1000 + let layerHeight = plane.height * 1000 + expressionLayer.frame = CGRect(origin: .zero, size: CGSize(width: layerWidth, height: layerHeight)) + expressionLayer.setNeedsDisplay() + + let material = SCNMaterial() + material.diffuse.contents = UIColor.black + plane.materials = [material] + + let planeNode = SCNNode(geometry: plane) + planeNode.name = "expressionPlane" + planeNode.position = SCNVector3(0, 0.03, 0.07) + return planeNode + } + + func updateDecorationOnNode(node: SCNNode, decorate: Int) { + currentDecorationNode?.removeFromParentNode() + + if decorate == 1 { + let container = SCNNode() + + let stackChanModelNode = createStackChanModel() + container.addChildNode(stackChanModelNode) + + let expressionPlaneNode = createPlane() + container.addChildNode(expressionPlaneNode) + + node.addChildNode(container) + currentDecorationNode = container + + } else if decorate == 2 { + let noseNode = createEmojiNoseNode(emoji: "🐽") + node.addChildNode(noseNode) + currentDecorationNode = noseNode + } + } + + func updateDecoration(decorate: Int,_ uiView: ARSCNView, context: Context, expressionData: ExpressionData) { + DispatchQueue.main.async { + if self.decorate != decorate { + self.decorate = decorate + if let faceNode = self.faceAnchorNode { + self.updateDecorationOnNode(node: faceNode, decorate: decorate) + } + } + + if decorate == 1 { + let scene = uiView.scene + guard let planeNode = scene.rootNode.childNode(withName: "expressionPlane", recursively: true), + let plane = planeNode.geometry as? SCNPlane else { + return + } + self.expressionLayer.data = expressionData + self.expressionLayer.setNeedsDisplay() + + let originalImage = self.expressionRenderer.image { ctx in + self.expressionLayer.render(in: ctx.cgContext) + } + let image = UIImage( + cgImage: originalImage.cgImage!, + scale: originalImage.scale, + orientation: .upMirrored + ) + + plane.materials.first?.diffuse.contents = image + } + + } + } + } +} + diff --git a/app/StackChan/View/AppDelegate.swift b/app/StackChan/View/AppDelegate.swift new file mode 100644 index 0000000..dcaef9a --- /dev/null +++ b/app/StackChan/View/AppDelegate.swift @@ -0,0 +1,17 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +import ObjectiveC +import UIKit + +class AppDelegate: NSObject, UIApplicationDelegate { + + static var orientationLock = UIInterfaceOrientationMask.portrait + + func application(_ application: UIApplication, supportedInterfaceOrientationsFor window: UIWindow?) -> UIInterfaceOrientationMask { + return AppDelegate.orientationLock + } +} diff --git a/app/StackChan/View/AvatarMotionControl.swift b/app/StackChan/View/AvatarMotionControl.swift new file mode 100644 index 0000000..e3b38e3 --- /dev/null +++ b/app/StackChan/View/AvatarMotionControl.swift @@ -0,0 +1,562 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +import SwiftUI + +struct AvatarMotionControl : View { + + @State private var selectedItem: ControlItem = .avatar + + @EnvironmentObject var appState: AppState + + @State private var avatarData: ExpressionData = ExpressionData(leftEye: ExpressionItem(weight:100), rightEye: ExpressionItem(weight:100), mouth: ExpressionItem(weight:0)) + + @State private var motionData: MotionData = MotionData(pitchServo: MotionDataItem(), yawServo: MotionDataItem()) + + private let tag = "AvatarMotionControl" + + @State private var lastJoystickUpdate: Date = .distantPast + + enum ControlItem: String,CaseIterable, Identifiable { + case avatar = "Avatar" + case motion = "Motion" + var id: String { rawValue } + } + + var body: some View { + VStack { + HStack { + Spacer() + let danceData = DanceData(leftEye: avatarData.leftEye, rightEye: avatarData.rightEye, mouth: avatarData.mouth, yawServo: motionData.yawServo, pitchServo: motionData.pitchServo, durationMs: 1000) + StackChanRobot(data: danceData) + .frame(height: 250) + Spacer() + } + HStack { + Picker("Select", selection: $selectedItem) { + ForEach(ControlItem.allCases) { item in + Text(item.rawValue) + .tag(item) + } + } + .pickerStyle(.segmented) + Button { + if selectedItem == .avatar { + withAnimation { + avatarData = ExpressionData(leftEye: ExpressionItem(weight:100), rightEye: ExpressionItem(weight:100), mouth: ExpressionItem(weight:0)) + } + saveAvatarData() + } else if selectedItem == .motion { + withAnimation { + motionData = MotionData(pitchServo: MotionDataItem(), yawServo: MotionDataItem()) + } + saveMotionData() + } + } label: { + Image(systemName: "arrow.counterclockwise") + } + .glassButtonStyle() + } + + if selectedItem == .avatar { + List { + Section("Left Eye") { + HStack { + Text("x") + .frame(width: 60,alignment: .leading) + Slider( + value: Binding( + get: { Double(avatarData.leftEye.x) }, + set: { avatarData.leftEye.x = Int($0) } + ), + in: -100...100, + onEditingChanged: { editing in + if !editing { + saveAvatarData() + } + } + ) + .frame(maxWidth: .infinity) + Text(String(avatarData.leftEye.x)) + .frame(width: 50,alignment: .trailing) + } + HStack { + Text("y") + .frame(width: 60,alignment: .leading) + Slider( + value: Binding( + get: { Double(avatarData.leftEye.y) }, + set: { avatarData.leftEye.y = Int($0) } + ), + in: -100...100, + onEditingChanged: { editing in + if !editing { + saveAvatarData() + } + } + ) + .frame(maxWidth: .infinity) + Text(String(avatarData.leftEye.y)) + .frame(width: 50,alignment: .trailing) + } + HStack { + Text("rotation") + .frame(width: 60,alignment: .leading) + Slider( + value: Binding( + get: { Double(avatarData.leftEye.rotation) }, + set: { avatarData.leftEye.rotation = Int($0) } + ), + in: -1800...1800, + onEditingChanged: { editing in + if !editing { + saveAvatarData() + } + } + ) + .frame(maxWidth: .infinity) + Text(String(avatarData.leftEye.rotation)) + .frame(width: 50,alignment: .trailing) + } + HStack { + Text("weight") + .frame(width: 60,alignment: .leading) + Slider( + value: Binding( + get: { Double(avatarData.leftEye.weight) }, + set: { avatarData.leftEye.weight = Int($0) } + ), + in: 0...100, + onEditingChanged: { editing in + if !editing { + saveAvatarData() + } + } + ) + .frame(maxWidth: .infinity) + Text(String(avatarData.leftEye.weight)) + .frame(width: 50,alignment: .trailing) + } + HStack { + Text("size") + .frame(width: 60,alignment: .leading) + Slider( + value: Binding( + get: { Double(avatarData.leftEye.size) }, + set: { avatarData.leftEye.size = Int($0) } + ), + in: -100...100, + onEditingChanged: { editing in + if !editing { + saveAvatarData() + } + } + ) + .frame(maxWidth: .infinity) + Text(String(avatarData.leftEye.size)) + .frame(width: 50,alignment: .trailing) + } + } + .listRowBackground(Color.clear) + + Section("Right Eye") { + HStack { + Text("x") + .frame(width: 60,alignment: .leading) + Slider( + value: Binding( + get: { Double(avatarData.rightEye.x) }, + set: { avatarData.rightEye.x = Int($0) } + ), + in: -100...100, + onEditingChanged: { editing in + if !editing { + saveAvatarData() + } + } + ) + .frame(maxWidth: .infinity) + Text(String(avatarData.rightEye.x)) + .frame(width: 50,alignment: .trailing) + } + HStack { + Text("y") + .frame(width: 60,alignment: .leading) + Slider( + value: Binding( + get: { Double(avatarData.rightEye.y) }, + set: { avatarData.rightEye.y = Int($0) } + ), + in: -100...100, + onEditingChanged: { editing in + if !editing { + saveAvatarData() + } + } + ) + .frame(maxWidth: .infinity) + Text(String(avatarData.rightEye.y)) + .frame(width: 50,alignment: .trailing) + } + HStack { + Text("rotation") + .frame(width: 60,alignment: .leading) + Slider( + value: Binding( + get: { Double(avatarData.rightEye.rotation) }, + set: { avatarData.rightEye.rotation = Int($0) } + ), + in: -1800...1800, + onEditingChanged: { editing in + if !editing { + saveAvatarData() + } + } + ) + .frame(maxWidth: .infinity) + Text(String(avatarData.rightEye.rotation)) + .frame(width: 50,alignment: .trailing) + } + HStack { + Text("weight") + .frame(width: 60,alignment: .leading) + Slider( + value: Binding( + get: { Double(avatarData.rightEye.weight) }, + set: { avatarData.rightEye.weight = Int($0) } + ), + in: 0...100, + onEditingChanged: { editing in + if !editing { + saveAvatarData() + } + } + ) + .frame(maxWidth: .infinity) + Text(String(avatarData.rightEye.weight)) + .frame(width: 50,alignment: .trailing) + } + HStack { + Text("size") + .frame(width: 60,alignment: .leading) + Slider( + value: Binding( + get: { Double(avatarData.rightEye.size) }, + set: { avatarData.rightEye.size = Int($0) } + ), + in: -100...100, + onEditingChanged: { editing in + if !editing { + saveAvatarData() + } + } + ) + .frame(maxWidth: .infinity) + Text(String(avatarData.rightEye.size)) + .frame(width: 50,alignment: .trailing) + } + } + .listRowBackground(Color.clear) + + Section("Mouth") { + HStack { + Text("x") + .frame(width: 60,alignment: .leading) + Slider( + value: Binding( + get: { Double(avatarData.mouth.x) }, + set: { avatarData.mouth.x = Int($0) } + ), + in: -100...100, + onEditingChanged: { editing in + if !editing { + saveAvatarData() + } + } + ) + .frame(maxWidth: .infinity) + Text(String(avatarData.mouth.x)) + .frame(width: 50,alignment: .trailing) + } + HStack { + Text("y") + .frame(width: 60,alignment: .leading) + Slider( + value: Binding( + get: { Double(avatarData.mouth.y) }, + set: { avatarData.mouth.y = Int($0) } + ), + in: -100...100, + onEditingChanged: { editing in + if !editing { + saveAvatarData() + } + } + ) + .frame(maxWidth: .infinity) + Text(String(avatarData.mouth.y)) + .frame(width: 50,alignment: .trailing) + } + HStack { + Text("rotation") + .frame(width: 60,alignment: .leading) + Slider( + value: Binding( + get: { Double(avatarData.mouth.rotation) }, + set: { avatarData.mouth.rotation = Int($0) } + ), + in: -1800...1800, + onEditingChanged: { editing in + if !editing { + saveAvatarData() + } + } + ) + .frame(maxWidth: .infinity) + Text(String(avatarData.mouth.rotation)) + .frame(width: 50,alignment: .trailing) + } + HStack { + Text("weight") + .frame(width: 60,alignment: .leading) + Slider( + value: Binding( + get: { Double(avatarData.mouth.weight) }, + set: { avatarData.mouth.weight = Int($0) } + ), + in: 0...100, + onEditingChanged: { editing in + if !editing { + saveAvatarData() + } + } + ) + .frame(maxWidth: .infinity) + Text(String(avatarData.mouth.weight)) + .frame(width: 50,alignment: .trailing) + } + } + .listRowBackground(Color.clear) + } + .listStyle(.grouped) + .scrollContentBackground(.hidden) + .background(.clear) + } else if selectedItem == .motion { + List { + Section("Joystick") { + HStack { + Spacer() + JoystickView { radians, strength in + if radians == 0 && strength == 0 { + calculationJoystick(radians: radians, strength: strength) + } else { + let now = Date() + if now.timeIntervalSince(lastJoystickUpdate) > 0.1 { + lastJoystickUpdate = now + calculationJoystick(radians: radians, strength: strength) + } + } + } + .frame(width: 200,height: 200) + Spacer() + } + } + .listRowBackground(Color.clear) + Section("Yaw Servo") { + HStack { + Text("angle") + .frame(width: 60,alignment: .leading) + Slider( + value: Binding( + get: { Double(motionData.yawServo.angle) }, + set: { + motionData.yawServo.rotate = Int(0) + motionData.yawServo.angle = Int($0) + } + ), + in: -1280...1280, + onEditingChanged: { editing in + if !editing { + saveMotionData() + } + } + ) + .frame(maxWidth: .infinity) + Text(String(motionData.yawServo.angle)) + .frame(width: 50,alignment: .trailing) + } + HStack { + Text("speed") + .frame(width: 60,alignment: .leading) + Slider( + value: Binding( + get: { Double(motionData.yawServo.speed) }, + set: { motionData.yawServo.speed = Int($0) } + ), + in: 0...1000, + onEditingChanged: { editing in + if !editing { + saveMotionData() + } + } + ) + .frame(maxWidth: .infinity) + Text(String(motionData.yawServo.speed)) + .frame(width: 50,alignment: .trailing) + } + HStack { + Text("rotate") + .frame(width: 60,alignment: .leading) + Slider( + value: Binding( + get: { Double(motionData.yawServo.rotate) }, + set: { + motionData.yawServo.angle = Int(0) + motionData.yawServo.rotate = Int($0) + } + ), + in: -1000...1000, + onEditingChanged: { editing in + if !editing { + saveMotionData() + } + } + ) + .frame(maxWidth: .infinity) + Text(String(motionData.yawServo.rotate)) + .frame(width: 50,alignment: .trailing) + } + } + .listRowBackground(Color.clear) + Section("Pitch Servo") { + HStack { + Text("angle") + .frame(width: 60,alignment: .leading) + Slider( + value: Binding( + get: { Double(motionData.pitchServo.angle) }, + set: { motionData.pitchServo.angle = Int($0) } + ), + in: 0...900, + onEditingChanged: { editing in + if !editing { + saveMotionData() + } + } + ) + .frame(maxWidth: .infinity) + Text(String(motionData.pitchServo.angle)) + .frame(width: 50,alignment: .trailing) + } + HStack { + Text("speed") + .frame(width: 60,alignment: .leading) + Slider( + value: Binding( + get: { Double(motionData.pitchServo.speed) }, + set: { motionData.pitchServo.speed = Int($0) } + ), + in: 0...1000, + onEditingChanged: { editing in + if !editing { + saveMotionData() + } + } + ) + .frame(maxWidth: .infinity) + Text(String(motionData.pitchServo.speed)) + .frame(width: 50,alignment: .trailing) + } + } + .listRowBackground(Color.clear) + } + .listStyle(.grouped) + .scrollContentBackground(.hidden) + .background(.clear) + } + Spacer() + } + .padding() + .ignoresSafeArea() + .onAppear { + WebSocketUtil.shared.addObserver(for: tag) { message in + switch message { + case .data(let data): + let result = appState.parseMessage(message: data) + if let msgType = result.0, let parsedData = result.1 { + switch msgType { + case MsgType.getAvatarPosture: + print("Received the result of obtaining the header information" + String(parsedData.count)) + default: + + break + } + } + case .string(let text): + print("Received a regular message:" + text) + @unknown default: + break + } + } + appState.sendWebSocketMessage(.getAvatarPosture) + } + .onDisappear { + WebSocketUtil.shared.removeObserver(for: tag) + } + } + + /// Calculate the joystick data + private func calculationJoystick(radians: CGFloat, strength: CGFloat) { + let x = strength * cos(radians) + let y = strength * sin(radians) + let deadZone: CGFloat = 0.0 + var yawValue: Int = 0 + if abs(x) > deadZone { + yawValue = Int(x * 1280) + motionData.yawServo.rotate = 0 + motionData.yawServo.angle = yawValue + motionData.yawServo.speed = 50 + } else { + motionData.yawServo.rotate = 0 + motionData.yawServo.angle = 0 + motionData.yawServo.speed = 500 + } + if y <= 0 { + if abs(y) > deadZone { + let normalizedY = max(-y, 0) + let newPitch = Int(normalizedY * 900) + motionData.pitchServo.angle = newPitch + motionData.pitchServo.speed = 50 + } else { + motionData.pitchServo.speed = 500 + motionData.pitchServo.angle = 0 + } + } else { + motionData.pitchServo.speed = 500 + motionData.pitchServo.angle = 0 + } + saveMotionData() + } + + private func saveAvatarData() { + if !appState.deviceMac.isEmpty { + let jsonString = appState.deviceMac + avatarData.toJsonString() + let data = jsonString.toData() + appState.sendWebSocketMessage(.controlAvatar, data) + } + } + + private func saveMotionData() { + if !appState.deviceMac.isEmpty { + let jsonString = appState.deviceMac + motionData.toJsonString() + print(jsonString) + + let data = jsonString.toData() + appState.sendWebSocketMessage(.controlMotion, data) + } + } +} + diff --git a/app/StackChan/View/BindingDevice.swift b/app/StackChan/View/BindingDevice.swift new file mode 100644 index 0000000..fff7c02 --- /dev/null +++ b/app/StackChan/View/BindingDevice.swift @@ -0,0 +1,320 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +import SwiftUI +import NetworkExtension +import CoreLocation +import NetworkExtension +import CoreBluetooth + +struct BindingDevice : View { + + enum BindingDevicePageType: Hashable { + case scanningEquipment + } + + @State private var path: [BindingDevicePageType] = [] + + var body: some View { + NavigationStack(path: $path) { + VStack{ + Spacer() + VStack(alignment: .leading, spacing: 16) { + + HStack { + Spacer() + Image("lateral_image") + .resizable() + .frame(maxWidth: 250,maxHeight: 250) + Spacer() + } + + Text("Get your StackChan device ready") + .font(.title2) + .bold() + + VStack(alignment: .leading, spacing: 12) { + HStack(alignment: .top) { + Image(systemName: "1.circle.fill") + .foregroundColor(.accentColor) + Text("Turn on your StackChan device") + } + HStack(alignment: .top) { + Image(systemName: "2.circle.fill") + .foregroundColor(.accentColor) + Text("After turning on the computer, turn the page to \"Setup\" and click to enter. A QR code will be displayed") + } + HStack(alignment: .top) { + Image(systemName: "3.circle.fill") + .foregroundColor(.accentColor) + Text("Align the QR code and scan it to bind the device") + } + } + .font(.body) + } + .padding() + Spacer() + NavigationLink(value: BindingDevicePageType.scanningEquipment) { + Text("Next") + .frame(maxWidth: .infinity) + } + .padding() + .controlSize(.large) + .buttonStyle(.borderedProminent) + } + .navigationTitle("Binding Device") + .navigationDestination(for: BindingDevicePageType.self) { PageType in + switch PageType { + case .scanningEquipment: + ScanningEquipment() + } + } + } + } +} + +struct ScanningEquipment : View { + + enum PairingStatus { + case ScanCode + case ConnectBlue + case InputWiFi + case DistributionNetwork + case ChangeTheName + case Empty + } + + enum Field { + case Name + case Password + case StackChanName + } + + @EnvironmentObject var appState: AppState + + @State var pairingStatus: PairingStatus = .ScanCode + + @State var wifiName: String = "" + @State var wifiPassword: String = "" + @State var stackChanName: String = "" + + @State private var locationManager = CLLocationManager() + @State private var locationDelegate = LocationDelegate() + + @FocusState private var focusedField: Field? + + var body: some View { + Group { + switch pairingStatus { + case .ScanCode: + GeometryReader { geometry in + ScanView { result in + switch result { + case .success(let data): + readCodeString(value: data) + break + case .failure(_): + break + } + } + .clipShape( + RoundedRectangle( + cornerRadius: min(geometry.size.width, geometry.size.height) * 0.1, + style: .continuous + ) + ) + } + .padding() + .navigationTitle("Scan Device QR Code") + case .ConnectBlue: + VStack { + ProgressView() + .progressViewStyle(.circular) + Text("Connecting to Bluetooth") + .font(.title3) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity,maxHeight: .infinity, alignment: .center) + .navigationTitle("Pairing devices") + case .InputWiFi: + VStack { + List { + Section(header: Text("Name")) { + TextField("Please enter the name of the wifi", text:$wifiName) + .focused($focusedField, equals: .Name) + .submitLabel(.next) + .onSubmit { + focusedField = .Password + } + } + Section(header: Text("Password")) { + TextField("Please enter the password of the wifi", text:$wifiPassword) + .focused($focusedField, equals: .Password) + .submitLabel(.done) + .onSubmit { + confirmWifi() + } + } + } + .listStyle(.insetGrouped) + + Spacer() + + Button { + focusedField = nil + confirmWifi() + } label: { + Text("Confirm") + .frame(maxWidth: .infinity) + } + .padding() + .controlSize(.large) + .buttonStyle(.borderedProminent) + } + .background(Color(UIColor.systemGroupedBackground)) + .navigationTitle("Enter Wifi Information") + case .DistributionNetwork: + VStack { + ProgressView() + .progressViewStyle(.circular) + Text("The network is being configured for the equipment") + .font(.title3) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity,maxHeight: .infinity, alignment: .center) + .navigationTitle("wait a moment") + case .ChangeTheName: + VStack { + List { + Section(header: Text("Name")) { + TextField("Please enter the name of the stackChan", text:$stackChanName) + .focused($focusedField, equals: .StackChanName) + .submitLabel(.done) + .onSubmit { + updataName() + } + } + } + .listStyle(.insetGrouped) + + Spacer() + + Button { + focusedField = nil + updataName() + } label: { + Text("Confirm") + .frame(maxWidth: .infinity) + } + .padding() + .controlSize(.large) + .buttonStyle(.borderedProminent) + } + .frame(maxWidth: .infinity,maxHeight: .infinity, alignment: .center) + .navigationTitle("Give me a name") + default: + EmptyView() + } + } + .alert(appState.alertTitle, isPresented: $appState.showAlert){ + Button { + appState.alertAction?() + } label: { + Text("Confirm") + } + } + .task { + } + } + + func updataName() { + + } + + func readCodeString(value: String) { + if let data = value.data(using: .utf8), let json = try? JSONSerialization.jsonObject(with: data) as? [String:Any], let mac = json["mac"] as? String { + let extracted = mac + let cleanedMac = extracted.uppercased().replacingOccurrences( + of: "[^A-F0-9]", + with: "", + options: .regularExpression + ) + appState.deviceMac = cleanedMac + appState.showBindingDevice = false + appState.connectWebSocket() + appState.openBlufi() + } + } + + private func getBlueAndWifiInfo() { + NEHotspotNetwork.fetchCurrent { network in + if let network = network { + wifiName = network.ssid + focusedField = .Password + } + } + BlufiUtil.shared.startScan() + } + + private func getPermission() { + if #available(iOS 14.0, *) { + switch locationManager.authorizationStatus { + case .authorizedWhenInUse, .authorizedAlways: + getBlueAndWifiInfo() + break + case .denied, .restricted: + break + case .notDetermined: + locationManager.requestWhenInUseAuthorization() + break + default: + break + } + } else { + locationManager.requestWhenInUseAuthorization() + } + } + + private func confirmWifi() { + + if !BlufiUtil.shared.blueSwitch { + appState.alertTitle = "Please turn on Bluetooth" + appState.showAlert = true + return + } + + if wifiName.isEmpty || wifiPassword.isEmpty { + appState.alertTitle = "Please enter Wi-Fi name and password" + appState.showAlert = true + return + } + + withAnimation{ + pairingStatus = .DistributionNetwork + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + withAnimation{ + pairingStatus = .ChangeTheName + } + } + } + + private func hideKeyboard() { + UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) + } +} + + +class LocationDelegate: NSObject,CLLocationManagerDelegate { + var onAuthorized: (() -> Void)? + + func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { + if manager.authorizationStatus == .authorizedWhenInUse || manager.authorizationStatus == .authorizedAlways { + onAuthorized?() + } + } +} diff --git a/app/StackChan/View/CameraPage.swift b/app/StackChan/View/CameraPage.swift new file mode 100644 index 0000000..f809975 --- /dev/null +++ b/app/StackChan/View/CameraPage.swift @@ -0,0 +1,571 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +import SwiftUI + +struct CameraPage: View { + + @State var showControlButton = true + @State var fullScreenDisplay = false + + @State var controlWidth: CGFloat = 0 + @State var controlHeight: CGFloat = 0 + + private let controlButtonSize: CGFloat = 88 + private let bottomButtonSize: CGFloat = 50 + + @State var openMicrophone : Bool = false + @State var openSpeaker: Bool = true + @State var startRecord: Bool = false + @State var isPress: Int? = nil + + @EnvironmentObject var appState: AppState + + @AppStorage("recordMotion") private var recordMotionData: Data = Data() + + @State var recordMotion: [MotionData] = [] + + private let feedback = UIImpactFeedbackGenerator(style: .medium) + + @State private var removeRecordPoint: Bool = false + + @State private var cameraImage: Data? = nil + + @State var showAlert = false + @State var alertMessage = "" + + private let tag: String = "CameraPage" + + @State private var motionData: MotionData = MotionData(pitchServo: MotionDataItem(), yawServo: MotionDataItem()) + + @State private var pressTimer: Timer? = nil + + private let longPressInterval = 0.05 + private let longStepValue = 50 + + var body: some View { + GeometryReader { geo in + if fullScreenDisplay { + VStack { + cameraView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .toolbar(.hidden, for: .bottomBar,.navigationBar,.tabBar,.automatic) + .ignoresSafeArea(.all) + .background(.black) + } else { + let isPortrait = geo.size.height > geo.size.width + ZStack(alignment: isPortrait ? .bottomTrailing : .topTrailing) { + if isPortrait { + VStack { + cameraView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + cameraControl(isPortrait: isPortrait) + .frame(maxWidth: .infinity,maxHeight: controlHeight) + .opacity(showControlButton ? 1: 0) + } + } else { + HStack { + cameraView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + cameraControl(isPortrait: isPortrait) + .frame(maxWidth: controlWidth, maxHeight: .infinity) + .opacity(showControlButton ? 1: 0) + } + } + + HStack { + Spacer() + Button { + withAnimation(.easeInOut(duration: 0.3)) { + showControlButton.toggle() + updateControlSize(isPortrait: isPortrait, size: geo.size) + } + } label: { + Image(systemName: + showControlButton + ? (isPortrait ? "chevron.down" : "chevron.right") + : (isPortrait ? "chevron.up" : "chevron.left") + ) + .frame(width: bottomButtonSize, height: bottomButtonSize) + .font(.system(size: bottomButtonSize / 2)) + } + .glassEffectCircle() + } + .padding(12) + } + .padding(0) + .onAppear { + DispatchQueue.main.async { + if controlWidth == 0 && controlHeight == 0 { + controlHeight = geo.size.height / 2 + } + } + WebSocketUtil.shared.addObserver(for: tag) { (message: URLSessionWebSocketTask.Message) in + switch message { + case .data(let data): + let result = appState.parseMessage(message: data) + if let msgType = result.0, let parsedData = result.1 { + switch msgType { + case MsgType.jpeg: + cameraImage = parsedData + default: + break + } + } + case .string(let text): + print("Received a regular message: \(text)") + @unknown default: + break + } + } + //on device camera + appState.sendWebSocketMessage(.onCamera,appState.deviceMac.toData()) + } + .onDisappear { + WebSocketUtil.shared.removeObserver(for: tag) + //off device camera + appState.sendWebSocketMessage(.offCamera,appState.deviceMac.toData()) + } + .onChange(of: geo.size) { newValue in + let isPortrait = newValue.height > newValue.width + updateControlSize(isPortrait: isPortrait, size: newValue) + } + .toolbar(.hidden, for: .tabBar) + .navigationTitle("SENTINEL") + } + } + .alert(alertMessage, isPresented: $showAlert) { + Button { + + } label: { + Text("Confirm") + } + } + .onAppear { + recordMotion = getRecordMotion() + } + } + + private func getRecordMotion() -> [MotionData] { + guard let decoded = try? JSONDecoder().decode([MotionData].self, from: recordMotionData) else { + return [] + } + return decoded + } + + private func setRecordMotion(_ newValue: [MotionData]) { + if let encoded = try? JSONEncoder().encode(newValue) { + recordMotionData = encoded + recordMotion = newValue + } + } + + private func updateControlSize(isPortrait: Bool, size: CGSize) { + withAnimation { + if showControlButton { + if isPortrait { + controlHeight = size.height / 2 + } else { + controlWidth = size.width / 2 + } + } else { + if isPortrait { + controlHeight = 0 + } else { + controlWidth = 0 + } + } + } + } + + private func cameraView() -> some View { + GeometryReader { geo in + VStack { + Spacer() + ZStack(alignment: .bottom) { + if let cameraData = cameraImage, let uiImage = UIImage(data: cameraData) { + Image(uiImage: uiImage) + .resizable() + .scaledToFit() + } else { + Color.gray + } + HStack { + Spacer() + Button { + withAnimation { + fullScreenDisplay.toggle() + } + } label: { + Image(systemName: fullScreenDisplay ? "arrow.down.right.and.arrow.up.left" : "arrow.up.left.and.arrow.down.right") + .frame(width: 44,height: 44) + .foregroundColor(.white) + } + .glassEffectCircle() + } + .padding() + } + .frame( + maxWidth: geo.size.width, + maxHeight: min(geo.size.height, geo.size.width * 3 / 4) + ) + Spacer() + } + } + } + + @ViewBuilder private func cameraRecordPoint() -> some View { + ZStack { + ScrollViewReader { proxy in + ScrollView(.vertical, showsIndicators: true) { + VStack(spacing: 5) { + Color.clear.frame(width: bottomButtonSize / 2,height: bottomButtonSize / 2) + ForEach(Array(recordMotion.indices), id: \.self) { index in + ZStack(alignment: .topTrailing) { + Button { + if removeRecordPoint { + recordMotion.remove(at: index) + setRecordMotion(recordMotion) + } else { + feedback.impactOccurred() + motionData = recordMotion[index] + saveMotionData() + } + } label: { + Text(String(index + 1)) + .frame(width: bottomButtonSize, height: bottomButtonSize) + .background( + Circle() + .fill(Color(UIColor.secondarySystemFill)) + ) + .font(.system(size: bottomButtonSize / 2)) + } + if removeRecordPoint { + Image(systemName: "minus.circle.fill") + .font(.system(size: 20)) + .foregroundColor(.red) + } + } + } + Color.clear.frame(width: bottomButtonSize / 2,height: bottomButtonSize / 2).id(-1) + } + .padding(0) + } + .padding(0) + .onChange(of: recordMotion.count) { _ in + withAnimation { + proxy.scrollTo(-1, anchor: .bottom) + } + } + } + .padding(.vertical, bottomButtonSize / 2) + + VStack { + Button { + withAnimation { + recordMotion.append(motionData) + setRecordMotion(recordMotion) + } + } label: { + Image(systemName: "plus") + .frame(width: bottomButtonSize, height: bottomButtonSize) + .font(.system(size: bottomButtonSize / 2)) + } + .glassEffectCircle() + + Spacer() + + Button { + withAnimation { + removeRecordPoint.toggle() + } + } label: { + Image(systemName: removeRecordPoint ? "checkmark" : "minus") + .frame(width: bottomButtonSize, height: bottomButtonSize) + .foregroundColor(removeRecordPoint ? .accent : Color(UIColor.label)) + .font(.system(size: bottomButtonSize / 2)) + } + .glassEffectCircle() + } + .padding(0) + }.background( + RoundedRectangle(cornerRadius: 50) + .fill(.ultraThinMaterial) + ) + } + + @ViewBuilder private func cameraControl(isPortrait: Bool) -> some View { + + let microphone = Button { + feedback.impactOccurred() + withAnimation { + openMicrophone.toggle() + } + } label: { + Image(systemName: openMicrophone ? "microphone" : "microphone.slash") + .frame(width: bottomButtonSize, height: bottomButtonSize) + .font(.system(size: bottomButtonSize / 2)) + } + .glassEffectCircle() + + let speaker = Button { + feedback.impactOccurred() + withAnimation { + openSpeaker.toggle() + } + } label: { + Image(systemName: openSpeaker ? "speaker" : "speaker.slash") + .frame(width: bottomButtonSize, height: bottomButtonSize) + .font(.system(size: bottomButtonSize / 2)) + } + .glassEffectCircle() + + let record = Button { + feedback.impactOccurred() + withAnimation { + startRecord.toggle() + } + } label: { + Image(systemName: !startRecord ? "record.circle" : "record.circle.fill") + .frame(width: bottomButtonSize, height: bottomButtonSize) + .font(.system(size: bottomButtonSize / 2)) + } + .glassEffectCircle() + .foregroundColor(!startRecord ? Color(UIColor.label) : .red) + + let directionButton = ZStack{ + Button(action: { + if let messsage = "Hi".toData() { + appState.sendWebSocketMessage(.onCamera, messsage) + } + }) { + Image(systemName: "arrow.up") + .frame(width: controlButtonSize, height: controlButtonSize) + .background( + Circle().fill(.ultraThinMaterial) + .overlay( + Circle() + .fill(isPress == 1 ? Color.accent : .clear) + ) + ) + .foregroundColor(isPress == 1 ? .white : Color(UIColor.label)) + .font(.system(size: controlButtonSize / 2)) + } + .buttonStyle(.plain) + .simultaneousGesture( + DragGesture(minimumDistance: 0) + .onChanged { _ in + if isPress != nil { + return + } + withAnimation { + isPress = 1 + } + feedback.impactOccurred() + print("Up pressed") + + pressTimer?.invalidate() + pressTimer = Timer.scheduledTimer(withTimeInterval: longPressInterval, repeats: true, block: { _ in + self.motionData.pitchServo.angle += longStepValue + self.saveMotionData() + }) + } + .onEnded { _ in + isPress = nil + + pressTimer?.invalidate() + pressTimer = nil + } + ) + .offset(x: 0, y: -(controlButtonSize / 1.3)) + + Button(action: {}) { + Image(systemName: "arrow.down") + .frame(width: controlButtonSize, height: controlButtonSize) + .background( + Circle().fill(.ultraThinMaterial) + .overlay( + Circle() + .fill(isPress == 3 ? Color.accent : .clear) + ) + ) + .foregroundColor(isPress == 3 ? .white : Color(UIColor.label)) + .font(.system(size: controlButtonSize / 2)) + } + .buttonStyle(.plain) + .simultaneousGesture( + DragGesture(minimumDistance: 0) + .onChanged { _ in + if isPress != nil { + return + } + withAnimation { + isPress = 3 + } + feedback.impactOccurred() + + pressTimer?.invalidate() + pressTimer = Timer.scheduledTimer(withTimeInterval: longPressInterval, repeats: true, block: { _ in + self.motionData.pitchServo.angle -= longStepValue + self.saveMotionData() + }) + } + .onEnded { _ in + isPress = nil + + pressTimer?.invalidate() + pressTimer = nil + } + ) + .offset(x: 0, y: (controlButtonSize / 1.3)) + + Button(action: {}) { + Image(systemName: "arrow.left") + .frame(width: controlButtonSize, height: controlButtonSize) + .background( + Circle().fill(.ultraThinMaterial) + .overlay( + Circle() + .fill(isPress == 4 ? Color.accent : .clear) + ) + ) + .foregroundColor(isPress == 4 ? .white : Color(UIColor.label)) + .font(.system(size: controlButtonSize / 2)) + } + .buttonStyle(.plain) + .simultaneousGesture( + DragGesture(minimumDistance: 0) + .onChanged { _ in + if isPress != nil { + return + } + withAnimation { + isPress = 4 + } + feedback.impactOccurred() + + pressTimer?.invalidate() + pressTimer = Timer.scheduledTimer(withTimeInterval: longPressInterval, repeats: true, block: { _ in + self.motionData.yawServo.angle -= longStepValue + self.saveMotionData() + }) + } + .onEnded { _ in + isPress = nil + + pressTimer?.invalidate() + pressTimer = nil + } + ) + .offset(x: -(controlButtonSize / 1.3), y: 0) + + Button(action: {}) { + Image(systemName: "arrow.right") + .frame(width: controlButtonSize, height: controlButtonSize) + .background( + Circle().fill(.ultraThinMaterial) + .overlay( + Circle() + .fill(isPress == 2 ? Color.accent : .clear) + ) + ) + .foregroundColor(isPress == 2 ? .white : Color(UIColor.label)) + .font(.system(size: controlButtonSize / 2)) + } + .buttonStyle(.plain) + .simultaneousGesture( + DragGesture(minimumDistance: 0) + .onChanged { _ in + if isPress != nil { + return + } + withAnimation { + isPress = 2 + } + feedback.impactOccurred() + + pressTimer?.invalidate() + pressTimer = Timer.scheduledTimer(withTimeInterval: longPressInterval, repeats: true, block: { _ in + self.motionData.yawServo.angle += longStepValue + self.saveMotionData() + }) + } + .onEnded { _ in + isPress = nil + + pressTimer?.invalidate() + pressTimer = nil + } + ) + .offset(x: (controlButtonSize / 1.3), y: 0) + } + + if isPortrait { + VStack { + Spacer() + HStack { + VStack { + Text("View\nPresets").font(.caption).multilineTextAlignment(.center) + cameraRecordPoint() + } + Spacer() + directionButton + Spacer() + } + .padding(0) + Spacer() + HStack { + record + Spacer() + speaker + microphone + Color.clear.frame(width: bottomButtonSize,height: bottomButtonSize) + } + .padding(0) + } + .padding(12) + .foregroundColor(Color(UIColor.label)) + } else { + HStack { + HStack { + VStack { + Text("View\nPresets").font(.caption).multilineTextAlignment(.center) + cameraRecordPoint() + } + Spacer() + directionButton + Spacer() + } + Spacer() + VStack { + Color.clear.frame(width: bottomButtonSize,height: bottomButtonSize) + speaker + microphone + Spacer() + record + } + .padding(0) + } + .padding(12) + .foregroundColor(Color(UIColor.label)) + } + } + + private func saveMotionData() { + if !appState.deviceMac.isEmpty { + let jsonString = appState.deviceMac + motionData.toJsonString() + let data = jsonString.toData() + appState.sendWebSocketMessage(.controlMotion, data) + } + } +} + +struct CameraPageViewPreview : PreviewProvider { + static var previews: some View { + CameraPage() + } +} diff --git a/app/StackChan/View/ContentView.swift b/app/StackChan/View/ContentView.swift new file mode 100644 index 0000000..164b359 --- /dev/null +++ b/app/StackChan/View/ContentView.swift @@ -0,0 +1,74 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +import SwiftUI +import CoreBluetooth + +struct ContentView: View { + + @EnvironmentObject var appState: AppState + + var body: some View { + TabView { + StackChan() + .tabItem { + Label( "StackChan", systemImage: "ipod") + } + Nearby() + .tabItem { + Label("Nearby", systemImage: "sensor") + } + Moments() + .tabItem { + Label("Moments", systemImage: "person.3") + } + Settings() + .tabItem { + Label("Settings", systemImage: "gear") + } + } + .task { + appState.openBlufi() + if appState.deviceMac != "" { + appState.connectWebSocket() + } + } + .sheet(isPresented: $appState.showBindingDevice) { + BindingDevice() + .interactiveDismissDisabled(appState.forcedDisplayBindingDevice) + } + .sheet(isPresented: $appState.showDeviceWifiSet) { + SelectBlufiDevice() + .presentationDetents([.medium]) + .interactiveDismissDisabled(true) + } + .alert("Let's give the lovely StackChan a new name", isPresented: $appState.showCjamgeNameAlert, actions: { + TextField("Please enter the name", text: $appState.newName) + Button("Cancel", role: .cancel) { + appState.showCjamgeNameAlert = false + } + Button("Confirm") { + appState.showCjamgeNameAlert = false + withAnimation { + appState.deviceInfo.name = appState.newName + } + appState.updateDeviceInfo() + } + }) + .alert("Please switch StackChan to the SETUP page, select \"App Bind Code\", and then switch to the settings page on the app to choose \"Bind Device\"", isPresented: $appState.showBindingDeviceAlert) { + Button("Confirm") { + appState.showBindingDeviceAlert = false + } + } + } +} + + +struct ContentViewPreview : PreviewProvider { + static var previews: some View { + ContentView() + } +} diff --git a/app/StackChan/View/Dance.swift b/app/StackChan/View/Dance.swift new file mode 100644 index 0000000..b01fd46 --- /dev/null +++ b/app/StackChan/View/Dance.swift @@ -0,0 +1,815 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +import SwiftUI + +struct DanceData : Codable,Identifiable { + var leftEye: ExpressionItem // Left eye, default weight = 100 + var rightEye: ExpressionItem // Right eye, default weight = 100 + var mouth: ExpressionItem // Mouth, default weight = 0 + var yawServo: MotionDataItem // Yaw rotation, angle range (-1280 ~ 1280), default 0 + var pitchServo: MotionDataItem // Pitch movement, angle range (0 ~ 900), default 0 + var durationMs: Int // Duration in milliseconds, default 1000 + var id: String = UUID().uuidString + + enum CodingKeys: String, CodingKey { + case leftEye, rightEye, mouth, yawServo, pitchServo, durationMs + } + + func copy() -> DanceData { + DanceData( + leftEye: self.leftEye.copy(), + rightEye: self.rightEye.copy(), + mouth: self.mouth.copy(), + yawServo: self.yawServo.copy(), + pitchServo: self.pitchServo.copy(), + durationMs: self.durationMs, + id: UUID().uuidString + ) + } +} + +/// +struct Dance : View { + + @State var danceList: [DanceData] = [] + + @State var modelDanceList: [DanceData] = [] + + @State private var selectedDance: Int = 0 + + @EnvironmentObject var appState: AppState + + @State var showAddDance : Bool = false + + @State var isRun: Bool = false + + @State var editDanceData = DanceData(leftEye: ExpressionItem(), rightEye: ExpressionItem(), mouth: ExpressionItem(), yawServo: MotionDataItem(), pitchServo: MotionDataItem(), durationMs: 1000) + + @State var editDanceDataIndex: Int? = nil + + @State private var danceTimer: Timer? = nil + + var body: some View { + List { + ForEach(Array(danceList.enumerated()), id: \.element.id) { index,item in + danceItemView(index: index) + .swipeActions(edge: .trailing) { + Button(role: .destructive) { + danceList.remove(at: index) + saveDance() + } label: { + Label("Delete", systemImage: "trash") + } + } + } + .onMove { source, destination in + danceList.move(fromOffsets: source, toOffset: destination) + saveDance() + } + } + .listStyle(.insetGrouped) + .refreshable { + getDanceList() + } + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button { + isRun.toggle() + if isRun { + startDance() + } else { + stopDance() + } + } label: { + Label { + Text(isRun ? "Stop" : "Run") + } icon: { + Image(systemName: isRun ? "stop.fill" : "play.fill") + } + } + } + if #available(iOS 26.0, *) { + ToolbarSpacer(.fixed, placement: .primaryAction) + } + + ToolbarItem(placement: .primaryAction) { + Menu { + Button { + selectedDance = 0 + } label: { + if selectedDance == 0 { + Label("Dance One", systemImage: "checkmark") + } else { + Text("Dance One") + } + } + + Button { + selectedDance = 1 + } label: { + if selectedDance == 1 { + Label("Dance Two", systemImage: "checkmark") + } else { + Text("Dance Two") + } + } + + Button { + selectedDance = 2 + } label: { + if selectedDance == 2 { + Label("Dance Three", systemImage: "checkmark") + } else { + Text("Dance Three") + } + } + } label: { + Label { + Text("Dance") + } icon: { + Image(systemName: "figure.dance") + } + } + } + + if #available(iOS 26.0, *) { + ToolbarSpacer(.fixed, placement: .primaryAction) + } + + ToolbarItem(placement: .primaryAction) { + Button { + editDanceData = DanceData(leftEye: ExpressionItem(weight: 100), rightEye: ExpressionItem(weight: 100), mouth: ExpressionItem(), yawServo: MotionDataItem(), pitchServo: MotionDataItem(), durationMs: 1000) + editDanceDataIndex = nil + danceList.append(editDanceData) + saveDance() + } label: { + Label { + Text("Add Dance") + } icon: { + Image(systemName: "plus") + } + } + } + } + .toolbar(.hidden, for: .tabBar) + .navigationTitle(danceTitle()) + .onAppear { + getDanceList() + } + .onDisappear{ + isRun = false + stopDance() + } + } + + private func startDance() { + var duration = 1000 + let jsonString = danceList.toJsonString() + for i in danceList { + duration = duration + i.durationMs + } + appState.sendWebSocketMessage(.dance, jsonString.toData()) + let interval = max(Double(duration) / 1000.0, 0.1) + danceTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { timer in + appState.sendWebSocketMessage(.dance, jsonString.toData()) + } + } + + /// Stop the dance timer + private func stopDance() { + danceTimer?.invalidate() + danceTimer = nil + } + + private func danceTitle() -> String { + switch selectedDance { + case 0: return "Dance One" + case 1: return "Dance Two" + case 2: return "Dance Three" + default: return "Dance" + } + } + + private func sendDanceData(data: DanceData) { + if !appState.deviceMac.isEmpty { + let motionData = MotionData(pitchServo: data.pitchServo, yawServo: data.yawServo) + let jsonString = appState.deviceMac + motionData.toJsonString() + let data = jsonString.toData() + appState.sendWebSocketMessage(.controlMotion, data) + } + } + + private func danceItemView(index: Int) -> some View { + HStack { + VStack { + if modelDanceList.count > index { + StackChanRobot(data: modelDanceList[index]) + .frame(width: 80,height: 80) + } + Spacer() + Button { + let currentData = danceList[index].copy() + if danceList.indices.contains(index + 1) { + danceList.insert(currentData, at: index + 1) + } else { + danceList.append(currentData) + } + saveDance() + } label: { + Label("Copy", systemImage: "doc.on.doc") + } + } + VStack(alignment: .leading) { + Text("Left-Right") + .frame(width: 80,alignment: .leading) + HStack { + Slider( + value: Binding( + get: { Double(danceList[index].yawServo.angle) }, + set: { danceList[index].yawServo.angle = Int($0) } + ), + in: -1280...1280, + step: 10, + onEditingChanged: { editing in + if !editing { + saveDance() + sendDanceData(data: danceList[index]) + } + } + ) + .frame(maxWidth: .infinity) + Text(String(danceList[index].yawServo.angle)) + .frame(width: 50,alignment: .trailing) + } + Text("Up-down") + .frame(width: 80,alignment: .leading) + HStack { + Slider( + value: Binding( + get: { Double(danceList[index].pitchServo.angle) }, + set: { danceList[index].pitchServo.angle = Int($0) } + ), + in: 0...900, + step: 10, + onEditingChanged: { editing in + if !editing { + saveDance() + sendDanceData(data: danceList[index]) + } + } + ) + .frame(maxWidth: .infinity) + Text(String(danceList[index].pitchServo.angle)) + .frame(width: 50,alignment: .trailing) + } + Text("Duration") + .frame(width: 80,alignment: .leading) + HStack { + Slider( + value: Binding( + get: { Double(danceList[index].durationMs) }, + set: { danceList[index].durationMs = Int($0) } + ), + in: 0...3000, + step: 10, + onEditingChanged: { editing in + if !editing { + saveDance() + } + } + ) + .frame(maxWidth: .infinity) + Text(String(danceList[index].durationMs)) + .frame(width: 50,alignment: .trailing) + } + } + } + } + + private func getDanceList() { + let map = [ + ValueConstant.mac: appState.deviceMac + ] + Networking.shared.get(pathUrl: Urls.dance,parameters: map) { result in + switch result { + case .success(let success): + do { + let response = try Response<[String:[DanceData]]>.decode(from: success) + if response.isSuccess, let map = response.data { + if selectedDance == 0 { + danceList = map["0"] ?? [] + } else if selectedDance == 1 { + danceList = map["1"] ?? [] + } else if selectedDance == 2 { + danceList = map["2"] ?? [] + } + modelDanceList = danceList + } + } catch { + print("Failed to parse response data") + } + case .failure(let failure): + print("Request failed:", failure) + } + } + } + + private func saveDance() { + if let dict = danceList.toListDictionary() { + let map: [String: Any] = [ + ValueConstant.mac: appState.deviceMac, + ValueConstant.list: dict, + ValueConstant.index: selectedDance + ] + Networking.shared.post(pathUrl: Urls.dance,parameters: map) { result in + switch result { + case .success(let success): + do { + let response = try Response.decode(from: success) + if response.isSuccess, let data = response.data { + modelDanceList = danceList + print(data) + } + } catch { + print("Failed to parse response data") + } + case .failure(let failure): + print("Request failed:", failure) + } + } + } + } +} + +struct AddAvatarMotion : View { + + @Binding var isPresented: Bool + + @Binding var editDanceDataIndex: Int? + + @State private var selectedItem: ControlItem = .avatar + + @EnvironmentObject var appState: AppState + + @Binding var danceData: DanceData + + let onCallBack : ((DanceData) -> Void)? + + enum ControlItem: String,CaseIterable, Identifiable { + case avatar = "Avatar" + case motion = "Motion" + var id: String { rawValue } + } + + var body: some View { + NavigationStack { + VStack { + HStack { + Spacer() + StackChanRobot(data: danceData,allowsCameraControl: false) + .frame(width: 300,height: 300) + Spacer() + } + HStack { + Text("duration") + .frame(width: 100,alignment: .leading) + Slider( + value: Binding( + get: { Double(danceData.durationMs) }, + set: { danceData.durationMs = Int($0) } + ), + in: 0...3000 + ) + .frame(maxWidth: .infinity) + Text(String(danceData.durationMs)) + .frame(width: 50,alignment: .trailing) + } + HStack { + Picker("Select", selection: $selectedItem) { + ForEach(ControlItem.allCases) { item in + Text(item.rawValue) + .tag(item) + } + } + .pickerStyle(.segmented) + Button { + withAnimation { + danceData = DanceData(leftEye: ExpressionItem(weight: 100), rightEye: ExpressionItem(weight: 100), mouth: ExpressionItem(), yawServo: MotionDataItem(), pitchServo: MotionDataItem(), durationMs: 1000) + } + saveData() + } label: { + Image(systemName: "arrow.counterclockwise") + } + .glassButtonStyle() + } + + if selectedItem == .avatar { + List { + Section("Left Eye") { + HStack { + Text("x") + .frame(width: 60,alignment: .leading) + Slider( + value: Binding( + get: { Double(danceData.leftEye.x) }, + set: { danceData.leftEye.x = Int($0) } + ), + in: -100...100, + onEditingChanged: { editing in + if !editing { + saveData() + } + } + ) + .frame(maxWidth: .infinity) + Text(String(danceData.leftEye.x)) + .frame(width: 50,alignment: .trailing) + } + HStack { + Text("y") + .frame(width: 60,alignment: .leading) + Slider( + value: Binding( + get: { Double(danceData.leftEye.y) }, + set: { danceData.leftEye.y = Int($0) } + ), + in: -100...100, + onEditingChanged: { editing in + if !editing { + saveData() + } + } + ) + .frame(maxWidth: .infinity) + Text(String(danceData.leftEye.y)) + .frame(width: 50,alignment: .trailing) + } + HStack { + Text("rotation") + .frame(width: 60,alignment: .leading) + Slider( + value: Binding( + get: { Double(danceData.leftEye.rotation) }, + set: { danceData.leftEye.rotation = Int($0) } + ), + in: -1800...1800, + onEditingChanged: { editing in + if !editing { + saveData() + } + } + ) + .frame(maxWidth: .infinity) + Text(String(danceData.leftEye.rotation)) + .frame(width: 50,alignment: .trailing) + } + HStack { + Text("weight") + .frame(width: 60,alignment: .leading) + Slider( + value: Binding( + get: { Double(danceData.leftEye.weight) }, + set: { danceData.leftEye.weight = Int($0) } + ), + in: 0...100, + onEditingChanged: { editing in + if !editing { + saveData() + } + } + ) + .frame(maxWidth: .infinity) + Text(String(danceData.leftEye.weight)) + .frame(width: 50,alignment: .trailing) + } + HStack { + Text("size") + .frame(width: 60,alignment: .leading) + Slider( + value: Binding( + get: { Double(danceData.leftEye.size) }, + set: { danceData.leftEye.size = Int($0) } + ), + in: -100...100, + onEditingChanged: { editing in + if !editing { + saveData() + } + } + ) + .frame(maxWidth: .infinity) + Text(String(danceData.leftEye.size)) + .frame(width: 50,alignment: .trailing) + } + } + .listRowBackground(Color.clear) + + Section("Right Eye") { + HStack { + Text("x") + .frame(width: 60,alignment: .leading) + Slider( + value: Binding( + get: { Double(danceData.rightEye.x) }, + set: { danceData.rightEye.x = Int($0) } + ), + in: -100...100, + onEditingChanged: { editing in + if !editing { + saveData() + } + } + ) + .frame(maxWidth: .infinity) + Text(String(danceData.rightEye.x)) + .frame(width: 50,alignment: .trailing) + } + HStack { + Text("y") + .frame(width: 60,alignment: .leading) + Slider( + value: Binding( + get: { Double(danceData.rightEye.y) }, + set: { danceData.rightEye.y = Int($0) } + ), + in: -100...100, + onEditingChanged: { editing in + if !editing { + saveData() + } + } + ) + .frame(maxWidth: .infinity) + Text(String(danceData.rightEye.y)) + .frame(width: 50,alignment: .trailing) + } + HStack { + Text("rotation") + .frame(width: 60,alignment: .leading) + Slider( + value: Binding( + get: { Double(danceData.rightEye.rotation) }, + set: { danceData.rightEye.rotation = Int($0) } + ), + in: -1800...1800, + onEditingChanged: { editing in + if !editing { + saveData() + } + } + ) + .frame(maxWidth: .infinity) + Text(String(danceData.rightEye.rotation)) + .frame(width: 50,alignment: .trailing) + } + HStack { + Text("weight") + .frame(width: 60,alignment: .leading) + Slider( + value: Binding( + get: { Double(danceData.rightEye.weight) }, + set: { danceData.rightEye.weight = Int($0) } + ), + in: 0...100, + onEditingChanged: { editing in + if !editing { + saveData() + } + } + ) + .frame(maxWidth: .infinity) + Text(String(danceData.rightEye.weight)) + .frame(width: 50,alignment: .trailing) + } + HStack { + Text("size") + .frame(width: 60,alignment: .leading) + Slider( + value: Binding( + get: { Double(danceData.rightEye.size) }, + set: { danceData.rightEye.size = Int($0) } + ), + in: -100...100, + onEditingChanged: { editing in + if !editing { + saveData() + } + } + ) + .frame(maxWidth: .infinity) + Text(String(danceData.rightEye.size)) + .frame(width: 50,alignment: .trailing) + } + } + .listRowBackground(Color.clear) + + Section("Mouth") { + HStack { + Text("x") + .frame(width: 60,alignment: .leading) + Slider( + value: Binding( + get: { Double(danceData.mouth.x) }, + set: { danceData.mouth.x = Int($0) } + ), + in: -100...100, + onEditingChanged: { editing in + if !editing { + saveData() + } + } + ) + .frame(maxWidth: .infinity) + Text(String(danceData.mouth.x)) + .frame(width: 50,alignment: .trailing) + } + HStack { + Text("y") + .frame(width: 60,alignment: .leading) + Slider( + value: Binding( + get: { Double(danceData.mouth.y) }, + set: { danceData.mouth.y = Int($0) } + ), + in: -100...100, + onEditingChanged: { editing in + if !editing { + saveData() + } + } + ) + .frame(maxWidth: .infinity) + Text(String(danceData.mouth.y)) + .frame(width: 50,alignment: .trailing) + } + HStack { + Text("rotation") + .frame(width: 60,alignment: .leading) + Slider( + value: Binding( + get: { Double(danceData.mouth.rotation) }, + set: { danceData.mouth.rotation = Int($0) } + ), + in: -1800...1800, + onEditingChanged: { editing in + if !editing { + saveData() + } + } + ) + .frame(maxWidth: .infinity) + Text(String(danceData.mouth.rotation)) + .frame(width: 50,alignment: .trailing) + } + HStack { + Text("weight") + .frame(width: 60,alignment: .leading) + Slider( + value: Binding( + get: { Double(danceData.mouth.weight) }, + set: { danceData.mouth.weight = Int($0) } + ), + in: 0...100, + onEditingChanged: { editing in + if !editing { + saveData() + } + } + ) + .frame(maxWidth: .infinity) + Text(String(danceData.mouth.weight)) + .frame(width: 50,alignment: .trailing) + } + } + .listRowBackground(Color.clear) + } + .listStyle(.grouped) + .scrollContentBackground(.hidden) + .background(.clear) + } else if selectedItem == .motion { + List { + Section("Yaw Servo") { + HStack { + Text("angle") + .frame(width: 60,alignment: .leading) + Slider( + value: Binding( + get: { Double(danceData.yawServo.angle) }, + set: { + danceData.yawServo.angle = Int($0) + } + ), + in: -1280...1280, + onEditingChanged: { editing in + if !editing { + saveData() + } + } + ) + .frame(maxWidth: .infinity) + Text(String(danceData.yawServo.angle)) + .frame(width: 50,alignment: .trailing) + } + HStack { + Text("speed") + .frame(width: 60,alignment: .leading) + Slider( + value: Binding( + get: { Double(danceData.yawServo.speed) }, + set: { danceData.yawServo.speed = Int($0) } + ), + in: 0...1000, + onEditingChanged: { editing in + if !editing { + saveData() + } + } + ) + .frame(maxWidth: .infinity) + Text(String(danceData.yawServo.speed)) + .frame(width: 50,alignment: .trailing) + } + } + .listRowBackground(Color.clear) + Section("Pitch Servo") { + HStack { + Text("angle") + .frame(width: 60,alignment: .leading) + Slider( + value: Binding( + get: { Double(danceData.pitchServo.angle) }, + set: { danceData.pitchServo.angle = Int($0) } + ), + in: 0...900, + onEditingChanged: { editing in + if !editing { + saveData() + } + } + ) + .frame(maxWidth: .infinity) + Text(String(danceData.pitchServo.angle)) + .frame(width: 50,alignment: .trailing) + } + HStack { + Text("speed") + .frame(width: 60,alignment: .leading) + Slider( + value: Binding( + get: { Double(danceData.pitchServo.speed) }, + set: { danceData.pitchServo.speed = Int($0) } + ), + in: 0...1000, + onEditingChanged: { editing in + if !editing { + saveData() + } + } + ) + .frame(maxWidth: .infinity) + Text(String(danceData.pitchServo.speed)) + .frame(width: 50,alignment: .trailing) + } + } + .listRowBackground(Color.clear) + } + .listStyle(.grouped) + .scrollContentBackground(.hidden) + .background(.clear) + } + Spacer() + } + .padding() + .ignoresSafeArea(edges: .bottom) + .navigationTitle(editDanceDataIndex == nil ? "Add Dance" : "Edit Dance") + .navigationBarTitleDisplayMode(.inline) + .toolbar{ + ToolbarItem(placement: .confirmationAction) { + Button { + self.onCallBack?(danceData) + isPresented = false + } label: { + Image(systemName: "checkmark") + } + } + ToolbarItem(placement: .cancellationAction) { + Button { + isPresented = false + } label: { + Image(systemName: "xmark") + } + } + } + } + } + + private func saveData() { + if !appState.deviceMac.isEmpty { + + } + } +} diff --git a/app/StackChan/View/DeviceWifiConfig.swift b/app/StackChan/View/DeviceWifiConfig.swift new file mode 100644 index 0000000..8756f8b --- /dev/null +++ b/app/StackChan/View/DeviceWifiConfig.swift @@ -0,0 +1,250 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +import SwiftUI +import NetworkExtension +import CoreLocation +import CoreBluetooth + +enum BlufDeviceConfigPageType: Hashable { + case selectDevice + case wifiConfig +} + +struct SelectBlufiDevice : View { + + @EnvironmentObject var appState: AppState + + @State var path: [BlufDeviceConfigPageType] = [] + + private func getDeviceId(blufiInfo: BlufiDeviceInfo) -> String? { + if let manufacturerData = blufiInfo.advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data { + let companyID = manufacturerData.prefix(2) + _ = UInt16(littleEndian: companyID.withUnsafeBytes { $0.load(as: UInt16.self) }) + let customData = manufacturerData.suffix(from: 2) + let address = customData.map { String(format: "%02X", $0) }.joined() + return address + } + return nil + } + + var body: some View { + NavigationStack(path: $path) { + List { + Section(header: Text("StackChan Device List").textCase(nil)) { + ForEach(appState.blufDeviceList, id: \.peripheral.identifier.uuidString) { blufiDeviceInfo in + Button { + if let mac = getDeviceId(blufiInfo: blufiDeviceInfo) { + appState.deviceMac = mac + appState.connectWebSocket() + } + print("start connect device") + BlufiUtil.shared.connect(peripheral: blufiDeviceInfo.peripheral) + } label: { + HStack { + Image("lateral_image") + .resizable() + .frame(width: 25, height: 25) + + VStack(alignment: .leading) { + Text("Name: " + (blufiDeviceInfo.peripheral.name ?? "StackChan")) + if let deviceId = getDeviceId(blufiInfo: blufiDeviceInfo) { + Text("Device ID: \(deviceId)") + .font(.caption) + .foregroundStyle(.secondary) + } + } + Spacer() + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + } + } + .navigationTitle("Select Device") + .listStyle(.insetGrouped) + .background(Color(UIColor.systemGroupedBackground)) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button { + appState.manualShutdownTime = Date() + appState.showDeviceWifiSet = false + } label: { + Label("Cancel", systemImage: "xmark") + } + } + } + .navigationDestination(for: BlufDeviceConfigPageType.self) { BlufDeviceConfigPageType in + switch BlufDeviceConfigPageType { + case .selectDevice: + SelectBlufiDevice() + case .wifiConfig: + DeviceWifiConfig() + } + } + .onAppear { + BlufiUtil.shared.characteristicCallback = { characteristic in + // Check whether the characteristic is writable + if characteristic.properties.contains(.write) || characteristic.properties.contains(.writeWithoutResponse) { + if characteristic.uuid.uuidString == "E2E5E5E3-1234-5678-1234-56789ABCDEF0" { + BlufiUtil.shared.writeWifiSetCharacteristic = characteristic + self.path.append(.wifiConfig) + } + } + } + } + } + } +} + +/// Wi-Fi configuration view +struct DeviceWifiConfig : View { + + enum Field { + case Name + case Password + } + + @State private var wifiName: String = "" + @State private var wifiPassword: String = "" + + @State private var locationManager = CLLocationManager() + @State private var locationDelegate = LocationDelegate() + + @EnvironmentObject private var appState: AppState + + @FocusState private var focusedField: Field? + + @State private var showAlert: Bool = false + @State private var alertMessage: String = "" + + @State private var title: String = "StackChan Wifi Setting" + + var body: some View { + List { + Section(header: Text("Name")) { + TextField("Please enter the name of the wifi", text:$wifiName) + .focused($focusedField, equals: .Name) + .submitLabel(.next) + .onSubmit { + focusedField = .Password + } + } + Section(header: Text("Password")) { + TextField("Please enter the password of the wifi", text:$wifiPassword) + .focused($focusedField, equals: .Password) + .submitLabel(.done) + .onSubmit { + confirmWifi() + } + } + } + .listStyle(.insetGrouped) + .background(Color(UIColor.systemGroupedBackground)) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button { + confirmWifi() + } label: { + Label("Submit", systemImage: "checkmark") + } + } + ToolbarItem(placement: .cancellationAction) { + Button { + appState.showDeviceWifiSet = false + BlufiUtil.shared.disconnectCurrentPeripheral() + } label: { + Label("Cancel", systemImage: "xmark") + } + } + } + .alert(alertMessage, isPresented: $showAlert, actions: { + Button("Confirm") { + alertMessage = "" + showAlert = false + } + }) + .navigationTitle(title) + .onAppear { + BlufiUtil.shared.wifiSetCharacteristicCall = { data in + let json = data.hexEncodedString() + + print(data) + + if let model = BlufiModel.fromJson(json), let state = model.data?.state { + if state == "wifiConnecting" { + // Configuring Wi-Fi + title = "In the configuration..." + } else if state == "wifiConnected" { + // Configuration succeeded + title = "Configuration successful" + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + appState.showDeviceWifiSet = false + } + } else if state == "wifiConnectFailed" { + // Configuration failed + title = "Configuration failed" + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + alertMessage = "Configuration failed, please re-enter wifi name and password" + showAlert = true + focusedField = .Password + } + } + } + } + locationDelegate.onAuthorized = { + getPermission() + } + locationManager.delegate = locationDelegate + getPermission() + } + .onDisappear { + locationManager.delegate = nil + } + } + + private func getWifiInfo() { + NEHotspotNetwork.fetchCurrent { network in + if let network = network { + wifiName = network.ssid + focusedField = .Password + } + } + } + + private func confirmWifi() { + if wifiName.isEmpty || wifiPassword.isEmpty { + alertMessage = "Please enter the full name and password" + showAlert = true + return + } + + let model = BlufiModel(cmd: "setWifi",data: BlufiWifi(ssid: wifiName,password: wifiPassword)) + if let json = model.toJson() { + BlufiUtil.shared.sendWifiSetData(json) + } + } + + private func getPermission() { + if #available(iOS 14.0, *) { + switch locationManager.authorizationStatus { + case .authorizedWhenInUse, .authorizedAlways: + getWifiInfo() + break + case .denied, .restricted: + break + case .notDetermined: + locationManager.requestWhenInUseAuthorization() + break + default: + break + } + } else { + locationManager.requestWhenInUseAuthorization() + } + } +} diff --git a/app/StackChan/View/JoystickView.swift b/app/StackChan/View/JoystickView.swift new file mode 100644 index 0000000..5b8cdb1 --- /dev/null +++ b/app/StackChan/View/JoystickView.swift @@ -0,0 +1,84 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +import SwiftUI + +struct JoystickView: View { + + @State private var dragOffset: CGSize = .zero + + let callback: ((_ radians: CGFloat,_ strength: CGFloat) -> Void)? + + @State private var isDragging: Bool = false + + var body: some View { + GeometryReader { proxy in + let diameter = min(proxy.size.width, proxy.size.height) + let radius = diameter / 2 + let joystickDiameter = diameter / 4 + let stickRadius = joystickDiameter / 2 + let lineWidth: CGFloat = 4 + let maxRadius = radius - stickRadius - (lineWidth / 2) + ZStack { + Circle() + .stroke(Color(UIColor.separator), lineWidth: lineWidth) + .frame(width: diameter,height: diameter) + Circle() + .fill(Color.accent) + .frame(width: joystickDiameter,height: joystickDiameter) + .glassEffectCircle() + .offset(dragOffset) + } + .contentShape(Circle()) + .highPriorityGesture( + DragGesture(minimumDistance: 0) + .onChanged { value in + withAnimation { + isDragging = true + } + let dx = value.location.x - radius + let dy = value.location.y - radius + let distance = sqrt(dx * dx + dy * dy) + if distance <= maxRadius { + dragOffset = CGSize(width: dx, height: dy) + } else { + let angle = atan2(dy, dx) + dragOffset = CGSize( + width: cos(angle) * maxRadius, + height: sin(angle) * maxRadius + ) + } + } + .onEnded { _ in + withAnimation { + isDragging = false + dragOffset = .zero + } + }, + including: .all + ) + .padding(0) + .onChange(of: dragOffset) { newValue in + guard isDragging else { return } + let dx = newValue.width + let dy = newValue.height + let distance = sqrt(dx * dx + dy * dy) + let radians = atan2(dy, dx) + // 直接使用之前定义的 maxRadius + let strength = min(distance / maxRadius, 1) + callback?(radians,strength) + } + } + } +} + +struct JoystickViewPreview : PreviewProvider { + static var previews: some View { + JoystickView { radians, strength in + print(radians) + } + } +} diff --git a/app/StackChan/View/MimicryEmotion.swift b/app/StackChan/View/MimicryEmotion.swift new file mode 100644 index 0000000..c558401 --- /dev/null +++ b/app/StackChan/View/MimicryEmotion.swift @@ -0,0 +1,633 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +import SwiftUI +import AVFoundation +import ARKit + +struct MimicryEmotion: View { + + @State var microphone: Bool = false // Whether the microphone is enabled + @State var emotions: [String] = ["Smile"] // Commonly detected emotions + + @State var expressionData: ExpressionData = ExpressionData(leftEye: ExpressionItem(), rightEye: ExpressionItem(), mouth: ExpressionItem()) + @State var headData: MotionData = MotionData(pitchServo: MotionDataItem(), yawServo: MotionDataItem()) + + @State private var lastSendTime: Date = Date(timeIntervalSince1970: 0) + + @State private var volume: CGFloat = 0 + + @State var cameraImage: Data = Data() + + // Emotion detection threshold configuration + private let emotionThresholds = EmotionThresholds() + + private let feedback = UIImpactFeedbackGenerator(style: .medium) + + @EnvironmentObject var appState: AppState + + private let tag = "MimicryEmotion" + + @Binding var deviceMac: String + + @Environment(\.dismiss) var dismiss + + @State var decorate: Int = 1 // Decoration: 0 = none, 1 = StackChan, 2 = pig nose + + @State var showPhoneScreen: Bool = false // Whether to display phone screen on StackChan + + private let stackChanTargetSize = CGSize(width: 320, height: 240) + + var body: some View { + ZStack { + // Face camera preview + VStack(spacing: 0) { + if let uiImage = UIImage(data: cameraImage) { + Image(uiImage: uiImage) + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity) + } else { + Color.black.aspectRatio(4/3, contentMode: .fit) + } + ARCameraView(expressionData: $expressionData, decorate: $decorate, captureScreen: $showPhoneScreen, onCallback: { session, anchors in + DispatchQueue.main.async { + emotionDetection(session: session, anchors: anchors) + } + }, onFrameCallback: { image in + compressMobilePhoneScreen(image: image) + }) + .frame(maxWidth: .infinity,maxHeight: .infinity) + } + .ignoresSafeArea() + + VStack { + + HStack { + Spacer() + } + + Spacer() + + HStack { + Button { + withAnimation { + if decorate == 0 { + decorate = 1 + } else if decorate == 1 { + decorate = 2 + } else if decorate == 2 { + decorate = 0 + } + } + feedback.impactOccurred() + } label: { + switch decorate { + case 0: + Image(systemName: "slash.circle") + .frame(width: 88, height: 88) + .font(.system(size: 44)) + .foregroundStyle(.white) + case 1: + Image("lateral_image") + .resizable() + .frame(width: 44, height: 44) + .padding(22) + case 2: + Text("🐽") + .frame(width: 88, height: 88) + .font(.system(size: 44)) + .foregroundStyle(.white) + default: + Text("🎲") + .frame(width: 88, height: 88) + .font(.system(size: 44)) + .foregroundStyle(.white) + } + } + .glassEffectCircle() + Spacer() + Button { + withAnimation { + microphone.toggle() + } + feedback.impactOccurred() + if microphone { + AudioAcquisitionUtil.shared.start() + } else { + AudioAcquisitionUtil.shared.stop() + } + } label: { + if microphone { + Image(systemName: "microphone") + .frame(width: 88, height: 88) + .font(.system(size: 44)) + .foregroundStyle(.white) + .symbolVariant(volume > 0.3 ? .fill : .none) + } else { + Image(systemName: "microphone.slash") + .frame(width: 88, height: 88) + .font(.system(size: 44)) + .foregroundStyle(.white) + } + } + .glassEffectCircle() + Button { + withAnimation { + showPhoneScreen.toggle() + } + feedback.impactOccurred() + if showPhoneScreen { + appState.sendWebSocketMessage(.onPhoneScreen, deviceMac.toData()) + } else { + appState.sendWebSocketMessage(.offPhoneScreen, deviceMac.toData()) + } + } label: { + if showPhoneScreen { + Image(systemName: "iphone.gen1.badge.play") + .frame(width: 88, height: 88) + .font(.system(size: 44)) + .foregroundStyle(.accent) + } else { + Image(systemName: "iphone.gen1.badge.play") + .frame(width: 88, height: 88) + .font(.system(size: 44)) + .foregroundStyle(.white) + } + } + .glassEffectCircle() + } + } + .padding() + } + .onAppear { + AudioAcquisitionUtil.shared.onAudioData = { data in + + } + AudioAcquisitionUtil.shared.onDecibel = { value in + self.volume = CGFloat(value) + } + + /// Register audio and video listener callbacks + WebSocketUtil.shared.addObserver(for: tag) { (message: URLSessionWebSocketTask.Message) in + switch message { + case .data(let data): + let result = appState.parseMessage(message: data) + if let msgType = result.0, let parsedData = result.1 { + switch msgType { + case MsgType.jpeg: + DispatchQueue.main.async { + cameraImage = parsedData + } + case MsgType.hangupCall: + // Hang up the call + + print("StackChan hung up the call") + + DispatchQueue.main.async { + dismiss() + } + default: + break + } + } + case .string(let text): + print("Received text message: \(text)") + @unknown default: + break + } + } + // Turn on device camera + + appState.sendWebSocketMessage(.onCamera,deviceMac.toData()) + } + .onDisappear { + WebSocketUtil.shared.removeObserver(for: tag) + appState.sendWebSocketMessage(.offPhoneScreen, deviceMac.toData()) + // Turn off device camera + appState.sendWebSocketMessage(.offCamera,deviceMac.toData()) + if deviceMac != appState.deviceMac { + appState.sendWebSocketMessage(.hangupCall) + } + } + .toolbar(.hidden, for: .tabBar) + .preferredColorScheme(.dark) + .navigationTitle("AVATAR") + .navigationBarTitleDisplayMode(.inline) + } + // Compress phone screen image and send to StackChan + private func compressMobilePhoneScreen(image: UIImage) { + if let jpegData = image.compress(to: stackChanTargetSize, memorySize: 0.02, cropCenter: true) { + guard let macData = deviceMac.toData() else { return } + let data = macData + jpegData + appState.sendWebSocketMessage(.jpeg, data) + } + } + + // Detect head motion data from AR session + private func detectHeadData(session: ARSession,faceAnchor: ARFaceAnchor) -> MotionData { + // Get face transform in world coordinate space + let faceTransform = faceAnchor.transform + + // Get camera transform of the current frame (phone position and orientation in world space) + guard let cameraTransform = session.currentFrame?.camera.transform else { + return MotionData(pitchServo: MotionDataItem(angle: 0, speed: 500), + yawServo: MotionDataItem(angle: 0, speed: 500)) + } + + // Relative transform = inverse camera transform × face transform + let relativeTransform = simd_mul(simd_inverse(cameraTransform), faceTransform) + let relativeMatrix = SCNMatrix4(relativeTransform) + + // Extract yaw and pitch angles from relative rotation matrix + let pitch = atan2(relativeMatrix.m31, relativeMatrix.m33) // Vertical rotation (pitch) + let yaw = asin(-relativeMatrix.m32) // Horizontal rotation (yaw) + + // Convert radians to degrees + let pitchDeg = pitch * 180.0 / .pi + let yawDeg = yaw * 180.0 / .pi + + // Map yaw angle to servo range (-1280 to 1280) + let yawServoAngle = max(-1280, min(1280, Int(-yawDeg * 20))) + + // Map pitch angle to servo range (0 to 900) + // Looking straight = 0, looking up = 900 + let pitchServoAngle = max(0, min(900, Int(-pitchDeg * 10))) + + let pitchItem = MotionDataItem(angle: pitchServoAngle, speed: 500) + let yawItem = MotionDataItem(angle: yawServoAngle, speed: 500) + + return MotionData(pitchServo: pitchItem, yawServo: yawItem) + } + + // Build ExpressionData from blendShapes + private func buildExpressionData(faceAnchor: ARFaceAnchor) -> ExpressionData { + let blendShapes = faceAnchor.blendShapes + + // Left eye blink amount mapped to 0~100 + let eyeBlinkLeft = blendShapes[.eyeBlinkLeft]?.floatValue ?? 0 + let leftEyeWeight = max(0, min(100, Int((1.0 - eyeBlinkLeft) * 100))) + + // Right eye blink amount mapped to 0~100 + let eyeBlinkRight = blendShapes[.eyeBlinkRight]?.floatValue ?? 0 + let rightEyeWeight = max(0, min(100, Int((1.0 - eyeBlinkRight) * 100))) + + // Build ExpressionItem + let leftEye = ExpressionItem( + x: max(-100, min(100, Int(faceAnchor.lookAtPoint.x * 800))), + y: max(-100, min(100, Int(-faceAnchor.lookAtPoint.y * 500))), + rotation: 0, + weight: leftEyeWeight + ) + + let rightEye = ExpressionItem( + x: max(-100, min(100, Int(faceAnchor.lookAtPoint.x * 800))), + y: max(-100, min(100, Int(-faceAnchor.lookAtPoint.y * 500))), + rotation: 0, + weight: rightEyeWeight + ) + + // Mouth + let jawOpen = blendShapes[.jawOpen]?.floatValue ?? 0 + let mouthSmileLeft = blendShapes[.mouthSmileLeft]?.floatValue ?? 0 + let mouthSmileRight = blendShapes[.mouthSmileRight]?.floatValue ?? 0 + + // Calculate X and Y offsets + let mouthX = max(-100, min(100, Int((mouthSmileRight - mouthSmileLeft) * 100))) + + // Calculate mouth open weight + let mouthWeight = max(0, min(100, Int(jawOpen * 100))) + + let mouth = ExpressionItem( + x: mouthX, + y: 0, + rotation: 0, + weight: mouthWeight + ) + + var expressionData = ExpressionData(leftEye: leftEye, + rightEye: rightEye, + mouth: mouth) + + // // Start emotion-based adjustment + if isHappy(blendShapes: blendShapes) { + // Happy + expressionData.leftEye.weight -= 35 + expressionData.leftEye.rotation = -2150 + expressionData.rightEye.weight -= 35 + expressionData.rightEye.rotation = 2150 + } + // if isShy(faceAnchor: faceAnchor, blendShapes: blendShapes) { + // // Shy + // } + // if isAmazed(blendShapes: blendShapes) { + // // Amazed + // } + if isAnger(blendShapes: blendShapes) { + // Angry + expressionData.leftEye.rotation = 450 + expressionData.rightEye.rotation = -450 + } + // if isTired(blendShapes: blendShapes) { + // // Tired + // } + return expressionData + } + + /// Main emotion detection function + private func emotionDetection(session: ARSession,anchors: [ARAnchor]) { + var detectedEmotions: [String] = [] + if let anchor = anchors.first { + guard let faceAnchor = anchor as? ARFaceAnchor else { return } + let faceData = buildExpressionData(faceAnchor: faceAnchor) + let headData = detectHeadData(session:session,faceAnchor: faceAnchor) + withAnimation { + self.expressionData = faceData + self.headData = headData + } + + /// Send data via Bluetooth + let now = Date() + if now.timeIntervalSince(lastSendTime) >= 0.5 { + self.sendExpressionData(data: faceData) + self.sendHeadData(data: headData) + lastSendTime = now + } + + + let blendShapes = faceAnchor.blendShapes + + // Detect five basic emotions + if isHappy(blendShapes: blendShapes) { + detectedEmotions.append("Happy") + } + if isShy(faceAnchor: faceAnchor, blendShapes: blendShapes) { + detectedEmotions.append("Shy") + } + if isAmazed(blendShapes: blendShapes) { + detectedEmotions.append("Amazed") + } + if isAnger(blendShapes: blendShapes) { + detectedEmotions.append("Angry") + } + if isTired(blendShapes: blendShapes) { + detectedEmotions.append("Tired") + } + + // Add gaze and head direction detection + detectedEmotions.append(getGazeDirection(faceAnchor: faceAnchor)) + detectedEmotions.append(getHeadDirection(faceAnchor: faceAnchor)) + } + withAnimation { + self.emotions = detectedEmotions + } + } + + private func sendExpressionData(data : ExpressionData) { + let jsonString = deviceMac + data.toJsonString() + let data = jsonString.toData() + appState.sendWebSocketMessage(.controlAvatar, data) + } + + private func sendHeadData(data : MotionData) { + let jsonString = deviceMac + data.toJsonString() + let data = jsonString.toData() + appState.sendWebSocketMessage(.controlMotion, data) + } + + /// Happy emotion detection + private func isHappy(blendShapes: [ARFaceAnchor.BlendShapeLocation: NSNumber]) -> Bool { + let smileLeft = blendShapes[.mouthSmileLeft]?.floatValue ?? 0 + let smileRight = blendShapes[.mouthSmileRight]?.floatValue ?? 0 + let eyeSquintLeft = blendShapes[.eyeSquintLeft]?.floatValue ?? 0 + let eyeSquintRight = blendShapes[.eyeSquintRight]?.floatValue ?? 0 + let cheekSquintLeft = blendShapes[.cheekSquintLeft]?.floatValue ?? 0 + let cheekSquintRight = blendShapes[.cheekSquintRight]?.floatValue ?? 0 + + // Calculate overall smile intensity + let smileIntensity = (smileLeft + smileRight) / 2 + let eyeSquintIntensity = (eyeSquintLeft + eyeSquintRight) / 2 + let cheekSquintIntensity = (cheekSquintLeft + cheekSquintRight) / 2 + + // Happy expression requires a clear smile with eye muscle involvement + return smileIntensity > emotionThresholds.happy.smile && + (eyeSquintIntensity > emotionThresholds.happy.eyeSquint || + cheekSquintIntensity > emotionThresholds.happy.cheekSquint) + } + + /// Shy emotion detection + private func isShy(faceAnchor: ARFaceAnchor, blendShapes: [ARFaceAnchor.BlendShapeLocation: NSNumber]) -> Bool { + // 1. Slight or clear head tilt downward + let transform = faceAnchor.transform + let rotation = SCNMatrix4(transform) + let pitch = asin(-rotation.m32) // 上下旋转 + let isHeadDown = pitch > emotionThresholds.shy.headPitch + + // 2. Mouth closed with a slight smile + let mouthClose = blendShapes[.mouthClose]?.floatValue ?? 0 + let smileLeft = blendShapes[.mouthSmileLeft]?.floatValue ?? 0 + let smileRight = blendShapes[.mouthSmileRight]?.floatValue ?? 0 + let smileIntensity = (smileLeft + smileRight) / 2 + let isMouthClosedSmile = mouthClose > emotionThresholds.shy.mouthPress && smileIntensity > emotionThresholds.shy.smile + + // 3. Eyes looking sideways or downward + let lookAt = faceAnchor.lookAtPoint + let isLookingSideways = abs(lookAt.x) > emotionThresholds.gaze.xThreshold // Looking left or right + let isLookingDown = lookAt.y < -emotionThresholds.gaze.yThreshold // Looking downward + + return isHeadDown && isMouthClosedSmile && (isLookingSideways || isLookingDown) + } + + /// Amazed emotion detection + private func isAmazed(blendShapes: [ARFaceAnchor.BlendShapeLocation: NSNumber]) -> Bool { + let jawOpen = blendShapes[.jawOpen]?.floatValue ?? 0 + let eyeWideLeft = blendShapes[.eyeWideLeft]?.floatValue ?? 0 + let eyeWideRight = blendShapes[.eyeWideRight]?.floatValue ?? 0 + let browInnerUp = blendShapes[.browInnerUp]?.floatValue ?? 0 + let mouthFunnel = blendShapes[.mouthFunnel]?.floatValue ?? 0 + + // Amazed traits: wide eyes + raised brows + (open mouth or O-shape) + let isEyesWide = (eyeWideLeft + eyeWideRight) / 2 > emotionThresholds.amazed.eyeWide + let isBrowRaised = browInnerUp > emotionThresholds.amazed.browInnerUp + let isMouthAction = jawOpen > emotionThresholds.amazed.jawOpen || + mouthFunnel > emotionThresholds.amazed.mouthFunnel + + return isEyesWide && isBrowRaised && isMouthAction + } + + /// Angry emotion detection + private func isAnger(blendShapes: [ARFaceAnchor.BlendShapeLocation: NSNumber]) -> Bool { + // Brow features + let browDownLeft = blendShapes[.browDownLeft]?.floatValue ?? 0 + let browDownRight = blendShapes[.browDownRight]?.floatValue ?? 0 + + // Eye features + let eyeSquintLeft = blendShapes[.eyeSquintLeft]?.floatValue ?? 0 + let eyeSquintRight = blendShapes[.eyeSquintRight]?.floatValue ?? 0 + + // Mouth features + let mouthFrownLeft = blendShapes[.mouthFrownLeft]?.floatValue ?? 0 + let mouthFrownRight = blendShapes[.mouthFrownRight]?.floatValue ?? 0 + let mouthPressLeft = blendShapes[.mouthPressLeft]?.floatValue ?? 0 + let mouthPressRight = blendShapes[.mouthPressRight]?.floatValue ?? 0 + + // Nose features + let noseSneerLeft = blendShapes[.noseSneerLeft]?.floatValue ?? 0 + let noseSneerRight = blendShapes[.noseSneerRight]?.floatValue ?? 0 + + // Calculate averages + let avgBrowDown = (browDownLeft + browDownRight) / 2 + let avgEyeSquint = (eyeSquintLeft + eyeSquintRight) / 2 + let avgMouthFrown = (mouthFrownLeft + mouthFrownRight) / 2 + let avgMouthPress = (mouthPressLeft + mouthPressRight) / 2 + let avgNoseSneer = (noseSneerLeft + noseSneerRight) / 2 + + // Anger scoring system + var angerScore = 0 + + if avgBrowDown > emotionThresholds.anger.browDown { angerScore += 3 } + if avgEyeSquint > emotionThresholds.anger.eyeSquint { angerScore += 2 } + if avgMouthFrown > emotionThresholds.anger.mouthFrown { angerScore += 2 } + if avgMouthPress > emotionThresholds.anger.mouthPress { angerScore += 1 } + if avgNoseSneer > emotionThresholds.anger.noseSneer { angerScore += 1 } + + // Must reach threshold and include brow-down feature + return angerScore >= emotionThresholds.anger.minScore && + avgBrowDown > emotionThresholds.anger.browDown + } + + /// Tired emotion detection + private func isTired(blendShapes: [ARFaceAnchor.BlendShapeLocation: NSNumber]) -> Bool { + let eyeBlinkLeft = blendShapes[.eyeBlinkLeft]?.floatValue ?? 0 + let eyeBlinkRight = blendShapes[.eyeBlinkRight]?.floatValue ?? 0 + let eyeSquintLeft = blendShapes[.eyeSquintLeft]?.floatValue ?? 0 + let eyeSquintRight = blendShapes[.eyeSquintRight]?.floatValue ?? 0 + + // Tired traits: eyes closed or squinting + let eyesClosed = (eyeBlinkLeft > emotionThresholds.tired.eyeClose && + eyeBlinkRight > emotionThresholds.tired.eyeClose) || + (eyeSquintLeft > emotionThresholds.tired.eyeSquint && + eyeSquintRight > emotionThresholds.tired.eyeSquint) + + return eyesClosed + } + + // MARK: - Helper Functions + + /// Get gaze direction + private func getGazeDirection(faceAnchor: ARFaceAnchor) -> String { + let lookAtPoint = faceAnchor.lookAtPoint + var direction = "" + + if lookAtPoint.x < -emotionThresholds.gaze.xThreshold { + direction += "Left" + } else if lookAtPoint.x > emotionThresholds.gaze.xThreshold { + direction += "Right" + } + + if lookAtPoint.y < -emotionThresholds.gaze.yThreshold { + direction += "Down" + } else if lookAtPoint.y > emotionThresholds.gaze.yThreshold { + direction += "Up" + } + + return direction.isEmpty ? "Looking Forward" : direction + " Look" + } + + /// Get head direction + private func getHeadDirection(faceAnchor: ARFaceAnchor) -> String { + let transform = faceAnchor.transform + let rotation = SCNMatrix4(transform) + let yaw = atan2(rotation.m31, rotation.m33) + let pitch = asin(-rotation.m32) + + var horizontal = "" + var vertical = "" + + if yaw < -emotionThresholds.head.yawThreshold { + horizontal = "Left" + } else if yaw > emotionThresholds.head.yawThreshold { + horizontal = "Right" + } + + // Correct vertical direction + if pitch < -emotionThresholds.head.pitchThreshold { + vertical = "Up" + } else if pitch > emotionThresholds.head.pitchThreshold { + vertical = "Down" + } + + if horizontal.isEmpty && vertical.isEmpty { + return "Head Facing Forward" + } else { + return "Head Facing " + vertical + horizontal + } + } +} + +// MARK: - Threshold Configuration + +private struct EmotionThresholds { + // Happy emotion thresholds + struct Happy { + let smile: Float = 0.3 + let eyeSquint: Float = 0.15 + let cheekSquint: Float = 0.1 + } + + // Shy emotion thresholds + struct Shy { + let headPitch: Float = 0.08 + let eyeSquint: Float = 0.1 + let mouthPress: Float = 0.25 + let smile: Float = 0.15 + } + + // Amazed emotion thresholds + struct Amazed { + let eyeWide: Float = 0.4 + let browInnerUp: Float = 0.3 + let jawOpen: Float = 0.4 + let mouthFunnel: Float = 0.3 + } + + // Angry emotion thresholds + struct Anger { + let browDown: Float = 0.35 + let eyeSquint: Float = 0.25 + let mouthFrown: Float = 0.2 + let mouthPress: Float = 0.2 + let noseSneer: Float = 0.15 + let minScore: Int = 5 + } + + // Tired emotion thresholds + struct Tired { + let eyeClose: Float = 0.7 + let eyeSquint: Float = 0.5 + let jawOpen: Float = 0.3 + } + + // Gaze detection thresholds + struct Gaze { + let xThreshold: Float = 0.02 + let yThreshold: Float = 0.02 + } + + // Head direction thresholds + struct Head { + let yawThreshold: Float = 0.25 + let pitchThreshold: Float = 0.25 + } + + let happy = Happy() + let shy = Shy() + let amazed = Amazed() + let anger = Anger() + let tired = Tired() + let gaze = Gaze() + let head = Head() +} diff --git a/app/StackChan/View/Moments.swift b/app/StackChan/View/Moments.swift new file mode 100644 index 0000000..4dfad70 --- /dev/null +++ b/app/StackChan/View/Moments.swift @@ -0,0 +1,509 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +import SwiftUI +import PhotosUI + +struct Moments : View { + + @State private var posts: [Post] = [] + + @State private var showAddMoment: Bool = false + + @EnvironmentObject var appState: AppState + + @State private var page = 1 + private let pageSize = 10 + @State private var isLoadingMore = false + @State private var hasMore = true + + @State private var postId: Int? = nil + @State private var editPostCommentContent: String? = nil + @State private var showAddPostComment: Bool = false + + var body: some View { + NavigationStack { + ZStack { + LinearGradient( + colors: [Color.accent.opacity(0.5), Color.pink.opacity(0.1),Color.blue.opacity(0.2)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + .ignoresSafeArea() + ScrollView { + LazyVStack(spacing:12) { + ForEach(posts, id: \.self.id) { post in + postItemView(post: post) + .onAppear { + if post.id == posts.last?.id { + loadMoreIfNeeded() + } + } + } + if isLoadingMore { + ProgressView() + .padding() + } + } + .padding(12) + } + .refreshable { + page = 1 + posts.removeAll() + getPost() + } + } + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button { + if appState.deviceMac.isEmpty { + appState.showBindingDeviceAlert = true + } else { + self.showAddMoment = true + } + } label: { + Label("Add", systemImage: "plus") + } + } + } + .navigationTitle("Moments") + .sheet(isPresented: $showAddMoment) { + AddMoment(showAddMoment:$showAddMoment) { post in + // Add a new post + addPost(post: post) + } + .interactiveDismissDisabled(true) + } + .alert("Add PostComment", isPresented: $showAddPostComment) { + TextField("Enter your comment", text: Binding( + get: { editPostCommentContent ?? "" }, + set: { editPostCommentContent = $0 } + )) + Button(role: .cancel) { + showAddPostComment = false + } label: { + Text("Cancel") + } + if #available(iOS 26.0, *) { + Button(role: .confirm) { + showAddPostComment = false + addPostComment() + } label: { + Text("Confirm") + } + } else { + Button { + showAddPostComment = false + addPostComment() + } label: { + Text("Confirm") + } + } + } message: { + Text("Please enter your comment below.") + } + .onAppear { + page = 1 + posts.removeAll() + getPost() + } + } + } + + private func postItemView(post: Post) -> some View { + return VStack(alignment: .leading,spacing: 12) { + HStack { + Image("logo_icon") + .resizable() + .frame(width: 25, height: 25) + .clipShape(Circle()) + Text(post.name ?? "StackChanUser") + .font(.system(size: 25)) + Spacer() + if post.mac == appState.deviceMac { + Button(role: .destructive) { + deletePost(post) + } label: { + Image(systemName: "trash") + } + .glassButtonStyle() + } + } + Text(post.contentText ?? "") + .font(.body) + .foregroundStyle(.primary) + if let imageUrl = post.contentImage, imageUrl != "" { + HStack { + AsyncImage(url: URL(string: imageUrl)) { phase in + switch phase { + case .empty: + ProgressView() + .frame(maxWidth: .infinity, maxHeight: 300) + case .success(let image): + image + .resizable() + .scaledToFit() + .frame(maxHeight: 300) + case .failure: + Image(systemName: "photo") + .resizable() + .scaledToFit() + .frame(maxHeight: 300) + @unknown default: + EmptyView() + } + } + Spacer() + } + } + HStack(spacing: 25) { + Text(post.createdAt ?? "") + .font(.caption) + .foregroundStyle(.secondary) + + Spacer() + + Button(action: { + // Comment + postId = post.id + editPostCommentContent = "" + showAddPostComment = true + }) { + Label("99", systemImage: "text.bubble") + } + + Button(action: { + // Like + }) { + Label("99", systemImage: "hand.thumbsup") + } + + Button(action: { + // Share + }) { + Image(systemName: "square.and.arrow.up") + } + } + .padding(12) + .font(.caption) + .foregroundStyle(.secondary) + + if let comments = post.postCommentList, !comments.isEmpty { + ForEach(comments, id: \.id) { comment in + VStack(alignment: .leading, spacing: 4) { + HStack { + Text((comment.name ?? "User") + ": ") + .font(.caption) + .fontWeight(.semibold) + .foregroundColor(.accentColor) + Text(comment.content ?? "") + .font(.caption) + Spacer() + } + } + } + } + } + .padding(12) + .frame(maxWidth: .infinity) + .glassEffectRegular(cornerRadius: 25) + } + + private func addPostComment() { + if let content = editPostCommentContent,let postId = postId, !appState.deviceMac.isEmpty { + let map: [String: Any] = [ + ValueConstant.mac : appState.deviceMac, + ValueConstant.postId : postId, + ValueConstant.content : content + ] + Networking.shared.post(pathUrl: Urls.postCommentCreate, parameters: map) { result in + switch result { + case .success(let success): + do { + let response = try Response<[String: Int]>.decode(from: success) + if response.isSuccess { + getPostComment(postId: postId) + } + } catch { + // Failed to parse data + } + case .failure(let failure): + // Request failed: + print("Request failed:", failure) + } + } + } + } + + private func getPostComment(postId: Int) { + if !appState.deviceMac.isEmpty { + let map: [String: Any] = [ + ValueConstant.postId: postId, + ValueConstant.mac: appState.deviceMac, + ValueConstant.page: 1, + ValueConstant.pageSize: 30, + ] + + Networking.shared.get(pathUrl: Urls.postCommentGet,parameters: map) { result in + switch result { + case .success(let success): + do { + let response = try Response.decode(from: success) + if response.isSuccess,let list = response.data?.list { + for index in posts.indices { + if posts[index].id == postId { + posts[index].postCommentList = list + break + } + } + } + } catch { + // Failed to parse data + } + case .failure(let failure): + // Request failed: + print("Request failed:", failure) + } + } + + } + + + } + + private func addPost(post: Post) { + let map: [String:Any] = [ + ValueConstant.mac: appState.deviceMac, + ValueConstant.content_text: post.contentText ?? "", + ValueConstant.content_image: post.contentImage ?? "", + ] + Networking.shared.post(pathUrl: Urls.postAdd, parameters: map) { result in + switch result { + case .success(let success): + do { + let response = try Response<[String:Int]>.decode(from: success) + if response.isSuccess { + // Refresh posts + page = 1 + posts.removeAll() + getPost() + } + } catch { + // Failed to parse data + } + case .failure(let failure): + // Request failed: + print("Request failed:", failure) + } + } + } + + /// Delete a post + private func deletePost(_ post: Post) { + let map: [String: Any] = [ + ValueConstant.id: post.id + ] + Networking.shared.delete(pathUrl: Urls.postDelete, parameters: map) { result in + switch result { + case .success(let success): + do { + let response = try Response.decode(from: success) + if response.isSuccess { + // Remove post locally + withAnimation { + posts.removeAll { $0.id == post.id } + } + } + } catch { + // Failed to parse data + } + case .failure(let failure): + // Delete failed: + print("Delete failed:", failure) + } + } + } + + /// Fetch post list + private func getPost() { + isLoadingMore = true + let map:[String:Any] = [ + ValueConstant.page: page, + ValueConstant.pageSize: pageSize + ] + Networking.shared.get(pathUrl: Urls.postGet,parameters: map) { result in + isLoadingMore = false + switch result { + case .success(let success): + do { + let response = try Response<[Post]>.decode(from: success) + if response.isSuccess,let list = response.data { + withAnimation { + if list.count < pageSize { + hasMore = false + } + posts.append(contentsOf: list) + } + } + } catch { + + // Failed to parse data + } + case .failure(let failure): + // Request failed: + print("Request failed:", failure) + } + } + } + + private func loadMoreIfNeeded() { + guard !isLoadingMore, hasMore else { return } + page += 1 + getPost() + } + +} + + + +struct AddMoment : View { + + @Binding var showAddMoment: Bool + + var callBack: ((Post) -> Void)? + + @State private var post: Post = Post(id: 0) + @State private var photoItem: PhotosPickerItem? + @State private var isUploading: Bool = false + + @EnvironmentObject var appState: AppState + + var body: some View { + NavigationStack { + List { + Section("Text") { + TextField("Please enter the post content", text: Binding( + get: { + post.contentText ?? "" + }, + set: { + post.contentText = $0 + } + ), axis: .vertical) + .textFieldStyle(.plain) + } + Section("image") { + PhotosPicker(selection: $photoItem, matching: .images) { + if isUploading { + ProgressView("Uploading...") + } else { + HStack { + Spacer() + if let urlString = post.contentImage, + let url = URL(string: urlString) { + AsyncImage(url: url) { phase in + switch phase { + case .empty: + ProgressView() + .frame(width: 200, height: 200) + case .success(let image): + image + .resizable() + .scaledToFit() + .frame(maxHeight: 300) + case .failure: + Image(systemName: "photo") + .frame(width: 200, height: 200) + @unknown default: + EmptyView() + } + } + } else { + Label("Select Image", systemImage: "plus.circle") + } + Spacer() + } + } + } + .onChange(of: photoItem) { _ in + updateImage() + } + } + } + .navigationTitle("Add Post") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button { + self.showAddMoment = false + } label: { + Label("Cancel", systemImage: "xmark") + } + } + ToolbarItem(placement: .confirmationAction) { + Button { + callBack?(post) + self.showAddMoment = false + } label: { + Label("Confirm", systemImage: "checkmark") + } + } + } + } + } + + /// Upload file + private func updateImage() { + guard let photoItem else { return } + isUploading = true + Task { + do { + let data = try await photoItem.loadTransferable(type: Data.self) + guard var imageData = data else { + // Failed to get image data + print("Failed to get image data") + isUploading = false + return + } + + // Compress the image to no more than 2MB + if let uiImage = UIImage(data: imageData), + let compressedData = uiImage.compress(toMemorySize: 2.0) { + imageData = compressedData + } + + let map: [String:Any] = [ + ValueConstant.file: imageData, + ValueConstant.directory: ValueConstant.moments, + ValueConstant.name: UUID().uuidString + ".jpg", + ] + Networking.shared.postFromData(pathUrl: Urls.uploadFile,parameters: map) { result in + isUploading = false + switch result { + case .success(let success): + do { + let response = try Response.decode(from: success) + if response.isSuccess, let url = response.data?.path { + let fileUrl = Urls.getFileUrl() + url + DispatchQueue.main.async { + post.contentImage = fileUrl + } + } + } catch { + // Failed to parse data + } + case .failure(let failure): + // Request failed: + print("Request failed:", failure) + } + } + } catch { + isUploading = false + // Failed to load image data: + print("Failed to load image data:", error) + } + } + } +} diff --git a/app/StackChan/View/Nearby.swift b/app/StackChan/View/Nearby.swift new file mode 100644 index 0000000..4406b3a --- /dev/null +++ b/app/StackChan/View/Nearby.swift @@ -0,0 +1,405 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +import SwiftUI +import MultipeerConnectivity +import CoreBluetooth + +struct TextMessage : Codable { + var name: String = "" + var content: String = "" +} + +struct Nearby: View { + + @State var deviceList: [DeviceInfo] = [] + + @State var proxySize : CGSize = CGSize(width: 0, height: 0) + + @EnvironmentObject var appState: AppState + + @State var deviceMac: String = "" + + @State private var showCallPopup: Bool = false + + @State private var displayMode: Int = 1 // 1 star map mode, 2 list mode + + private let tag = "Nearby" + @State private var callTitle: String = "Under request..." + + var body: some View { + NavigationStack(path: $appState.nearbyPath) { + ZStack { + DazzlingBackground(backColors: [Color.accent.opacity(0.5), Color.pink.opacity(0.1),Color.blue.opacity(0.2)],background: Color(UIColor.systemBackground)) + .ignoresSafeArea() + + if displayMode == 1 { + Canvas { context, size in + let center = CGPoint(x: size.width / 2, y: size.height / 2) + + for device in deviceList { + var path = Path() + path.move(to: center) + path.addLine(to: device.postion) + context.stroke( + path, + with: .color(.white), + lineWidth: 3 + ) + } + } + + GeometryReader { proxy in + Color.clear + .onAppear { + proxySize = proxy.size + } + .onChange(of: proxy.size) { newSize in + proxySize = newSize + } + ForEach(deviceList, id: \.device.mac) { device in + Menu { + Button { + // Hi button logic + let textMessage = TextMessage(name:"App",content: "👋") + sendMessage(device: device,msgType: .textMessage,data: textMessage.toJsonString()) + launchAnimation(device: device, text: "👋") + } label: { + Label("👋", systemImage: "hand.wave") + } + Button { + // Heart button logic + let textMessage = TextMessage(name:"App",content: "❤️") + sendMessage(device: device,msgType: .textMessage,data: textMessage.toJsonString()) + launchAnimation(device: device, text:"❤️") + } label: { + Label("❤️", systemImage: "heart.fill") + } + Button { + // Video call button logic + sendMessage(device: device,msgType: .requestCall, data: "") + // Show request popup animation + + } label: { + Label("Video Call", systemImage: "video.fill") + } + } label: { + let name = (device.device.name?.isEmpty == false) ? device.device.name! : "StackChan" + AvatarView(name: name) + .frame(width: 100) + } + .position(x: device.postion.x, y: device.postion.y) + } + } + RippleDiffusion { + AvatarView(name: appState.deviceInfo.name ?? "Me") + } + ForEach(flyingTexts) { flying in + Text(flying.text) + .font(.largeTitle) + .position(x: flying.start.x + (flying.end.x - flying.start.x) * flying.progress, + y: flying.start.y + (flying.end.y - flying.start.y) * flying.progress + ) + .opacity(1 - flying.progress) + } + } else { + ScrollView { + LazyVStack(spacing: 12) { + ForEach(deviceList, id: \.device.mac) { device in + Menu { + Button { + // Hi button logic + let textMessage = TextMessage(name:"App",content: "👋") + sendMessage(device: device,msgType: .textMessage,data: textMessage.toJsonString()) + launchAnimation(device: device, text: "👋") + } label: { + Label("👋", systemImage: "hand.wave") + } + Button { + // Heart button logic + let textMessage = TextMessage(name:"App",content: "❤️") + sendMessage(device: device,msgType: .textMessage,data: textMessage.toJsonString()) + launchAnimation(device: device, text:"❤️") + } label: { + Label("❤️", systemImage: "heart.fill") + } + Button { + // Video call button logic + sendMessage(device: device,msgType: .requestCall, data: "") + // Show request popup animation + + } label: { + Label("Video Call", systemImage: "video.fill") + } + } label: { + Text(device.device.name ?? "Unknown") + .frame(maxWidth: .infinity) + .padding(12) + .glassEffectRegular(cornerRadius: 25) + } + } + } + } + .padding(12) + } + } + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button { + if displayMode == 1 { + displayMode = 2 + } else { + displayMode = 1 + } + } label: { + Label { + Text("Display Mode") + } icon: { + Image(systemName: displayMode == 1 ? "circle.hexagonpath" : "list.bullet") + } + } + } + } + .navigationTitle("Nearby") + .navigationDestination(for: PageType.self) { PageType in + switch PageType { + case .cameraPage: + CameraPage() + case .minicryEmotion: + MimicryEmotion(deviceMac: $deviceMac) + case .dance: + Dance() + } + } + .sheet(isPresented: $showCallPopup) { + VStack(alignment:.center) { + Spacer() + Text(callTitle).font(.largeTitle) + Spacer() + HStack(alignment:.center) { + Spacer() + AvatarView(name: "Caller") + Spacer() + ProgressView() + .scaleEffect(1.5) + Spacer() + AvatarView(name: "Receiver") + Spacer() + } + Spacer() + Button { + showCallPopup = false + appState.sendWebSocketMessage(.hangupCall) + } label: { + VStack { + Color.clear.frame( + height: 15 + ) + Image(systemName: "phone.down.fill") + .font(.system(size: 50)) + .frame(width: 100, height: 100) + .background(.red) + .clipShape(Circle()) + .foregroundColor(.white) + .shadow(color: Color.gray, radius: 10, x: 0, y: 0) + Text("Hang up") + .frame(height: 15) + .foregroundColor(Color(UIColor.label)) + } + .frame(width: 100) + } + Spacer() + } + .presentationDetents([.medium]) + .presentationBackgroundClear() + .interactiveDismissDisabled(true) + } + .onAppear { + // bleInit() + getDeviceList() + WebSocketUtil.shared.addObserver(for: tag) { message in + switch message { + case .data(let data): + let result = appState.parseMessage(message: data) + if let msgType = result.0, let _ = result.1 { + switch msgType { + case MsgType.agreeCall: + // Agree to call + showCallPopup = false + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + appState.nearbyPath.append(.minicryEmotion) + } + case MsgType.refuseCall: + // Refuse call + callTitle = "The other party has refused." + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + showCallPopup = false + } + case MsgType.hangupCall: + showCallPopup = false + // Hang up call + default: + break + } + } + case .string(let text): + print("收到普通消息: \(text)") + @unknown default: + break + } + } + appState.getDeviceInfo() + } + .onDisappear { + WebSocketUtil.shared.removeObserver(for: tag) + } + } + } + + private func getDeviceList() { + let map = [ + ValueConstant.mac: appState.deviceMac + ] + Networking.shared.get(pathUrl: Urls.deviceRandomList,parameters: map) { result in + switch result { + case .success(let success): + do { + let response = try Response<[Device]>.decode(from: success) + if response.isSuccess, let list = response.data { + deviceList.removeAll() + for i in list { + let existingPositions = self.deviceList.map { $0.postion } + let newPosition = self.generateRandomPosition(existingPositions: existingPositions) + let newDevice = DeviceInfo(device: i, postion: newPosition) + withAnimation { + self.deviceList.append(newDevice) + } + } + } + } catch { + print("Data parsing failed") + } + case .failure(let failure): + print("Request failed:", failure) + } + } + } + + @State private var flyingTexts: [FlyingText] = [] + + private func launchAnimation(device: DeviceInfo, text: String) { + let mineCenter = CGPoint(x: proxySize.width/2, y: proxySize.height/2) + let targetCenter = device.postion + let id = UUID() + let flyingText = FlyingText(id: id, text: text, start: mineCenter, end: targetCenter, progress: 0) + flyingTexts.append(flyingText) + + withAnimation(.linear(duration: 1.0)) { + if let index = flyingTexts.firstIndex(where: { $0.id == id }) { + flyingTexts[index].progress = 1 + } + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0,) { + flyingTexts.removeAll{ $0.id == id } + } + } + + private func sendMessage(device: DeviceInfo,msgType: MsgType, data: String) { + if msgType == .requestCall { + deviceMac = device.device.mac + showCallPopup = true + callTitle = "Under request..." + // Automatically hang up after 20 seconds + DispatchQueue.main.asyncAfter(deadline: .now() + 20.0) { + if showCallPopup { + callTitle = "No one answered." + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0,) { + showCallPopup = false + appState.sendWebSocketMessage(.hangupCall) + } + } + } + } + let dataString = device.device.mac + data + appState.sendWebSocketMessage(msgType,dataString.toData()) + } + + struct FlyingText: Identifiable { + let id: UUID + let text: String + let start: CGPoint + let end: CGPoint + var progress: CGFloat + } + + private func bleInit() { + } + + // Randomly generate a position, avoiding existing devices and the center area + private func generateRandomPosition(existingPositions: [CGPoint]) -> CGPoint { + let center = CGPoint(x: proxySize.width / 2, y: proxySize.height / 2) + let selfSafeRadius: CGFloat = 100 // Avoid own avatar + let otherSafeRadius: CGFloat = 100 // Avoid other devices + let maxAttempts = 200 // Increased number of attempts + + for _ in 0.. otherSafeRadius }) { + return candidate + } + } + // If all attempts fail, randomly offset from the center to avoid overlap + var offsetX = CGFloat.random(in: selfSafeRadius...(selfSafeRadius + 50)) + var offsetY = CGFloat.random(in: selfSafeRadius...(selfSafeRadius + 50)) + // Randomly determine direction + offsetX *= Bool.random() ? 1 : -1 + offsetY *= Bool.random() ? 1 : -1 + return CGPoint(x: center.x + offsetX, y: center.y + offsetY) + } +} + +struct DeviceInfo { + let device: Device + let postion: CGPoint +} + +struct AvatarView : View { + + let name: String + + var body: some View { + VStack { + Color.clear.frame( + height: 15 + ) + Image("logo_icon") + .resizable() + .scaledToFit() + .frame(width: 100, height: 100) + .clipShape(Circle()) + .shadow(color: Color.gray, radius: 10, x: 0, y: 0) + Text(name) + .frame(height: 15) + .foregroundColor(Color(UIColor.label)) + } + } +} + + +struct NearbyPreview : PreviewProvider { + static var previews: some View { + Nearby() + } +} diff --git a/app/StackChan/View/ScanView.swift b/app/StackChan/View/ScanView.swift new file mode 100644 index 0000000..a7ec508 --- /dev/null +++ b/app/StackChan/View/ScanView.swift @@ -0,0 +1,213 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +import SwiftUI +import AVFoundation + +struct ScanView : UIViewControllerRepresentable { + func updateUIViewController(_ uiViewController: ScannerViewController, context: Context) {} + + typealias UIViewControllerType = ScannerViewController + + typealias ScanCompletion = (Result) -> Void + + var completion: ScanCompletion + + func makeUIViewController(context: Context) -> ScannerViewController { + let vc = ScannerViewController() + vc.completion = completion + return vc + } +} + +class ScannerViewController: UIViewController, AVCaptureMetadataOutputObjectsDelegate { + var completion: ScanView.ScanCompletion? + var captureSession: AVCaptureSession! + var previewLayer: AVCaptureVideoPreviewLayer! + + // Flag to control whether callbacks are allowed + private var isProcessing = false + + @objc private func toggleFlashlight() { + guard let device = AVCaptureDevice.default(for: .video), + device.hasTorch else { return } + do { + try device.lockForConfiguration() + if device.torchMode == .on { + device.torchMode = .off + } else { + try device.setTorchModeOn(level: 1.0) + } + } catch { + print("Failed to toggle flashlight: \(error)") + } + } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = UIColor.black + + // Initialize guide view + let guideImageView = UIImageView(image: UIImage(systemName: "viewfinder")) + guideImageView.tintColor = .white + guideImageView.contentMode = .scaleAspectFit + guideImageView.tag = 998 + + // Add breathing animation + let pulse = CABasicAnimation(keyPath: "transform.scale") + pulse.fromValue = 0.9 + pulse.toValue = 1.1 + pulse.duration = 0.7 + pulse.autoreverses = true + pulse.repeatCount = .infinity + guideImageView.layer.add(pulse, forKey: "breathingAnimation") + + view.addSubview(guideImageView) + + // Initialize flashlight button + let flashlightButton = UIButton(type: .system) + flashlightButton.setImage(UIImage(systemName: "flashlight.off.fill"), for: .normal) + flashlightButton.tintColor = .white + flashlightButton.addTarget(self, action: #selector(toggleFlashlight), for: .touchUpInside) + flashlightButton.tag = 999 + view.addSubview(flashlightButton) + + switch AVCaptureDevice.authorizationStatus(for: .video) { + case .authorized: + setupSession() + case .notDetermined: + AVCaptureDevice.requestAccess(for: .video) { granted in + DispatchQueue.main.async { + if granted { + self.setupSession() + } else { + self.completion?(.failure(NSError(domain: "Camera access not authorized", code: 0))) + } + } + } + default: + completion?(.failure(NSError(domain: "Camera access not authorized", code: 0))) + } + } + + private func setupSession() { + captureSession = AVCaptureSession() + captureSession.sessionPreset = .photo + guard let videoCaptureDevice = AVCaptureDevice.default(for: .video), + let videoInput = try? AVCaptureDeviceInput(device: videoCaptureDevice), + captureSession.canAddInput(videoInput) + else { + completion?(.failure(NSError(domain: "Failed to initialize camera", code: 0))) + return + } + captureSession.addInput(videoInput) + + let metadataOutput = AVCaptureMetadataOutput() + guard captureSession.canAddOutput(metadataOutput) else { + completion?(.failure(NSError(domain: "Unable to add capture output", code: 0))) + return + } + captureSession.addOutput(metadataOutput) + metadataOutput.setMetadataObjectsDelegate(self, queue: DispatchQueue.main) + metadataOutput.metadataObjectTypes = [.qr, .ean13, .code128] + + previewLayer = AVCaptureVideoPreviewLayer(session: captureSession) + previewLayer.videoGravity = .resizeAspectFill + view.layer.insertSublayer(previewLayer, at: 0) + + DispatchQueue.global(qos: .userInitiated).async { + self.captureSession.startRunning() + } + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + previewLayer?.frame = view.bounds + + if let connection = previewLayer?.connection, connection.isVideoOrientationSupported { + let deviceOrientation = UIDevice.current.orientation + + switch deviceOrientation { + case .portrait: + connection.videoOrientation = .portrait + case .portraitUpsideDown: + connection.videoOrientation = .portraitUpsideDown + case .landscapeLeft: + connection.videoOrientation = .landscapeRight // Note: device left equals camera right + case .landscapeRight: + connection.videoOrientation = .landscapeLeft // Note: device right equals camera left + default: + connection.videoOrientation = .portrait + } + } + + let guideImageViewSize: CGFloat = min(view.bounds.width, view.bounds.height) / 2 + let buttonSize: CGFloat = 44 + + if let guideImageView = view.viewWithTag(998) as? UIImageView { + guideImageView.frame = CGRect( + x: (view.bounds.width - guideImageViewSize) / 2, + y: (view.bounds.height - guideImageViewSize) / 2, + width: guideImageViewSize, + height: guideImageViewSize + ) + } + + if let flashlightButton = view.viewWithTag(999) as? UIButton { + var targetX = CGFloat(0) + var targetY = CGFloat(0) + + if view.bounds.width > view.bounds.height { + // Landscape + let guideRightX = (view.bounds.width + guideImageViewSize) / 2 + let rightEdgeX = view.bounds.width - buttonSize + targetX = (guideRightX + rightEdgeX) / 2 + targetY = (view.bounds.height / 2) - (buttonSize / 2) + } else { + // Portrait + let guideBottomY = (view.bounds.height + guideImageViewSize) / 2 + let bottomEdgeY = view.bounds.height - buttonSize + targetX = (view.bounds.width - buttonSize) / 2 + targetY = (guideBottomY + bottomEdgeY) / 2 + } + flashlightButton.frame = CGRect( + x: targetX, + y: targetY, + width: buttonSize, + height: buttonSize + ) + } + } + + func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) { + guard !isProcessing else { return } + isProcessing = true // Mark as processing to avoid duplicate triggers + + if let metadataObject = metadataObjects.first as? AVMetadataMachineReadableCodeObject, + let code = metadataObject.stringValue { + AudioServicesPlaySystemSound(SystemSoundID(1057)) + completion?(.success(code)) + // Do not stop captureSession immediately + // Call stopScanning() after external processing is finished + } else { + completion?(.failure(NSError(domain: "No QR code detected", code: 0))) + isProcessing = false // Allow next scan + } + } + + // Provide a method for external callers to stop scanning + func stopScanning() { + if captureSession.isRunning { + captureSession.stopRunning() + } + isProcessing = false + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + stopScanning() + } +} diff --git a/app/StackChan/View/Settings.swift b/app/StackChan/View/Settings.swift new file mode 100644 index 0000000..610753b --- /dev/null +++ b/app/StackChan/View/Settings.swift @@ -0,0 +1,119 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +import SwiftUI + +struct Settings : View { + + @EnvironmentObject var appState: AppState + + @State var showCjamgeNameAlert: Bool = false + @State var newName: String = "" + + @State var deviceInfo: Device = Device() + + var body: some View { + NavigationStack(path: $appState.settingsPath) { + List { + Section("conventional") { + Button { + if appState.deviceMac.isEmpty { + appState.showBindingDeviceAlert = true + } else { + appState.showCjamgeNameAlert = true + } + } label: { + HStack { + Image(systemName: "person") + .foregroundStyle(.white) + .frame(width: 28, height: 28) + .background(Color.blue) + .cornerRadius(8) + Text("Change Name") + Spacer() + Text(appState.deviceInfo.name ?? "") + .foregroundStyle(.secondary) + Image(systemName: "chevron.right") + .foregroundStyle(.secondary) + } + } + .foregroundStyle(.primary) + Button { + } label: { + HStack { + Image(systemName: "arrow.down.circle") + .foregroundStyle(.white) + .frame(width: 28, height: 28) + .background(Color.green) + .cornerRadius(8) + Text("Online upgrade") + Spacer() + Image(systemName: "chevron.right") + .foregroundStyle(.secondary) + } + } + .foregroundStyle(.primary) + } + + Section("system") { + Button { + } label: { + HStack { + Image(systemName: "exclamationmark.arrow.triangle.2.circlepath") + .foregroundStyle(.white) + .frame(width: 28, height: 28) + .background(Color.red) + .cornerRadius(8) + Text("Factory data reset") + Spacer() + Image(systemName: "chevron.right") + .foregroundStyle(.secondary) + } + }.foregroundStyle(.primary) + Button { + appState.forcedDisplayBindingDevice = false + appState.showBindingDevice = true + } label: { + HStack { + Image(systemName: "shuffle") + .foregroundStyle(.white) + .frame(width: 28, height: 28) + .background(Color.blue) + .cornerRadius(8) + Text("Bind StachChan") + Spacer() + Image(systemName: "chevron.right") + .foregroundStyle(.secondary) + } + } + .foregroundStyle(.primary) + } + } + .listStyle(.insetGrouped) + .navigationTitle("Settings") + .navigationDestination(for: PageType.self) { PageType in + switch PageType { + case .cameraPage: + CameraPage() + case .minicryEmotion: + MimicryEmotion(deviceMac: $appState.deviceMac) + case .dance: + Dance() + } + } + .onAppear { + appState.getDeviceInfo() + } + } + } +} + + +struct SettingPreview : PreviewProvider { + static var previews: some View { + Settings() + } +} diff --git a/app/StackChan/View/StackChan.swift b/app/StackChan/View/StackChan.swift new file mode 100644 index 0000000..b3ef857 --- /dev/null +++ b/app/StackChan/View/StackChan.swift @@ -0,0 +1,368 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +import SwiftUI + +struct StackChan : View { + + let gridHeight: CGFloat = 100 + + @EnvironmentObject var appState: AppState + + private let imageSize: CGFloat = 200 + + @State private var showAvatarMotionControl: Bool = false + + @State private var deviceMac: String = "" + + func getDeviceStatus() -> String { + if appState.deviceMac == "" { + return "Unbound device" + } else { + if appState.deviceIsOnline { + return "Device Online" + } else { + return "Device Offline" + } + } + } + + var body: some View { + let radius = UIScreen.main.bounds.minDimension / 12 + NavigationStack(path: $appState.stackChanPath) { + ZStack { + LinearGradient( + colors: [ + Color.accent.opacity(0.5), + Color.pink.opacity(0.1), + Color.blue.opacity(0.2) + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + .ignoresSafeArea() + ScrollView { + VStack(alignment: .trailing,spacing: 20) { + StackChanRotaryRobot() + .frame(width: imageSize,height: imageSize) + Text(getDeviceStatus()) + Button { + if appState.deviceMac.isEmpty { + appState.showBindingDeviceAlert = true + } else { + appState.stackChanPath.append(.minicryEmotion) + } + } label: { + HStack { + Image(systemName: "face.smiling") + .font(.system(size: 44)) + Spacer() + Text("AVATAR") + .font(.largeTitle) + } + .padding(.horizontal,20) + .foregroundColor(Color(UIColor.systemBackground)) + .padding() + .frame(maxWidth: .infinity) + .frame(height: gridHeight) + .background(.yellow) + .clipShape(RoundedRectangle(cornerRadius: radius)) + .glassEffectRegular(cornerRadius: radius) + } + + Button { + if appState.deviceMac.isEmpty { + appState.showBindingDeviceAlert = true + } else { + appState.stackChanPath.append(.cameraPage) + } + } label: { + HStack { + Image(systemName: "video") + .font(.system(size: 44)) + Spacer() + Text("SENTINEL") + .font(.largeTitle) + } + .padding(.horizontal,20) + .foregroundColor(Color(UIColor.systemBackground)) + .padding() + .frame(maxWidth: .infinity) + .frame(height: gridHeight) + .background(Color(UIColor.label).opacity(0.8)) + .clipShape(RoundedRectangle(cornerRadius: radius)) + .glassEffectRegular(cornerRadius: radius) + } + + Button { + if appState.deviceMac.isEmpty { + appState.showBindingDeviceAlert = true + } else { + showAvatarMotionControl = true + } + } label: { + HStack { + Image( + systemName: "arrow.up.and.down.and.arrow.left.and.right" + ) + .font(.system(size: 44)) + Spacer() + Text("MOTION") + .font(.largeTitle) + } + .padding(.horizontal,20) + .foregroundColor(Color(UIColor.label)) + .padding() + .frame(maxWidth: .infinity) + .frame(height: gridHeight) + .background(.gray.opacity(0.5)) + .clipShape(RoundedRectangle(cornerRadius: radius)) + .glassEffectRegular(cornerRadius: radius) + } + + Button { + if appState.deviceMac.isEmpty { + appState.showBindingDeviceAlert = true + } else { + appState.stackChanPath.append(.dance) + } + } label: { + HStack { + Image(systemName: "figure.dance") + .font(.system(size: 44)) + Spacer() + Text("DANCE") + .font(.largeTitle) + } + .padding(.horizontal,20) + .foregroundColor(Color(UIColor.systemBackground)) + .padding() + .frame(maxWidth: .infinity) + .frame(height: gridHeight) + .background(.orange) + .clipShape(RoundedRectangle(cornerRadius: radius)) + .glassEffectRegular(cornerRadius: radius) + } + } + .padding(20) + } + } + .sheet(isPresented: $showAvatarMotionControl) { + AvatarMotionControl() + .presentationDetents([.medium,.large]) + .presentationBackgroundClear() + .presentationDragIndicator(.visible) + } + .sheet(isPresented: $appState.showSwitchFace) { + SelectBlufiDevice() + .presentationDetents([.medium]) + .presentationBackgroundClear() + } + .navigationTitle("StackChan") + .navigationDestination(for: PageType.self) { PageType in + switch PageType { + case .cameraPage: + CameraPage() + case .minicryEmotion: + MimicryEmotion(deviceMac: $appState.deviceMac) + case .dance: + Dance() + } + } + } + } +} + + + +struct SwitchFacePreview : PreviewProvider { + static var previews: some View { + SwitchFace() + } +} + + + +struct SwitchFace : View { + + private let faceList: [ExpressionData] = [ + ExpressionData( + leftEye: ExpressionItem( + x: 0, + y: -50, + rotation: 1400, + weight: 60, + size: 0 + ), + rightEye: ExpressionItem( + x: 0, + y: -50, + rotation: -1400, + weight: 60, + size: 0 + ), + mouth: ExpressionItem(x: 0, y: 0, rotation: 0, weight: 50, size: 0) + ), + ExpressionData( + leftEye: ExpressionItem( + x: 0, + y: 0, + rotation: 0, + weight: 100, + size: 0 + ), + rightEye: ExpressionItem( + x: 0, + y: 0, + rotation: 0, + weight: 100, + size: 0 + ), + mouth: ExpressionItem(x: 0, y: 0, rotation: 0, weight: 0, size: 0) + ), + ExpressionData( + leftEye: ExpressionItem( + x: 0, + y: 0, + rotation: 250, + weight: 50, + size: 0 + ), + rightEye: ExpressionItem( + x: 0, + y: 0, + rotation: -250, + weight: 50, + size: 0 + ), + mouth: ExpressionItem(x: 0, y: 0, rotation: 0, weight: 0, size: 0) + ), + ExpressionData( + leftEye: ExpressionItem( + x: 0, + y: 0, + rotation: 0, + weight: 15, + size: 0 + ), + rightEye: ExpressionItem( + x: 0, + y: 0, + rotation: 0, + weight: 15, + size: 0 + ), + mouth: ExpressionItem(x: 0, y: 0, rotation: 0, weight: 0, size: 0) + ), + ExpressionData( + leftEye: ExpressionItem( + x: 0, + y: 0, + rotation: 0, + weight: 100, + size: 50 + ), + rightEye: ExpressionItem( + x: 5, + y: 0, + rotation: 0, + weight: 100, + size: -50 + ), + mouth: ExpressionItem(x: 0, y: 0, rotation: 0, weight: 0, size: 0) + ), + ExpressionData( + leftEye: ExpressionItem( + x: 0, + y: -50, + rotation: 0, + weight: 100, + size: 50 + ), + rightEye: ExpressionItem( + x: 0, + y: -50, + rotation: 0, + weight: 100, + size: 50 + ), + mouth: ExpressionItem(x: 0, y: 0, rotation: 0, weight: 100, size: 0) + ) + ] + + @State var selectedIndex: Int = 0 + + private let columns = Array(repeating: GridItem(.flexible(),spacing: 20), count: 2) + + @EnvironmentObject var appState: AppState + + var body: some View { + ScrollView { + LazyVGrid(columns: columns, spacing: 20) { + ForEach(0.. UIGraphicsImageRenderer { + let format = UIGraphicsImageRendererFormat.default() + format.scale = UIScreen.main.scale + format.opaque = false + return UIGraphicsImageRenderer( + size: expressionLayer.bounds.size, + format: format + ) + } +} + +struct StackChanSwitchFacePreview : PreviewProvider { + static var previews: some View { + SwitchFace() + } +} + + diff --git a/app/StackChan/View/StackChanModelView.swift b/app/StackChan/View/StackChanModelView.swift new file mode 100644 index 0000000..f724053 --- /dev/null +++ b/app/StackChan/View/StackChanModelView.swift @@ -0,0 +1,165 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +import SwiftUI +import ARKit + +struct StackChanModelView: View { + + @Binding var expressionData: ExpressionData + @Binding var headData: MotionData + + var body: some View { + Canvas { context, size in + let eyeSize = size.width / 10 + + func drawEye(_ item: ExpressionItem, at point: CGPoint) { + let visibleHeight = eyeSize * (CGFloat(item.weight) / 100) + + let eyeX = point.x + CGFloat(item.x / 10) + let eyeY = point.y + CGFloat(item.y / 10) + + let eyeRect = CGRect(x: eyeX, y: eyeY, width: eyeSize, height: eyeSize) + var eyePath = Path() + eyePath.addEllipse(in: eyeRect) + + let rotationDegrees = Double(item.rotation) / 10.0 + let rotationAngle = Angle(degrees: rotationDegrees) + + let maskRect = CGRect( + x: eyeX, + y: eyeY + eyeSize - visibleHeight, + width: eyeSize, + height: visibleHeight + ) + + context.drawLayer { context in + let center = CGPoint(x: eyeRect.midX, y: eyeRect.midY) + context.translateBy(x: center.x, y: center.y) + context.rotate(by: rotationAngle) + context.translateBy(x: -center.x, y: -center.y) + + context.clip(to: Path(maskRect)) + context.fill( + Path(ellipseIn: eyeRect), + with: .color(.white) + ) + } + } + + + let eyeY = (size.height * 0.35) - (eyeSize / 2) + let leftEyePoint = CGPoint(x: (size.width / 3) - (eyeSize / 2) ,y: eyeY) + let rightEyePoint = CGPoint(x: (size.width / 3 * 2) - (eyeSize / 2) ,y: eyeY) + + drawEye(expressionData.leftEye, at: leftEyePoint) + drawEye(expressionData.rightEye, at: rightEyePoint) + + + context.drawLayer { context in + + let width = size.width * 0.3 - CGFloat(expressionData.mouth.weight / 10) + let height = 3 + CGFloat(expressionData.mouth.weight) * 0.2 + let x = ((size.width - width) / 2) + CGFloat(expressionData.mouth.x / 10) + let y = (size.height * 0.65) + CGFloat(expressionData.mouth.y / 10) + + let rotationDegrees = Double(expressionData.mouth.rotation) / 10.0 + let rotationAngle = Angle(degrees: rotationDegrees) + + let mouthRect = CGRect(x: x, y: y, width: width, height: height) + let mouthPath = Path(roundedRect: mouthRect, cornerRadius: height / 2) + + let center = CGPoint(x: mouthRect.midX, y: mouthRect.midY) + context.translateBy(x: center.x, y: center.y) + context.rotate(by: rotationAngle) + context.translateBy(x: -center.x, y: -center.y) + + context.fill(mouthPath, with: .color(.white)) + } + } + } +} + + + + + + + + +struct SceneKitView: UIViewRepresentable { + @Binding var expressionData: ExpressionData + + private let planeNodeName = "expressionPlane" + + @State var expressionLayer = ExpressionLayer(data: ExpressionData(leftEye: ExpressionItem(), rightEye: ExpressionItem(), mouth: ExpressionItem())) + + func makeUIView(context: Context) -> SCNView { + let scnView = SCNView() + let scene = SCNScene() + scnView.scene = scene + scnView.allowsCameraControl = true + scnView.autoenablesDefaultLighting = true + scnView.backgroundColor = .clear + + expressionLayer.data = expressionData + expressionLayer.frame = CGRect(origin: .zero, size: CGSize(width: 250, height: 200)) + expressionLayer.setNeedsDisplay() + + DispatchQueue.main.async { + let plane = SCNPlane(width: 0.08, height: 0.06) + let material = SCNMaterial() + material.diffuse.contents = expressionLayer + material.isDoubleSided = true + plane.materials = [material] + + let planeNode = SCNNode(geometry: plane) + planeNode.name = planeNodeName + + var position = SCNVector3() + position.x += 0.02 + position.y += 0.015 + position.z += 0.01 + + planeNode.position = position + + scene.rootNode.addChildNode(planeNode) + + if let position = scnView.scene?.rootNode.position { + scnView.scene?.rootNode.position.z = position.z - 0.03 + } + } + + return scnView + } + + func updateUIView(_ uiView: SCNView, context: Context) { + guard let scene = uiView.scene, + let planeNode = scene.rootNode.childNode(withName: planeNodeName, recursively: true), + let plane = planeNode.geometry as? SCNPlane, + let material = plane.materials.first else { + return + } + expressionLayer.data = expressionData + expressionLayer.setNeedsDisplay() + material.diffuse.contents = expressionLayer + } + + +} + + +struct SceneKitViewPreview : PreviewProvider { + + static var previews: some View { + SceneKitView( + expressionData: .constant( + ExpressionData(leftEye: ExpressionItem(), rightEye: ExpressionItem(), mouth: ExpressionItem()) + ) + ) + .frame(maxWidth: 400,maxHeight: 400) + } +} diff --git a/app/StackChan/View/StackChanRobot.swift b/app/StackChan/View/StackChanRobot.swift new file mode 100644 index 0000000..ef93d6c --- /dev/null +++ b/app/StackChan/View/StackChanRobot.swift @@ -0,0 +1,360 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +import SwiftUI +import SceneKit +import Combine + +struct StackChanRobot : UIViewRepresentable { + + var data: DanceData + + var allowsCameraControl: Bool = false + + @State private var expressionLayer = ExpressionLayer(data: ExpressionData(leftEye: ExpressionItem(weight: 100), rightEye: ExpressionItem(weight: 100), mouth: ExpressionItem())) + + private let planeNodeName = "expressionPlane" + + private let rotateKey = "autoRotate" + + func makeUIView(context: Context) -> SCNView { + let sceneView = SCNView() + + if let scene = SCNScene(named: "stackChanModel.scn") { + scene.rootNode.eulerAngles = SCNVector3Zero + scene.rootNode.eulerAngles.x = -Float.pi / 2 + scene.rootNode.position.y = scene.rootNode.position.y + 25 + scene.rootNode.position.z = scene.rootNode.position.z - 35 + + let plane = SCNPlane(width: 42, height: 32) + let magnification: CGFloat = 5 + let size = CGSize(width: magnification * plane.width, height: magnification * plane.height) + expressionLayer.frame = CGRect(origin: .zero, size: size) + expressionLayer.setNeedsDisplay() + + let material = SCNMaterial() + plane.materials = [material] + let planeNode = SCNNode(geometry: plane) + planeNode.name = planeNodeName + planeNode.position = SCNVector3(0, -16, 0) + planeNode.eulerAngles = SCNVector3(Float.pi / 2, 0, 0) + scene.rootNode.addChildNode(planeNode) + + sceneView.scene = scene + } else { + print("Model not found") + } + + sceneView.autoenablesDefaultLighting = true + sceneView.allowsCameraControl = allowsCameraControl + sceneView.backgroundColor = UIColor.clear + setData(sceneView) + return sceneView + } + + func updateUIView(_ uiView: SCNView, context: Context) { + setData(uiView) + } + + /// Refresh model position and expression + private func setData(_ uiView: SCNView) { + if let stackNode = uiView.scene?.rootNode { + /// Set pitch angle (0–900) + let clampedPitch = max(0, min(900, data.pitchServo.angle)) + let pitchRatio = Float(clampedPitch) / 900.0 + let pitchAngle = -Float.pi / 2 * (1 + pitchRatio) + stackNode.eulerAngles.x = pitchAngle + + // Cancel previous auto-rotation + stackNode.removeAction(forKey: rotateKey) + + if data.yawServo.rotate == 0 { + /// Set yaw angle (-128 to 128, left to right) + let clampedYaw = max(-1280, min(1280, data.yawServo.angle)) // Clamp to -128~128 + let yawAngle = Float(clampedYaw) * Float.pi / 1800 // Convert to radians + stackNode.eulerAngles.y = yawAngle + } else { + let rotateSpeed = max(-1000, min(1000, data.yawServo.rotate)) + let radiansPerSecond = Float(rotateSpeed) / 1000.0 * Float.pi * 2 + // Rotate continuously using angular velocity (not a fixed-loop animation) + let rotateAction = SCNAction.customAction(duration: .infinity) { node, _ in + let deltaTime: Float = 1.0 / 60.0 // Approximate frame duration + node.eulerAngles.y += radiansPerSecond * deltaTime + } + stackNode.runAction(rotateAction, forKey: rotateKey) + } + } + // Find the plane node + if let planeNode = uiView.scene?.rootNode.childNode(withName: planeNodeName, recursively: true), + let plane = planeNode.geometry as? SCNPlane { + + // Render new expression image + let expressionData = ExpressionData(leftEye: data.leftEye, rightEye: data.rightEye, mouth: data.mouth) + expressionLayer.data = expressionData + expressionLayer.setNeedsDisplay() + let newImage = expressionRenderer().image { ctx in + self.expressionLayer.render(in: ctx.cgContext) + } + plane.firstMaterial?.diffuse.contents = newImage + } + } + + static func dismantleUIView(_ uiView: SCNView, coordinator: ()) { + // Remove all nodes from the scene + uiView.scene?.rootNode.childNodes.forEach { $0.removeFromParentNode() } + + // Clean up scene and materials + uiView.scene = nil + uiView.delegate = nil + + // Stop any rendering or animations + uiView.isPlaying = false + uiView.scene?.isPaused = true + } + + private func expressionRenderer() -> UIGraphicsImageRenderer { + let format = UIGraphicsImageRendererFormat.default() + format.scale = UIScreen.main.scale + format.opaque = false + return UIGraphicsImageRenderer( + size: expressionLayer.bounds.size, + format: format + ) + } + +} + + + +struct StackChanRotaryRobot : UIViewRepresentable { + + private let expressionLayer = ExpressionLayer(data: ExpressionData(leftEye: ExpressionItem(weight: 100), rightEye: ExpressionItem(weight: 100), mouth: ExpressionItem())) + + func updateUIView(_ uiView: SCNView, context: Context) { + + } + + private func expressionRenderer() -> UIGraphicsImageRenderer { + let format = UIGraphicsImageRendererFormat.default() + format.scale = UIScreen.main.scale + format.opaque = false + return UIGraphicsImageRenderer( + size: expressionLayer.bounds.size, + format: format + ) + } + + func makeUIView(context: Context) -> SCNView { + let sceneView = SCNView() + + if let scene = SCNScene(named: "stackChanModel.scn") { + scene.rootNode.eulerAngles = SCNVector3Zero + scene.rootNode.eulerAngles.x = -Float.pi / 2 + scene.rootNode.position.y = scene.rootNode.position.y + 25 + scene.rootNode.position.z = scene.rootNode.position.z - 45 + + let clampedPitch = max(0, min(900, 200)) + let pitchRatio = Float(clampedPitch) / 900.0 + let pitchAngle = -Float.pi / 2 * (1 + pitchRatio) + scene.rootNode.eulerAngles.x = pitchAngle + + // Add plane + let plane = SCNPlane(width: 42, height: 32) + let magnification: CGFloat = 5 + let size = CGSize(width: magnification * plane.width, height: magnification * plane.height) + expressionLayer.frame = CGRect(origin: .zero, size: size) + expressionLayer.setNeedsDisplay() + let newImage = expressionRenderer().image { ctx in + self.expressionLayer.render(in: ctx.cgContext) + } + let material = SCNMaterial() + material.diffuse.contents = newImage + plane.materials = [material] + let planeNode = SCNNode(geometry: plane) + planeNode.position = SCNVector3(0, -16, 0) + planeNode.eulerAngles = SCNVector3(Float.pi / 2, 0, 0) + scene.rootNode.addChildNode(planeNode) + + // Add infinite rotation animation around Y axis + let rotateAction = SCNAction.rotateBy(x: 0, y: CGFloat(2 * Double.pi), z: 0, duration: 5) + let repeatAction = SCNAction.repeatForever(rotateAction) + scene.rootNode.runAction(repeatAction) + + sceneView.scene = scene + } else { + print("Model not found") + } + + sceneView.autoenablesDefaultLighting = true + sceneView.allowsCameraControl = false + sceneView.backgroundColor = UIColor.clear + return sceneView + } + + static func dismantleUIView(_ uiView: UIViewType, coordinator: ()) { + // Remove all nodes from the scene + uiView.scene?.rootNode.childNodes.forEach { $0.removeFromParentNode() } + + // Clean up scene and materials + uiView.scene = nil + uiView.delegate = nil + + // Stop any rendering or animations + uiView.isPlaying = false + uiView.scene?.isPaused = true + } +} + + + +struct StackChanRobotPreview : PreviewProvider { + + static var previews: some View { + StackChanRotaryRobot() + .frame(maxWidth: .infinity,maxHeight: 400) + } +} + + + +class ExpressionLayer: CALayer { + var data: ExpressionData + + let reverse: Bool + + init(data: ExpressionData, reverse: Bool = false) { + self.data = data + self.reverse = reverse + super.init() + self.contentsScale = UIScreen.main.scale + self.setNeedsDisplay() + } + + override init(layer: Any) { + if let layer = layer as? ExpressionLayer { + self.data = layer.data + self.reverse = layer.reverse + } else { + self.data = ExpressionData(leftEye: ExpressionItem(), rightEye: ExpressionItem(), mouth: ExpressionItem()) + self.reverse = false + } + super.init(layer: layer) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func draw(in ctx: CGContext) { + let rect = self.frame + + // Background + ctx.setFillColor(UIColor.black.withAlphaComponent(0.7).cgColor) + ctx.fill(rect) + + let eyeSize = rect.width / 10 + + func drawEye(_ item: ExpressionItem, at point: CGPoint) { + + // Calculate scale based on size (-100 to 100) + // 0 -> 1.0 (keep current size) + // -100 -> 0.5 (half normal radius) + // 100 -> 2.0 (double normal radius) + let clampedSize = max(-100, min(100, item.size)) + let sizeScale: CGFloat + if clampedSize >= 0 { + sizeScale = 1.0 + CGFloat(clampedSize) / 100.0 + } else { + sizeScale = 1.0 + CGFloat(clampedSize) / 200.0 + } + + let scaledEyeSize = eyeSize * sizeScale + + let visibleHeight = scaledEyeSize * (CGFloat(item.weight) / 100) + + let centerX = point.x + CGFloat(item.x / 10) + eyeSize / 2 + let centerY = point.y + CGFloat(item.y / 10) + eyeSize / 2 + let eyeRect = CGRect( + x: centerX - scaledEyeSize / 2, + y: centerY - scaledEyeSize / 2, + width: scaledEyeSize, + height: scaledEyeSize + ) + + ctx.saveGState() + + // Rotation + let rotationDegrees = CGFloat(item.rotation) / 10.0 + let center = CGPoint(x: eyeRect.midX, y: eyeRect.midY) + ctx.translateBy(x: center.x, y: center.y) + ctx.rotate(by: rotationDegrees * .pi / 180) + ctx.translateBy(x: -center.x, y: -center.y) + + // Clip height + let maskRect = CGRect( + x: eyeRect.minX, + y: eyeRect.maxY - visibleHeight, + width: scaledEyeSize, + height: visibleHeight + ) + ctx.addRect(maskRect) + ctx.clip() + + ctx.setFillColor(UIColor.white.cgColor) + ctx.fillEllipse(in: eyeRect) + + ctx.restoreGState() + } + + let eyeY = (rect.height * 0.35) - (eyeSize / 2) + let leftEyePoint = CGPoint(x: (rect.width / 3) - (eyeSize / 2), y: eyeY) + let rightEyePoint = CGPoint(x: (rect.width / 3 * 2) - (eyeSize / 2), y: eyeY) + + + if reverse { + // Temporarily swap rotation angles + let leftEyeRotation = data.leftEye.rotation + let rightEyeRotation = data.rightEye.rotation + + var leftEye = data.leftEye + var rightEye = data.rightEye + + leftEye.rotation = rightEyeRotation + rightEye.rotation = leftEyeRotation + + drawEye(leftEye, at: rightEyePoint) + drawEye(rightEye, at: leftEyePoint) + } else { + drawEye(data.leftEye, at: leftEyePoint) + drawEye(data.rightEye, at: rightEyePoint) + } + + + + + // Draw mouth + ctx.saveGState() + + let width = rect.width * 0.3 - CGFloat(data.mouth.weight / 10) + let height = 3 + CGFloat(data.mouth.weight) * 0.2 + let x = ((rect.width - width) / 2) + CGFloat(data.mouth.x / 10) + let y = (rect.height * 0.65) + CGFloat(data.mouth.y / 10) + + let rotationDegrees = CGFloat(data.mouth.rotation) / 10.0 + let center = CGPoint(x: x + width / 2, y: y + height / 2) + ctx.translateBy(x: center.x, y: center.y) + ctx.rotate(by: rotationDegrees * .pi / 180) + ctx.translateBy(x: -center.x, y: -center.y) + + let mouthRect = CGRect(x: x, y: y, width: width, height: height) + let mouthPath = UIBezierPath(roundedRect: mouthRect, cornerRadius: height / 2) + ctx.addPath(mouthPath.cgPath) + ctx.setFillColor(UIColor.white.cgColor) + ctx.fillPath() + + ctx.restoreGState() + } +} diff --git a/app/ios/README.md b/app/ios/README.md deleted file mode 100644 index 1f82226..0000000 --- a/app/ios/README.md +++ /dev/null @@ -1 +0,0 @@ -# StackChan iOS App \ No newline at end of file