mirror of
https://github.com/m5stack/StackChan.git
synced 2026-04-27 19:12:40 +00:00
ios code
This commit is contained in:
+62
-1
@@ -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:
|
||||
|
||||
Double‑click the `.xcodeproj` file, or open Xcode → File → Open, then select the project.
|
||||
|
||||
1. Select your target device or simulator.
|
||||
|
||||
### Connect an iPhone (Optional but Recommended)
|
||||
- Connect your iPhone to the Mac using a USB cable.
|
||||
- Unlock the iPhone and tap **Trust This Computer** if prompted.
|
||||
- In Xcode, select your iPhone as the run destination at the top.
|
||||
|
||||
### Enable Developer Mode on iPhone (iOS 16+)
|
||||
> **Important:** Developer Mode will only appear after the iPhone has been connected to Xcode at least once.
|
||||
If you do not see this option, make sure your iPhone is connected to the Mac, unlocked, trusted, and recognized by Xcode.
|
||||
- On the iPhone, go to **Settings → Privacy & Security → Developer Mode**.
|
||||
- Turn on Developer Mode and restart the iPhone.
|
||||
- After restart, confirm enabling Developer Mode.
|
||||
|
||||
## 3. Configure Signing & Capabilities
|
||||
This step allows Xcode to install the app on your iPhone.
|
||||
|
||||
1. In Xcode, select the project in the left sidebar.
|
||||
2. Select the app target.
|
||||
3. Open the **Signing & Capabilities** tab.
|
||||
4. Sign in with your Apple ID (Xcode → Settings → Accounts → Add Apple ID).
|
||||
5. Set **Team** to your Apple ID.
|
||||
6. Change **Bundle Identifier** to a unique value, for example:
|
||||
`com.yourname.stackchan`
|
||||
7. Ensure no red error messages remain.
|
||||
|
||||
> **Note:** A free Apple ID is sufficient for testing on your own iPhone.
|
||||
|
||||
## 4. Modify network configuration
|
||||
Before running the app, you need to set the correct server IP:
|
||||
|
||||
1. Open the file `Network/Urls.swift`.
|
||||
2. Find the line defining the base URL, for example:
|
||||
```swift
|
||||
// Base URL configured according to the server's IP
|
||||
static let url = "192.168.51.24:12800/"
|
||||
```
|
||||
3. Replace the IP address (`192.168.51.24`) with the IP of the computer where the server is running.
|
||||
4. Save the file.
|
||||
|
||||
## 5. Run the project
|
||||
Press `Cmd + R` to build and run the app.
|
||||
|
||||
> **Note:** The first build may take several minutes as Xcode prepares the environment.
|
||||
|
||||
If running on an iPhone for the first time, you may need to trust yourself as a developer:
|
||||
- On your iPhone, go to **Settings → General → VPN & Device Management → Trust Developer** and trust the developer profile that appears.
|
||||
|
||||
The app will now connect to the server at the IP you configured.
|
||||
|
||||
@@ -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>
|
||||
BIN
Binary file not shown.
@@ -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>
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Bucket
|
||||
uuid = "632D2E91-955D-4E23-9652-CB7F63F6388B"
|
||||
type = "1"
|
||||
version = "2.0">
|
||||
</Bucket>
|
||||
+22
@@ -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.
@@ -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 |
@@ -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>
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD
|
||||
*
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
struct UploadFile : Codable {
|
||||
var path: String? = nil
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)?) {
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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?()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD
|
||||
*
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import SwiftUI
|
||||
import SceneKit
|
||||
import Combine
|
||||
|
||||
struct StackChanRobot : UIViewRepresentable {
|
||||
|
||||
var data: DanceData
|
||||
|
||||
var allowsCameraControl: Bool = false
|
||||
|
||||
@State private var expressionLayer = ExpressionLayer(data: ExpressionData(leftEye: ExpressionItem(weight: 100), rightEye: ExpressionItem(weight: 100), mouth: ExpressionItem()))
|
||||
|
||||
private let planeNodeName = "expressionPlane"
|
||||
|
||||
private let rotateKey = "autoRotate"
|
||||
|
||||
func makeUIView(context: Context) -> SCNView {
|
||||
let sceneView = SCNView()
|
||||
|
||||
if let scene = SCNScene(named: "stackChanModel.scn") {
|
||||
scene.rootNode.eulerAngles = SCNVector3Zero
|
||||
scene.rootNode.eulerAngles.x = -Float.pi / 2
|
||||
scene.rootNode.position.y = scene.rootNode.position.y + 25
|
||||
scene.rootNode.position.z = scene.rootNode.position.z - 35
|
||||
|
||||
let plane = SCNPlane(width: 42, height: 32)
|
||||
let magnification: CGFloat = 5
|
||||
let size = CGSize(width: magnification * plane.width, height: magnification * plane.height)
|
||||
expressionLayer.frame = CGRect(origin: .zero, size: size)
|
||||
expressionLayer.setNeedsDisplay()
|
||||
|
||||
let material = SCNMaterial()
|
||||
plane.materials = [material]
|
||||
let planeNode = SCNNode(geometry: plane)
|
||||
planeNode.name = planeNodeName
|
||||
planeNode.position = SCNVector3(0, -16, 0)
|
||||
planeNode.eulerAngles = SCNVector3(Float.pi / 2, 0, 0)
|
||||
scene.rootNode.addChildNode(planeNode)
|
||||
|
||||
sceneView.scene = scene
|
||||
} else {
|
||||
print("Model not found")
|
||||
}
|
||||
|
||||
sceneView.autoenablesDefaultLighting = true
|
||||
sceneView.allowsCameraControl = allowsCameraControl
|
||||
sceneView.backgroundColor = UIColor.clear
|
||||
setData(sceneView)
|
||||
return sceneView
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: SCNView, context: Context) {
|
||||
setData(uiView)
|
||||
}
|
||||
|
||||
/// Refresh model position and expression
|
||||
private func setData(_ uiView: SCNView) {
|
||||
if let stackNode = uiView.scene?.rootNode {
|
||||
/// Set pitch angle (0–900)
|
||||
let clampedPitch = max(0, min(900, data.pitchServo.angle))
|
||||
let pitchRatio = Float(clampedPitch) / 900.0
|
||||
let pitchAngle = -Float.pi / 2 * (1 + pitchRatio)
|
||||
stackNode.eulerAngles.x = pitchAngle
|
||||
|
||||
// Cancel previous auto-rotation
|
||||
stackNode.removeAction(forKey: rotateKey)
|
||||
|
||||
if data.yawServo.rotate == 0 {
|
||||
/// Set yaw angle (-128 to 128, left to right)
|
||||
let clampedYaw = max(-1280, min(1280, data.yawServo.angle)) // Clamp to -128~128
|
||||
let yawAngle = Float(clampedYaw) * Float.pi / 1800 // Convert to radians
|
||||
stackNode.eulerAngles.y = yawAngle
|
||||
} else {
|
||||
let rotateSpeed = max(-1000, min(1000, data.yawServo.rotate))
|
||||
let radiansPerSecond = Float(rotateSpeed) / 1000.0 * Float.pi * 2
|
||||
// Rotate continuously using angular velocity (not a fixed-loop animation)
|
||||
let rotateAction = SCNAction.customAction(duration: .infinity) { node, _ in
|
||||
let deltaTime: Float = 1.0 / 60.0 // Approximate frame duration
|
||||
node.eulerAngles.y += radiansPerSecond * deltaTime
|
||||
}
|
||||
stackNode.runAction(rotateAction, forKey: rotateKey)
|
||||
}
|
||||
}
|
||||
// Find the plane node
|
||||
if let planeNode = uiView.scene?.rootNode.childNode(withName: planeNodeName, recursively: true),
|
||||
let plane = planeNode.geometry as? SCNPlane {
|
||||
|
||||
// Render new expression image
|
||||
let expressionData = ExpressionData(leftEye: data.leftEye, rightEye: data.rightEye, mouth: data.mouth)
|
||||
expressionLayer.data = expressionData
|
||||
expressionLayer.setNeedsDisplay()
|
||||
let newImage = expressionRenderer().image { ctx in
|
||||
self.expressionLayer.render(in: ctx.cgContext)
|
||||
}
|
||||
plane.firstMaterial?.diffuse.contents = newImage
|
||||
}
|
||||
}
|
||||
|
||||
static func dismantleUIView(_ uiView: SCNView, coordinator: ()) {
|
||||
// Remove all nodes from the scene
|
||||
uiView.scene?.rootNode.childNodes.forEach { $0.removeFromParentNode() }
|
||||
|
||||
// Clean up scene and materials
|
||||
uiView.scene = nil
|
||||
uiView.delegate = nil
|
||||
|
||||
// Stop any rendering or animations
|
||||
uiView.isPlaying = false
|
||||
uiView.scene?.isPaused = true
|
||||
}
|
||||
|
||||
private func expressionRenderer() -> UIGraphicsImageRenderer {
|
||||
let format = UIGraphicsImageRendererFormat.default()
|
||||
format.scale = UIScreen.main.scale
|
||||
format.opaque = false
|
||||
return UIGraphicsImageRenderer(
|
||||
size: expressionLayer.bounds.size,
|
||||
format: format
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
struct StackChanRotaryRobot : UIViewRepresentable {
|
||||
|
||||
private let expressionLayer = ExpressionLayer(data: ExpressionData(leftEye: ExpressionItem(weight: 100), rightEye: ExpressionItem(weight: 100), mouth: ExpressionItem()))
|
||||
|
||||
func updateUIView(_ uiView: SCNView, context: Context) {
|
||||
|
||||
}
|
||||
|
||||
private func expressionRenderer() -> UIGraphicsImageRenderer {
|
||||
let format = UIGraphicsImageRendererFormat.default()
|
||||
format.scale = UIScreen.main.scale
|
||||
format.opaque = false
|
||||
return UIGraphicsImageRenderer(
|
||||
size: expressionLayer.bounds.size,
|
||||
format: format
|
||||
)
|
||||
}
|
||||
|
||||
func makeUIView(context: Context) -> SCNView {
|
||||
let sceneView = SCNView()
|
||||
|
||||
if let scene = SCNScene(named: "stackChanModel.scn") {
|
||||
scene.rootNode.eulerAngles = SCNVector3Zero
|
||||
scene.rootNode.eulerAngles.x = -Float.pi / 2
|
||||
scene.rootNode.position.y = scene.rootNode.position.y + 25
|
||||
scene.rootNode.position.z = scene.rootNode.position.z - 45
|
||||
|
||||
let clampedPitch = max(0, min(900, 200))
|
||||
let pitchRatio = Float(clampedPitch) / 900.0
|
||||
let pitchAngle = -Float.pi / 2 * (1 + pitchRatio)
|
||||
scene.rootNode.eulerAngles.x = pitchAngle
|
||||
|
||||
// Add plane
|
||||
let plane = SCNPlane(width: 42, height: 32)
|
||||
let magnification: CGFloat = 5
|
||||
let size = CGSize(width: magnification * plane.width, height: magnification * plane.height)
|
||||
expressionLayer.frame = CGRect(origin: .zero, size: size)
|
||||
expressionLayer.setNeedsDisplay()
|
||||
let newImage = expressionRenderer().image { ctx in
|
||||
self.expressionLayer.render(in: ctx.cgContext)
|
||||
}
|
||||
let material = SCNMaterial()
|
||||
material.diffuse.contents = newImage
|
||||
plane.materials = [material]
|
||||
let planeNode = SCNNode(geometry: plane)
|
||||
planeNode.position = SCNVector3(0, -16, 0)
|
||||
planeNode.eulerAngles = SCNVector3(Float.pi / 2, 0, 0)
|
||||
scene.rootNode.addChildNode(planeNode)
|
||||
|
||||
// Add infinite rotation animation around Y axis
|
||||
let rotateAction = SCNAction.rotateBy(x: 0, y: CGFloat(2 * Double.pi), z: 0, duration: 5)
|
||||
let repeatAction = SCNAction.repeatForever(rotateAction)
|
||||
scene.rootNode.runAction(repeatAction)
|
||||
|
||||
sceneView.scene = scene
|
||||
} else {
|
||||
print("Model not found")
|
||||
}
|
||||
|
||||
sceneView.autoenablesDefaultLighting = true
|
||||
sceneView.allowsCameraControl = false
|
||||
sceneView.backgroundColor = UIColor.clear
|
||||
return sceneView
|
||||
}
|
||||
|
||||
static func dismantleUIView(_ uiView: UIViewType, coordinator: ()) {
|
||||
// Remove all nodes from the scene
|
||||
uiView.scene?.rootNode.childNodes.forEach { $0.removeFromParentNode() }
|
||||
|
||||
// Clean up scene and materials
|
||||
uiView.scene = nil
|
||||
uiView.delegate = nil
|
||||
|
||||
// Stop any rendering or animations
|
||||
uiView.isPlaying = false
|
||||
uiView.scene?.isPaused = true
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
struct StackChanRobotPreview : PreviewProvider {
|
||||
|
||||
static var previews: some View {
|
||||
StackChanRotaryRobot()
|
||||
.frame(maxWidth: .infinity,maxHeight: 400)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
class ExpressionLayer: CALayer {
|
||||
var data: ExpressionData
|
||||
|
||||
let reverse: Bool
|
||||
|
||||
init(data: ExpressionData, reverse: Bool = false) {
|
||||
self.data = data
|
||||
self.reverse = reverse
|
||||
super.init()
|
||||
self.contentsScale = UIScreen.main.scale
|
||||
self.setNeedsDisplay()
|
||||
}
|
||||
|
||||
override init(layer: Any) {
|
||||
if let layer = layer as? ExpressionLayer {
|
||||
self.data = layer.data
|
||||
self.reverse = layer.reverse
|
||||
} else {
|
||||
self.data = ExpressionData(leftEye: ExpressionItem(), rightEye: ExpressionItem(), mouth: ExpressionItem())
|
||||
self.reverse = false
|
||||
}
|
||||
super.init(layer: layer)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func draw(in ctx: CGContext) {
|
||||
let rect = self.frame
|
||||
|
||||
// Background
|
||||
ctx.setFillColor(UIColor.black.withAlphaComponent(0.7).cgColor)
|
||||
ctx.fill(rect)
|
||||
|
||||
let eyeSize = rect.width / 10
|
||||
|
||||
func drawEye(_ item: ExpressionItem, at point: CGPoint) {
|
||||
|
||||
// Calculate scale based on size (-100 to 100)
|
||||
// 0 -> 1.0 (keep current size)
|
||||
// -100 -> 0.5 (half normal radius)
|
||||
// 100 -> 2.0 (double normal radius)
|
||||
let clampedSize = max(-100, min(100, item.size))
|
||||
let sizeScale: CGFloat
|
||||
if clampedSize >= 0 {
|
||||
sizeScale = 1.0 + CGFloat(clampedSize) / 100.0
|
||||
} else {
|
||||
sizeScale = 1.0 + CGFloat(clampedSize) / 200.0
|
||||
}
|
||||
|
||||
let scaledEyeSize = eyeSize * sizeScale
|
||||
|
||||
let visibleHeight = scaledEyeSize * (CGFloat(item.weight) / 100)
|
||||
|
||||
let centerX = point.x + CGFloat(item.x / 10) + eyeSize / 2
|
||||
let centerY = point.y + CGFloat(item.y / 10) + eyeSize / 2
|
||||
let eyeRect = CGRect(
|
||||
x: centerX - scaledEyeSize / 2,
|
||||
y: centerY - scaledEyeSize / 2,
|
||||
width: scaledEyeSize,
|
||||
height: scaledEyeSize
|
||||
)
|
||||
|
||||
ctx.saveGState()
|
||||
|
||||
// Rotation
|
||||
let rotationDegrees = CGFloat(item.rotation) / 10.0
|
||||
let center = CGPoint(x: eyeRect.midX, y: eyeRect.midY)
|
||||
ctx.translateBy(x: center.x, y: center.y)
|
||||
ctx.rotate(by: rotationDegrees * .pi / 180)
|
||||
ctx.translateBy(x: -center.x, y: -center.y)
|
||||
|
||||
// Clip height
|
||||
let maskRect = CGRect(
|
||||
x: eyeRect.minX,
|
||||
y: eyeRect.maxY - visibleHeight,
|
||||
width: scaledEyeSize,
|
||||
height: visibleHeight
|
||||
)
|
||||
ctx.addRect(maskRect)
|
||||
ctx.clip()
|
||||
|
||||
ctx.setFillColor(UIColor.white.cgColor)
|
||||
ctx.fillEllipse(in: eyeRect)
|
||||
|
||||
ctx.restoreGState()
|
||||
}
|
||||
|
||||
let eyeY = (rect.height * 0.35) - (eyeSize / 2)
|
||||
let leftEyePoint = CGPoint(x: (rect.width / 3) - (eyeSize / 2), y: eyeY)
|
||||
let rightEyePoint = CGPoint(x: (rect.width / 3 * 2) - (eyeSize / 2), y: eyeY)
|
||||
|
||||
|
||||
if reverse {
|
||||
// Temporarily swap rotation angles
|
||||
let leftEyeRotation = data.leftEye.rotation
|
||||
let rightEyeRotation = data.rightEye.rotation
|
||||
|
||||
var leftEye = data.leftEye
|
||||
var rightEye = data.rightEye
|
||||
|
||||
leftEye.rotation = rightEyeRotation
|
||||
rightEye.rotation = leftEyeRotation
|
||||
|
||||
drawEye(leftEye, at: rightEyePoint)
|
||||
drawEye(rightEye, at: leftEyePoint)
|
||||
} else {
|
||||
drawEye(data.leftEye, at: leftEyePoint)
|
||||
drawEye(data.rightEye, at: rightEyePoint)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
// Draw mouth
|
||||
ctx.saveGState()
|
||||
|
||||
let width = rect.width * 0.3 - CGFloat(data.mouth.weight / 10)
|
||||
let height = 3 + CGFloat(data.mouth.weight) * 0.2
|
||||
let x = ((rect.width - width) / 2) + CGFloat(data.mouth.x / 10)
|
||||
let y = (rect.height * 0.65) + CGFloat(data.mouth.y / 10)
|
||||
|
||||
let rotationDegrees = CGFloat(data.mouth.rotation) / 10.0
|
||||
let center = CGPoint(x: x + width / 2, y: y + height / 2)
|
||||
ctx.translateBy(x: center.x, y: center.y)
|
||||
ctx.rotate(by: rotationDegrees * .pi / 180)
|
||||
ctx.translateBy(x: -center.x, y: -center.y)
|
||||
|
||||
let mouthRect = CGRect(x: x, y: y, width: width, height: height)
|
||||
let mouthPath = UIBezierPath(roundedRect: mouthRect, cornerRadius: height / 2)
|
||||
ctx.addPath(mouthPath.cgPath)
|
||||
ctx.setFillColor(UIColor.white.cgColor)
|
||||
ctx.fillPath()
|
||||
|
||||
ctx.restoreGState()
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
# StackChan iOS App
|
||||
Reference in New Issue
Block a user