This commit is contained in:
袁智鸿
2026-01-07 17:59:49 +08:00
parent 35bd1e0898
commit 756a998a3f
60 changed files with 9068 additions and 3 deletions
+62 -1
View File
@@ -1 +1,62 @@
# StackChan App
# 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:
Doubleclick 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.
+404
View File
@@ -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 = "<group>"; };
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 = "<group>";
};
/* 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 = "<group>";
};
0EBD7D392ECDA27C0001A9D1 /* Products */ = {
isa = PBXGroup;
children = (
0EBD7D382ECDA27C0001A9D1 /* StackChan.app */,
);
name = Products;
sourceTree = "<group>";
};
/* 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 */;
}
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>
@@ -0,0 +1,78 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2620"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "0EBD7D372ECDA27C0001A9D1"
BuildableName = "StackChan.app"
BlueprintName = "StackChan"
ReferencedContainer = "container:StackChan.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "0EBD7D372ECDA27C0001A9D1"
BuildableName = "StackChan.app"
BlueprintName = "StackChan"
ReferencedContainer = "container:StackChan.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "0EBD7D372ECDA27C0001A9D1"
BuildableName = "StackChan.app"
BlueprintName = "StackChan"
ReferencedContainer = "container:StackChan.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Bucket
uuid = "632D2E91-955D-4E23-9652-CB7F63F6388B"
type = "1"
version = "2.0">
</Bucket>
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>SchemeUserState</key>
<dict>
<key>StackChan.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>0</integer>
</dict>
</dict>
<key>SuppressBuildableAutocreation</key>
<dict>
<key>0EBD7D372ECDA27C0001A9D1</key>
<dict>
<key>primary</key>
<true/>
</dict>
</dict>
</dict>
</plist>
Binary file not shown.
+268
View File
@@ -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<String>.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<Device>.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
}
}
}
}
@@ -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
}
}
@@ -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
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

@@ -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
}
}
@@ -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
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

+29
View File
@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<key>NSBonjourServices</key>
<array>
<string>_stackchan-mpc._tcp</string>
</array>
<key>com.apple.developer.networking.wifi-info</key>
<true/>
<key>NSLocalNetworkUsageDescription</key>
<string>This app requires access to the local network to communicate with devices.</string>
<key>NSMicrophoneUsageDescription</key>
<string>We need access to the microphone to capture audio data.</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>CFBundleDisplayName</key>
<string>StackChan World</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>Save the photo to the album</string>
</dict>
</plist>
+62
View File
@@ -0,0 +1,62 @@
/*
* SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD
*
* SPDX-License-Identifier: MIT
*/
import Foundation
struct BlufiModel<T:Codable>: 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<T>? {
guard let jsonData = json.data(using: .utf8) else { return nil }
let decoder = JSONDecoder()
return try? decoder.decode(BlufiModel<T>.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)
}
}
+12
View File
@@ -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
}
+100
View File
@@ -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,
)
}
}
+39
View File
@@ -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
}
+32
View File
@@ -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
}
+79
View File
@@ -0,0 +1,79 @@
/*
* SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD
*
* SPDX-License-Identifier: MIT
*/
import Foundation
struct Response<T: Codable>: 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<T> {
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
do {
return try decoder.decode(Response<T>.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<T> {
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")
}
}
+9
View File
@@ -0,0 +1,9 @@
/*
* SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD
*
* SPDX-License-Identifier: MIT
*/
struct UploadFile : Codable {
var path: String? = nil
}
+328
View File
@@ -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<Data, Error>) -> 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<Data, Error>) -> 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<Data, Error>) -> 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<Data, Error>) -> 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<Data, Error>) -> 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<Data, Error>) -> 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<String, Error>) -> 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"
}
}
}
+45
View File
@@ -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"
}
+154
View File
@@ -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<URLSessionWebSocketTask.Message, Error>) {
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)
}
}
}
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.networking.wifi-info</key>
<true/>
</dict>
</plist>
+20
View File
@@ -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)
}
}
}
@@ -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()
}
}
+431
View File
@@ -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()
}
}
@@ -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..<dotCount {
let pos = CGPoint(x: CGFloat.random(in: 0..<size.width), y: CGFloat.random(in: 0..<size.height))
let target = CGPoint(x: CGFloat.random(in: 0..<size.width), y: CGFloat.random(in: 0..<size.height))
let dotSize: CGFloat = CGFloat.random(in: 200...300)
dots.append(Dot(position: pos, target: target,dotSize: dotSize))
}
}
private func updateDots(size: CGSize) {
guard size.width > 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..<size.width), y: CGFloat.random(in: 0..<size.height))
} else {
dot.position.x += dx / distance * speed
dot.position.y += dy / distance * speed
}
dots[i] = dot
}
}
}
struct Dot {
var position: CGPoint
var target: CGPoint
var dotSize: CGFloat
}
struct DazzlingBackgroundPreview : PreviewProvider {
static var previews: some View {
DazzlingBackground(backColors: [Color.accent.opacity(0.5), Color.pink.opacity(0.2),Color.blue.opacity(0.5)],background: .white)
.ignoresSafeArea()
}
}
+245
View File
@@ -0,0 +1,245 @@
/*
* SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD
*
* SPDX-License-Identifier: MIT
*/
import ARKit
import AVFoundation
class DistanceDetector {
private var arSession: ARSession?
private var isDetectionActive = false
private var distanceCallback: ((Float) -> 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"]
)
}
}
+305
View File
@@ -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..<nextIndex])
guard let byte = UInt8(byteString, radix: 16) else { return nil }
data.append(byte)
index = nextIndex
}
return data
}
func toData() -> 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)
}
}
+43
View File
@@ -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
}
}
+49
View File
@@ -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
}
}
+130
View File
@@ -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)?) {
}
}
+51
View File
@@ -0,0 +1,51 @@
/*
* SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD
*
* SPDX-License-Identifier: MIT
*/
import SwiftUI
struct RippleDiffusion<Content: View> : 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 {
}
}
}
+52
View File
@@ -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<Self._Label>) -> 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)
}
}
+28
View File
@@ -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"
}
+227
View File
@@ -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
}
}
}
}
}
+17
View File
@@ -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
}
}
@@ -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)
}
}
}
+320
View File
@@ -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?()
}
}
}
+571
View File
@@ -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()
}
}
+74
View File
@@ -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()
}
}
+815
View File
@@ -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<String>.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 {
}
}
}
+250
View File
@@ -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<BlufiNotifyState>.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<BlufiWifi>(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()
}
}
}
+84
View File
@@ -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)
}
}
}
+633
View File
@@ -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()
}
+509
View File
@@ -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<GetPostComment>.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<String>.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<UploadFile>.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)
}
}
}
}
+405
View File
@@ -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..<maxAttempts {
let randomX = CGFloat.random(in: 50...(proxySize.width - 50))
let randomY = CGFloat.random(in: 65...(proxySize.height - 65))
let candidate = CGPoint(x: randomX, y: randomY)
// Avoid own avatar
if hypot(candidate.x - center.x, candidate.y - center.y) < selfSafeRadius {
continue
}
// Avoid existing devices
if existingPositions.allSatisfy({ hypot($0.x - candidate.x, $0.y - candidate.y) > 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()
}
}
+213
View File
@@ -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<String,Error>) -> 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()
}
}
+119
View File
@@ -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()
}
}
+368
View File
@@ -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..<faceList.count, id: \.self) { index in
FaceCell(expression: faceList[index], isSelected: selectedIndex == index)
.onTapGesture {
withAnimation(.easeInOut(duration: 0.2)) {
selectedIndex = index
}
let jsonString = appState.deviceMac + faceList[index].toJsonString()
let data = jsonString.toData()
appState.sendWebSocketMessage(.controlAvatar, data)
}
}
}
.padding(20)
}
.ignoresSafeArea()
}
}
struct FaceCell: View {
let expression: ExpressionData
let isSelected: Bool
let expressionLayer: ExpressionLayer
init(expression: ExpressionData, isSelected: Bool) {
self.expression = expression
self.isSelected = isSelected
self.expressionLayer = ExpressionLayer(data: expression)
self.expressionLayer.frame = CGRect(origin: .zero, size: CGSize(width: 320, height: 240))
self.expressionLayer.setNeedsDisplay()
}
var body: some View {
let newImage = expressionRenderer().image { ctx in
self.expressionLayer.render(in: ctx.cgContext)
}
Image(uiImage: newImage)
.resizable()
.aspectRatio(4/3, contentMode: .fit)
.clipShape(RoundedRectangle(cornerRadius: 12))
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(isSelected ? Color.blue : Color.gray.opacity(0.3),
lineWidth: isSelected ? 3 : 1)
)
.scaleEffect(isSelected ? 1.05 : 1.0)
}
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 StackChanSwitchFacePreview : PreviewProvider {
static var previews: some View {
SwitchFace()
}
}
+165
View File
@@ -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)
}
}
+360
View File
@@ -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 (0900)
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()
}
}
-1
View File
@@ -1 +0,0 @@
# StackChan iOS App