diff --git a/README.md b/README.md
index 48b99d3..9cf3f7f 100644
--- a/README.md
+++ b/README.md
@@ -1,21 +1,32 @@
-# StackChan Open Source
+# StackChan Open-Source
-
+
-**Pre-order your StackChan**: https://m5stack.com/stackchan
+Here are StackChan related open-source resources, including source code of the StackChan firmware, remote controller firmware, mobile app (iOS and Android), and server.
-> The software development is still in progress. Final features and documentation may change. Thank you for your understanding.
+Update of this repo could be a little late than the released firmware and mobile app.
+
+----
-**StackChan is a super kawaii AI desktop robot co-created by M5Stack and the user community.** It uses the M5Stack **flagship IoT development kit [CoreS3](https://docs.m5stack.com/en/core/CoreS3)** as its main controller, powered by an ESP32-S3 SoC featuring a 240 MHz dual-core processor, with 16MB Flash and 8MB PSRAM onboard, and supporting Wi-Fi and BLE. The main unit also integrates a 2.0-inch capacitive touch display with a high-strength glass cover, a 0.3 MP camera, a proximity sensor, a 9-axis IMU (accelerometer + gyroscope + magnetometer), a microSD card slot, a 1W speaker, dual microphones, and power/reset buttons.
+**StackChan is a super kawaii AI desktop robot co-created by M5Stack and the user community.** It uses the M5Stack **flagship IoT development kit [CoreS3](https://docs.m5stack.com/en/core/CoreS3)** as its main controller, powered by an ESP32-S3 SoC featuring a 240 MHz dual-core processor, with 16MB Flash and 8MB PSRAM onboard, and supporting Wi-Fi and BLE. The main unit also integrates a 2.0-inch capacitive touch display with a high-strength glass cover, a 0.3 MP camera, a proximity & ambient light sensor, a 9-axis IMU (accelerometer + gyroscope + magnetometer), a microSD card slot, a 1W speaker, dual microphones, and power/reset buttons.
-The **robot body**, connected to the main unit, includes a USB-C interface for power and data, a 700 mAh battery, two feedback servos (360-degree continuous rotation on the horizontal axis and 90-degree movement on the vertical axis), two rows totaling 12 RGB LEDs, infrared transmitter and receiver, a three-zone touch panel, and a full-featured NFC module.
+The **robot body**, connected to the main unit, includes a USB-C interface for power and data, a 550 mAh battery, two feedback servos (360-degree continuous rotation on the horizontal axis and 90-degree movement on the vertical axis), two rows totaling 12 RGB LEDs, infrared transmitter and receiver, a three-zone touch panel, and a full-featured NFC module.
-The **factory firmware** comes with rich features, including vivid and cute facial expressions and motions, the XiaoZhi AI agent, as well as support for iOS app video calls, remote avatars, and discovering other nearby StackChan devices. The product also supports programming via Arduino and UiFlow2, making it easy to implement a wide range of custom functionalities.
+The **factory firmware** is feature-rich, including an AI Agent, lively and expressive animations, ESP-NOW wireless remote control, and online app downloads. It can connect to a mobile app for video viewing, remote avatar control, and more, and also supports online updates (OTA). The product also supports programming via Arduino, UiFlow2, and other methods, and can connect to various expansion units in the M5Stack ecosystem, making it easy to implement a wide range of custom functions.
-> Do not forcibly rotate any movable parts connected to the motors by hand when you are unsure whether the motors are powered and under control, as this may cause hardware damage.
+> ⚠️ Do not forcibly rotate any movable parts connected to the motors by hand when you are unsure whether the motors are powered and under control, as this may cause hardware damage.
-- **StackChan World iOS app**: https://apps.apple.com/app/stackchan-world/id6756086326
+- Purchase link: [M5Stack Official Store](https://shop.m5stack.com/products/stackchan-kawaii-co-created-open-source-ai-desktop-robot) | [淘宝 Taobao](https://item.taobao.com/item.htm?id=1042238294510)
-- **StackChan World website**: https://stackchan.world/home
+- Product document page: [English](https://docs.m5stack.com/en/StackChan) | [日本語](https://docs.m5stack.com/ja/StackChan) | [中文](https://docs.m5stack.com/zh_CN/StackChan)
+
+- Board support package: https://github.com/m5stack/StackChan-BSP
+
+Thank you to the contributors of the StackChan community, especially:
+
+|  |  |
+| -------------------------------------------------------------------------------- | --------------------------------------------------------------------------- |
+| [@stack_chan](https://x.com/stack_chan) | [@mongonta555](https://x.com/mongonta555) |
+| Shinya Ishikawa | Takao Akaki |
diff --git a/app/README.md b/app/README.md
index 18b61e5..13d29de 100644
--- a/app/README.md
+++ b/app/README.md
@@ -1,62 +1,372 @@
-# Project Setup and Running Instructions
+# StackChan App
+
+[](https://opensource.org/licenses/MIT)
+[](https://flutter.dev/)
+[](https://dart.dev/)
+
+A powerful Flutter application for controlling and interacting with the StackChan AI robot companion. Features include
+Bluetooth connectivity, AI conversation capabilities, facial expression rendering, and dance choreography.
+
+## Features
+
+- 🤖 **Bluetooth Device Management** - Connect and control StackChan robots via BLE
+- 💬 **AI Conversation** - Natural language interaction powered by XiaoZhi AI
+- 🎭 **Facial Expression Rendering** - Real-time 3D face animation using Three.js
+- 🎵 **Music & Dance** - Create and play dance choreographies with music
+- 📷 **Camera Integration** - AR features and face detection
+- 🔐 **Secure Communication** - RSA encryption for data transmission
+
+## System Requirements
+
+- **Flutter SDK**: 3.0+
+- **Dart SDK**: 3.0+
+- **iOS**: 14.0+ (for iOS deployment)
+- **Android**: API 21+ (for Android deployment)
+- **macOS**: 11.0+ (for macOS deployment)
+## Installation
+
+### 1. Install Flutter
+
+Follow the official Flutter installation guide for your operating system:
+
+#### macOS
-## 1. Clone the repository
```bash
-git clone https://github.com/m5stack/StackChan
-cd StackChan/app
+# Download Flutter SDK
+git clone https://github.com/flutter/flutter.git -b stable
+export PATH="$PATH:`pwd`/flutter/bin"
+
+# Verify installation
+flutter doctor
```
-## 2. Open the project in Xcode
-Open the project in Xcode:
+#### Windows
-Double‑click the `.xcodeproj` file, or open Xcode → File → Open, then select the project.
+```bash
+# Download Flutter SDK from https://flutter.dev/docs/get-started/install/windows
+# Extract and add to PATH
-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/"
+# Verify installation
+flutter doctor
```
-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.
+#### Linux
-> **Note:** The first build may take several minutes as Xcode prepares the environment.
+```bash
+# Download Flutter SDK
+git clone https://github.com/flutter/flutter.git -b stable
+export PATH="$PATH:`pwd`/flutter/bin"
-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.
+# Install dependencies
+sudo apt-get install clang cmake ninja-build pkg-config libgtk-3-dev liblzma-dev
-The app will now connect to the server at the IP you configured.
+# Verify installation
+flutter doctor
+```
+
+### 2. Set Up Project
+
+```bash
+# Clone the repository
+git clone
+cd StackChan
+
+# Install dependencies
+flutter pub get
+```
+
+### 3. Configure Backend Server
+
+The application requires a backend server for full functionality. Configure the server endpoints before building:
+
+#### Option A: Using Environment Configuration (Recommended)
+
+Create a `.env` file in the project root or modify the configuration directly in code.
+
+#### Option B: Direct Code Configuration
+
+Modify `lib/network/urls.dart` to set your backend server URL:
+
+```dart
+// lib/network/urls.dart
+class Urls {
+ // Update this to your backend server address
+ static const String url = "your-backend-server:port/";
+
+// ... rest of the configuration
+}
+```
+
+#### Option C: Configure Value Constants
+
+Update `lib/util/value_constant.dart` for encryption keys and other constants:
+
+```dart
+// lib/util/value_constant.dart
+class ValueConstant {
+ // Server RSA Public Key for encryption
+ static const String serverPublicKey = """
+-----BEGIN PUBLIC KEY-----
+YOUR_SERVER_PUBLIC_KEY_HERE
+-----END PUBLIC KEY-----
+""";
+
+ // Client RSA Private Key for decryption
+ static const String clientPrivateKey = """
+-----BEGIN RSA PRIVATE KEY-----
+YOUR_CLIENT_PRIVATE_KEY_HERE
+-----END RSA PRIVATE KEY-----
+""";
+}
+```
+
+**Important**: For production deployments, use environment variables or secure key management instead of hardcoding
+keys.
+
+## Building the Application
+
+### iOS
+
+```bash
+# Install CocoaPods dependencies
+cd ios
+pod install
+cd ..
+
+# Run on iOS simulator
+flutter run -d ios
+
+# Build for release (iOS device)
+flutter build ios --release
+```
+
+### Android
+
+```bash
+# Run on Android emulator or connected device
+flutter run -d android
+
+# Build APK for release
+flutter build apk --release
+
+# Build App Bundle for Google Play
+flutter build appbundle --release
+```
+
+### Android Release Signing (JKS)
+
+For release builds (`apk --release` / `appbundle --release`), configure a keystore instead of hardcoding passwords in
+`build.gradle.kts`.
+
+#### 1. Generate a JKS file
+
+```bash
+keytool -genkeypair -v \
+ -keystore android/app/release.jks \
+ -alias release \
+ -keyalg RSA -keysize 2048 -validity 10000
+```
+
+#### 2. Create `android/key.properties`
+
+```properties
+storePassword=YOUR_STORE_PASSWORD
+keyPassword=YOUR_KEY_PASSWORD
+keyAlias=release
+storeFile=../app/release.jks
+```
+
+> `android/.gitignore` already ignores `key.properties` and `*.jks`. Keep these files private.
+
+#### 3. Load the properties in `android/app/build.gradle.kts`
+
+Use Kotlin DSL style configuration:
+
+```kotlin
+import java.util.Properties
+
+val keystoreProperties = Properties()
+val keystorePropertiesFile = rootProject.file("key.properties")
+if (keystorePropertiesFile.exists()) {
+ keystoreProperties.load(keystorePropertiesFile.inputStream())
+}
+
+android {
+ signingConfigs {
+ create("release") {
+ if (keystorePropertiesFile.exists()) {
+ storeFile = file(keystoreProperties["storeFile"] as String)
+ storePassword = keystoreProperties["storePassword"] as String
+ keyAlias = keystoreProperties["keyAlias"] as String
+ keyPassword = keystoreProperties["keyPassword"] as String
+ }
+ }
+ }
+
+ buildTypes {
+ release {
+ signingConfig = signingConfigs.getByName("release")
+ }
+ }
+}
+```
+
+#### 4. Build release artifacts
+
+```bash
+flutter build apk --release
+flutter build appbundle --release
+```
+
+#### 5. CI/CD recommendation
+
+In CI, inject signing values through environment variables or secure secrets storage. Do not commit keystore passwords or
+private keys to the repository.
+
+## Project Structure
+
+```
+lib/
+├── main.dart # Application entry point
+├── app_state.dart # Global state management
+├── model/ # Data models
+│ ├── XiaoZhi/ # AI service models
+│ ├── blue_device_info.dart # Bluetooth device models
+│ ├── dance_list.dart # Dance choreography models
+│ └── ...
+├── network/ # Network layer
+│ ├── http.dart # HTTP client with interceptors
+│ ├── urls.dart # API endpoint configurations
+│ └── web_socket_util.dart # WebSocket management
+├── util/ # Utilities
+│ ├── value_constant.dart # App constants and keys
+│ ├── rsa_util.dart # RSA encryption/decryption
+│ ├── blue_util.dart # Bluetooth utilities
+│ ├── music_util.dart # Music and audio processing
+│ └── ...
+└── view/ # UI layer
+ ├── home/ # Home screens
+ ├── popup/ # Modal screens
+ └── util/ # UI components and widgets
+```
+
+## Backend API Integration
+
+The application integrates with two main backend services:
+
+### 1. StackChan Backend (`lib/network/urls.dart`)
+
+- Device registration and management
+- Dance choreography storage
+- User authentication
+- File upload and media management
+
+### 2. XiaoZhi AI Service (`lib/util/XiaoZhi_util.dart`)
+
+- AI conversation and chat functionality
+- Agent management and configuration
+- TTS (Text-to-Speech) voice selection
+- License and activation management
+
+**Base URL Configuration:**
+
+- StackChan Backend: `http://:/stackChan/`
+- XiaoZhi AI: `https://XiaoZhi.me/`
+
+## Development
+
+### Code Style
+
+This project follows the official Dart and Flutter style guidelines:
+
+- Use `camelCase` for variables and functions
+- Use `PascalCase` for classes and types
+- Use `snake_case` for JSON keys (API communication)
+- Document public APIs with doc comments
+
+### Running Tests
+
+```bash
+# Run all tests
+flutter test
+
+# Run specific test file
+flutter test test/widget_test.dart
+
+# Run with coverage
+flutter test --coverage
+```
+
+### Linting
+
+```bash
+# Run static analysis
+flutter analyze
+```
+
+## Contributing
+
+We welcome contributions! Please see our [Contributing Guide](CONTRIBUTING.md) for more details.
+
+1. Fork the repository
+2. Create your feature branch (`git checkout -b feature/amazing-feature`)
+3. Commit your changes (`git commit -m 'Add some amazing feature'`)
+4. Push to the branch (`git push origin feature/amazing-feature`)
+5. Open a Pull Request
+
+## Troubleshooting
+
+### Common Issues
+
+**1. Flutter doctor reports missing dependencies**
+
+- Follow the instructions provided by `flutter doctor` to install missing components
+- For iOS: Ensure Xcode is installed and command line tools are selected
+- For Android: Ensure Android Studio and SDK are properly configured
+
+**2. Bluetooth not working**
+
+- Ensure Bluetooth permissions are granted
+- For iOS: Check `NSBluetoothAlwaysUsageDescription` in Info.plist
+- For Android: Check `BLUETOOTH_SCAN` and `BLUETOOTH_CONNECT` permissions
+
+**3. Backend connection fails**
+
+- Verify server URL is correct in `lib/network/urls.dart`
+- Check network connectivity
+- Verify backend server is running and accessible
+- Check SSL certificates for HTTPS connections
+
+**4. Build fails on iOS**
+
+```bash
+cd ios
+rm -rf Pods Podfile.lock
+pod install --repo-update
+cd ..
+flutter clean
+flutter pub get
+```
+
+## License
+
+This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
+
+## Acknowledgments
+
+- M5Stack Technology CO LTD for the StackChan hardware
+- Flutter team for the amazing framework
+- Three.js for 3D rendering capabilities
+- All contributors and open source libraries used in this project
+
+## Support
+
+For support, please:
+
+1. Check the [Issues](../../issues) page for known problems
+2. Create a new issue if your problem isn't already listed
+3. For security issues, please contact security@m5stack.com directly
+
+---
+
+**Note**: This application requires compatible StackChan hardware and backend services for full functionality.
\ No newline at end of file
diff --git a/app/StackChan.xcodeproj/project.pbxproj b/app/StackChan.xcodeproj/project.pbxproj
deleted file mode 100644
index 309af2c..0000000
--- a/app/StackChan.xcodeproj/project.pbxproj
+++ /dev/null
@@ -1,404 +0,0 @@
-// !$*UTF8*$!
-{
- archiveVersion = 1;
- classes = {
- };
- objectVersion = 77;
- objects = {
-
-/* Begin PBXBuildFile section */
- 0E4478B92F0A538600010197 /* README.MD in Resources */ = {isa = PBXBuildFile; fileRef = 0E4478B82F0A538600010197 /* README.MD */; };
-/* End PBXBuildFile section */
-
-/* Begin PBXFileReference section */
- 0E4478B82F0A538600010197 /* README.MD */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.MD; sourceTree = ""; };
- 0EBD7D382ECDA27C0001A9D1 /* StackChan.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = StackChan.app; sourceTree = BUILT_PRODUCTS_DIR; };
-/* End PBXFileReference section */
-
-/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
- 0EBD7E222ECDC9510001A9D1 /* Exceptions for "StackChan" folder in "StackChan" target */ = {
- isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
- membershipExceptions = (
- Info.plist,
- );
- target = 0EBD7D372ECDA27C0001A9D1 /* StackChan */;
- };
-/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
-
-/* Begin PBXFileSystemSynchronizedRootGroup section */
- 0EBD7D3A2ECDA27C0001A9D1 /* StackChan */ = {
- isa = PBXFileSystemSynchronizedRootGroup;
- exceptions = (
- 0EBD7E222ECDC9510001A9D1 /* Exceptions for "StackChan" folder in "StackChan" target */,
- );
- explicitFileTypes = {
- Info.plist = text.xml;
- };
- path = StackChan;
- sourceTree = "";
- };
-/* End PBXFileSystemSynchronizedRootGroup section */
-
-/* Begin PBXFrameworksBuildPhase section */
- 0EBD7D352ECDA27C0001A9D1 /* Frameworks */ = {
- isa = PBXFrameworksBuildPhase;
- buildActionMask = 2147483647;
- files = (
- );
- runOnlyForDeploymentPostprocessing = 0;
- };
-/* End PBXFrameworksBuildPhase section */
-
-/* Begin PBXGroup section */
- 0EBD7D2F2ECDA27C0001A9D1 = {
- isa = PBXGroup;
- children = (
- 0EBD7D3A2ECDA27C0001A9D1 /* StackChan */,
- 0EBD7D392ECDA27C0001A9D1 /* Products */,
- 0E4478B82F0A538600010197 /* README.MD */,
- );
- sourceTree = "";
- };
- 0EBD7D392ECDA27C0001A9D1 /* Products */ = {
- isa = PBXGroup;
- children = (
- 0EBD7D382ECDA27C0001A9D1 /* StackChan.app */,
- );
- name = Products;
- sourceTree = "";
- };
-/* End PBXGroup section */
-
-/* Begin PBXNativeTarget section */
- 0EBD7D372ECDA27C0001A9D1 /* StackChan */ = {
- isa = PBXNativeTarget;
- buildConfigurationList = 0EBD7D432ECDA27D0001A9D1 /* Build configuration list for PBXNativeTarget "StackChan" */;
- buildPhases = (
- 0EBD7D342ECDA27C0001A9D1 /* Sources */,
- 0EBD7D352ECDA27C0001A9D1 /* Frameworks */,
- 0EBD7D362ECDA27C0001A9D1 /* Resources */,
- );
- buildRules = (
- );
- dependencies = (
- );
- fileSystemSynchronizedGroups = (
- 0EBD7D3A2ECDA27C0001A9D1 /* StackChan */,
- );
- name = StackChan;
- packageProductDependencies = (
- );
- productName = StackChan;
- productReference = 0EBD7D382ECDA27C0001A9D1 /* StackChan.app */;
- productType = "com.apple.product-type.application";
- };
-/* End PBXNativeTarget section */
-
-/* Begin PBXProject section */
- 0EBD7D302ECDA27C0001A9D1 /* Project object */ = {
- isa = PBXProject;
- attributes = {
- BuildIndependentTargetsInParallel = 1;
- LastSwiftUpdateCheck = 2610;
- LastUpgradeCheck = 2620;
- TargetAttributes = {
- 0EBD7D372ECDA27C0001A9D1 = {
- CreatedOnToolsVersion = 26.1.1;
- };
- };
- };
- buildConfigurationList = 0EBD7D332ECDA27C0001A9D1 /* Build configuration list for PBXProject "StackChan" */;
- developmentRegion = en;
- hasScannedForEncodings = 0;
- knownRegions = (
- en,
- Base,
- );
- mainGroup = 0EBD7D2F2ECDA27C0001A9D1;
- minimizedProjectReferenceProxies = 1;
- packageReferences = (
- );
- preferredProjectObjectVersion = 77;
- productRefGroup = 0EBD7D392ECDA27C0001A9D1 /* Products */;
- projectDirPath = "";
- projectRoot = "";
- targets = (
- 0EBD7D372ECDA27C0001A9D1 /* StackChan */,
- );
- };
-/* End PBXProject section */
-
-/* Begin PBXResourcesBuildPhase section */
- 0EBD7D362ECDA27C0001A9D1 /* Resources */ = {
- isa = PBXResourcesBuildPhase;
- buildActionMask = 2147483647;
- files = (
- 0E4478B92F0A538600010197 /* README.MD in Resources */,
- );
- runOnlyForDeploymentPostprocessing = 0;
- };
-/* End PBXResourcesBuildPhase section */
-
-/* Begin PBXSourcesBuildPhase section */
- 0EBD7D342ECDA27C0001A9D1 /* Sources */ = {
- isa = PBXSourcesBuildPhase;
- buildActionMask = 2147483647;
- files = (
- );
- runOnlyForDeploymentPostprocessing = 0;
- };
-/* End PBXSourcesBuildPhase section */
-
-/* Begin XCBuildConfiguration section */
- 0EBD7D412ECDA27D0001A9D1 /* Debug */ = {
- isa = XCBuildConfiguration;
- buildSettings = {
- ALWAYS_SEARCH_USER_PATHS = NO;
- ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
- CLANG_ANALYZER_NONNULL = YES;
- CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
- CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
- CLANG_ENABLE_MODULES = YES;
- CLANG_ENABLE_OBJC_ARC = YES;
- CLANG_ENABLE_OBJC_WEAK = YES;
- CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
- CLANG_WARN_BOOL_CONVERSION = YES;
- CLANG_WARN_COMMA = YES;
- CLANG_WARN_CONSTANT_CONVERSION = YES;
- CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
- CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
- CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
- CLANG_WARN_EMPTY_BODY = YES;
- CLANG_WARN_ENUM_CONVERSION = YES;
- CLANG_WARN_INFINITE_RECURSION = YES;
- CLANG_WARN_INT_CONVERSION = YES;
- CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
- CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
- CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
- CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
- CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
- CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
- CLANG_WARN_STRICT_PROTOTYPES = YES;
- CLANG_WARN_SUSPICIOUS_MOVE = YES;
- CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
- CLANG_WARN_UNREACHABLE_CODE = YES;
- CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
- COPY_PHASE_STRIP = NO;
- DEBUG_INFORMATION_FORMAT = dwarf;
- DEVELOPMENT_TEAM = NG678HLKHZ;
- ENABLE_STRICT_OBJC_MSGSEND = YES;
- ENABLE_TESTABILITY = YES;
- ENABLE_USER_SCRIPT_SANDBOXING = YES;
- GCC_C_LANGUAGE_STANDARD = gnu17;
- GCC_DYNAMIC_NO_PIC = NO;
- GCC_NO_COMMON_BLOCKS = YES;
- GCC_OPTIMIZATION_LEVEL = 0;
- GCC_PREPROCESSOR_DEFINITIONS = (
- "DEBUG=1",
- "$(inherited)",
- );
- GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
- GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
- GCC_WARN_UNDECLARED_SELECTOR = YES;
- GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
- GCC_WARN_UNUSED_FUNCTION = YES;
- GCC_WARN_UNUSED_VARIABLE = YES;
- IPHONEOS_DEPLOYMENT_TARGET = 26.1;
- LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
- MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
- MTL_FAST_MATH = YES;
- ONLY_ACTIVE_ARCH = YES;
- SDKROOT = iphoneos;
- STRING_CATALOG_GENERATE_SYMBOLS = YES;
- SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
- SWIFT_OPTIMIZATION_LEVEL = "-Onone";
- };
- name = Debug;
- };
- 0EBD7D422ECDA27D0001A9D1 /* Release */ = {
- isa = XCBuildConfiguration;
- buildSettings = {
- ALWAYS_SEARCH_USER_PATHS = NO;
- ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
- CLANG_ANALYZER_NONNULL = YES;
- CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
- CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
- CLANG_ENABLE_MODULES = YES;
- CLANG_ENABLE_OBJC_ARC = YES;
- CLANG_ENABLE_OBJC_WEAK = YES;
- CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
- CLANG_WARN_BOOL_CONVERSION = YES;
- CLANG_WARN_COMMA = YES;
- CLANG_WARN_CONSTANT_CONVERSION = YES;
- CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
- CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
- CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
- CLANG_WARN_EMPTY_BODY = YES;
- CLANG_WARN_ENUM_CONVERSION = YES;
- CLANG_WARN_INFINITE_RECURSION = YES;
- CLANG_WARN_INT_CONVERSION = YES;
- CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
- CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
- CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
- CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
- CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
- CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
- CLANG_WARN_STRICT_PROTOTYPES = YES;
- CLANG_WARN_SUSPICIOUS_MOVE = YES;
- CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
- CLANG_WARN_UNREACHABLE_CODE = YES;
- CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
- COPY_PHASE_STRIP = NO;
- DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
- DEVELOPMENT_TEAM = NG678HLKHZ;
- ENABLE_NS_ASSERTIONS = NO;
- ENABLE_STRICT_OBJC_MSGSEND = YES;
- ENABLE_USER_SCRIPT_SANDBOXING = YES;
- GCC_C_LANGUAGE_STANDARD = gnu17;
- GCC_NO_COMMON_BLOCKS = YES;
- GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
- GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
- GCC_WARN_UNDECLARED_SELECTOR = YES;
- GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
- GCC_WARN_UNUSED_FUNCTION = YES;
- GCC_WARN_UNUSED_VARIABLE = YES;
- IPHONEOS_DEPLOYMENT_TARGET = 26.1;
- LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
- MTL_ENABLE_DEBUG_INFO = NO;
- MTL_FAST_MATH = YES;
- SDKROOT = iphoneos;
- STRING_CATALOG_GENERATE_SYMBOLS = YES;
- SWIFT_COMPILATION_MODE = wholemodule;
- VALIDATE_PRODUCT = YES;
- };
- name = Release;
- };
- 0EBD7D442ECDA27D0001A9D1 /* Debug */ = {
- isa = XCBuildConfiguration;
- baseConfigurationReferenceAnchor = 0EBD7D3A2ECDA27C0001A9D1 /* StackChan */;
- baseConfigurationReferenceRelativePath = App.xcconfig;
- buildSettings = {
- ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
- ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
- CODE_SIGN_ENTITLEMENTS = StackChan/StackChan.entitlements;
- CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 3;
- DEVELOPMENT_TEAM = NG678HLKHZ;
- ENABLE_PREVIEWS = YES;
- GENERATE_INFOPLIST_FILE = YES;
- HEADER_SEARCH_PATHS = "";
- INFOPLIST_FILE = StackChan/Info.plist;
- INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Bluetooth permission is required to connect to nearby devices";
- INFOPLIST_KEY_NSCameraUsageDescription = "Camera permission is required to scan the code";
- INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Local network access is required to discover devices";
- INFOPLIST_KEY_NSLocationAlwaysAndWhenInUseUsageDescription = "Location permission is required to access Wi-Fi information";
- INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "Location permission is required to access Wi-Fi information";
- INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
- INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
- INFOPLIST_KEY_UILaunchScreen_Generation = YES;
- INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
- IPHONEOS_DEPLOYMENT_TARGET = 16.6;
- LD_RUNPATH_SEARCH_PATHS = (
- "$(inherited)",
- "@executable_path/Frameworks",
- );
- LIBRARY_SEARCH_PATHS = "$(inherited)";
- MARKETING_VERSION = 1.0.3;
- OTHER_LDFLAGS = (
- "$(inherited)",
- "-ObjC",
- );
- PRODUCT_BUNDLE_IDENTIFIER = com.m5stack.StackChan;
- PRODUCT_NAME = "$(TARGET_NAME)";
- STRING_CATALOG_GENERATE_SYMBOLS = YES;
- SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
- SUPPORTS_MACCATALYST = NO;
- SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES;
- SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = YES;
- SWIFT_APPROACHABLE_CONCURRENCY = YES;
- SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
- SWIFT_EMIT_LOC_STRINGS = YES;
- SWIFT_OBJC_BRIDGING_HEADER = "";
- SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
- SWIFT_VERSION = 5.0;
- TARGETED_DEVICE_FAMILY = "1,2";
- };
- name = Debug;
- };
- 0EBD7D452ECDA27D0001A9D1 /* Release */ = {
- isa = XCBuildConfiguration;
- baseConfigurationReferenceAnchor = 0EBD7D3A2ECDA27C0001A9D1 /* StackChan */;
- baseConfigurationReferenceRelativePath = App.xcconfig;
- buildSettings = {
- ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
- ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
- CODE_SIGN_ENTITLEMENTS = StackChan/StackChan.entitlements;
- CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 3;
- DEVELOPMENT_TEAM = NG678HLKHZ;
- ENABLE_PREVIEWS = YES;
- GENERATE_INFOPLIST_FILE = YES;
- HEADER_SEARCH_PATHS = "";
- INFOPLIST_FILE = StackChan/Info.plist;
- INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Bluetooth permission is required to connect to nearby devices";
- INFOPLIST_KEY_NSCameraUsageDescription = "Camera permission is required to scan the code";
- INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Local network access is required to discover devices";
- INFOPLIST_KEY_NSLocationAlwaysAndWhenInUseUsageDescription = "Location permission is required to access Wi-Fi information";
- INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "Location permission is required to access Wi-Fi information";
- INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
- INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
- INFOPLIST_KEY_UILaunchScreen_Generation = YES;
- INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
- IPHONEOS_DEPLOYMENT_TARGET = 16.6;
- LD_RUNPATH_SEARCH_PATHS = (
- "$(inherited)",
- "@executable_path/Frameworks",
- );
- LIBRARY_SEARCH_PATHS = "$(inherited)";
- MARKETING_VERSION = 1.0.3;
- OTHER_LDFLAGS = (
- "$(inherited)",
- "-ObjC",
- );
- PRODUCT_BUNDLE_IDENTIFIER = com.m5stack.StackChan;
- PRODUCT_NAME = "$(TARGET_NAME)";
- STRING_CATALOG_GENERATE_SYMBOLS = YES;
- SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
- SUPPORTS_MACCATALYST = NO;
- SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES;
- SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = YES;
- SWIFT_APPROACHABLE_CONCURRENCY = YES;
- SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
- SWIFT_EMIT_LOC_STRINGS = YES;
- SWIFT_OBJC_BRIDGING_HEADER = "";
- SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
- SWIFT_VERSION = 5.0;
- TARGETED_DEVICE_FAMILY = "1,2";
- };
- name = Release;
- };
-/* End XCBuildConfiguration section */
-
-/* Begin XCConfigurationList section */
- 0EBD7D332ECDA27C0001A9D1 /* Build configuration list for PBXProject "StackChan" */ = {
- isa = XCConfigurationList;
- buildConfigurations = (
- 0EBD7D412ECDA27D0001A9D1 /* Debug */,
- 0EBD7D422ECDA27D0001A9D1 /* Release */,
- );
- defaultConfigurationIsVisible = 0;
- defaultConfigurationName = Release;
- };
- 0EBD7D432ECDA27D0001A9D1 /* Build configuration list for PBXNativeTarget "StackChan" */ = {
- isa = XCConfigurationList;
- buildConfigurations = (
- 0EBD7D442ECDA27D0001A9D1 /* Debug */,
- 0EBD7D452ECDA27D0001A9D1 /* Release */,
- );
- defaultConfigurationIsVisible = 0;
- defaultConfigurationName = Release;
- };
-/* End XCConfigurationList section */
- };
- rootObject = 0EBD7D302ECDA27C0001A9D1 /* Project object */;
-}
diff --git a/app/StackChan.xcodeproj/project.xcworkspace/xcuserdata/yuanzhihong.xcuserdatad/UserInterfaceState.xcuserstate b/app/StackChan.xcodeproj/project.xcworkspace/xcuserdata/yuanzhihong.xcuserdatad/UserInterfaceState.xcuserstate
deleted file mode 100644
index 93919c4..0000000
Binary files a/app/StackChan.xcodeproj/project.xcworkspace/xcuserdata/yuanzhihong.xcuserdatad/UserInterfaceState.xcuserstate and /dev/null differ
diff --git a/app/StackChan.xcodeproj/xcuserdata/yuanzhihong.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist b/app/StackChan.xcodeproj/xcuserdata/yuanzhihong.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist
deleted file mode 100644
index 6c47dd4..0000000
--- a/app/StackChan.xcodeproj/xcuserdata/yuanzhihong.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
diff --git a/app/StackChan.xcodeproj/xcuserdata/yuanzhihong.xcuserdatad/xcschemes/xcschememanagement.plist b/app/StackChan.xcodeproj/xcuserdata/yuanzhihong.xcuserdatad/xcschemes/xcschememanagement.plist
deleted file mode 100644
index d0e43d9..0000000
--- a/app/StackChan.xcodeproj/xcuserdata/yuanzhihong.xcuserdatad/xcschemes/xcschememanagement.plist
+++ /dev/null
@@ -1,22 +0,0 @@
-
-
-
-
- SchemeUserState
-
- StackChan.xcscheme_^#shared#^_
-
- orderHint
- 0
-
-
- SuppressBuildableAutocreation
-
- 0EBD7D372ECDA27C0001A9D1
-
- primary
-
-
-
-
-
diff --git a/app/StackChan/3DModel/stackChanModel.scn b/app/StackChan/3DModel/stackChanModel.scn
deleted file mode 100644
index 65dd27c..0000000
Binary files a/app/StackChan/3DModel/stackChanModel.scn and /dev/null differ
diff --git a/app/StackChan/AppState.swift b/app/StackChan/AppState.swift
deleted file mode 100644
index 974990b..0000000
--- a/app/StackChan/AppState.swift
+++ /dev/null
@@ -1,268 +0,0 @@
-/*
- * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD
- *
- * SPDX-License-Identifier: MIT
- */
-
-import Combine
-import SwiftUI
-import CoreBluetooth
-import ARKit
-import Combine
-
-enum PageType: Hashable {
- case minicryEmotion
- case cameraPage
- case dance
-}
-
-class AppState: ObservableObject {
- static let shared = AppState()
- private init() {}
-
- static let deviceId = UIDevice.current.identifierForVendor?.uuidString ?? UUID().uuidString
-
- static var isRelease : Bool = true
- @Published var showAlert: Bool = false
- @Published var alertTitle: String = ""
- var alertAction: (() -> Void)? = nil
-
- func presentAlert(title: String,action: (() -> Void)? = nil) {
- self.alertTitle = title
- self.alertAction = action
- self.showAlert = true
- }
-
- @AppStorage("deviceMac") var deviceMac: String = ""
-
- @Published var stackChanPath: [PageType] = []
- @Published var nearbyPath: [PageType] = []
- @Published var settingsPath: [PageType] = []
-
- @Published var showBindingDevice = false
- @Published var forcedDisplayBindingDevice = true
-
- @Published var showCjamgeNameAlert: Bool = false
-
- @Published var showBindingDeviceAlert: Bool = false
-
- @Published var newName: String = ""
- @Published var deviceInfo: Device = Device()
-
- let detector = DistanceDetector()
- @Published var showSwitchFace: Bool = false
-
- @Published var blufDeviceList: [BlufiDeviceInfo] = []
-
- /// Whether currently pairing a device
- @Published var showDeviceWifiSet = false
-
- // Manual shutdown time, if just manually shut down, temporarily do not configure
- var manualShutdownTime: Date? = nil
-
- @Published var deviceIsOnline: Bool = false
-
- func connectBulDevice(macAddress: String) {
- if BlufiUtil.shared.blueSwitch {
- BlufiUtil.shared.startScan()
- } else {
- BlufiUtil.shared.centralManagerDidUpdateState = { state in
- switch state {
- case .poweredOn:
- BlufiUtil.shared.startScan()
- default: break
- }
- }
- }
- BlufiUtil.shared.characteristicCallback = { characteristic in
- if characteristic.properties.contains(.write) || characteristic.properties.contains(.writeWithoutResponse) {
-
- if characteristic.uuid.uuidString == "E2E5E5E2-1234-5678-1234-56789ABCDEF0" {
- BlufiUtil.shared.writeExpressionCharacteristic = characteristic
- print("✏️ Expression writable characteristic assigned: \(characteristic.uuid)")
- }
-
- if characteristic.uuid.uuidString == "E2E5E5E1-1234-5678-1234-56789ABCDEF0" {
- BlufiUtil.shared.writeHeadCharacteristic = characteristic
- print("✏️ Head writable characteristic assigned: \(characteristic.uuid)")
- }
- }
- }
- }
-
- func connectWebSocket() {
- let webSocketUrl = Urls.getWebSocketUrl() + "?mac=" + deviceMac + "&deviceType=App&deviceId=" + AppState.deviceId
- WebSocketUtil.shared.connect(urlString: webSocketUrl)
- }
-
- func sendWebSocketMessage(_ msgType: MsgType,_ data: Data? = nil) {
- var buffer = Data([msgType.rawValue])
-
- let payload = data ?? Data()
-
- // payload length
- let dataLen = UInt32(payload.count)
- buffer.append(UInt8((dataLen >> 24) & 0xFF))
- buffer.append(UInt8((dataLen >> 16) & 0xFF))
- buffer.append(UInt8((dataLen >> 8) & 0xFF))
- buffer.append(UInt8(dataLen & 0xFF))
-
- // data
- buffer.append(payload)
-
- WebSocketUtil.shared.send(data: buffer)
- }
-
- /// Parse message data
- func parseMessage(message: Data) -> (MsgType?,Data?) {
- guard message.count >= 5 else {
- return (nil,nil)
- }
-
- let typeByte = message[0]
- guard let msgType = MsgType(rawValue: typeByte) else {
- return (nil,nil)
- }
-
- let lengthData = message[1...4]
- let dataLength = lengthData.reduce(0) { (result, byte) -> UInt32 in
- return (result << 8) | UInt32(byte)
- }
-
- if message.count < 5 + Int(dataLength) {
- return (nil,nil)
- }
-
- let payload = message[5..<(5 + Int(dataLength))]
-
- return (msgType, Data(payload))
- }
-
- func updateDeviceInfo() {
- let map = [
- ValueConstant.mac: deviceMac,
- ValueConstant.name: deviceInfo.name,
- ]
- Networking.shared.put(pathUrl: Urls.deviceInfo, parameters: map) { result in
- switch result {
- case .success(let success):
- do {
- let response = try Response.decode(from: success)
- if response.isSuccess {
- print("Update successful")
- }
- } catch {
- print("Failed to parse data")
- }
- case .failure(let failure):
- print("Request failed:", failure)
- }
- }
- }
-
- /// Distance detection, callback when close
- func startDistanceDetection() {
- detector.startDistanceDetection(
- distanceUpdate: { distance in
- let distanceInCm = distance * 100
- if distanceInCm < 5 {
- if self.showSwitchFace == false {
- self.showSwitchFace = true
- }
- }
- },
- belowThreshold: {
- // Execute your business logic
- // For example: stop machine, send notification, etc.
- }
- )
- }
-
- func stopDistanceDetection() {
- detector.stopDistanceDetection()
- }
-
- // Wrapper for ARSessionDelegate
- class ARSessionDelegateWrapper: NSObject, ARSessionDelegate {
- var onFrameUpdate: (ARFrame) -> Void
-
- init(onFrameUpdate: @escaping (ARFrame) -> Void) {
- self.onFrameUpdate = onFrameUpdate
- }
-
- func session(_ session: ARSession, didUpdate frame: ARFrame) {
- onFrameUpdate(frame)
- }
- }
-
- func getDeviceInfo() {
- let map = [
- ValueConstant.mac: deviceMac
- ]
- Networking.shared.get(pathUrl: Urls.deviceInfo,parameters: map) { result in
- switch result {
- case .success(let success):
- do {
- let response = try Response.decode(from: success)
- if response.isSuccess, let deviceInfo = response.data {
- withAnimation {
- self.deviceInfo = deviceInfo
- self.newName = self.deviceInfo.name ?? ""
- }
- if deviceInfo.name == "" {
- self.showCjamgeNameAlert = true
- }
- }
- } catch {
- print("Failed to parse data")
- }
- case .failure(let failure):
- print("Request failed:", failure)
- }
- }
- }
-
- /// Enable Bluetooth functionality
- func openBlufi() {
- BlufiUtil.shared.blufDevicesMonitoring = { discovereDevices in
- self.blufDeviceList = discovereDevices
-
- // Check manualShutdownTime, if exists and not exceeding 5 seconds, temporarily do not show popup
- if let shutdownTime = self.manualShutdownTime {
- let timeInterval = Date().timeIntervalSince(shutdownTime)
- if timeInterval < 5 {
- return
- }
- }
- if !self.showDeviceWifiSet {
- if !self.blufDeviceList.isEmpty {
- self.showDeviceWifiSet = true
- }
- }
- }
- }
-
- // webSocket Message Monitoring
- func webSocketMessageMonitoring() {
- WebSocketUtil.shared.addObserver(for: "App") { (message: URLSessionWebSocketTask.Message) in
- switch message {
- case .data(let data):
- let result = self.parseMessage(message: data)
- if let msgType = result.0 {
- switch msgType {
- case MsgType.deviceOnline:
- self.deviceIsOnline = false
- case MsgType.deviceOffline:
- self.deviceIsOnline = true
- default:
- break
- }
- }
- case .string(let text):
- print("Received a regular message: \(text)")
- @unknown default:
- break
- }
- }
- }
-}
diff --git a/app/StackChan/Assets.xcassets/AccentColor.colorset/Contents.json b/app/StackChan/Assets.xcassets/AccentColor.colorset/Contents.json
deleted file mode 100644
index 2f961ca..0000000
--- a/app/StackChan/Assets.xcassets/AccentColor.colorset/Contents.json
+++ /dev/null
@@ -1,23 +0,0 @@
-{
- "colors" : [
- {
- "color" : {
- "color-space" : "srgb",
- "components" : {
- "alpha" : "1.000",
- "blue" : "0.057",
- "green" : "0.689",
- "red" : "0.936"
- }
- },
- "idiom" : "universal"
- }
- ],
- "info" : {
- "author" : "xcode",
- "version" : 1
- },
- "properties" : {
- "localizable" : true
- }
-}
diff --git a/app/StackChan/Assets.xcassets/AppIcon.appiconset/Contents.json b/app/StackChan/Assets.xcassets/AppIcon.appiconset/Contents.json
deleted file mode 100644
index 0380c9b..0000000
--- a/app/StackChan/Assets.xcassets/AppIcon.appiconset/Contents.json
+++ /dev/null
@@ -1,36 +0,0 @@
-{
- "images" : [
- {
- "filename" : "app_logo.jpg",
- "idiom" : "universal",
- "platform" : "ios",
- "size" : "1024x1024"
- },
- {
- "appearances" : [
- {
- "appearance" : "luminosity",
- "value" : "dark"
- }
- ],
- "idiom" : "universal",
- "platform" : "ios",
- "size" : "1024x1024"
- },
- {
- "appearances" : [
- {
- "appearance" : "luminosity",
- "value" : "tinted"
- }
- ],
- "idiom" : "universal",
- "platform" : "ios",
- "size" : "1024x1024"
- }
- ],
- "info" : {
- "author" : "xcode",
- "version" : 1
- }
-}
diff --git a/app/StackChan/Assets.xcassets/Contents.json b/app/StackChan/Assets.xcassets/Contents.json
deleted file mode 100644
index 73c0059..0000000
--- a/app/StackChan/Assets.xcassets/Contents.json
+++ /dev/null
@@ -1,6 +0,0 @@
-{
- "info" : {
- "author" : "xcode",
- "version" : 1
- }
-}
diff --git a/app/StackChan/Assets.xcassets/lateral_image.imageset/Contents.json b/app/StackChan/Assets.xcassets/lateral_image.imageset/Contents.json
deleted file mode 100644
index 5fab30d..0000000
--- a/app/StackChan/Assets.xcassets/lateral_image.imageset/Contents.json
+++ /dev/null
@@ -1,21 +0,0 @@
-{
- "images" : [
- {
- "filename" : "7.595.png",
- "idiom" : "universal",
- "scale" : "1x"
- },
- {
- "idiom" : "universal",
- "scale" : "2x"
- },
- {
- "idiom" : "universal",
- "scale" : "3x"
- }
- ],
- "info" : {
- "author" : "xcode",
- "version" : 1
- }
-}
diff --git a/app/StackChan/Info.plist b/app/StackChan/Info.plist
deleted file mode 100644
index c1c146d..0000000
--- a/app/StackChan/Info.plist
+++ /dev/null
@@ -1,29 +0,0 @@
-
-
-
-
- NSAppTransportSecurity
-
- NSAllowsArbitraryLoads
-
-
- NSBonjourServices
-
- _stackchan-mpc._tcp
-
- com.apple.developer.networking.wifi-info
-
-
- NSLocalNetworkUsageDescription
- This app requires access to the local network to communicate with devices.
- NSMicrophoneUsageDescription
- We need access to the microphone to capture audio data.
- ITSAppUsesNonExemptEncryption
-
- CFBundleDisplayName
- StackChan World
-
- NSPhotoLibraryAddUsageDescription
- Save the photo to the album
-
-
diff --git a/app/StackChan/Model/BlufiModel.swift b/app/StackChan/Model/BlufiModel.swift
deleted file mode 100644
index 0412dab..0000000
--- a/app/StackChan/Model/BlufiModel.swift
+++ /dev/null
@@ -1,62 +0,0 @@
-/*
- * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD
- *
- * SPDX-License-Identifier: MIT
- */
-
-import Foundation
-
-struct BlufiModel: Codable {
-
- var cmd: String? = nil
- var data: T? = nil
-
- func toJson() -> String? {
- let encoder = JSONEncoder()
- encoder.outputFormatting = .prettyPrinted
- guard let jsonData = try? encoder.encode(self) else { return nil }
- return String(data: jsonData, encoding: .utf8)
- }
-
- static func fromJson(_ json: String) -> BlufiModel? {
- guard let jsonData = json.data(using: .utf8) else { return nil }
- let decoder = JSONDecoder()
- return try? decoder.decode(BlufiModel.self, from: jsonData)
- }
-}
-
-struct BlufiWifi : Codable {
- var ssid: String?
- var password: String?
-
- func toJson() -> String? {
- let encoder = JSONEncoder()
- encoder.outputFormatting = .prettyPrinted
- guard let jsonData = try? encoder.encode(self) else { return nil }
- return String(data: jsonData, encoding: .utf8)
- }
-
- static func fromJson(_ json: String) -> BlufiWifi? {
- guard let jsonData = json.data(using: .utf8) else { return nil }
- let decoder = JSONDecoder()
- return try? decoder.decode(BlufiWifi.self, from: jsonData)
- }
-}
-
-struct BlufiNotifyState : Codable {
- var type: Int?
- var state: String?
-
- func toJson() -> String? {
- let encoder = JSONEncoder()
- encoder.outputFormatting = .prettyPrinted
- guard let jsonData = try? encoder.encode(self) else { return nil }
- return String(data: jsonData, encoding: .utf8)
- }
-
- static func fromJson(_ json: String) -> BlufiNotifyState? {
- guard let jsonData = json.data(using: .utf8) else { return nil }
- let decoder = JSONDecoder()
- return try? decoder.decode(BlufiNotifyState.self, from: jsonData)
- }
-}
diff --git a/app/StackChan/Model/Device.swift b/app/StackChan/Model/Device.swift
deleted file mode 100644
index fb042ee..0000000
--- a/app/StackChan/Model/Device.swift
+++ /dev/null
@@ -1,12 +0,0 @@
-/*
- * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD
- *
- * SPDX-License-Identifier: MIT
- */
-
-import Foundation
-
-struct Device : Codable {
- var mac: String = UUID().uuidString
- var name: String? = nil
-}
diff --git a/app/StackChan/Model/MessageModel.swift b/app/StackChan/Model/MessageModel.swift
deleted file mode 100644
index 458b114..0000000
--- a/app/StackChan/Model/MessageModel.swift
+++ /dev/null
@@ -1,39 +0,0 @@
-/*
- * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD
- *
- * SPDX-License-Identifier: MIT
- */
-
-import Foundation
-
-enum MsgType: UInt8, Codable {
- case opus = 0x01
- case jpeg = 0x02
- case controlAvatar = 0x03
- case controlMotion = 0x04
-
- case onCamera = 0x05
- case offCamera = 0x06
-
- case textMessage = 0x07
- case requestCall = 0x09
- case refuseCall = 0x0A
- case agreeCall = 0x0B
- case hangupCall = 0x0C
-
- case updateDeviceName = 0x0D
- case getDeviceName = 0x0E
-
- case ping = 0x10
- case pong = 0x11
-
- case onPhoneScreen = 0x12
- case offPhoneScreen = 0x13
-
- case dance = 0x14
-
- case getAvatarPosture = 0x15
-
- case deviceOffline = 0x16
- case deviceOnline = 0x17
-}
diff --git a/app/StackChan/Model/Post.swift b/app/StackChan/Model/Post.swift
deleted file mode 100644
index 698702c..0000000
--- a/app/StackChan/Model/Post.swift
+++ /dev/null
@@ -1,32 +0,0 @@
-/*
- * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD
- *
- * SPDX-License-Identifier: MIT
- */
-
-import Foundation
-
-struct Post : Codable{
- var id: Int
- var mac: String? = nil
- var name: String? = nil
- var contentText: String? = nil
- var contentImage: String? = nil
- var createdAt: String? = nil
- var postCommentList: [PostComment]? = nil
-}
-
-struct PostComment: Codable {
- var id: Int? = nil
- var postId: Int? = nil
- var mac: String? = nil
- var name: String? = nil
- var content: String? = nil
- var createAt: String? = nil
-}
-
-
-struct GetPostComment: Codable {
- var list: [PostComment]? = nil
- var total: Int? = nil
-}
diff --git a/app/StackChan/Model/Response.swift b/app/StackChan/Model/Response.swift
deleted file mode 100644
index 68b1d49..0000000
--- a/app/StackChan/Model/Response.swift
+++ /dev/null
@@ -1,79 +0,0 @@
-/*
- * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD
- *
- * SPDX-License-Identifier: MIT
- */
-
-import Foundation
-
-struct Response: Codable {
- let code: Int?
- let message: String?
- let data: T?
-
- var isSuccess: Bool {
- return code == 0
- }
-
- func unwrap(or defaultValue: T) -> T {
- return data ?? defaultValue
- }
-
- static func decode(from jsonData: Data) throws -> Response {
- let decoder = JSONDecoder()
- decoder.keyDecodingStrategy = .convertFromSnakeCase
- do {
- return try decoder.decode(Response.self, from: jsonData)
- } catch let DecodingError.dataCorrupted(context) {
- print("🔴 Data corrupted: \(context.debugDescription)")
- printCodingPath(context.codingPath)
- printJSON(jsonData)
- throw DecodingError.dataCorrupted(context)
- } catch let DecodingError.keyNotFound(key, context) {
- print("🔴 Key '\(key.stringValue)' not found: \(context.debugDescription)")
- printCodingPath(context.codingPath)
- printJSON(jsonData)
- throw DecodingError.keyNotFound(key, context)
- } catch let DecodingError.typeMismatch(type, context) {
- print("🔴 Type '\(type)' mismatch: \(context.debugDescription)")
- printCodingPath(context.codingPath)
- printJSON(jsonData)
- throw DecodingError.typeMismatch(type, context)
- } catch let DecodingError.valueNotFound(value, context) {
- print("🔴 Value '\(value)' not found: \(context.debugDescription)")
- printCodingPath(context.codingPath)
- printJSON(jsonData)
- throw DecodingError.valueNotFound(value, context)
- } catch {
- print("🔴 Other errors in the analysis: \(error)")
- printJSON(jsonData)
- throw error
- }
- }
-
- static func decode(from json: [String: Any]) throws -> Response {
- let data = try JSONSerialization.data(withJSONObject: json, options: [])
- return try decode(from: data)
- }
-
- func debugDescription() -> String {
- return "Response(code: \(code ?? 0), message: \(message ?? ""), data: \(String(describing: data)))"
- }
-}
-
-fileprivate func printCodingPath(_ codingPath: [CodingKey]) {
- let path = codingPath.map { $0.stringValue }.joined(separator: ".")
- print("📍 Error path: \(path)")
-}
-
-fileprivate func printJSON(_ data: Data) {
- if let obj = try? JSONSerialization.jsonObject(with: data, options: []),
- let prettyData = try? JSONSerialization.data(withJSONObject: obj, options: [.prettyPrinted]),
- let str = String(data: prettyData, encoding: .utf8) {
- print("📄 Original JSON:\n\(str)")
- } else if let str = String(data: data, encoding: .utf8) {
- print("📄 Original JSON:\n\(str)")
- } else {
- print("⚠️ Unable to parse the original JSON")
- }
-}
diff --git a/app/StackChan/Model/UploadFile.swift b/app/StackChan/Model/UploadFile.swift
deleted file mode 100644
index bc63fa5..0000000
--- a/app/StackChan/Model/UploadFile.swift
+++ /dev/null
@@ -1,9 +0,0 @@
-/*
- * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD
- *
- * SPDX-License-Identifier: MIT
- */
-
-struct UploadFile : Codable {
- var path: String? = nil
-}
diff --git a/app/StackChan/Network/Networking.swift b/app/StackChan/Network/Networking.swift
deleted file mode 100644
index b8576b0..0000000
--- a/app/StackChan/Network/Networking.swift
+++ /dev/null
@@ -1,328 +0,0 @@
-/*
- * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD
- *
- * SPDX-License-Identifier: MIT
- */
-
-import Foundation
-
-class Networking {
-
- static let shared = Networking()
- private init() {}
-
- enum HTTPMethod: String {
- case GET,POST,PUT,DELETE
- }
-
- private func request(
- urlString: String,
- method: HTTPMethod,
- parameters: Any? = nil,
- headers: [String: String] = [:],
- completion: @escaping (Result) -> Void
- ) {
- var finalURLString = urlString
- var httpBody: Data? = nil
-
- if method == .GET {
- if let params = parameters as? [String: Any], !params.isEmpty {
- var components = URLComponents(string: urlString)
- components?.queryItems = params.map { URLQueryItem(name: $0.key, value: "\($0.value)") }
- if let urlWithQuery = components?.url?.absoluteString {
- finalURLString = urlWithQuery
- }
- }
- } else {
- if let params = parameters {
- requestSetContentType: do {
- requestSetBody: do {
- do {
- if let dict = params as? [String: Any] {
- httpBody = try JSONSerialization.data(withJSONObject: dict, options: [])
- } else if let array = params as? [Any] {
- httpBody = try JSONSerialization.data(withJSONObject: array, options: [])
- } else {
- httpBody = try JSONSerialization.data(withJSONObject: params, options: [])
- }
- } catch {
- completion(.failure(error))
- return
- }
- }
- }
- }
- }
-
- guard let url = URL(string: finalURLString) else {
- completion(.failure(NSError(domain: "Invalid URL", code: -1)))
- return
- }
-
- var request = URLRequest(url: url)
- request.httpMethod = method.rawValue
-
- if method != .GET, httpBody != nil {
- request.setValue("application/json", forHTTPHeaderField: "Content-Type")
- request.httpBody = httpBody
- }
-
- setHandler(request: &request, headers: headers)
- logRequest(request)
-
- URLSession.shared.dataTask(with: request) { data, response, error in
- DispatchQueue.main.async {
- if let error = error {
- completion(.failure(error))
- return
- }
- guard let data = data else {
- completion(.failure(NSError(domain: "No data returned", code: -2)))
- return
- }
- self.logResponse(data: data)
- completion(.success(data))
- }
- }.resume()
- }
-
- func get(pathUrl: String, parameters: [String: Any] = [:], headers: [String: String] = [:], baseUrlString: String? = nil, completion: @escaping (Result) -> Void) {
- let finalUrl = (baseUrlString ?? Urls.getBaseUrl()) + pathUrl
- request(urlString: finalUrl, method: .GET, parameters: parameters, headers: headers, completion: completion)
- }
-
- func post(pathUrl: String, parameters: Any? = nil, headers: [String: String] = [:], baseUrlString: String? = nil, completion: @escaping (Result) -> Void) {
- let finalUrl = (baseUrlString ?? Urls.getBaseUrl()) + pathUrl
- request(urlString: finalUrl, method: .POST, parameters: parameters, headers: headers, completion: completion)
- }
-
- private func setHandler(request: inout URLRequest, headers: [String: String]) {
- for (key, value) in headers {
- request.setValue(value, forHTTPHeaderField: key)
- }
- if let token = UserDefaults.standard.string(forKey: ValueConstant.token), !token.isEmpty {
- request.setValue(token, forHTTPHeaderField: ValueConstant.Authorization)
- }
- }
-
- func postFromData(pathUrl: String,
- parameters: [String: Any?] = [:],
- headers: [String: String] = [:],
- baseUrlString: String? = nil,
- suffix:String? = nil,
- completion: @escaping (Result) -> Void) {
- let finalUrl = (baseUrlString ?? Urls.getBaseUrl()) + pathUrl
- guard let url = URL(string: finalUrl) else {
- completion(.failure(NSError(domain: "Invalid URL", code: -1)))
- return
- }
-
- var request = URLRequest(url: url)
- request.httpMethod = HTTPMethod.POST.rawValue
-
- let boundary = "Boundary-\(UUID().uuidString)"
- request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
-
- setHandler(request: &request, headers: headers)
-
- var requestBody = Data()
-
- for (key, value) in parameters {
- if let value = value {
- if let fileData = value as? Data {
- let type = mimeType(for: fileData)
- let fileName = UUID().uuidString + (suffix ?? "")
- requestBody.append("--\(boundary)\r\n".data(using: .utf8)!)
- requestBody.append("Content-Disposition: form-data; name=\"\(key)\"; filename=\"\(fileName)\"\r\n".data(using: .utf8)!)
- requestBody.append("Content-Type: \(type)\r\n\r\n".data(using: .utf8)!)
- requestBody.append(fileData)
- requestBody.append("\r\n".data(using: .utf8)!)
- } else if let array = value as? [Any] {
- if let jsonData = try? JSONSerialization.data(withJSONObject: array, options: []) {
- let jsonString = String(data: jsonData, encoding: .utf8)
- requestBody.append("--\(boundary)\r\n".data(using: .utf8)!)
- requestBody.append("Content-Disposition: form-data; name=\"\(key)\"\r\n\r\n".data(using: .utf8)!)
- requestBody.append("\(jsonString ?? "[]")\r\n".data(using: .utf8)!)
- }
- } else if let dict = value as? [String:Any] {
- if let jsonData = try? JSONSerialization.data(withJSONObject: dict, options: []) {
- let jsonString = String(data: jsonData, encoding: .utf8)
- requestBody.append("--\(boundary)\r\n".data(using: .utf8)!)
- requestBody.append("Content-Disposition: form-data; name=\"\(key)\"\r\n\r\n".data(using: .utf8)!)
- requestBody.append("\(jsonString ?? "{}")\r\n".data(using: .utf8)!)
- }
- } else {
- let str = "\(value)"
- requestBody.append("--\(boundary)\r\n".data(using: .utf8)!)
- requestBody.append("Content-Disposition: form-data; name=\"\(key)\"\r\n\r\n".data(using: .utf8)!)
- requestBody.append("\(str)\r\n".data(using: .utf8)!)
- }
- }
- }
- requestBody.append("--\(boundary)--\r\n".data(using: .utf8)!)
- request.httpBody = requestBody
-
- logRequest(request)
-
- URLSession.shared.dataTask(with: request) { data, response, error in
- DispatchQueue.main.async {
- if let error = error {
- completion(.failure(error))
- return
- }
- guard let data = data else {
- completion(.failure(NSError(domain: "No data returned", code: -2)))
- return
- }
- self.logResponse(data: data)
- completion(.success(data))
- }
- }.resume()
- }
-
- func anyToJson(data: Any) -> String {
- func convert(_ value: Any) -> Any {
- if let dict = value as? [String: Any] {
- var newDict: [String: Any] = [:]
- for (k, v) in dict {
- newDict[k] = convert(v)
- }
- return newDict
- } else if let dict = value as? [String: Any?] {
- var newDict: [String: Any] = [:]
- for (k, v) in dict {
- if let unwrapped = v {
- newDict[k] = convert(unwrapped)
- } else {
- newDict[k] = NSNull()
- }
- }
- return newDict
- } else if let array = value as? [Any] {
- return array.map { convert($0) }
- } else if let array = value as? [Any?] {
- return array.map { $0 == nil ? NSNull() : convert($0!) }
- } else if value is Int || value is Double || value is Bool || value is String {
- return value
- } else {
- return "\(value)"
- }
- }
-
- let converted = convert(data)
-
- if JSONSerialization.isValidJSONObject(converted) {
- do {
- let jsonData = try JSONSerialization.data(withJSONObject: converted, options: [])
- return String(data: jsonData, encoding: .utf8) ?? "[]"
- } catch {
- print("JSON Serialization error: \(error)")
- return "[]"
- }
- } else {
- if let str = converted as? String {
- return "\"\(str)\""
- } else {
- return "\(converted)"
- }
- }
- }
-
- func put(pathUrl: String, parameters: Any? = nil, headers: [String: String] = [:], baseUrlString: String? = nil, completion: @escaping (Result) -> Void) {
- let finalUrl = (baseUrlString ?? Urls.getBaseUrl()) + pathUrl
- request(urlString: finalUrl, method: .PUT, parameters: parameters, headers: headers, completion: completion)
- }
-
- func delete(pathUrl: String, parameters: Any? = nil, headers: [String: String] = [:], baseUrlString: String? = nil, completion: @escaping (Result) -> Void) {
- let finalUrl = (baseUrlString ?? Urls.getBaseUrl()) + pathUrl
- request(urlString: finalUrl, method: .DELETE, parameters: parameters, headers: headers, completion: completion)
- }
-
- func download(pathUrl: String,
- parameters: [String: Any] = [:],
- headers: [String: String] = [:],
- baseUrlString: String? = nil,
- completion: @escaping (Result) -> Void) {
-
- let finalUrl = (baseUrlString ?? Urls.getBaseUrl()) + pathUrl
- let key = FileUtils.shared.hashedKey(for: finalUrl)
- let cacheURL = FileUtils.shared.cacheDirectory().appendingPathComponent(key)
-
- if FileManager.default.fileExists(atPath: cacheURL.path) {
- completion(.success(cacheURL.path))
- return
- }
-
- var request = URLRequest(url: URL(string: finalUrl)!)
- request.httpMethod = "GET"
-
- setHandler(request: &request, headers: headers)
-
- URLSession.shared.downloadTask(with: request) { tempURL, response, error in
- DispatchQueue.main.async {
- if let error = error {
- completion(.failure(error))
- return
- }
-
- guard let tempURL = tempURL else {
- completion(.failure(NSError(domain: "No file downloaded", code: -3)))
- return
- }
-
- do {
- let directory = cacheURL.deletingLastPathComponent()
- if !FileManager.default.fileExists(atPath: directory.path) {
- try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
- }
-
- if FileManager.default.fileExists(atPath: cacheURL.path) {
- try FileManager.default.removeItem(at: cacheURL)
- }
- try FileManager.default.moveItem(at: tempURL, to: cacheURL)
-
- completion(.success(cacheURL.path))
- } catch {
- completion(.failure(error))
- }
- }
- }.resume()
- }
-
- private func logRequest(_ request: URLRequest) {
- print("➡️ Request URL: \(request.url?.absoluteString ?? "")")
- print("➡️ Method: \(request.httpMethod ?? "")")
- print("➡️ Headers: \(request.allHTTPHeaderFields ?? [:])")
- if let body = request.httpBody {
- if let bodyString = String(data: body, encoding: .utf8) {
- print("➡️ Body:")
- bodyString.jsonPrint()
- } else {
- let sizeInMB = Double(body.count) / (1024 * 1024)
- print(String(format: "➡️ Body (binary data, size: %.2f MB)", sizeInMB))
- }
- }
- }
-
- private func logResponse(data: Data) {
- if let responseString = String(data: data, encoding: .utf8) {
- print("⬅️ Response:")
- responseString.jsonPrint()
- } else {
- print("⬅️ Response (binary data, length: \(data.count) bytes)")
- }
- }
-
- private func mimeType(for data: Data) -> String {
- var bytes = [UInt8](repeating: 0, count: 1)
- data.copyBytes(to: &bytes, count: 1)
- switch bytes[0] {
- case 0xFF: return "image/jpeg"
- case 0x89: return "image/png"
- case 0x47: return "image/gif"
- case 0x25: return "application/pdf"
- case 0x49, 0x4D: return "image/tiff"
- default: return "application/octet-stream"
- }
- }
-}
diff --git a/app/StackChan/Network/Urls.swift b/app/StackChan/Network/Urls.swift
deleted file mode 100644
index c15ff0a..0000000
--- a/app/StackChan/Network/Urls.swift
+++ /dev/null
@@ -1,45 +0,0 @@
-/*
- * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD
- *
- * SPDX-License-Identifier: MIT
- */
-
-struct Urls {
-
- // Base URL configured according to the server's IP
- static let url = "192.168.51.43:12800/"
-
- static func getBaseUrl() -> String {
- return "http://" + url + "stackChan/"
- }
-
- static func getFileUrl() -> String {
- return "http://" + url
- }
-
- static func getWebSocketUrl() -> String {
- return "ws://" + url + "stackChan/ws"
- }
-
- static let registerMac = "api/v2/device/registerMac"
-
- static let dance = "dance"
-
- static let deviceRandomList = "device/randomList"
-
- static let uploadFile = "uploadFile"
-
- static let postAdd = "post/add"
-
- static let postGet = "post/get"
-
- static let postDelete = "post/delete"
-
- static let deviceInfo = "device/info"
-
- static let postCommentCreate = "post/comment/create"
-
- static let postCommentDelete = "post/comment/delete"
-
- static let postCommentGet = "post/comment/get"
-}
diff --git a/app/StackChan/Network/WebSocketUtil.swift b/app/StackChan/Network/WebSocketUtil.swift
deleted file mode 100644
index 3569fed..0000000
--- a/app/StackChan/Network/WebSocketUtil.swift
+++ /dev/null
@@ -1,154 +0,0 @@
-/*
- * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD
- *
- * SPDX-License-Identifier: MIT
- */
-
-import Foundation
-
-class WebSocketUtil {
- static let shared = WebSocketUtil()
- private init() {}
-
- private var webSocketTask: URLSessionWebSocketTask?
-
- private var observers = [String: (URLSessionWebSocketTask.Message) -> Void]()
-
- private var reconnectDelay: TimeInterval = 3.0
- private var reconnectingNow: Bool = true
- private var urlString: String = ""
-
- func connect(urlString: String) {
- if let task = webSocketTask {
- switch task.state {
- case .running, .suspended :
- disconnect()
- default:
- break
- }
- }
-
- self.urlString = urlString
-
- print("Start connecting to WebSocket:" + urlString)
-
- guard let url = URL(string: urlString) else {
- print("Invalid URL")
- return
- }
-
- let request = URLRequest(url: url)
- let session = URLSession(configuration: .default)
-
- webSocketTask = session.webSocketTask(with: request)
-
- webSocketTask?.resume()
-
- DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
- if self.webSocketTask?.state == .running {
- print("webSocket Connection successful")
- self.reconnectingNow = false
- self.webSocketTask?.receive { [weak self] result in
- self?.handleReceive(result: result)
- }
- } else {
- print("webSocket Connection failed. Reconnecting is being prepared.")
- self.reconnectingNow = true
- DispatchQueue.main.asyncAfter(deadline: .now() + self.reconnectDelay) {
- self.connect(urlString: urlString)
- }
- }
- }
- }
-
- private func handleReceive(result: Result) {
- switch result {
- case .success(let message):
- let isPing = replyPong(message: message)
- if !isPing {
- self.notifyObservers(message: message)
- }
- self.webSocketTask?.receive { [weak self] next in
- self?.handleReceive(result: next)
- }
-
- case .failure(let error):
- print("❌ Failed to receive the message: \(error.localizedDescription)")
- print("⚠️ WebSocket Connection lost. Attempting to reconnect.…")
- if !self.reconnectingNow && !self.urlString.isEmpty {
- self.connect(urlString: self.urlString)
- }
- }
- }
-
- func replyPong(message: URLSessionWebSocketTask.Message) -> Bool {
- switch message {
- case .data(let data):
- let result = AppState.shared.parseMessage(message: data)
- if let msgType = result.0, let _ = result.1 {
- switch msgType {
- case MsgType.ping:
- AppState.shared.sendWebSocketMessage(.pong)
- return true
- default:
- return false
- }
- }
- case .string(_):
- return false
- @unknown default:
- return false
- }
- return false
- }
-
-
- func send(message: String) {
- let wsMessage = URLSessionWebSocketTask.Message.string(message)
- print("This is the message being sent.: \(message)")
- webSocketTask?.send(wsMessage) { error in
- if let error = error {
- print("❌ Message sending failed: \(error.localizedDescription)")
- if !self.reconnectingNow && !self.urlString.isEmpty {
- self.connect(urlString: self.urlString)
- }
- }
- }
- }
-
- func send(data: Data) {
- let wsMessage = URLSessionWebSocketTask.Message.data(data)
- webSocketTask?.send(wsMessage) { error in
- if let error = error {
- print("❌ Failed to send binary message: \(error.localizedDescription)")
- if !self.reconnectingNow && !self.urlString.isEmpty {
- self.connect(urlString: self.urlString)
- }
- }
- }
- }
-
- func disconnect() {
- webSocketTask?.cancel(with: .goingAway, reason: nil)
- self.reconnectingNow = false
- print("webSocket Disconnected")
- }
-
- func addObserver(for key: String, observer: @escaping (URLSessionWebSocketTask.Message) -> Void) {
- observers[key] = observer
- }
-
- func removeObserver(for key: String) {
- observers.removeValue(forKey: key)
- }
-
- func removeAllObservers() {
- observers.removeAll()
- }
-
- func notifyObservers(message: URLSessionWebSocketTask.Message) {
- for observer in observers.values {
- observer(message)
- }
- }
-}
diff --git a/app/StackChan/StackChanApp.swift b/app/StackChan/StackChanApp.swift
deleted file mode 100644
index afb0f46..0000000
--- a/app/StackChan/StackChanApp.swift
+++ /dev/null
@@ -1,20 +0,0 @@
-/*
- * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD
- *
- * SPDX-License-Identifier: MIT
- */
-
-import SwiftUI
-
-@main
-struct StackChanApp: App {
-
- @StateObject private var appState = AppState.shared
-
- var body: some Scene {
- WindowGroup {
- ContentView()
- .environmentObject(appState)
- }
- }
-}
diff --git a/app/StackChan/Utils/AudioAcquisitionUtil.swift b/app/StackChan/Utils/AudioAcquisitionUtil.swift
deleted file mode 100644
index e7d0d4c..0000000
--- a/app/StackChan/Utils/AudioAcquisitionUtil.swift
+++ /dev/null
@@ -1,67 +0,0 @@
-/*
- * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD
- *
- * SPDX-License-Identifier: MIT
- */
-
-import SwiftUI
-import AVFoundation
-import Accelerate
-
-// Audio acquisition utility class, singleton pattern
-class AudioAcquisitionUtil {
-
- static let shared = AudioAcquisitionUtil()
- private init() {}
-
- private let engine = AVAudioEngine()
- private let bus = 0
- var onAudioData: ((Data) -> Void)?
- var onDecibel: ((Float) -> Void)?
-
- func start() {
- let inputNode = engine.inputNode
- let hwFormat = inputNode.inputFormat(forBus: bus)
- inputNode.installTap(onBus: bus, bufferSize: 1024, format: hwFormat) { buffer, time in
- let audioBuffer = buffer.audioBufferList.pointee.mBuffers
- let data = Data(bytes: audioBuffer.mData!, count: Int(audioBuffer.mDataByteSize))
-
- // Callback with raw audio data
- DispatchQueue.main.async {
- self.onAudioData?(data)
- }
-
- // Calculate decibels and normalize
- if let floatChannelData = buffer.floatChannelData {
- let channelData = floatChannelData[0]
- let frameLength = vDSP_Length(buffer.frameLength)
-
- var rms: Float = 0
- vDSP_rmsqv(channelData, 1, &rms, frameLength) // Calculate RMS
-
- // Convert RMS to a 0-1 range, normal environment ~0
- // Here we assume the maximum RMS value could be around 0.1; adjust based on actual environment
- let normalizedDb = min(max(rms / 0.1, 0), 1)
-
- DispatchQueue.main.async {
- self.onDecibel?(normalizedDb)
- }
- }
- }
-
- do {
- try engine.start()
- } catch {
- print("Audio engine start error: \(error)")
- }
- }
-
- func stop() {
- engine.inputNode.removeTap(onBus: bus)
- engine.stop()
- }
-
- deinit {
- stop()
- }
-}
diff --git a/app/StackChan/Utils/BlufiUtil.swift b/app/StackChan/Utils/BlufiUtil.swift
deleted file mode 100644
index a51fd56..0000000
--- a/app/StackChan/Utils/BlufiUtil.swift
+++ /dev/null
@@ -1,431 +0,0 @@
-/*
- * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD
- *
- * SPDX-License-Identifier: MIT
- */
-
-import ObjectiveC
-import CoreBluetooth
-
-class BlufiUtil: NSObject,CBCentralManagerDelegate,CBPeripheralDelegate {
-
- static let shared = BlufiUtil()
-
- private var centralManager: CBCentralManager!
- var discovereDevices: [BlufiDeviceInfo] = []
-
- var blufDevicesMonitoring: (([BlufiDeviceInfo]) -> Void)?
- var centralManagerDidUpdateState: ((CBManagerState) -> Void)?
- var characteristicCallback: ((CBCharacteristic) -> Void)?
-
- var connectionStateChanged: ((CBPeripheral, Bool) -> Void)?
-
- var blueSwitch: Bool = false
- private let autoReconnect = true
- var currentPeripheral: CBPeripheral? = nil
- var writeCharacteristic: CBCharacteristic? = nil
- var automaticScanning: Bool = true
-
- var writeExpressionCharacteristic: CBCharacteristic? = nil
- var writeHeadCharacteristic: CBCharacteristic? = nil
- var writeWifiSetCharacteristic: CBCharacteristic? = nil
-
- var wifiSetCharacteristicCall: ((Data) -> Void)? = nil
-
- /// Service UUID
- private let targetServiceUUIDs: [CBUUID] = [CBUUID(string: "e2e5e5e0-1234-5678-1234-56789abcdef0")]
-
- private let scanOptions: [String: Any] = [
- CBCentralManagerScanOptionAllowDuplicatesKey: true
- ]
- /// Timer to clean up devices that are not discovered
- private var cleanupTimer: Timer?
- private let deviceTimeout: TimeInterval = 3
-
- private override init() {
- super.init()
- centralManager = CBCentralManager(delegate: self, queue: .main)
- }
-
- // Print logs according to Bluetooth state
- func centralManagerDidUpdateState(_ central: CBCentralManager) {
- centralManagerDidUpdateState?(central.state)
- switch central.state {
- case .unknown:
- print("Bluetooth state unknown")
- case .resetting:
- print("Bluetooth is resetting")
- case .unsupported:
- print("This device does not support Bluetooth")
- case .unauthorized:
- print("No permission to use Bluetooth, please check settings")
- case .poweredOff:
- print("Bluetooth is off, please turn on Bluetooth")
- blueSwitch = false
- case .poweredOn:
- print("Bluetooth is on, ready to start scanning devices")
- blueSwitch = true
- if automaticScanning {
- startScan()
- }
- if autoReconnect {
- reconnect()
- }
- @unknown default:
- print("Encountered unknown Bluetooth state")
- }
- }
-
- // Start scanning BLE devices
- func startScan() {
- guard centralManager.state == .poweredOn else {
- print("Bluetooth is not ready when scanning")
- return
- }
- discovereDevices.removeAll()
- print("Started scanning nearby BLE devices")
- centralManager.scanForPeripherals(withServices: targetServiceUUIDs, options: scanOptions)
- startCleanupTimer()
- }
-
- // Periodically clean up non-existing devices
- private func startCleanupTimer() {
- cleanupTimer?.invalidate()
- cleanupTimer = Timer.scheduledTimer(withTimeInterval: 2, repeats: true, block: { timer in
- let now = Date()
- let originalCount = self.discovereDevices.count
-
- self.discovereDevices.removeAll {
- now.timeIntervalSince($0.lastSeen) > self.deviceTimeout
- }
-
- // If indeed some equipment has been removed, then notify the external party.
- if self.discovereDevices.count != originalCount {
- self.blufDevicesMonitoring?(self.discovereDevices)
- }
- })
- }
-
- func stopScan() {
- print("Stopped scanning BLE devices")
- centralManager.stopScan()
- }
-
- func connect(peripheral: CBPeripheral) {
- print("Started connecting to the specified BLE device")
- centralManager.connect(peripheral)
- }
-
- /// Actively disconnect the current peripheral
- func disconnectCurrentPeripheral() {
- guard let peripheral = currentPeripheral else {
- print("⚠️ No connected peripheral to disconnect")
- return
- }
- centralManager.cancelPeripheralConnection(peripheral)
- print("⬅️ Disconnecting peripheral: \(peripheral.name ?? "Unknown device")")
- writeWifiSetCharacteristic = nil
- writeHeadCharacteristic = nil
- writeExpressionCharacteristic = nil
- }
-
-
- /// Send head data
- func sendHeadData(_ data: String) {
- guard let currentPeripheral = currentPeripheral else {
- print("⚠️ No connected peripheral")
- return
- }
-
- guard let writeHeadCharacteristic = self.writeHeadCharacteristic else {
- print("⚠️ No writable characteristic on current peripheral")
- return
- }
-
- guard let dataToSend = data.data(using: .utf8) else {
- print("⚠️ Failed to convert string to Data")
- return
- }
-
- currentPeripheral.writeValue(dataToSend, for: writeHeadCharacteristic, type: .withResponse)
- print("➡️ Head data sent: \(data)")
- }
-
- /// Send Wi-Fi configuration data
- func sendWifiSetData(_ data: String) {
- guard let currentPeripheral = currentPeripheral else {
- print("⚠️ No connected peripheral")
- return
- }
- guard let writeWifiSetCharacteristic = self.writeWifiSetCharacteristic else {
- print("⚠️ No writable characteristic on current peripheral")
- return
- }
- guard let dataToSend = data.data(using: .utf8) else {
- print("⚠️ Failed to convert string to Data")
- return
- }
- currentPeripheral.writeValue(dataToSend, for: writeWifiSetCharacteristic, type: .withResponse)
- print("➡️ Head data sent: \(data)")
- }
-
- /// Send expression data
- func sendExpressionData(_ data: String) {
- guard let currentPeripheral = currentPeripheral else {
- print("⚠️ No connected peripheral")
- return
- }
-
- guard let writeExpressionCharacteristic = self.writeExpressionCharacteristic else {
- print("⚠️ No writable characteristic on current peripheral")
- return
- }
-
- guard let dataToSend = data.data(using: .utf8) else {
- print("⚠️ Failed to convert string to Data")
- return
- }
-
- currentPeripheral.writeValue(dataToSend, for: writeExpressionCharacteristic, type: .withResponse)
- print("➡️ Expression data sent: \(data)")
- }
-
- func sendData(_ data: String) {
- guard let currentPeripheral = currentPeripheral else {
- print("⚠️ No connected peripheral")
- return
- }
-
- guard let writeCharacteristic = self.writeCharacteristic else {
- print("⚠️ No writable characteristic on current peripheral")
- return
- }
-
- guard let dataToSend = data.data(using: .utf8) else {
- print("⚠️ Failed to convert string to Data")
- return
- }
-
- currentPeripheral.writeValue(dataToSend, for: writeCharacteristic, type: .withResponse)
- print("➡️ Data sent: \(data)")
- }
-
- func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
- // Called when successfully connected to a peripheral
- print("✅ Successfully connected to device: \(peripheral.name ?? "Unknown device")")
- self.currentPeripheral = peripheral
- peripheral.delegate = self
- peripheral.discoverServices(nil)
- connectionStateChanged?(peripheral,true)
- }
-
- func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: (any Error)?) {
- // Called when connecting to a peripheral fails
- if let error = error {
- print("❌ Failed to connect to device: \(peripheral.name ?? "Unknown device"), error: \(error.localizedDescription)")
- } else {
- print("❌ Failed to connect to device: \(peripheral.name ?? "Unknown device"), unknown error")
- }
-
- connectionStateChanged?(peripheral,false)
- }
-
- func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: (any Error)?) {
- // Called when a peripheral disconnects
- if let error = error {
- print("⚠️ Device disconnected: \(peripheral.name ?? "Unknown device"), error: \(error.localizedDescription)")
- } else {
- print("⚠️ Device disconnected: \(peripheral.name ?? "Unknown device"), no error")
- }
- currentPeripheral = nil
-
- connectionStateChanged?(peripheral,false)
-
- if autoReconnect {
- reconnect()
- }
- }
-
- func centralManager(_ central: CBCentralManager, didUpdateANCSAuthorizationFor peripheral: CBPeripheral) {
- // Called when ANCS (Apple Notification Center Service) authorization status changes
- print("ℹ️ ANCS authorization status updated, device: \(peripheral.name ?? "Unknown device")")
- }
-
- func centralManager(_ central: CBCentralManager, connectionEventDidOccur event: CBConnectionEvent, for peripheral: CBPeripheral) {
- // Called when a connection event occurs (e.g., peripheral connected or disconnected)
- print("🔔 Connection event occurred: \(event.rawValue), device: \(peripheral.name ?? "Unknown device")")
- }
-
- func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String : Any], rssi RSSI: NSNumber) {
- // New device added to the list
- let deviceInfo = BlufiDeviceInfo(peripheral: peripheral, advertisementData: advertisementData, rssi: RSSI, lastSeen: Date())
-
- if !discovereDevices.contains(where: { $0.peripheral.identifier.uuidString == peripheral.identifier.uuidString }) {
- discovereDevices.append(deviceInfo)
- } else {
- if let index = discovereDevices.firstIndex(where: { $0.peripheral.identifier.uuidString == peripheral.identifier.uuidString }) {
- discovereDevices[index] = deviceInfo
- }
- }
- blufDevicesMonitoring?(discovereDevices)
- }
-
- func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, timestamp: CFAbsoluteTime, isReconnecting: Bool, error: (any Error)?) {
- if let error = error {
- print("⚠️ Device disconnected: \(peripheral.name ?? "Unknown device"), timestamp: \(timestamp), reconnecting: \(isReconnecting), error: \(error.localizedDescription)")
- } else {
- print("⚠️ Device disconnected: \(peripheral.name ?? "Unknown device"), timestamp: \(timestamp), reconnecting: \(isReconnecting), no error")
- }
- }
-
- func peripheralDidUpdateName(_ peripheral: CBPeripheral) {
- // Called when peripheral name updates
- print("ℹ️ Peripheral name updated: \(peripheral.name ?? "Unknown device")")
- }
-
- func peripheralDidUpdateRSSI(_ peripheral: CBPeripheral, error: (any Error)?) {
- // Called when peripheral RSSI (signal strength) updates
- if let error = error {
- print("❌ Failed to update peripheral RSSI: \(error.localizedDescription)")
- } else {
- print("ℹ️ Peripheral RSSI updated")
- }
- }
-
- func peripheral(_ peripheral: CBPeripheral, didOpen channel: CBL2CAPChannel?, error: (any Error)?) {
- // Called when L2CAP channel opens
- if let error = error {
- print("❌ Failed to open L2CAP channel: \(error.localizedDescription)")
- } else {
- print("✅ L2CAP channel opened: \(channel?.psm ?? 0)")
- }
- }
-
- func peripheral(_ peripheral: CBPeripheral, didModifyServices invalidatedServices: [CBService]) {
- // Called when peripheral services are modified
- print("⚠️ Peripheral services modified, number of invalidated services: \(invalidatedServices.count)")
- }
-
- func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: (any Error)?) {
- // Called after discovering services
- if let error = error {
- print("❌ Failed to discover services: \(error.localizedDescription)")
- return
- }
- print("✅ Discovered peripheral services, service count: \(peripheral.services?.count ?? 0)")
- peripheral.services?.forEach { service in
- print("📦 Service UUID: \(service.uuid)")
- peripheral.discoverCharacteristics(nil, for: service)
- }
- }
-
- func peripheral(_ peripheral: CBPeripheral, didReadRSSI RSSI: NSNumber, error: (any Error)?) {
- // Called when RSSI value is read
- if let error = error {
- print("❌ Failed to read RSSI: \(error.localizedDescription)")
- } else {
- print("ℹ️ Read RSSI: \(RSSI)")
- }
- }
-
- func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: (any Error)?) {
- }
-
- func peripheral(_ peripheral: CBPeripheral, didWriteValueFor descriptor: CBDescriptor, error: (any Error)?) {
- // Called after writing a descriptor value
- if let error = error {
- print("❌ Failed to write descriptor value: \(descriptor.uuid), error: \(error.localizedDescription)")
- } else {
- print("✅ Successfully wrote descriptor value: \(descriptor.uuid)")
- }
- }
-
- func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: (any Error)?) {
- // Called when characteristic value updates (message received)
- if let error = error {
- print("❌ Failed to update characteristic value: \(characteristic.uuid), error: \(error.localizedDescription)")
- } else {
- print("Bluetooth message received")
- if characteristic.uuid.uuidString == writeWifiSetCharacteristic?.uuid.uuidString, let data = characteristic.value {
- /// Callback for Wi-Fi configuration message
- self.wifiSetCharacteristicCall?(data)
- }
- // print("ℹ️ Characteristic value updated: \(characteristic.uuid), value: \(characteristic.value?.hexEncodedString() ?? "nil")")
- }
- }
-
- func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor descriptor: CBDescriptor, error: (any Error)?) {
- // Called when descriptor value updates
- if let error = error {
- print("❌ Failed to update descriptor value: \(descriptor.uuid), error: \(error.localizedDescription)")
- } else {
- print("ℹ️ Descriptor value updated: \(descriptor.uuid), value: \(descriptor.value ?? "nil")")
- }
- }
-
- func peripheral(_ peripheral: CBPeripheral, didDiscoverDescriptorsFor characteristic: CBCharacteristic, error: (any Error)?) {
- // Called when descriptors of a characteristic are discovered
- if let error = error {
- print("❌ Failed to discover descriptors: \(characteristic.uuid), error: \(error.localizedDescription)")
- } else {
- print("✅ Discovered characteristic descriptors: \(characteristic.uuid), descriptor count: \(characteristic.descriptors?.count ?? 0)")
- }
- }
-
- func peripheralIsReady(toSendWriteWithoutResponse peripheral: CBPeripheral) {
- // Called when peripheral is ready to send writes without response
- print("ℹ️ Peripheral is ready to send write without response: \(peripheral.name ?? "Unknown device")")
- }
-
- func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: (any Error)?) {
- // Called when characteristics of a service are discovered
- if let error = error {
- print("❌ Failed to discover characteristics: \(service.uuid), error: \(error.localizedDescription)")
- return
- }
- print("✅ Discovered characteristics count: \(service.characteristics?.count ?? 0) for service: \(service.uuid)")
- service.characteristics?.forEach { characteristic in
- print("🔹 Characteristic UUID: \(characteristic.uuid), properties: \(characteristic.properties)")
- characteristicCallback?(characteristic)
- }
- }
-
- func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: (any Error)?) {
- // Called when characteristic notification state updates
- if let error = error {
- print("❌ Failed to update characteristic notification state: \(characteristic.uuid), error: \(error.localizedDescription)")
- } else {
- print("ℹ️ Characteristic notification state updated: \(characteristic.uuid), isNotifying: \(characteristic.isNotifying)")
- }
- }
-
- func peripheral(_ peripheral: CBPeripheral, didDiscoverIncludedServicesFor service: CBService, error: (any Error)?) {
- // Called when included services are discovered
- if let error = error {
- print("❌ Failed to discover included services: \(service.uuid), error: \(error.localizedDescription)")
- } else {
- print("✅ Discovered included services count: \(service.includedServices?.count ?? 0) for service: \(service.uuid)")
- }
- }
-
- // Method to reconnect
- func reconnect() {
-
- }
-
-}
-
-
-struct BlufiDeviceInfo {
- let peripheral: CBPeripheral
- let advertisementData: [String : Any]
- let rssi: NSNumber
- var lastSeen: Date
-}
-
-
-extension Data {
- func hexEncodedString() -> String {
- return map { String(format: "%02hhx", $0) }.joined()
- }
-}
diff --git a/app/StackChan/Utils/DazzlingBackground.swift b/app/StackChan/Utils/DazzlingBackground.swift
deleted file mode 100644
index 3a8151d..0000000
--- a/app/StackChan/Utils/DazzlingBackground.swift
+++ /dev/null
@@ -1,102 +0,0 @@
-/*
- * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD
- *
- * SPDX-License-Identifier: MIT
- */
-
-import SwiftUI
-
-struct DazzlingBackground : View {
-
- let backColors: [Color]
- let background: Color
- let dotCount: Int
- let speed: CGFloat
-
- @State private var dots: [Dot] = []
-
- init(backColors: [Color], background: Color, dotCount: Int = 5, speed: CGFloat = 2.2) {
- self.backColors = backColors
- self.background = background
- self.dotCount = dotCount
- self.speed = speed
- }
-
- var body: some View {
- GeometryReader { proxy in
- ZStack {
- LinearGradient(
- colors: backColors,
- startPoint: .topLeading,
- endPoint: .bottomTrailing
- )
- TimelineView(.animation) { timeline in
- Canvas { context, size in
- for dot in dots {
- let circle = Path(ellipseIn: CGRect(x: dot.position.x, y: dot.position.y, width: dot.dotSize, height: dot.dotSize))
- context.fill(circle, with: .color(.purple.opacity(0.4)))
- }
- }
- .blur(radius: 50)
- .drawingGroup()
- .onAppear {
- DispatchQueue.main.async {
- if dots.isEmpty, proxy.size.width > 0, proxy.size.height > 0 {
- startDots(size: proxy.size)
- }
- }
- }
- .onChange(of: timeline.date) { _ in
- DispatchQueue.main.async {
- updateDots(size: proxy.size)
- }
- }
- }
- }
- .background(background)
- }
-
- }
-
- private func startDots(size: CGSize) {
- guard size.width > 0, size.height > 0 else { return }
- for _ in 0.. 0, size.height > 0 else { return }
- for i in dots.indices {
- var dot = dots[i]
- let dx = dot.target.x - dot.position.x
- let dy = dot.target.y - dot.position.y
- let distance = sqrt(dx*dx + dy*dy)
- if distance < speed {
- dot.target = CGPoint(x: CGFloat.random(in: 0.. Void)?
- private var thresholdCallback: (() -> Void)?
- private let thresholdDistance: Float = 0.05 // 5cm in meters
- private var timer: Timer?
-
- func startDistanceDetection(
- distanceUpdate: ((Float) -> Void)? = nil,
- belowThreshold: (() -> Void)? = nil
- ) {
- guard ARWorldTrackingConfiguration.isSupported else {
- return
- }
- if #available(iOS 13.0, *) {
- guard ARWorldTrackingConfiguration.supportsFrameSemantics(.sceneDepth) else {
- return
- }
- } else {
- return
- }
-
- checkCameraPermission { [weak self] granted in
- guard granted else {
- return
- }
-
- self?.setupARSession()
- self?.setupCallbacks(distanceUpdate: distanceUpdate, belowThreshold: belowThreshold)
- self?.startDetectionTimer()
- }
- }
-
- /// 停止距离检测
- func stopDistanceDetection() {
- isDetectionActive = false
- timer?.invalidate()
- timer = nil
- arSession?.pause()
- arSession = nil
- }
-
- // MARK: - Private Methods
-
- private func checkCameraPermission(completion: @escaping (Bool) -> Void) {
- let status = AVCaptureDevice.authorizationStatus(for: .video)
-
- switch status {
- case .authorized:
- completion(true)
- case .notDetermined:
- AVCaptureDevice.requestAccess(for: .video) { granted in
- DispatchQueue.main.async {
- completion(granted)
- }
- }
- default:
- completion(false)
- }
- }
-
- private func setupARSession() {
- arSession = ARSession()
- let configuration = ARWorldTrackingConfiguration()
-
- if #available(iOS 13.0, *) {
- configuration.frameSemantics.insert(.sceneDepth)
- }
-
- arSession?.run(configuration)
- isDetectionActive = true
- }
-
- private func setupCallbacks(
- distanceUpdate: ((Float) -> Void)?,
- belowThreshold: (() -> Void)?
- ) {
- self.distanceCallback = distanceUpdate
- self.thresholdCallback = belowThreshold
- }
-
- private func startDetectionTimer() {
- timer?.invalidate()
-
- timer = Timer.scheduledTimer(
- timeInterval: 0.1,
- target: self,
- selector: #selector(performDistanceCheck),
- userInfo: nil,
- repeats: true
- )
- }
-
- @objc private func performDistanceCheck() {
- guard isDetectionActive,
- let frame = arSession?.currentFrame else {
- return
- }
-
- let distance = getCurrentDistance(from: frame)
-
- if let distance = distance {
- distanceCallback?(distance)
-
- if distance < thresholdDistance {
- handleBelowThreshold()
- }
- }
- }
-
- private func getCurrentDistance(from frame: ARFrame) -> Float? {
- if #available(iOS 13.0, *) {
- return getDistanceUsingSceneDepth(from: frame)
- } else {
- return getDistanceUsingHitTest(from: frame)
- }
- }
-
- @available(iOS 13.0, *)
- private func getDistanceUsingSceneDepth(from frame: ARFrame) -> Float? {
- guard let depthData = frame.sceneDepth else {
- return nil
- }
-
- let depthPixelBuffer = depthData.depthMap
- let width = CVPixelBufferGetWidth(depthPixelBuffer)
- let height = CVPixelBufferGetHeight(depthPixelBuffer)
-
- let centerX = width / 2
- let centerY = height / 2
-
- CVPixelBufferLockBaseAddress(depthPixelBuffer, .readOnly)
-
- guard let baseAddress = CVPixelBufferGetBaseAddress(depthPixelBuffer) else {
- CVPixelBufferUnlockBaseAddress(depthPixelBuffer, .readOnly)
- return nil
- }
-
- let floatBuffer = baseAddress.assumingMemoryBound(to: Float32.self)
-
- var totalDistance: Float = 0
- var validSamples = 0
-
- let sampleRadius = 5
- for x in max(0, centerX - sampleRadius)...min(width - 1, centerX + sampleRadius) {
- for y in max(0, centerY - sampleRadius)...min(height - 1, centerY + sampleRadius) {
- let distance = floatBuffer[y * width + x]
- if distance.isFinite && distance > 0 {
- totalDistance += distance
- validSamples += 1
- }
- }
- }
-
- CVPixelBufferUnlockBaseAddress(depthPixelBuffer, .readOnly)
-
- guard validSamples > 0 else {
- return nil
- }
-
- return totalDistance / Float(validSamples)
- }
-
- private func getDistanceUsingHitTest(from frame: ARFrame) -> Float? {
- return nil
- }
-
- private func handleBelowThreshold() {
- NSObject.cancelPreviousPerformRequests(withTarget: self, selector: #selector(executeThresholdCallback), object: nil)
- }
-
- @objc private func executeThresholdCallback() {
- let generator = UIImpactFeedbackGenerator(style: .heavy)
- generator.impactOccurred()
- AudioServicesPlaySystemSound(1013)
- thresholdCallback?()
- }
-
- deinit {
- stopDistanceDetection()
- }
-}
-
-
-func exampleBasicUsage() {
- let detector = DistanceDetector()
-
- detector.startDistanceDetection(
- distanceUpdate: { distance in
- let distanceInCm = distance * 100
- print(String(distanceInCm))
- },
- belowThreshold: {
- }
- )
-}
-
-class ProximityMonitor {
- private let detector = DistanceDetector()
- private var isMonitoring = false
-
- func startMonitoring() {
- detector.startDistanceDetection(
- distanceUpdate: { [weak self] distance in
- self?.handleDistanceUpdate(distance)
- },
- belowThreshold: { [weak self] in
- self?.handleProximityAlert()
- }
- )
- isMonitoring = true
- }
-
- func stopMonitoring() {
- detector.stopDistanceDetection()
- isMonitoring = false
- }
-
- private func handleDistanceUpdate(_ distance: Float) {
- let distanceInCm = distance * 100
- if distanceInCm < 10 {
- } else if distanceInCm < 30 {
- }
- }
-
- private func handleProximityAlert() {
- NotificationCenter.default.post(
- name: NSNotification.Name("ProximityAlert"),
- object: nil,
- userInfo: ["alert": "object_too_close"]
- )
- }
-}
diff --git a/app/StackChan/Utils/FileUtils.swift b/app/StackChan/Utils/FileUtils.swift
deleted file mode 100644
index 8b740be..0000000
--- a/app/StackChan/Utils/FileUtils.swift
+++ /dev/null
@@ -1,43 +0,0 @@
-/*
- * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD
- *
- * SPDX-License-Identifier: MIT
- */
-
-import Foundation
-import CryptoKit
-
-class FileUtils {
-
- static let shared = FileUtils()
- private init() {}
-
- func cacheDirectory() -> URL {
- let paths = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)
- return paths[0]
- }
-
- func hashedKey(for url: String) -> String {
- let data = Data(url.utf8)
- let hash = SHA256.hash(data: data)
- return hash.compactMap { String(format: "%02x", $0) }.joined()
- }
-
- //File download, with built-in cache. First, check if the file exists locally. If it does, directly return the path; if not, download the file and then return the path.
- func download(url: String) async throws -> String {
- let key = hashedKey(for: url)
- let cacheURL = cacheDirectory().appendingPathComponent(key)
-
- if FileManager.default.fileExists(atPath: cacheURL.path) {
- return cacheURL.path
- }
-
- guard let requestURL = URL(string: url) else {
- throw URLError(.badURL)
- }
-
- let (data, _) = try await URLSession.shared.data(from: requestURL)
- try data.write(to: cacheURL)
- return cacheURL.path
- }
-}
diff --git a/app/StackChan/Utils/ImageUtils.swift b/app/StackChan/Utils/ImageUtils.swift
deleted file mode 100644
index daca842..0000000
--- a/app/StackChan/Utils/ImageUtils.swift
+++ /dev/null
@@ -1,49 +0,0 @@
-/*
- * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD
- *
- * SPDX-License-Identifier: MIT
- */
-
-import UIKit
-
-final class ImageUtils {
-
- // MARK: - Singleton
- static let shared = ImageUtils()
- private init() {}
-
- // MARK: - Public Methods
-
- /// Resize the image to the specified resolution and export it /// - Parameters:
- /// - image: Input the original image
- /// - targetSize: Target resolution (e.g. CGSize(width: 1080, height: 1920))
- /// - format: Export format (default JPEG)
- /// - quality: JPEG compression quality (0-1, default 1)
- /// - Returns: Converted Data (JPEG or PNG), nil if failure
- func exportScaledImageData(
- from image: UIImage,
- targetSize: CGSize,
- format: ImageFormat = .jpeg,
- quality: CGFloat = 1.0
- ) -> Data? {
-
- // Use UIGraphicsImageRenderer for high-quality scaling
- let renderer = UIGraphicsImageRenderer(size: targetSize)
- let scaledImage = renderer.image { _ in
- image.draw(in: CGRect(origin: .zero, size: targetSize))
- }
-
- switch format {
- case .jpeg:
- return scaledImage.jpegData(compressionQuality: quality)
- case .png:
- return scaledImage.pngData()
- }
- }
-
- // MARK: - Image Format Enum
- enum ImageFormat {
- case jpeg
- case png
- }
-}
diff --git a/app/StackChan/Utils/MultipeerUtil.swift b/app/StackChan/Utils/MultipeerUtil.swift
deleted file mode 100644
index f28e2e9..0000000
--- a/app/StackChan/Utils/MultipeerUtil.swift
+++ /dev/null
@@ -1,130 +0,0 @@
-/*
- * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD
- *
- * SPDX-License-Identifier: MIT
- */
-
-import MultipeerConnectivity
-
-class MultipeerUtil: NSObject, MCSessionDelegate, MCNearbyServiceAdvertiserDelegate, MCNearbyServiceBrowserDelegate {
-
- static let shared = MultipeerUtil()
-
- private let serviceType = "stackchan-mpc"
-
- private let myPeerID = MCPeerID(displayName: UIDevice.current.name)
- private var session: MCSession!
-
- private var advertiser: MCNearbyServiceAdvertiser!
- private var browser: MCNearbyServiceBrowser!
-
- var appendScanPeer: ((MCPeerID,[String : String]?) -> Void)?
- var removeScanPeer: ((MCPeerID) -> Void)?
- var onMessageReceived: ((Data, MCPeerID) -> Void)?
-
- private override init() {
- super.init()
- session = MCSession(peer: myPeerID, securityIdentity: nil, encryptionPreference: .required)
- session.delegate = self
-
- advertiser = MCNearbyServiceAdvertiser(peer: myPeerID, discoveryInfo: [
- "deviceId": UIDevice.current.identifierForVendor?.uuidString ?? UUID().uuidString
- ], serviceType: serviceType)
- advertiser.delegate = self
-
- browser = MCNearbyServiceBrowser(peer: myPeerID, serviceType: serviceType)
- browser.delegate = self
-
- }
-
- func startAdvertising() {
- advertiser.stopAdvertisingPeer()
- advertiser.startAdvertisingPeer()
- print("Started advertising Multipeer service")
- }
-
- func stopAdvertising() {
- advertiser.stopAdvertisingPeer()
- print("Stopped advertising Multipeer service")
- }
-
- func startBrowsing() {
- browser.stopBrowsingForPeers()
- browser.startBrowsingForPeers()
- print("Started browsing nearby devices")
- }
-
- func stopBrowsing() {
- browser.stopBrowsingForPeers()
- print("Stopped browsing nearby devices")
- }
-
- func sendMessage(_ data: Data, to peer: MCPeerID) {
- if !session.connectedPeers.contains(peer) {
- print("Not connected to \(peer.displayName), attempting to connect...")
- Task {
- await connectAndSend(data: data, to: peer)
- }
- return
- }
-
- do {
- try session.send(data, toPeers: [peer], with: .reliable)
- print("Message sent successfully")
- } catch {
- print("Failed to send message: \(error)")
- }
- }
-
- private func connectAndSend(data: Data, to peer: MCPeerID) async {
- browser.invitePeer(peer, to: session, withContext: nil, timeout: 30)
- for _ in 0..<30 {
- try? await Task.sleep(nanoseconds: 500_000_000) // 0.5 seconds
- if session.connectedPeers.contains(peer) {
- sendMessage(data, to: peer)
- return
- }
- }
-
- print("Connection timed out, unable to send message to \(peer.displayName)")
- }
-
- func browser(_ browser: MCNearbyServiceBrowser, foundPeer peerID: MCPeerID, withDiscoveryInfo info: [String : String]?) {
- print("Discovered peer: " + peerID.displayName)
- appendScanPeer?(peerID, info)
- }
-
- func browser(_ browser: MCNearbyServiceBrowser, lostPeer peerID: MCPeerID) {
- removeScanPeer?(peerID)
- }
-
- func advertiser(_ advertiser: MCNearbyServiceAdvertiser, didReceiveInvitationFromPeer peerID: MCPeerID,
- withContext context: Data?, invitationHandler: @escaping (Bool, MCSession?) -> Void) {
- invitationHandler(true, session)
- }
-
- func session(_ session: MCSession, peer peerID: MCPeerID, didChange state: MCSessionState) {
- print("Peer \(peerID.displayName) state changed: \(state.rawValue)")
- }
-
- func session(_ session: MCSession, didReceive data: Data, fromPeer peerID: MCPeerID) {
- onMessageReceived?(data, peerID)
- }
-
-
- func session(_ session: MCSession, didReceive stream: InputStream, withName streamName: String, fromPeer peerID: MCPeerID) {
-
- }
-
- func session(_ session: MCSession, didReceiveCertificate certificate: [Any]?, fromPeer peerID: MCPeerID, certificateHandler: @escaping (Bool) -> Void) {
-
- }
-
- func session(_ session: MCSession, didStartReceivingResourceWithName resourceName: String, fromPeer peerID: MCPeerID, with progress: Progress) {
-
- }
-
- func session(_ session: MCSession, didFinishReceivingResourceWithName resourceName: String, fromPeer peerID: MCPeerID, at localURL: URL?, withError error: (any Error)?) {
-
- }
-}
diff --git a/app/StackChan/Utils/RippleDiffusion.swift b/app/StackChan/Utils/RippleDiffusion.swift
deleted file mode 100644
index 75a8aae..0000000
--- a/app/StackChan/Utils/RippleDiffusion.swift
+++ /dev/null
@@ -1,51 +0,0 @@
-/*
- * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD
- *
- * SPDX-License-Identifier: MIT
- */
-
-import SwiftUI
-
-struct RippleDiffusion : View {
-
- let content: () -> Content
-
- init(@ViewBuilder content: @escaping () -> Content) {
- self.content = content
- }
-
- @State private var animate = false
-
- var body: some View {
- ZStack(alignment: .center) {
- ForEach(0..<3) { index in
- Circle()
- .stroke(Color.accentColor.opacity(0.7), lineWidth: 2)
- .frame(width: CGFloat(index + 1) * 100, height: CGFloat(index + 1) * 100)
- .scaleEffect(animate ? 2.0 : 0.1)
- .opacity(animate ? 0 : 1)
- .animation(
- Animation.easeOut(duration: 1.8)
- .repeatForever(autoreverses: false)
- .delay(Double(index) * 0.3),
- value: animate
- )
- }
- content()
- }
- .onAppear {
- DispatchQueue.main.async {
- animate = true
- }
- }
- }
-}
-
-
-struct RippleDiffusionPreview : PreviewProvider {
- static var previews: some View {
- RippleDiffusion {
-
- }
- }
-}
diff --git a/app/StackChan/Utils/Style.swift b/app/StackChan/Utils/Style.swift
deleted file mode 100644
index eab9f2b..0000000
--- a/app/StackChan/Utils/Style.swift
+++ /dev/null
@@ -1,52 +0,0 @@
-/*
- * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD
- *
- * SPDX-License-Identifier: MIT
- */
-
-import SwiftUI
-
-
-struct WideOrangeButton: ButtonStyle {
-
- func makeBody(configuration: Configuration) -> some View {
- configuration.label
- .padding(.all,15)
- .frame(maxWidth: .infinity,minHeight: 44)
- .foregroundColor(.white)
- .background(RoundedRectangle(cornerRadius: 20.0)
- .fill(.blue)
- )
- .shadow(color: .black.opacity(0.2), radius: 5, x: 0, y: 0)
- .opacity(configuration.isPressed ? 0.6 : 1.0)
- }
-}
-
-struct MyTextFieldStyle : TextFieldStyle {
- func _body(configuration: TextField) -> some View {
- configuration
- .padding(.all,15)
- .frame(maxWidth: .infinity,minHeight: 44)
- .background(
- RoundedRectangle(cornerRadius: 20.0)
- .fill(.background)
- )
- .shadow(color: .black.opacity(0.2), radius: 5, x: 0, y: 0)
- }
-}
-
-struct SopDirectoryButtonStyle: ButtonStyle {
- let select: Bool
-
- func makeBody(configuration: Configuration) -> some View {
- configuration.label
- .padding(.all, 20)
- .frame(maxWidth: .infinity, minHeight: 44)
- .foregroundColor(select ? .accentColor : .primary)
- .background(
- RoundedRectangle(cornerRadius: 20)
- .fill(select ? Color.blue.opacity(0.2) : Color.clear)
- )
- .opacity(configuration.isPressed ? 0.6 : 1.0)
- }
-}
diff --git a/app/StackChan/Utils/ValueConstant.swift b/app/StackChan/Utils/ValueConstant.swift
deleted file mode 100644
index 5286683..0000000
--- a/app/StackChan/Utils/ValueConstant.swift
+++ /dev/null
@@ -1,28 +0,0 @@
-/*
- * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD
- *
- * SPDX-License-Identifier: MIT
- */
-
-struct ValueConstant {
- static let token = "token"
- static let deviceToken = "deviceToken"
- static let Authorization = "Authorization"
- static let deviceType = "deviceType"
- static let mac = "mac"
- static let index = "index"
- static let data = "data"
- static let list = "list"
- static let file = "file"
- static let directory = "directory"
- static let moments = "moments"
- static let name = "name"
- static let content_text = "content_text"
- static let content_image = "content_image"
- static let page = "page"
- static let pageSize = "pageSize"
- static let id = "id"
- static let postId = "postId"
- static let content = "content"
-}
-
diff --git a/app/StackChan/View/ARCameraView.swift b/app/StackChan/View/ARCameraView.swift
deleted file mode 100644
index 7dffdf4..0000000
--- a/app/StackChan/View/ARCameraView.swift
+++ /dev/null
@@ -1,227 +0,0 @@
-/*
- * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD
- *
- * SPDX-License-Identifier: MIT
- */
-
-import SwiftUI
-import RealityKit
-import ARKit
-
-struct ARCameraView : UIViewRepresentable {
-
- @Binding var expressionData: ExpressionData
-
- @Binding var decorate: Int
-
- @Binding var captureScreen: Bool
-
- let onCallback : ((ARSession,[ARAnchor]) -> Void)?
-
- let onFrameCallback : ((UIImage) -> Void)?
-
- func makeUIView(context: Context) -> ARSCNView {
- let arView = ARSCNView(frame: .zero)
- arView.contentMode = .scaleAspectFit
- arView.autoresizingMask = [.flexibleWidth,.flexibleHeight]
-
- let configuration = ARFaceTrackingConfiguration()
- configuration.isLightEstimationEnabled = true
- //4K
- if let format = ARFaceTrackingConfiguration.supportedVideoFormats.last {
- configuration.videoFormat = format
- }
- //HDR
- configuration.videoHDRAllowed = true
-
- arView.automaticallyUpdatesLighting = true
- arView.session.delegate = context.coordinator
- arView.delegate = context.coordinator
- arView.session.run(configuration, options: [.resetTracking,.removeExistingAnchors])
-
- return arView
- }
-
- func updateUIView(_ uiView: ARSCNView, context: Context) {
- context.coordinator.updateDecoration(decorate: decorate, uiView, context: context, expressionData: expressionData)
- }
-
- func makeCoordinator() -> Corrdinator {
- Corrdinator(parent: self)
- }
-
- //robot data
- var robot: ExpressionData = ExpressionData(leftEye: ExpressionItem(), rightEye: ExpressionItem(), mouth: ExpressionItem())
-
- static func dismantleUIView(_ uiView: ARSCNView, coordinator: Corrdinator) {
- uiView.session.pause()
- uiView.delegate = nil
- uiView.session.delegate = nil
- uiView.isPlaying = false
- uiView.scene.rootNode.enumerateChildNodes { node, _ in
- node.removeFromParentNode()
- }
- uiView.scene = SCNScene()
- }
-
- class Corrdinator: NSObject, ARSessionDelegate , ARSCNViewDelegate {
- var parent: ARCameraView
-
- var decorate: Int = 0
-
- var expressionLayer = ExpressionLayer(data: ExpressionData(leftEye: ExpressionItem(), rightEye: ExpressionItem(), mouth: ExpressionItem()),reverse: true)
-
- var faceAnchorNode: SCNNode?
- var currentDecorationNode: SCNNode?
-
- private var lastCaptureTime: TimeInterval = 0
-
- init(parent: ARCameraView) {
- self.parent = parent
- }
-
- func session(_ session: ARSession, didUpdate anchors: [ARAnchor]) {
- parent.onCallback?(session,anchors)
- }
-
- func renderer(_ renderer: any SCNSceneRenderer, updateAtTime time: TimeInterval) {
- if parent.captureScreen {
- if time - lastCaptureTime >= 0.5 {
- lastCaptureTime = time
- guard let scnView = renderer as? ARSCNView else { return }
- let renderedImage = scnView.snapshot()
- parent.onFrameCallback?(renderedImage)
- }
- }
- }
-
- private lazy var expressionRenderer: UIGraphicsImageRenderer = {
- let format = UIGraphicsImageRendererFormat.default()
- format.scale = UIScreen.main.scale
- format.opaque = false
- return UIGraphicsImageRenderer(
- size: expressionLayer.bounds.size,
- format: format
- )
- }()
-
- func createEmojiNoseNode(emoji: String) -> SCNNode {
- let size = CGSize(width: 300, height: 300)
- UIGraphicsBeginImageContextWithOptions(size, false, 0)
- (emoji as NSString).draw(in: CGRect(origin: .zero, size: size),
- withAttributes: [.font: UIFont.systemFont(ofSize: size.width - 20)])
- let image = UIGraphicsGetImageFromCurrentImageContext()
- UIGraphicsEndImageContext()
-
- let nosePlane = SCNPlane(width: 0.05, height: 0.05)
- nosePlane.firstMaterial?.diffuse.contents = image
- nosePlane.firstMaterial?.isDoubleSided = true
-
- let noseNode = SCNNode(geometry: nosePlane)
- noseNode.name = "noseNode"
- noseNode.position = SCNVector3(0, 0, 0.07)
- return noseNode
- }
-
- func createStackChanModel() -> SCNNode {
- guard let scene = SCNScene(named: "stackChanModel.scn"),
- let modelNode = scene.rootNode.childNodes.first else {
- print("no model")
- return SCNNode()
- }
- modelNode.name = "stackChanModel"
- modelNode.scale = SCNVector3(0.004, 0.004, 0.004)
- modelNode.opacity = 0.4
- modelNode.position = SCNVector3(0, 0.03, 0)
- modelNode.eulerAngles = SCNVector3Zero
- modelNode.eulerAngles.x = -Float.pi / 2
-
- return modelNode
- }
-
- func renderer(_ renderer: SCNSceneRenderer, nodeFor anchor: ARAnchor) -> SCNNode? {
- guard anchor is ARFaceAnchor else { return nil }
-
- let node = SCNNode()
- self.faceAnchorNode = node
-
- updateDecorationOnNode(node: node, decorate: decorate)
-
- return node
- }
-
- func createPlane() -> SCNNode {
- let plane = SCNPlane(width: 0.16, height: 0.12)
-
- let layerWidth = plane.width * 1000
- let layerHeight = plane.height * 1000
- expressionLayer.frame = CGRect(origin: .zero, size: CGSize(width: layerWidth, height: layerHeight))
- expressionLayer.setNeedsDisplay()
-
- let material = SCNMaterial()
- material.diffuse.contents = UIColor.black
- plane.materials = [material]
-
- let planeNode = SCNNode(geometry: plane)
- planeNode.name = "expressionPlane"
- planeNode.position = SCNVector3(0, 0.03, 0.07)
- return planeNode
- }
-
- func updateDecorationOnNode(node: SCNNode, decorate: Int) {
- currentDecorationNode?.removeFromParentNode()
-
- if decorate == 1 {
- let container = SCNNode()
-
- let stackChanModelNode = createStackChanModel()
- container.addChildNode(stackChanModelNode)
-
- let expressionPlaneNode = createPlane()
- container.addChildNode(expressionPlaneNode)
-
- node.addChildNode(container)
- currentDecorationNode = container
-
- } else if decorate == 2 {
- let noseNode = createEmojiNoseNode(emoji: "🐽")
- node.addChildNode(noseNode)
- currentDecorationNode = noseNode
- }
- }
-
- func updateDecoration(decorate: Int,_ uiView: ARSCNView, context: Context, expressionData: ExpressionData) {
- DispatchQueue.main.async {
- if self.decorate != decorate {
- self.decorate = decorate
- if let faceNode = self.faceAnchorNode {
- self.updateDecorationOnNode(node: faceNode, decorate: decorate)
- }
- }
-
- if decorate == 1 {
- let scene = uiView.scene
- guard let planeNode = scene.rootNode.childNode(withName: "expressionPlane", recursively: true),
- let plane = planeNode.geometry as? SCNPlane else {
- return
- }
- self.expressionLayer.data = expressionData
- self.expressionLayer.setNeedsDisplay()
-
- let originalImage = self.expressionRenderer.image { ctx in
- self.expressionLayer.render(in: ctx.cgContext)
- }
- let image = UIImage(
- cgImage: originalImage.cgImage!,
- scale: originalImage.scale,
- orientation: .upMirrored
- )
-
- plane.materials.first?.diffuse.contents = image
- }
-
- }
- }
- }
-}
-
diff --git a/app/StackChan/View/AppDelegate.swift b/app/StackChan/View/AppDelegate.swift
deleted file mode 100644
index dcaef9a..0000000
--- a/app/StackChan/View/AppDelegate.swift
+++ /dev/null
@@ -1,17 +0,0 @@
-/*
- * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD
- *
- * SPDX-License-Identifier: MIT
- */
-
-import ObjectiveC
-import UIKit
-
-class AppDelegate: NSObject, UIApplicationDelegate {
-
- static var orientationLock = UIInterfaceOrientationMask.portrait
-
- func application(_ application: UIApplication, supportedInterfaceOrientationsFor window: UIWindow?) -> UIInterfaceOrientationMask {
- return AppDelegate.orientationLock
- }
-}
diff --git a/app/StackChan/View/AvatarMotionControl.swift b/app/StackChan/View/AvatarMotionControl.swift
deleted file mode 100644
index e3b38e3..0000000
--- a/app/StackChan/View/AvatarMotionControl.swift
+++ /dev/null
@@ -1,562 +0,0 @@
-/*
- * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD
- *
- * SPDX-License-Identifier: MIT
- */
-
-import SwiftUI
-
-struct AvatarMotionControl : View {
-
- @State private var selectedItem: ControlItem = .avatar
-
- @EnvironmentObject var appState: AppState
-
- @State private var avatarData: ExpressionData = ExpressionData(leftEye: ExpressionItem(weight:100), rightEye: ExpressionItem(weight:100), mouth: ExpressionItem(weight:0))
-
- @State private var motionData: MotionData = MotionData(pitchServo: MotionDataItem(), yawServo: MotionDataItem())
-
- private let tag = "AvatarMotionControl"
-
- @State private var lastJoystickUpdate: Date = .distantPast
-
- enum ControlItem: String,CaseIterable, Identifiable {
- case avatar = "Avatar"
- case motion = "Motion"
- var id: String { rawValue }
- }
-
- var body: some View {
- VStack {
- HStack {
- Spacer()
- let danceData = DanceData(leftEye: avatarData.leftEye, rightEye: avatarData.rightEye, mouth: avatarData.mouth, yawServo: motionData.yawServo, pitchServo: motionData.pitchServo, durationMs: 1000)
- StackChanRobot(data: danceData)
- .frame(height: 250)
- Spacer()
- }
- HStack {
- Picker("Select", selection: $selectedItem) {
- ForEach(ControlItem.allCases) { item in
- Text(item.rawValue)
- .tag(item)
- }
- }
- .pickerStyle(.segmented)
- Button {
- if selectedItem == .avatar {
- withAnimation {
- avatarData = ExpressionData(leftEye: ExpressionItem(weight:100), rightEye: ExpressionItem(weight:100), mouth: ExpressionItem(weight:0))
- }
- saveAvatarData()
- } else if selectedItem == .motion {
- withAnimation {
- motionData = MotionData(pitchServo: MotionDataItem(), yawServo: MotionDataItem())
- }
- saveMotionData()
- }
- } label: {
- Image(systemName: "arrow.counterclockwise")
- }
- .glassButtonStyle()
- }
-
- if selectedItem == .avatar {
- List {
- Section("Left Eye") {
- HStack {
- Text("x")
- .frame(width: 60,alignment: .leading)
- Slider(
- value: Binding(
- get: { Double(avatarData.leftEye.x) },
- set: { avatarData.leftEye.x = Int($0) }
- ),
- in: -100...100,
- onEditingChanged: { editing in
- if !editing {
- saveAvatarData()
- }
- }
- )
- .frame(maxWidth: .infinity)
- Text(String(avatarData.leftEye.x))
- .frame(width: 50,alignment: .trailing)
- }
- HStack {
- Text("y")
- .frame(width: 60,alignment: .leading)
- Slider(
- value: Binding(
- get: { Double(avatarData.leftEye.y) },
- set: { avatarData.leftEye.y = Int($0) }
- ),
- in: -100...100,
- onEditingChanged: { editing in
- if !editing {
- saveAvatarData()
- }
- }
- )
- .frame(maxWidth: .infinity)
- Text(String(avatarData.leftEye.y))
- .frame(width: 50,alignment: .trailing)
- }
- HStack {
- Text("rotation")
- .frame(width: 60,alignment: .leading)
- Slider(
- value: Binding(
- get: { Double(avatarData.leftEye.rotation) },
- set: { avatarData.leftEye.rotation = Int($0) }
- ),
- in: -1800...1800,
- onEditingChanged: { editing in
- if !editing {
- saveAvatarData()
- }
- }
- )
- .frame(maxWidth: .infinity)
- Text(String(avatarData.leftEye.rotation))
- .frame(width: 50,alignment: .trailing)
- }
- HStack {
- Text("weight")
- .frame(width: 60,alignment: .leading)
- Slider(
- value: Binding(
- get: { Double(avatarData.leftEye.weight) },
- set: { avatarData.leftEye.weight = Int($0) }
- ),
- in: 0...100,
- onEditingChanged: { editing in
- if !editing {
- saveAvatarData()
- }
- }
- )
- .frame(maxWidth: .infinity)
- Text(String(avatarData.leftEye.weight))
- .frame(width: 50,alignment: .trailing)
- }
- HStack {
- Text("size")
- .frame(width: 60,alignment: .leading)
- Slider(
- value: Binding(
- get: { Double(avatarData.leftEye.size) },
- set: { avatarData.leftEye.size = Int($0) }
- ),
- in: -100...100,
- onEditingChanged: { editing in
- if !editing {
- saveAvatarData()
- }
- }
- )
- .frame(maxWidth: .infinity)
- Text(String(avatarData.leftEye.size))
- .frame(width: 50,alignment: .trailing)
- }
- }
- .listRowBackground(Color.clear)
-
- Section("Right Eye") {
- HStack {
- Text("x")
- .frame(width: 60,alignment: .leading)
- Slider(
- value: Binding(
- get: { Double(avatarData.rightEye.x) },
- set: { avatarData.rightEye.x = Int($0) }
- ),
- in: -100...100,
- onEditingChanged: { editing in
- if !editing {
- saveAvatarData()
- }
- }
- )
- .frame(maxWidth: .infinity)
- Text(String(avatarData.rightEye.x))
- .frame(width: 50,alignment: .trailing)
- }
- HStack {
- Text("y")
- .frame(width: 60,alignment: .leading)
- Slider(
- value: Binding(
- get: { Double(avatarData.rightEye.y) },
- set: { avatarData.rightEye.y = Int($0) }
- ),
- in: -100...100,
- onEditingChanged: { editing in
- if !editing {
- saveAvatarData()
- }
- }
- )
- .frame(maxWidth: .infinity)
- Text(String(avatarData.rightEye.y))
- .frame(width: 50,alignment: .trailing)
- }
- HStack {
- Text("rotation")
- .frame(width: 60,alignment: .leading)
- Slider(
- value: Binding(
- get: { Double(avatarData.rightEye.rotation) },
- set: { avatarData.rightEye.rotation = Int($0) }
- ),
- in: -1800...1800,
- onEditingChanged: { editing in
- if !editing {
- saveAvatarData()
- }
- }
- )
- .frame(maxWidth: .infinity)
- Text(String(avatarData.rightEye.rotation))
- .frame(width: 50,alignment: .trailing)
- }
- HStack {
- Text("weight")
- .frame(width: 60,alignment: .leading)
- Slider(
- value: Binding(
- get: { Double(avatarData.rightEye.weight) },
- set: { avatarData.rightEye.weight = Int($0) }
- ),
- in: 0...100,
- onEditingChanged: { editing in
- if !editing {
- saveAvatarData()
- }
- }
- )
- .frame(maxWidth: .infinity)
- Text(String(avatarData.rightEye.weight))
- .frame(width: 50,alignment: .trailing)
- }
- HStack {
- Text("size")
- .frame(width: 60,alignment: .leading)
- Slider(
- value: Binding(
- get: { Double(avatarData.rightEye.size) },
- set: { avatarData.rightEye.size = Int($0) }
- ),
- in: -100...100,
- onEditingChanged: { editing in
- if !editing {
- saveAvatarData()
- }
- }
- )
- .frame(maxWidth: .infinity)
- Text(String(avatarData.rightEye.size))
- .frame(width: 50,alignment: .trailing)
- }
- }
- .listRowBackground(Color.clear)
-
- Section("Mouth") {
- HStack {
- Text("x")
- .frame(width: 60,alignment: .leading)
- Slider(
- value: Binding(
- get: { Double(avatarData.mouth.x) },
- set: { avatarData.mouth.x = Int($0) }
- ),
- in: -100...100,
- onEditingChanged: { editing in
- if !editing {
- saveAvatarData()
- }
- }
- )
- .frame(maxWidth: .infinity)
- Text(String(avatarData.mouth.x))
- .frame(width: 50,alignment: .trailing)
- }
- HStack {
- Text("y")
- .frame(width: 60,alignment: .leading)
- Slider(
- value: Binding(
- get: { Double(avatarData.mouth.y) },
- set: { avatarData.mouth.y = Int($0) }
- ),
- in: -100...100,
- onEditingChanged: { editing in
- if !editing {
- saveAvatarData()
- }
- }
- )
- .frame(maxWidth: .infinity)
- Text(String(avatarData.mouth.y))
- .frame(width: 50,alignment: .trailing)
- }
- HStack {
- Text("rotation")
- .frame(width: 60,alignment: .leading)
- Slider(
- value: Binding(
- get: { Double(avatarData.mouth.rotation) },
- set: { avatarData.mouth.rotation = Int($0) }
- ),
- in: -1800...1800,
- onEditingChanged: { editing in
- if !editing {
- saveAvatarData()
- }
- }
- )
- .frame(maxWidth: .infinity)
- Text(String(avatarData.mouth.rotation))
- .frame(width: 50,alignment: .trailing)
- }
- HStack {
- Text("weight")
- .frame(width: 60,alignment: .leading)
- Slider(
- value: Binding(
- get: { Double(avatarData.mouth.weight) },
- set: { avatarData.mouth.weight = Int($0) }
- ),
- in: 0...100,
- onEditingChanged: { editing in
- if !editing {
- saveAvatarData()
- }
- }
- )
- .frame(maxWidth: .infinity)
- Text(String(avatarData.mouth.weight))
- .frame(width: 50,alignment: .trailing)
- }
- }
- .listRowBackground(Color.clear)
- }
- .listStyle(.grouped)
- .scrollContentBackground(.hidden)
- .background(.clear)
- } else if selectedItem == .motion {
- List {
- Section("Joystick") {
- HStack {
- Spacer()
- JoystickView { radians, strength in
- if radians == 0 && strength == 0 {
- calculationJoystick(radians: radians, strength: strength)
- } else {
- let now = Date()
- if now.timeIntervalSince(lastJoystickUpdate) > 0.1 {
- lastJoystickUpdate = now
- calculationJoystick(radians: radians, strength: strength)
- }
- }
- }
- .frame(width: 200,height: 200)
- Spacer()
- }
- }
- .listRowBackground(Color.clear)
- Section("Yaw Servo") {
- HStack {
- Text("angle")
- .frame(width: 60,alignment: .leading)
- Slider(
- value: Binding(
- get: { Double(motionData.yawServo.angle) },
- set: {
- motionData.yawServo.rotate = Int(0)
- motionData.yawServo.angle = Int($0)
- }
- ),
- in: -1280...1280,
- onEditingChanged: { editing in
- if !editing {
- saveMotionData()
- }
- }
- )
- .frame(maxWidth: .infinity)
- Text(String(motionData.yawServo.angle))
- .frame(width: 50,alignment: .trailing)
- }
- HStack {
- Text("speed")
- .frame(width: 60,alignment: .leading)
- Slider(
- value: Binding(
- get: { Double(motionData.yawServo.speed) },
- set: { motionData.yawServo.speed = Int($0) }
- ),
- in: 0...1000,
- onEditingChanged: { editing in
- if !editing {
- saveMotionData()
- }
- }
- )
- .frame(maxWidth: .infinity)
- Text(String(motionData.yawServo.speed))
- .frame(width: 50,alignment: .trailing)
- }
- HStack {
- Text("rotate")
- .frame(width: 60,alignment: .leading)
- Slider(
- value: Binding(
- get: { Double(motionData.yawServo.rotate) },
- set: {
- motionData.yawServo.angle = Int(0)
- motionData.yawServo.rotate = Int($0)
- }
- ),
- in: -1000...1000,
- onEditingChanged: { editing in
- if !editing {
- saveMotionData()
- }
- }
- )
- .frame(maxWidth: .infinity)
- Text(String(motionData.yawServo.rotate))
- .frame(width: 50,alignment: .trailing)
- }
- }
- .listRowBackground(Color.clear)
- Section("Pitch Servo") {
- HStack {
- Text("angle")
- .frame(width: 60,alignment: .leading)
- Slider(
- value: Binding(
- get: { Double(motionData.pitchServo.angle) },
- set: { motionData.pitchServo.angle = Int($0) }
- ),
- in: 0...900,
- onEditingChanged: { editing in
- if !editing {
- saveMotionData()
- }
- }
- )
- .frame(maxWidth: .infinity)
- Text(String(motionData.pitchServo.angle))
- .frame(width: 50,alignment: .trailing)
- }
- HStack {
- Text("speed")
- .frame(width: 60,alignment: .leading)
- Slider(
- value: Binding(
- get: { Double(motionData.pitchServo.speed) },
- set: { motionData.pitchServo.speed = Int($0) }
- ),
- in: 0...1000,
- onEditingChanged: { editing in
- if !editing {
- saveMotionData()
- }
- }
- )
- .frame(maxWidth: .infinity)
- Text(String(motionData.pitchServo.speed))
- .frame(width: 50,alignment: .trailing)
- }
- }
- .listRowBackground(Color.clear)
- }
- .listStyle(.grouped)
- .scrollContentBackground(.hidden)
- .background(.clear)
- }
- Spacer()
- }
- .padding()
- .ignoresSafeArea()
- .onAppear {
- WebSocketUtil.shared.addObserver(for: tag) { message in
- switch message {
- case .data(let data):
- let result = appState.parseMessage(message: data)
- if let msgType = result.0, let parsedData = result.1 {
- switch msgType {
- case MsgType.getAvatarPosture:
- print("Received the result of obtaining the header information" + String(parsedData.count))
- default:
-
- break
- }
- }
- case .string(let text):
- print("Received a regular message:" + text)
- @unknown default:
- break
- }
- }
- appState.sendWebSocketMessage(.getAvatarPosture)
- }
- .onDisappear {
- WebSocketUtil.shared.removeObserver(for: tag)
- }
- }
-
- /// Calculate the joystick data
- private func calculationJoystick(radians: CGFloat, strength: CGFloat) {
- let x = strength * cos(radians)
- let y = strength * sin(radians)
- let deadZone: CGFloat = 0.0
- var yawValue: Int = 0
- if abs(x) > deadZone {
- yawValue = Int(x * 1280)
- motionData.yawServo.rotate = 0
- motionData.yawServo.angle = yawValue
- motionData.yawServo.speed = 50
- } else {
- motionData.yawServo.rotate = 0
- motionData.yawServo.angle = 0
- motionData.yawServo.speed = 500
- }
- if y <= 0 {
- if abs(y) > deadZone {
- let normalizedY = max(-y, 0)
- let newPitch = Int(normalizedY * 900)
- motionData.pitchServo.angle = newPitch
- motionData.pitchServo.speed = 50
- } else {
- motionData.pitchServo.speed = 500
- motionData.pitchServo.angle = 0
- }
- } else {
- motionData.pitchServo.speed = 500
- motionData.pitchServo.angle = 0
- }
- saveMotionData()
- }
-
- private func saveAvatarData() {
- if !appState.deviceMac.isEmpty {
- let jsonString = appState.deviceMac + avatarData.toJsonString()
- let data = jsonString.toData()
- appState.sendWebSocketMessage(.controlAvatar, data)
- }
- }
-
- private func saveMotionData() {
- if !appState.deviceMac.isEmpty {
- let jsonString = appState.deviceMac + motionData.toJsonString()
- print(jsonString)
-
- let data = jsonString.toData()
- appState.sendWebSocketMessage(.controlMotion, data)
- }
- }
-}
-
diff --git a/app/StackChan/View/BindingDevice.swift b/app/StackChan/View/BindingDevice.swift
deleted file mode 100644
index fff7c02..0000000
--- a/app/StackChan/View/BindingDevice.swift
+++ /dev/null
@@ -1,320 +0,0 @@
-/*
- * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD
- *
- * SPDX-License-Identifier: MIT
- */
-
-import SwiftUI
-import NetworkExtension
-import CoreLocation
-import NetworkExtension
-import CoreBluetooth
-
-struct BindingDevice : View {
-
- enum BindingDevicePageType: Hashable {
- case scanningEquipment
- }
-
- @State private var path: [BindingDevicePageType] = []
-
- var body: some View {
- NavigationStack(path: $path) {
- VStack{
- Spacer()
- VStack(alignment: .leading, spacing: 16) {
-
- HStack {
- Spacer()
- Image("lateral_image")
- .resizable()
- .frame(maxWidth: 250,maxHeight: 250)
- Spacer()
- }
-
- Text("Get your StackChan device ready")
- .font(.title2)
- .bold()
-
- VStack(alignment: .leading, spacing: 12) {
- HStack(alignment: .top) {
- Image(systemName: "1.circle.fill")
- .foregroundColor(.accentColor)
- Text("Turn on your StackChan device")
- }
- HStack(alignment: .top) {
- Image(systemName: "2.circle.fill")
- .foregroundColor(.accentColor)
- Text("After turning on the computer, turn the page to \"Setup\" and click to enter. A QR code will be displayed")
- }
- HStack(alignment: .top) {
- Image(systemName: "3.circle.fill")
- .foregroundColor(.accentColor)
- Text("Align the QR code and scan it to bind the device")
- }
- }
- .font(.body)
- }
- .padding()
- Spacer()
- NavigationLink(value: BindingDevicePageType.scanningEquipment) {
- Text("Next")
- .frame(maxWidth: .infinity)
- }
- .padding()
- .controlSize(.large)
- .buttonStyle(.borderedProminent)
- }
- .navigationTitle("Binding Device")
- .navigationDestination(for: BindingDevicePageType.self) { PageType in
- switch PageType {
- case .scanningEquipment:
- ScanningEquipment()
- }
- }
- }
- }
-}
-
-struct ScanningEquipment : View {
-
- enum PairingStatus {
- case ScanCode
- case ConnectBlue
- case InputWiFi
- case DistributionNetwork
- case ChangeTheName
- case Empty
- }
-
- enum Field {
- case Name
- case Password
- case StackChanName
- }
-
- @EnvironmentObject var appState: AppState
-
- @State var pairingStatus: PairingStatus = .ScanCode
-
- @State var wifiName: String = ""
- @State var wifiPassword: String = ""
- @State var stackChanName: String = ""
-
- @State private var locationManager = CLLocationManager()
- @State private var locationDelegate = LocationDelegate()
-
- @FocusState private var focusedField: Field?
-
- var body: some View {
- Group {
- switch pairingStatus {
- case .ScanCode:
- GeometryReader { geometry in
- ScanView { result in
- switch result {
- case .success(let data):
- readCodeString(value: data)
- break
- case .failure(_):
- break
- }
- }
- .clipShape(
- RoundedRectangle(
- cornerRadius: min(geometry.size.width, geometry.size.height) * 0.1,
- style: .continuous
- )
- )
- }
- .padding()
- .navigationTitle("Scan Device QR Code")
- case .ConnectBlue:
- VStack {
- ProgressView()
- .progressViewStyle(.circular)
- Text("Connecting to Bluetooth")
- .font(.title3)
- .multilineTextAlignment(.center)
- }
- .frame(maxWidth: .infinity,maxHeight: .infinity, alignment: .center)
- .navigationTitle("Pairing devices")
- case .InputWiFi:
- VStack {
- List {
- Section(header: Text("Name")) {
- TextField("Please enter the name of the wifi", text:$wifiName)
- .focused($focusedField, equals: .Name)
- .submitLabel(.next)
- .onSubmit {
- focusedField = .Password
- }
- }
- Section(header: Text("Password")) {
- TextField("Please enter the password of the wifi", text:$wifiPassword)
- .focused($focusedField, equals: .Password)
- .submitLabel(.done)
- .onSubmit {
- confirmWifi()
- }
- }
- }
- .listStyle(.insetGrouped)
-
- Spacer()
-
- Button {
- focusedField = nil
- confirmWifi()
- } label: {
- Text("Confirm")
- .frame(maxWidth: .infinity)
- }
- .padding()
- .controlSize(.large)
- .buttonStyle(.borderedProminent)
- }
- .background(Color(UIColor.systemGroupedBackground))
- .navigationTitle("Enter Wifi Information")
- case .DistributionNetwork:
- VStack {
- ProgressView()
- .progressViewStyle(.circular)
- Text("The network is being configured for the equipment")
- .font(.title3)
- .multilineTextAlignment(.center)
- }
- .frame(maxWidth: .infinity,maxHeight: .infinity, alignment: .center)
- .navigationTitle("wait a moment")
- case .ChangeTheName:
- VStack {
- List {
- Section(header: Text("Name")) {
- TextField("Please enter the name of the stackChan", text:$stackChanName)
- .focused($focusedField, equals: .StackChanName)
- .submitLabel(.done)
- .onSubmit {
- updataName()
- }
- }
- }
- .listStyle(.insetGrouped)
-
- Spacer()
-
- Button {
- focusedField = nil
- updataName()
- } label: {
- Text("Confirm")
- .frame(maxWidth: .infinity)
- }
- .padding()
- .controlSize(.large)
- .buttonStyle(.borderedProminent)
- }
- .frame(maxWidth: .infinity,maxHeight: .infinity, alignment: .center)
- .navigationTitle("Give me a name")
- default:
- EmptyView()
- }
- }
- .alert(appState.alertTitle, isPresented: $appState.showAlert){
- Button {
- appState.alertAction?()
- } label: {
- Text("Confirm")
- }
- }
- .task {
- }
- }
-
- func updataName() {
-
- }
-
- func readCodeString(value: String) {
- if let data = value.data(using: .utf8), let json = try? JSONSerialization.jsonObject(with: data) as? [String:Any], let mac = json["mac"] as? String {
- let extracted = mac
- let cleanedMac = extracted.uppercased().replacingOccurrences(
- of: "[^A-F0-9]",
- with: "",
- options: .regularExpression
- )
- appState.deviceMac = cleanedMac
- appState.showBindingDevice = false
- appState.connectWebSocket()
- appState.openBlufi()
- }
- }
-
- private func getBlueAndWifiInfo() {
- NEHotspotNetwork.fetchCurrent { network in
- if let network = network {
- wifiName = network.ssid
- focusedField = .Password
- }
- }
- BlufiUtil.shared.startScan()
- }
-
- private func getPermission() {
- if #available(iOS 14.0, *) {
- switch locationManager.authorizationStatus {
- case .authorizedWhenInUse, .authorizedAlways:
- getBlueAndWifiInfo()
- break
- case .denied, .restricted:
- break
- case .notDetermined:
- locationManager.requestWhenInUseAuthorization()
- break
- default:
- break
- }
- } else {
- locationManager.requestWhenInUseAuthorization()
- }
- }
-
- private func confirmWifi() {
-
- if !BlufiUtil.shared.blueSwitch {
- appState.alertTitle = "Please turn on Bluetooth"
- appState.showAlert = true
- return
- }
-
- if wifiName.isEmpty || wifiPassword.isEmpty {
- appState.alertTitle = "Please enter Wi-Fi name and password"
- appState.showAlert = true
- return
- }
-
- withAnimation{
- pairingStatus = .DistributionNetwork
- }
-
- DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
- withAnimation{
- pairingStatus = .ChangeTheName
- }
- }
- }
-
- private func hideKeyboard() {
- UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
- }
-}
-
-
-class LocationDelegate: NSObject,CLLocationManagerDelegate {
- var onAuthorized: (() -> Void)?
-
- func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
- if manager.authorizationStatus == .authorizedWhenInUse || manager.authorizationStatus == .authorizedAlways {
- onAuthorized?()
- }
- }
-}
diff --git a/app/StackChan/View/CameraPage.swift b/app/StackChan/View/CameraPage.swift
deleted file mode 100644
index f809975..0000000
--- a/app/StackChan/View/CameraPage.swift
+++ /dev/null
@@ -1,571 +0,0 @@
-/*
- * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD
- *
- * SPDX-License-Identifier: MIT
- */
-
-import SwiftUI
-
-struct CameraPage: View {
-
- @State var showControlButton = true
- @State var fullScreenDisplay = false
-
- @State var controlWidth: CGFloat = 0
- @State var controlHeight: CGFloat = 0
-
- private let controlButtonSize: CGFloat = 88
- private let bottomButtonSize: CGFloat = 50
-
- @State var openMicrophone : Bool = false
- @State var openSpeaker: Bool = true
- @State var startRecord: Bool = false
- @State var isPress: Int? = nil
-
- @EnvironmentObject var appState: AppState
-
- @AppStorage("recordMotion") private var recordMotionData: Data = Data()
-
- @State var recordMotion: [MotionData] = []
-
- private let feedback = UIImpactFeedbackGenerator(style: .medium)
-
- @State private var removeRecordPoint: Bool = false
-
- @State private var cameraImage: Data? = nil
-
- @State var showAlert = false
- @State var alertMessage = ""
-
- private let tag: String = "CameraPage"
-
- @State private var motionData: MotionData = MotionData(pitchServo: MotionDataItem(), yawServo: MotionDataItem())
-
- @State private var pressTimer: Timer? = nil
-
- private let longPressInterval = 0.05
- private let longStepValue = 50
-
- var body: some View {
- GeometryReader { geo in
- if fullScreenDisplay {
- VStack {
- cameraView()
- .frame(maxWidth: .infinity, maxHeight: .infinity)
- }
- .toolbar(.hidden, for: .bottomBar,.navigationBar,.tabBar,.automatic)
- .ignoresSafeArea(.all)
- .background(.black)
- } else {
- let isPortrait = geo.size.height > geo.size.width
- ZStack(alignment: isPortrait ? .bottomTrailing : .topTrailing) {
- if isPortrait {
- VStack {
- cameraView()
- .frame(maxWidth: .infinity, maxHeight: .infinity)
- cameraControl(isPortrait: isPortrait)
- .frame(maxWidth: .infinity,maxHeight: controlHeight)
- .opacity(showControlButton ? 1: 0)
- }
- } else {
- HStack {
- cameraView()
- .frame(maxWidth: .infinity, maxHeight: .infinity)
- cameraControl(isPortrait: isPortrait)
- .frame(maxWidth: controlWidth, maxHeight: .infinity)
- .opacity(showControlButton ? 1: 0)
- }
- }
-
- HStack {
- Spacer()
- Button {
- withAnimation(.easeInOut(duration: 0.3)) {
- showControlButton.toggle()
- updateControlSize(isPortrait: isPortrait, size: geo.size)
- }
- } label: {
- Image(systemName:
- showControlButton
- ? (isPortrait ? "chevron.down" : "chevron.right")
- : (isPortrait ? "chevron.up" : "chevron.left")
- )
- .frame(width: bottomButtonSize, height: bottomButtonSize)
- .font(.system(size: bottomButtonSize / 2))
- }
- .glassEffectCircle()
- }
- .padding(12)
- }
- .padding(0)
- .onAppear {
- DispatchQueue.main.async {
- if controlWidth == 0 && controlHeight == 0 {
- controlHeight = geo.size.height / 2
- }
- }
- WebSocketUtil.shared.addObserver(for: tag) { (message: URLSessionWebSocketTask.Message) in
- switch message {
- case .data(let data):
- let result = appState.parseMessage(message: data)
- if let msgType = result.0, let parsedData = result.1 {
- switch msgType {
- case MsgType.jpeg:
- cameraImage = parsedData
- default:
- break
- }
- }
- case .string(let text):
- print("Received a regular message: \(text)")
- @unknown default:
- break
- }
- }
- //on device camera
- appState.sendWebSocketMessage(.onCamera,appState.deviceMac.toData())
- }
- .onDisappear {
- WebSocketUtil.shared.removeObserver(for: tag)
- //off device camera
- appState.sendWebSocketMessage(.offCamera,appState.deviceMac.toData())
- }
- .onChange(of: geo.size) { newValue in
- let isPortrait = newValue.height > newValue.width
- updateControlSize(isPortrait: isPortrait, size: newValue)
- }
- .toolbar(.hidden, for: .tabBar)
- .navigationTitle("SENTINEL")
- }
- }
- .alert(alertMessage, isPresented: $showAlert) {
- Button {
-
- } label: {
- Text("Confirm")
- }
- }
- .onAppear {
- recordMotion = getRecordMotion()
- }
- }
-
- private func getRecordMotion() -> [MotionData] {
- guard let decoded = try? JSONDecoder().decode([MotionData].self, from: recordMotionData) else {
- return []
- }
- return decoded
- }
-
- private func setRecordMotion(_ newValue: [MotionData]) {
- if let encoded = try? JSONEncoder().encode(newValue) {
- recordMotionData = encoded
- recordMotion = newValue
- }
- }
-
- private func updateControlSize(isPortrait: Bool, size: CGSize) {
- withAnimation {
- if showControlButton {
- if isPortrait {
- controlHeight = size.height / 2
- } else {
- controlWidth = size.width / 2
- }
- } else {
- if isPortrait {
- controlHeight = 0
- } else {
- controlWidth = 0
- }
- }
- }
- }
-
- private func cameraView() -> some View {
- GeometryReader { geo in
- VStack {
- Spacer()
- ZStack(alignment: .bottom) {
- if let cameraData = cameraImage, let uiImage = UIImage(data: cameraData) {
- Image(uiImage: uiImage)
- .resizable()
- .scaledToFit()
- } else {
- Color.gray
- }
- HStack {
- Spacer()
- Button {
- withAnimation {
- fullScreenDisplay.toggle()
- }
- } label: {
- Image(systemName: fullScreenDisplay ? "arrow.down.right.and.arrow.up.left" : "arrow.up.left.and.arrow.down.right")
- .frame(width: 44,height: 44)
- .foregroundColor(.white)
- }
- .glassEffectCircle()
- }
- .padding()
- }
- .frame(
- maxWidth: geo.size.width,
- maxHeight: min(geo.size.height, geo.size.width * 3 / 4)
- )
- Spacer()
- }
- }
- }
-
- @ViewBuilder private func cameraRecordPoint() -> some View {
- ZStack {
- ScrollViewReader { proxy in
- ScrollView(.vertical, showsIndicators: true) {
- VStack(spacing: 5) {
- Color.clear.frame(width: bottomButtonSize / 2,height: bottomButtonSize / 2)
- ForEach(Array(recordMotion.indices), id: \.self) { index in
- ZStack(alignment: .topTrailing) {
- Button {
- if removeRecordPoint {
- recordMotion.remove(at: index)
- setRecordMotion(recordMotion)
- } else {
- feedback.impactOccurred()
- motionData = recordMotion[index]
- saveMotionData()
- }
- } label: {
- Text(String(index + 1))
- .frame(width: bottomButtonSize, height: bottomButtonSize)
- .background(
- Circle()
- .fill(Color(UIColor.secondarySystemFill))
- )
- .font(.system(size: bottomButtonSize / 2))
- }
- if removeRecordPoint {
- Image(systemName: "minus.circle.fill")
- .font(.system(size: 20))
- .foregroundColor(.red)
- }
- }
- }
- Color.clear.frame(width: bottomButtonSize / 2,height: bottomButtonSize / 2).id(-1)
- }
- .padding(0)
- }
- .padding(0)
- .onChange(of: recordMotion.count) { _ in
- withAnimation {
- proxy.scrollTo(-1, anchor: .bottom)
- }
- }
- }
- .padding(.vertical, bottomButtonSize / 2)
-
- VStack {
- Button {
- withAnimation {
- recordMotion.append(motionData)
- setRecordMotion(recordMotion)
- }
- } label: {
- Image(systemName: "plus")
- .frame(width: bottomButtonSize, height: bottomButtonSize)
- .font(.system(size: bottomButtonSize / 2))
- }
- .glassEffectCircle()
-
- Spacer()
-
- Button {
- withAnimation {
- removeRecordPoint.toggle()
- }
- } label: {
- Image(systemName: removeRecordPoint ? "checkmark" : "minus")
- .frame(width: bottomButtonSize, height: bottomButtonSize)
- .foregroundColor(removeRecordPoint ? .accent : Color(UIColor.label))
- .font(.system(size: bottomButtonSize / 2))
- }
- .glassEffectCircle()
- }
- .padding(0)
- }.background(
- RoundedRectangle(cornerRadius: 50)
- .fill(.ultraThinMaterial)
- )
- }
-
- @ViewBuilder private func cameraControl(isPortrait: Bool) -> some View {
-
- let microphone = Button {
- feedback.impactOccurred()
- withAnimation {
- openMicrophone.toggle()
- }
- } label: {
- Image(systemName: openMicrophone ? "microphone" : "microphone.slash")
- .frame(width: bottomButtonSize, height: bottomButtonSize)
- .font(.system(size: bottomButtonSize / 2))
- }
- .glassEffectCircle()
-
- let speaker = Button {
- feedback.impactOccurred()
- withAnimation {
- openSpeaker.toggle()
- }
- } label: {
- Image(systemName: openSpeaker ? "speaker" : "speaker.slash")
- .frame(width: bottomButtonSize, height: bottomButtonSize)
- .font(.system(size: bottomButtonSize / 2))
- }
- .glassEffectCircle()
-
- let record = Button {
- feedback.impactOccurred()
- withAnimation {
- startRecord.toggle()
- }
- } label: {
- Image(systemName: !startRecord ? "record.circle" : "record.circle.fill")
- .frame(width: bottomButtonSize, height: bottomButtonSize)
- .font(.system(size: bottomButtonSize / 2))
- }
- .glassEffectCircle()
- .foregroundColor(!startRecord ? Color(UIColor.label) : .red)
-
- let directionButton = ZStack{
- Button(action: {
- if let messsage = "Hi".toData() {
- appState.sendWebSocketMessage(.onCamera, messsage)
- }
- }) {
- Image(systemName: "arrow.up")
- .frame(width: controlButtonSize, height: controlButtonSize)
- .background(
- Circle().fill(.ultraThinMaterial)
- .overlay(
- Circle()
- .fill(isPress == 1 ? Color.accent : .clear)
- )
- )
- .foregroundColor(isPress == 1 ? .white : Color(UIColor.label))
- .font(.system(size: controlButtonSize / 2))
- }
- .buttonStyle(.plain)
- .simultaneousGesture(
- DragGesture(minimumDistance: 0)
- .onChanged { _ in
- if isPress != nil {
- return
- }
- withAnimation {
- isPress = 1
- }
- feedback.impactOccurred()
- print("Up pressed")
-
- pressTimer?.invalidate()
- pressTimer = Timer.scheduledTimer(withTimeInterval: longPressInterval, repeats: true, block: { _ in
- self.motionData.pitchServo.angle += longStepValue
- self.saveMotionData()
- })
- }
- .onEnded { _ in
- isPress = nil
-
- pressTimer?.invalidate()
- pressTimer = nil
- }
- )
- .offset(x: 0, y: -(controlButtonSize / 1.3))
-
- Button(action: {}) {
- Image(systemName: "arrow.down")
- .frame(width: controlButtonSize, height: controlButtonSize)
- .background(
- Circle().fill(.ultraThinMaterial)
- .overlay(
- Circle()
- .fill(isPress == 3 ? Color.accent : .clear)
- )
- )
- .foregroundColor(isPress == 3 ? .white : Color(UIColor.label))
- .font(.system(size: controlButtonSize / 2))
- }
- .buttonStyle(.plain)
- .simultaneousGesture(
- DragGesture(minimumDistance: 0)
- .onChanged { _ in
- if isPress != nil {
- return
- }
- withAnimation {
- isPress = 3
- }
- feedback.impactOccurred()
-
- pressTimer?.invalidate()
- pressTimer = Timer.scheduledTimer(withTimeInterval: longPressInterval, repeats: true, block: { _ in
- self.motionData.pitchServo.angle -= longStepValue
- self.saveMotionData()
- })
- }
- .onEnded { _ in
- isPress = nil
-
- pressTimer?.invalidate()
- pressTimer = nil
- }
- )
- .offset(x: 0, y: (controlButtonSize / 1.3))
-
- Button(action: {}) {
- Image(systemName: "arrow.left")
- .frame(width: controlButtonSize, height: controlButtonSize)
- .background(
- Circle().fill(.ultraThinMaterial)
- .overlay(
- Circle()
- .fill(isPress == 4 ? Color.accent : .clear)
- )
- )
- .foregroundColor(isPress == 4 ? .white : Color(UIColor.label))
- .font(.system(size: controlButtonSize / 2))
- }
- .buttonStyle(.plain)
- .simultaneousGesture(
- DragGesture(minimumDistance: 0)
- .onChanged { _ in
- if isPress != nil {
- return
- }
- withAnimation {
- isPress = 4
- }
- feedback.impactOccurred()
-
- pressTimer?.invalidate()
- pressTimer = Timer.scheduledTimer(withTimeInterval: longPressInterval, repeats: true, block: { _ in
- self.motionData.yawServo.angle -= longStepValue
- self.saveMotionData()
- })
- }
- .onEnded { _ in
- isPress = nil
-
- pressTimer?.invalidate()
- pressTimer = nil
- }
- )
- .offset(x: -(controlButtonSize / 1.3), y: 0)
-
- Button(action: {}) {
- Image(systemName: "arrow.right")
- .frame(width: controlButtonSize, height: controlButtonSize)
- .background(
- Circle().fill(.ultraThinMaterial)
- .overlay(
- Circle()
- .fill(isPress == 2 ? Color.accent : .clear)
- )
- )
- .foregroundColor(isPress == 2 ? .white : Color(UIColor.label))
- .font(.system(size: controlButtonSize / 2))
- }
- .buttonStyle(.plain)
- .simultaneousGesture(
- DragGesture(minimumDistance: 0)
- .onChanged { _ in
- if isPress != nil {
- return
- }
- withAnimation {
- isPress = 2
- }
- feedback.impactOccurred()
-
- pressTimer?.invalidate()
- pressTimer = Timer.scheduledTimer(withTimeInterval: longPressInterval, repeats: true, block: { _ in
- self.motionData.yawServo.angle += longStepValue
- self.saveMotionData()
- })
- }
- .onEnded { _ in
- isPress = nil
-
- pressTimer?.invalidate()
- pressTimer = nil
- }
- )
- .offset(x: (controlButtonSize / 1.3), y: 0)
- }
-
- if isPortrait {
- VStack {
- Spacer()
- HStack {
- VStack {
- Text("View\nPresets").font(.caption).multilineTextAlignment(.center)
- cameraRecordPoint()
- }
- Spacer()
- directionButton
- Spacer()
- }
- .padding(0)
- Spacer()
- HStack {
- record
- Spacer()
- speaker
- microphone
- Color.clear.frame(width: bottomButtonSize,height: bottomButtonSize)
- }
- .padding(0)
- }
- .padding(12)
- .foregroundColor(Color(UIColor.label))
- } else {
- HStack {
- HStack {
- VStack {
- Text("View\nPresets").font(.caption).multilineTextAlignment(.center)
- cameraRecordPoint()
- }
- Spacer()
- directionButton
- Spacer()
- }
- Spacer()
- VStack {
- Color.clear.frame(width: bottomButtonSize,height: bottomButtonSize)
- speaker
- microphone
- Spacer()
- record
- }
- .padding(0)
- }
- .padding(12)
- .foregroundColor(Color(UIColor.label))
- }
- }
-
- private func saveMotionData() {
- if !appState.deviceMac.isEmpty {
- let jsonString = appState.deviceMac + motionData.toJsonString()
- let data = jsonString.toData()
- appState.sendWebSocketMessage(.controlMotion, data)
- }
- }
-}
-
-struct CameraPageViewPreview : PreviewProvider {
- static var previews: some View {
- CameraPage()
- }
-}
diff --git a/app/StackChan/View/ContentView.swift b/app/StackChan/View/ContentView.swift
deleted file mode 100644
index 164b359..0000000
--- a/app/StackChan/View/ContentView.swift
+++ /dev/null
@@ -1,74 +0,0 @@
-/*
- * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD
- *
- * SPDX-License-Identifier: MIT
- */
-
-import SwiftUI
-import CoreBluetooth
-
-struct ContentView: View {
-
- @EnvironmentObject var appState: AppState
-
- var body: some View {
- TabView {
- StackChan()
- .tabItem {
- Label( "StackChan", systemImage: "ipod")
- }
- Nearby()
- .tabItem {
- Label("Nearby", systemImage: "sensor")
- }
- Moments()
- .tabItem {
- Label("Moments", systemImage: "person.3")
- }
- Settings()
- .tabItem {
- Label("Settings", systemImage: "gear")
- }
- }
- .task {
- appState.openBlufi()
- if appState.deviceMac != "" {
- appState.connectWebSocket()
- }
- }
- .sheet(isPresented: $appState.showBindingDevice) {
- BindingDevice()
- .interactiveDismissDisabled(appState.forcedDisplayBindingDevice)
- }
- .sheet(isPresented: $appState.showDeviceWifiSet) {
- SelectBlufiDevice()
- .presentationDetents([.medium])
- .interactiveDismissDisabled(true)
- }
- .alert("Let's give the lovely StackChan a new name", isPresented: $appState.showCjamgeNameAlert, actions: {
- TextField("Please enter the name", text: $appState.newName)
- Button("Cancel", role: .cancel) {
- appState.showCjamgeNameAlert = false
- }
- Button("Confirm") {
- appState.showCjamgeNameAlert = false
- withAnimation {
- appState.deviceInfo.name = appState.newName
- }
- appState.updateDeviceInfo()
- }
- })
- .alert("Please switch StackChan to the SETUP page, select \"App Bind Code\", and then switch to the settings page on the app to choose \"Bind Device\"", isPresented: $appState.showBindingDeviceAlert) {
- Button("Confirm") {
- appState.showBindingDeviceAlert = false
- }
- }
- }
-}
-
-
-struct ContentViewPreview : PreviewProvider {
- static var previews: some View {
- ContentView()
- }
-}
diff --git a/app/StackChan/View/Dance.swift b/app/StackChan/View/Dance.swift
deleted file mode 100644
index b01fd46..0000000
--- a/app/StackChan/View/Dance.swift
+++ /dev/null
@@ -1,815 +0,0 @@
-/*
- * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD
- *
- * SPDX-License-Identifier: MIT
- */
-
-import SwiftUI
-
-struct DanceData : Codable,Identifiable {
- var leftEye: ExpressionItem // Left eye, default weight = 100
- var rightEye: ExpressionItem // Right eye, default weight = 100
- var mouth: ExpressionItem // Mouth, default weight = 0
- var yawServo: MotionDataItem // Yaw rotation, angle range (-1280 ~ 1280), default 0
- var pitchServo: MotionDataItem // Pitch movement, angle range (0 ~ 900), default 0
- var durationMs: Int // Duration in milliseconds, default 1000
- var id: String = UUID().uuidString
-
- enum CodingKeys: String, CodingKey {
- case leftEye, rightEye, mouth, yawServo, pitchServo, durationMs
- }
-
- func copy() -> DanceData {
- DanceData(
- leftEye: self.leftEye.copy(),
- rightEye: self.rightEye.copy(),
- mouth: self.mouth.copy(),
- yawServo: self.yawServo.copy(),
- pitchServo: self.pitchServo.copy(),
- durationMs: self.durationMs,
- id: UUID().uuidString
- )
- }
-}
-
-///
-struct Dance : View {
-
- @State var danceList: [DanceData] = []
-
- @State var modelDanceList: [DanceData] = []
-
- @State private var selectedDance: Int = 0
-
- @EnvironmentObject var appState: AppState
-
- @State var showAddDance : Bool = false
-
- @State var isRun: Bool = false
-
- @State var editDanceData = DanceData(leftEye: ExpressionItem(), rightEye: ExpressionItem(), mouth: ExpressionItem(), yawServo: MotionDataItem(), pitchServo: MotionDataItem(), durationMs: 1000)
-
- @State var editDanceDataIndex: Int? = nil
-
- @State private var danceTimer: Timer? = nil
-
- var body: some View {
- List {
- ForEach(Array(danceList.enumerated()), id: \.element.id) { index,item in
- danceItemView(index: index)
- .swipeActions(edge: .trailing) {
- Button(role: .destructive) {
- danceList.remove(at: index)
- saveDance()
- } label: {
- Label("Delete", systemImage: "trash")
- }
- }
- }
- .onMove { source, destination in
- danceList.move(fromOffsets: source, toOffset: destination)
- saveDance()
- }
- }
- .listStyle(.insetGrouped)
- .refreshable {
- getDanceList()
- }
- .toolbar {
- ToolbarItem(placement: .primaryAction) {
- Button {
- isRun.toggle()
- if isRun {
- startDance()
- } else {
- stopDance()
- }
- } label: {
- Label {
- Text(isRun ? "Stop" : "Run")
- } icon: {
- Image(systemName: isRun ? "stop.fill" : "play.fill")
- }
- }
- }
- if #available(iOS 26.0, *) {
- ToolbarSpacer(.fixed, placement: .primaryAction)
- }
-
- ToolbarItem(placement: .primaryAction) {
- Menu {
- Button {
- selectedDance = 0
- } label: {
- if selectedDance == 0 {
- Label("Dance One", systemImage: "checkmark")
- } else {
- Text("Dance One")
- }
- }
-
- Button {
- selectedDance = 1
- } label: {
- if selectedDance == 1 {
- Label("Dance Two", systemImage: "checkmark")
- } else {
- Text("Dance Two")
- }
- }
-
- Button {
- selectedDance = 2
- } label: {
- if selectedDance == 2 {
- Label("Dance Three", systemImage: "checkmark")
- } else {
- Text("Dance Three")
- }
- }
- } label: {
- Label {
- Text("Dance")
- } icon: {
- Image(systemName: "figure.dance")
- }
- }
- }
-
- if #available(iOS 26.0, *) {
- ToolbarSpacer(.fixed, placement: .primaryAction)
- }
-
- ToolbarItem(placement: .primaryAction) {
- Button {
- editDanceData = DanceData(leftEye: ExpressionItem(weight: 100), rightEye: ExpressionItem(weight: 100), mouth: ExpressionItem(), yawServo: MotionDataItem(), pitchServo: MotionDataItem(), durationMs: 1000)
- editDanceDataIndex = nil
- danceList.append(editDanceData)
- saveDance()
- } label: {
- Label {
- Text("Add Dance")
- } icon: {
- Image(systemName: "plus")
- }
- }
- }
- }
- .toolbar(.hidden, for: .tabBar)
- .navigationTitle(danceTitle())
- .onAppear {
- getDanceList()
- }
- .onDisappear{
- isRun = false
- stopDance()
- }
- }
-
- private func startDance() {
- var duration = 1000
- let jsonString = danceList.toJsonString()
- for i in danceList {
- duration = duration + i.durationMs
- }
- appState.sendWebSocketMessage(.dance, jsonString.toData())
- let interval = max(Double(duration) / 1000.0, 0.1)
- danceTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { timer in
- appState.sendWebSocketMessage(.dance, jsonString.toData())
- }
- }
-
- /// Stop the dance timer
- private func stopDance() {
- danceTimer?.invalidate()
- danceTimer = nil
- }
-
- private func danceTitle() -> String {
- switch selectedDance {
- case 0: return "Dance One"
- case 1: return "Dance Two"
- case 2: return "Dance Three"
- default: return "Dance"
- }
- }
-
- private func sendDanceData(data: DanceData) {
- if !appState.deviceMac.isEmpty {
- let motionData = MotionData(pitchServo: data.pitchServo, yawServo: data.yawServo)
- let jsonString = appState.deviceMac + motionData.toJsonString()
- let data = jsonString.toData()
- appState.sendWebSocketMessage(.controlMotion, data)
- }
- }
-
- private func danceItemView(index: Int) -> some View {
- HStack {
- VStack {
- if modelDanceList.count > index {
- StackChanRobot(data: modelDanceList[index])
- .frame(width: 80,height: 80)
- }
- Spacer()
- Button {
- let currentData = danceList[index].copy()
- if danceList.indices.contains(index + 1) {
- danceList.insert(currentData, at: index + 1)
- } else {
- danceList.append(currentData)
- }
- saveDance()
- } label: {
- Label("Copy", systemImage: "doc.on.doc")
- }
- }
- VStack(alignment: .leading) {
- Text("Left-Right")
- .frame(width: 80,alignment: .leading)
- HStack {
- Slider(
- value: Binding(
- get: { Double(danceList[index].yawServo.angle) },
- set: { danceList[index].yawServo.angle = Int($0) }
- ),
- in: -1280...1280,
- step: 10,
- onEditingChanged: { editing in
- if !editing {
- saveDance()
- sendDanceData(data: danceList[index])
- }
- }
- )
- .frame(maxWidth: .infinity)
- Text(String(danceList[index].yawServo.angle))
- .frame(width: 50,alignment: .trailing)
- }
- Text("Up-down")
- .frame(width: 80,alignment: .leading)
- HStack {
- Slider(
- value: Binding(
- get: { Double(danceList[index].pitchServo.angle) },
- set: { danceList[index].pitchServo.angle = Int($0) }
- ),
- in: 0...900,
- step: 10,
- onEditingChanged: { editing in
- if !editing {
- saveDance()
- sendDanceData(data: danceList[index])
- }
- }
- )
- .frame(maxWidth: .infinity)
- Text(String(danceList[index].pitchServo.angle))
- .frame(width: 50,alignment: .trailing)
- }
- Text("Duration")
- .frame(width: 80,alignment: .leading)
- HStack {
- Slider(
- value: Binding(
- get: { Double(danceList[index].durationMs) },
- set: { danceList[index].durationMs = Int($0) }
- ),
- in: 0...3000,
- step: 10,
- onEditingChanged: { editing in
- if !editing {
- saveDance()
- }
- }
- )
- .frame(maxWidth: .infinity)
- Text(String(danceList[index].durationMs))
- .frame(width: 50,alignment: .trailing)
- }
- }
- }
- }
-
- private func getDanceList() {
- let map = [
- ValueConstant.mac: appState.deviceMac
- ]
- Networking.shared.get(pathUrl: Urls.dance,parameters: map) { result in
- switch result {
- case .success(let success):
- do {
- let response = try Response<[String:[DanceData]]>.decode(from: success)
- if response.isSuccess, let map = response.data {
- if selectedDance == 0 {
- danceList = map["0"] ?? []
- } else if selectedDance == 1 {
- danceList = map["1"] ?? []
- } else if selectedDance == 2 {
- danceList = map["2"] ?? []
- }
- modelDanceList = danceList
- }
- } catch {
- print("Failed to parse response data")
- }
- case .failure(let failure):
- print("Request failed:", failure)
- }
- }
- }
-
- private func saveDance() {
- if let dict = danceList.toListDictionary() {
- let map: [String: Any] = [
- ValueConstant.mac: appState.deviceMac,
- ValueConstant.list: dict,
- ValueConstant.index: selectedDance
- ]
- Networking.shared.post(pathUrl: Urls.dance,parameters: map) { result in
- switch result {
- case .success(let success):
- do {
- let response = try Response.decode(from: success)
- if response.isSuccess, let data = response.data {
- modelDanceList = danceList
- print(data)
- }
- } catch {
- print("Failed to parse response data")
- }
- case .failure(let failure):
- print("Request failed:", failure)
- }
- }
- }
- }
-}
-
-struct AddAvatarMotion : View {
-
- @Binding var isPresented: Bool
-
- @Binding var editDanceDataIndex: Int?
-
- @State private var selectedItem: ControlItem = .avatar
-
- @EnvironmentObject var appState: AppState
-
- @Binding var danceData: DanceData
-
- let onCallBack : ((DanceData) -> Void)?
-
- enum ControlItem: String,CaseIterable, Identifiable {
- case avatar = "Avatar"
- case motion = "Motion"
- var id: String { rawValue }
- }
-
- var body: some View {
- NavigationStack {
- VStack {
- HStack {
- Spacer()
- StackChanRobot(data: danceData,allowsCameraControl: false)
- .frame(width: 300,height: 300)
- Spacer()
- }
- HStack {
- Text("duration")
- .frame(width: 100,alignment: .leading)
- Slider(
- value: Binding(
- get: { Double(danceData.durationMs) },
- set: { danceData.durationMs = Int($0) }
- ),
- in: 0...3000
- )
- .frame(maxWidth: .infinity)
- Text(String(danceData.durationMs))
- .frame(width: 50,alignment: .trailing)
- }
- HStack {
- Picker("Select", selection: $selectedItem) {
- ForEach(ControlItem.allCases) { item in
- Text(item.rawValue)
- .tag(item)
- }
- }
- .pickerStyle(.segmented)
- Button {
- withAnimation {
- danceData = DanceData(leftEye: ExpressionItem(weight: 100), rightEye: ExpressionItem(weight: 100), mouth: ExpressionItem(), yawServo: MotionDataItem(), pitchServo: MotionDataItem(), durationMs: 1000)
- }
- saveData()
- } label: {
- Image(systemName: "arrow.counterclockwise")
- }
- .glassButtonStyle()
- }
-
- if selectedItem == .avatar {
- List {
- Section("Left Eye") {
- HStack {
- Text("x")
- .frame(width: 60,alignment: .leading)
- Slider(
- value: Binding(
- get: { Double(danceData.leftEye.x) },
- set: { danceData.leftEye.x = Int($0) }
- ),
- in: -100...100,
- onEditingChanged: { editing in
- if !editing {
- saveData()
- }
- }
- )
- .frame(maxWidth: .infinity)
- Text(String(danceData.leftEye.x))
- .frame(width: 50,alignment: .trailing)
- }
- HStack {
- Text("y")
- .frame(width: 60,alignment: .leading)
- Slider(
- value: Binding(
- get: { Double(danceData.leftEye.y) },
- set: { danceData.leftEye.y = Int($0) }
- ),
- in: -100...100,
- onEditingChanged: { editing in
- if !editing {
- saveData()
- }
- }
- )
- .frame(maxWidth: .infinity)
- Text(String(danceData.leftEye.y))
- .frame(width: 50,alignment: .trailing)
- }
- HStack {
- Text("rotation")
- .frame(width: 60,alignment: .leading)
- Slider(
- value: Binding(
- get: { Double(danceData.leftEye.rotation) },
- set: { danceData.leftEye.rotation = Int($0) }
- ),
- in: -1800...1800,
- onEditingChanged: { editing in
- if !editing {
- saveData()
- }
- }
- )
- .frame(maxWidth: .infinity)
- Text(String(danceData.leftEye.rotation))
- .frame(width: 50,alignment: .trailing)
- }
- HStack {
- Text("weight")
- .frame(width: 60,alignment: .leading)
- Slider(
- value: Binding(
- get: { Double(danceData.leftEye.weight) },
- set: { danceData.leftEye.weight = Int($0) }
- ),
- in: 0...100,
- onEditingChanged: { editing in
- if !editing {
- saveData()
- }
- }
- )
- .frame(maxWidth: .infinity)
- Text(String(danceData.leftEye.weight))
- .frame(width: 50,alignment: .trailing)
- }
- HStack {
- Text("size")
- .frame(width: 60,alignment: .leading)
- Slider(
- value: Binding(
- get: { Double(danceData.leftEye.size) },
- set: { danceData.leftEye.size = Int($0) }
- ),
- in: -100...100,
- onEditingChanged: { editing in
- if !editing {
- saveData()
- }
- }
- )
- .frame(maxWidth: .infinity)
- Text(String(danceData.leftEye.size))
- .frame(width: 50,alignment: .trailing)
- }
- }
- .listRowBackground(Color.clear)
-
- Section("Right Eye") {
- HStack {
- Text("x")
- .frame(width: 60,alignment: .leading)
- Slider(
- value: Binding(
- get: { Double(danceData.rightEye.x) },
- set: { danceData.rightEye.x = Int($0) }
- ),
- in: -100...100,
- onEditingChanged: { editing in
- if !editing {
- saveData()
- }
- }
- )
- .frame(maxWidth: .infinity)
- Text(String(danceData.rightEye.x))
- .frame(width: 50,alignment: .trailing)
- }
- HStack {
- Text("y")
- .frame(width: 60,alignment: .leading)
- Slider(
- value: Binding(
- get: { Double(danceData.rightEye.y) },
- set: { danceData.rightEye.y = Int($0) }
- ),
- in: -100...100,
- onEditingChanged: { editing in
- if !editing {
- saveData()
- }
- }
- )
- .frame(maxWidth: .infinity)
- Text(String(danceData.rightEye.y))
- .frame(width: 50,alignment: .trailing)
- }
- HStack {
- Text("rotation")
- .frame(width: 60,alignment: .leading)
- Slider(
- value: Binding(
- get: { Double(danceData.rightEye.rotation) },
- set: { danceData.rightEye.rotation = Int($0) }
- ),
- in: -1800...1800,
- onEditingChanged: { editing in
- if !editing {
- saveData()
- }
- }
- )
- .frame(maxWidth: .infinity)
- Text(String(danceData.rightEye.rotation))
- .frame(width: 50,alignment: .trailing)
- }
- HStack {
- Text("weight")
- .frame(width: 60,alignment: .leading)
- Slider(
- value: Binding(
- get: { Double(danceData.rightEye.weight) },
- set: { danceData.rightEye.weight = Int($0) }
- ),
- in: 0...100,
- onEditingChanged: { editing in
- if !editing {
- saveData()
- }
- }
- )
- .frame(maxWidth: .infinity)
- Text(String(danceData.rightEye.weight))
- .frame(width: 50,alignment: .trailing)
- }
- HStack {
- Text("size")
- .frame(width: 60,alignment: .leading)
- Slider(
- value: Binding(
- get: { Double(danceData.rightEye.size) },
- set: { danceData.rightEye.size = Int($0) }
- ),
- in: -100...100,
- onEditingChanged: { editing in
- if !editing {
- saveData()
- }
- }
- )
- .frame(maxWidth: .infinity)
- Text(String(danceData.rightEye.size))
- .frame(width: 50,alignment: .trailing)
- }
- }
- .listRowBackground(Color.clear)
-
- Section("Mouth") {
- HStack {
- Text("x")
- .frame(width: 60,alignment: .leading)
- Slider(
- value: Binding(
- get: { Double(danceData.mouth.x) },
- set: { danceData.mouth.x = Int($0) }
- ),
- in: -100...100,
- onEditingChanged: { editing in
- if !editing {
- saveData()
- }
- }
- )
- .frame(maxWidth: .infinity)
- Text(String(danceData.mouth.x))
- .frame(width: 50,alignment: .trailing)
- }
- HStack {
- Text("y")
- .frame(width: 60,alignment: .leading)
- Slider(
- value: Binding(
- get: { Double(danceData.mouth.y) },
- set: { danceData.mouth.y = Int($0) }
- ),
- in: -100...100,
- onEditingChanged: { editing in
- if !editing {
- saveData()
- }
- }
- )
- .frame(maxWidth: .infinity)
- Text(String(danceData.mouth.y))
- .frame(width: 50,alignment: .trailing)
- }
- HStack {
- Text("rotation")
- .frame(width: 60,alignment: .leading)
- Slider(
- value: Binding(
- get: { Double(danceData.mouth.rotation) },
- set: { danceData.mouth.rotation = Int($0) }
- ),
- in: -1800...1800,
- onEditingChanged: { editing in
- if !editing {
- saveData()
- }
- }
- )
- .frame(maxWidth: .infinity)
- Text(String(danceData.mouth.rotation))
- .frame(width: 50,alignment: .trailing)
- }
- HStack {
- Text("weight")
- .frame(width: 60,alignment: .leading)
- Slider(
- value: Binding(
- get: { Double(danceData.mouth.weight) },
- set: { danceData.mouth.weight = Int($0) }
- ),
- in: 0...100,
- onEditingChanged: { editing in
- if !editing {
- saveData()
- }
- }
- )
- .frame(maxWidth: .infinity)
- Text(String(danceData.mouth.weight))
- .frame(width: 50,alignment: .trailing)
- }
- }
- .listRowBackground(Color.clear)
- }
- .listStyle(.grouped)
- .scrollContentBackground(.hidden)
- .background(.clear)
- } else if selectedItem == .motion {
- List {
- Section("Yaw Servo") {
- HStack {
- Text("angle")
- .frame(width: 60,alignment: .leading)
- Slider(
- value: Binding(
- get: { Double(danceData.yawServo.angle) },
- set: {
- danceData.yawServo.angle = Int($0)
- }
- ),
- in: -1280...1280,
- onEditingChanged: { editing in
- if !editing {
- saveData()
- }
- }
- )
- .frame(maxWidth: .infinity)
- Text(String(danceData.yawServo.angle))
- .frame(width: 50,alignment: .trailing)
- }
- HStack {
- Text("speed")
- .frame(width: 60,alignment: .leading)
- Slider(
- value: Binding(
- get: { Double(danceData.yawServo.speed) },
- set: { danceData.yawServo.speed = Int($0) }
- ),
- in: 0...1000,
- onEditingChanged: { editing in
- if !editing {
- saveData()
- }
- }
- )
- .frame(maxWidth: .infinity)
- Text(String(danceData.yawServo.speed))
- .frame(width: 50,alignment: .trailing)
- }
- }
- .listRowBackground(Color.clear)
- Section("Pitch Servo") {
- HStack {
- Text("angle")
- .frame(width: 60,alignment: .leading)
- Slider(
- value: Binding(
- get: { Double(danceData.pitchServo.angle) },
- set: { danceData.pitchServo.angle = Int($0) }
- ),
- in: 0...900,
- onEditingChanged: { editing in
- if !editing {
- saveData()
- }
- }
- )
- .frame(maxWidth: .infinity)
- Text(String(danceData.pitchServo.angle))
- .frame(width: 50,alignment: .trailing)
- }
- HStack {
- Text("speed")
- .frame(width: 60,alignment: .leading)
- Slider(
- value: Binding(
- get: { Double(danceData.pitchServo.speed) },
- set: { danceData.pitchServo.speed = Int($0) }
- ),
- in: 0...1000,
- onEditingChanged: { editing in
- if !editing {
- saveData()
- }
- }
- )
- .frame(maxWidth: .infinity)
- Text(String(danceData.pitchServo.speed))
- .frame(width: 50,alignment: .trailing)
- }
- }
- .listRowBackground(Color.clear)
- }
- .listStyle(.grouped)
- .scrollContentBackground(.hidden)
- .background(.clear)
- }
- Spacer()
- }
- .padding()
- .ignoresSafeArea(edges: .bottom)
- .navigationTitle(editDanceDataIndex == nil ? "Add Dance" : "Edit Dance")
- .navigationBarTitleDisplayMode(.inline)
- .toolbar{
- ToolbarItem(placement: .confirmationAction) {
- Button {
- self.onCallBack?(danceData)
- isPresented = false
- } label: {
- Image(systemName: "checkmark")
- }
- }
- ToolbarItem(placement: .cancellationAction) {
- Button {
- isPresented = false
- } label: {
- Image(systemName: "xmark")
- }
- }
- }
- }
- }
-
- private func saveData() {
- if !appState.deviceMac.isEmpty {
-
- }
- }
-}
diff --git a/app/StackChan/View/DeviceWifiConfig.swift b/app/StackChan/View/DeviceWifiConfig.swift
deleted file mode 100644
index 8756f8b..0000000
--- a/app/StackChan/View/DeviceWifiConfig.swift
+++ /dev/null
@@ -1,250 +0,0 @@
-/*
- * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD
- *
- * SPDX-License-Identifier: MIT
- */
-
-import SwiftUI
-import NetworkExtension
-import CoreLocation
-import CoreBluetooth
-
-enum BlufDeviceConfigPageType: Hashable {
- case selectDevice
- case wifiConfig
-}
-
-struct SelectBlufiDevice : View {
-
- @EnvironmentObject var appState: AppState
-
- @State var path: [BlufDeviceConfigPageType] = []
-
- private func getDeviceId(blufiInfo: BlufiDeviceInfo) -> String? {
- if let manufacturerData = blufiInfo.advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data {
- let companyID = manufacturerData.prefix(2)
- _ = UInt16(littleEndian: companyID.withUnsafeBytes { $0.load(as: UInt16.self) })
- let customData = manufacturerData.suffix(from: 2)
- let address = customData.map { String(format: "%02X", $0) }.joined()
- return address
- }
- return nil
- }
-
- var body: some View {
- NavigationStack(path: $path) {
- List {
- Section(header: Text("StackChan Device List").textCase(nil)) {
- ForEach(appState.blufDeviceList, id: \.peripheral.identifier.uuidString) { blufiDeviceInfo in
- Button {
- if let mac = getDeviceId(blufiInfo: blufiDeviceInfo) {
- appState.deviceMac = mac
- appState.connectWebSocket()
- }
- print("start connect device")
- BlufiUtil.shared.connect(peripheral: blufiDeviceInfo.peripheral)
- } label: {
- HStack {
- Image("lateral_image")
- .resizable()
- .frame(width: 25, height: 25)
-
- VStack(alignment: .leading) {
- Text("Name: " + (blufiDeviceInfo.peripheral.name ?? "StackChan"))
- if let deviceId = getDeviceId(blufiInfo: blufiDeviceInfo) {
- Text("Device ID: \(deviceId)")
- .font(.caption)
- .foregroundStyle(.secondary)
- }
- }
- Spacer()
- }
- .contentShape(Rectangle())
- }
- .buttonStyle(.plain)
- }
- }
- }
- .navigationTitle("Select Device")
- .listStyle(.insetGrouped)
- .background(Color(UIColor.systemGroupedBackground))
- .toolbar {
- ToolbarItem(placement: .cancellationAction) {
- Button {
- appState.manualShutdownTime = Date()
- appState.showDeviceWifiSet = false
- } label: {
- Label("Cancel", systemImage: "xmark")
- }
- }
- }
- .navigationDestination(for: BlufDeviceConfigPageType.self) { BlufDeviceConfigPageType in
- switch BlufDeviceConfigPageType {
- case .selectDevice:
- SelectBlufiDevice()
- case .wifiConfig:
- DeviceWifiConfig()
- }
- }
- .onAppear {
- BlufiUtil.shared.characteristicCallback = { characteristic in
- // Check whether the characteristic is writable
- if characteristic.properties.contains(.write) || characteristic.properties.contains(.writeWithoutResponse) {
- if characteristic.uuid.uuidString == "E2E5E5E3-1234-5678-1234-56789ABCDEF0" {
- BlufiUtil.shared.writeWifiSetCharacteristic = characteristic
- self.path.append(.wifiConfig)
- }
- }
- }
- }
- }
- }
-}
-
-/// Wi-Fi configuration view
-struct DeviceWifiConfig : View {
-
- enum Field {
- case Name
- case Password
- }
-
- @State private var wifiName: String = ""
- @State private var wifiPassword: String = ""
-
- @State private var locationManager = CLLocationManager()
- @State private var locationDelegate = LocationDelegate()
-
- @EnvironmentObject private var appState: AppState
-
- @FocusState private var focusedField: Field?
-
- @State private var showAlert: Bool = false
- @State private var alertMessage: String = ""
-
- @State private var title: String = "StackChan Wifi Setting"
-
- var body: some View {
- List {
- Section(header: Text("Name")) {
- TextField("Please enter the name of the wifi", text:$wifiName)
- .focused($focusedField, equals: .Name)
- .submitLabel(.next)
- .onSubmit {
- focusedField = .Password
- }
- }
- Section(header: Text("Password")) {
- TextField("Please enter the password of the wifi", text:$wifiPassword)
- .focused($focusedField, equals: .Password)
- .submitLabel(.done)
- .onSubmit {
- confirmWifi()
- }
- }
- }
- .listStyle(.insetGrouped)
- .background(Color(UIColor.systemGroupedBackground))
- .toolbar {
- ToolbarItem(placement: .confirmationAction) {
- Button {
- confirmWifi()
- } label: {
- Label("Submit", systemImage: "checkmark")
- }
- }
- ToolbarItem(placement: .cancellationAction) {
- Button {
- appState.showDeviceWifiSet = false
- BlufiUtil.shared.disconnectCurrentPeripheral()
- } label: {
- Label("Cancel", systemImage: "xmark")
- }
- }
- }
- .alert(alertMessage, isPresented: $showAlert, actions: {
- Button("Confirm") {
- alertMessage = ""
- showAlert = false
- }
- })
- .navigationTitle(title)
- .onAppear {
- BlufiUtil.shared.wifiSetCharacteristicCall = { data in
- let json = data.hexEncodedString()
-
- print(data)
-
- if let model = BlufiModel.fromJson(json), let state = model.data?.state {
- if state == "wifiConnecting" {
- // Configuring Wi-Fi
- title = "In the configuration..."
- } else if state == "wifiConnected" {
- // Configuration succeeded
- title = "Configuration successful"
- DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
- appState.showDeviceWifiSet = false
- }
- } else if state == "wifiConnectFailed" {
- // Configuration failed
- title = "Configuration failed"
- DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
- alertMessage = "Configuration failed, please re-enter wifi name and password"
- showAlert = true
- focusedField = .Password
- }
- }
- }
- }
- locationDelegate.onAuthorized = {
- getPermission()
- }
- locationManager.delegate = locationDelegate
- getPermission()
- }
- .onDisappear {
- locationManager.delegate = nil
- }
- }
-
- private func getWifiInfo() {
- NEHotspotNetwork.fetchCurrent { network in
- if let network = network {
- wifiName = network.ssid
- focusedField = .Password
- }
- }
- }
-
- private func confirmWifi() {
- if wifiName.isEmpty || wifiPassword.isEmpty {
- alertMessage = "Please enter the full name and password"
- showAlert = true
- return
- }
-
- let model = BlufiModel(cmd: "setWifi",data: BlufiWifi(ssid: wifiName,password: wifiPassword))
- if let json = model.toJson() {
- BlufiUtil.shared.sendWifiSetData(json)
- }
- }
-
- private func getPermission() {
- if #available(iOS 14.0, *) {
- switch locationManager.authorizationStatus {
- case .authorizedWhenInUse, .authorizedAlways:
- getWifiInfo()
- break
- case .denied, .restricted:
- break
- case .notDetermined:
- locationManager.requestWhenInUseAuthorization()
- break
- default:
- break
- }
- } else {
- locationManager.requestWhenInUseAuthorization()
- }
- }
-}
diff --git a/app/StackChan/View/JoystickView.swift b/app/StackChan/View/JoystickView.swift
deleted file mode 100644
index 5b8cdb1..0000000
--- a/app/StackChan/View/JoystickView.swift
+++ /dev/null
@@ -1,84 +0,0 @@
-/*
- * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD
- *
- * SPDX-License-Identifier: MIT
- */
-
-import SwiftUI
-
-struct JoystickView: View {
-
- @State private var dragOffset: CGSize = .zero
-
- let callback: ((_ radians: CGFloat,_ strength: CGFloat) -> Void)?
-
- @State private var isDragging: Bool = false
-
- var body: some View {
- GeometryReader { proxy in
- let diameter = min(proxy.size.width, proxy.size.height)
- let radius = diameter / 2
- let joystickDiameter = diameter / 4
- let stickRadius = joystickDiameter / 2
- let lineWidth: CGFloat = 4
- let maxRadius = radius - stickRadius - (lineWidth / 2)
- ZStack {
- Circle()
- .stroke(Color(UIColor.separator), lineWidth: lineWidth)
- .frame(width: diameter,height: diameter)
- Circle()
- .fill(Color.accent)
- .frame(width: joystickDiameter,height: joystickDiameter)
- .glassEffectCircle()
- .offset(dragOffset)
- }
- .contentShape(Circle())
- .highPriorityGesture(
- DragGesture(minimumDistance: 0)
- .onChanged { value in
- withAnimation {
- isDragging = true
- }
- let dx = value.location.x - radius
- let dy = value.location.y - radius
- let distance = sqrt(dx * dx + dy * dy)
- if distance <= maxRadius {
- dragOffset = CGSize(width: dx, height: dy)
- } else {
- let angle = atan2(dy, dx)
- dragOffset = CGSize(
- width: cos(angle) * maxRadius,
- height: sin(angle) * maxRadius
- )
- }
- }
- .onEnded { _ in
- withAnimation {
- isDragging = false
- dragOffset = .zero
- }
- },
- including: .all
- )
- .padding(0)
- .onChange(of: dragOffset) { newValue in
- guard isDragging else { return }
- let dx = newValue.width
- let dy = newValue.height
- let distance = sqrt(dx * dx + dy * dy)
- let radians = atan2(dy, dx)
- // 直接使用之前定义的 maxRadius
- let strength = min(distance / maxRadius, 1)
- callback?(radians,strength)
- }
- }
- }
-}
-
-struct JoystickViewPreview : PreviewProvider {
- static var previews: some View {
- JoystickView { radians, strength in
- print(radians)
- }
- }
-}
diff --git a/app/StackChan/View/Moments.swift b/app/StackChan/View/Moments.swift
deleted file mode 100644
index 4dfad70..0000000
--- a/app/StackChan/View/Moments.swift
+++ /dev/null
@@ -1,509 +0,0 @@
-/*
- * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD
- *
- * SPDX-License-Identifier: MIT
- */
-
-import SwiftUI
-import PhotosUI
-
-struct Moments : View {
-
- @State private var posts: [Post] = []
-
- @State private var showAddMoment: Bool = false
-
- @EnvironmentObject var appState: AppState
-
- @State private var page = 1
- private let pageSize = 10
- @State private var isLoadingMore = false
- @State private var hasMore = true
-
- @State private var postId: Int? = nil
- @State private var editPostCommentContent: String? = nil
- @State private var showAddPostComment: Bool = false
-
- var body: some View {
- NavigationStack {
- ZStack {
- LinearGradient(
- colors: [Color.accent.opacity(0.5), Color.pink.opacity(0.1),Color.blue.opacity(0.2)],
- startPoint: .topLeading,
- endPoint: .bottomTrailing
- )
- .ignoresSafeArea()
- ScrollView {
- LazyVStack(spacing:12) {
- ForEach(posts, id: \.self.id) { post in
- postItemView(post: post)
- .onAppear {
- if post.id == posts.last?.id {
- loadMoreIfNeeded()
- }
- }
- }
- if isLoadingMore {
- ProgressView()
- .padding()
- }
- }
- .padding(12)
- }
- .refreshable {
- page = 1
- posts.removeAll()
- getPost()
- }
- }
- .toolbar {
- ToolbarItem(placement: .primaryAction) {
- Button {
- if appState.deviceMac.isEmpty {
- appState.showBindingDeviceAlert = true
- } else {
- self.showAddMoment = true
- }
- } label: {
- Label("Add", systemImage: "plus")
- }
- }
- }
- .navigationTitle("Moments")
- .sheet(isPresented: $showAddMoment) {
- AddMoment(showAddMoment:$showAddMoment) { post in
- // Add a new post
- addPost(post: post)
- }
- .interactiveDismissDisabled(true)
- }
- .alert("Add PostComment", isPresented: $showAddPostComment) {
- TextField("Enter your comment", text: Binding(
- get: { editPostCommentContent ?? "" },
- set: { editPostCommentContent = $0 }
- ))
- Button(role: .cancel) {
- showAddPostComment = false
- } label: {
- Text("Cancel")
- }
- if #available(iOS 26.0, *) {
- Button(role: .confirm) {
- showAddPostComment = false
- addPostComment()
- } label: {
- Text("Confirm")
- }
- } else {
- Button {
- showAddPostComment = false
- addPostComment()
- } label: {
- Text("Confirm")
- }
- }
- } message: {
- Text("Please enter your comment below.")
- }
- .onAppear {
- page = 1
- posts.removeAll()
- getPost()
- }
- }
- }
-
- private func postItemView(post: Post) -> some View {
- return VStack(alignment: .leading,spacing: 12) {
- HStack {
- Image("logo_icon")
- .resizable()
- .frame(width: 25, height: 25)
- .clipShape(Circle())
- Text(post.name ?? "StackChanUser")
- .font(.system(size: 25))
- Spacer()
- if post.mac == appState.deviceMac {
- Button(role: .destructive) {
- deletePost(post)
- } label: {
- Image(systemName: "trash")
- }
- .glassButtonStyle()
- }
- }
- Text(post.contentText ?? "")
- .font(.body)
- .foregroundStyle(.primary)
- if let imageUrl = post.contentImage, imageUrl != "" {
- HStack {
- AsyncImage(url: URL(string: imageUrl)) { phase in
- switch phase {
- case .empty:
- ProgressView()
- .frame(maxWidth: .infinity, maxHeight: 300)
- case .success(let image):
- image
- .resizable()
- .scaledToFit()
- .frame(maxHeight: 300)
- case .failure:
- Image(systemName: "photo")
- .resizable()
- .scaledToFit()
- .frame(maxHeight: 300)
- @unknown default:
- EmptyView()
- }
- }
- Spacer()
- }
- }
- HStack(spacing: 25) {
- Text(post.createdAt ?? "")
- .font(.caption)
- .foregroundStyle(.secondary)
-
- Spacer()
-
- Button(action: {
- // Comment
- postId = post.id
- editPostCommentContent = ""
- showAddPostComment = true
- }) {
- Label("99", systemImage: "text.bubble")
- }
-
- Button(action: {
- // Like
- }) {
- Label("99", systemImage: "hand.thumbsup")
- }
-
- Button(action: {
- // Share
- }) {
- Image(systemName: "square.and.arrow.up")
- }
- }
- .padding(12)
- .font(.caption)
- .foregroundStyle(.secondary)
-
- if let comments = post.postCommentList, !comments.isEmpty {
- ForEach(comments, id: \.id) { comment in
- VStack(alignment: .leading, spacing: 4) {
- HStack {
- Text((comment.name ?? "User") + ": ")
- .font(.caption)
- .fontWeight(.semibold)
- .foregroundColor(.accentColor)
- Text(comment.content ?? "")
- .font(.caption)
- Spacer()
- }
- }
- }
- }
- }
- .padding(12)
- .frame(maxWidth: .infinity)
- .glassEffectRegular(cornerRadius: 25)
- }
-
- private func addPostComment() {
- if let content = editPostCommentContent,let postId = postId, !appState.deviceMac.isEmpty {
- let map: [String: Any] = [
- ValueConstant.mac : appState.deviceMac,
- ValueConstant.postId : postId,
- ValueConstant.content : content
- ]
- Networking.shared.post(pathUrl: Urls.postCommentCreate, parameters: map) { result in
- switch result {
- case .success(let success):
- do {
- let response = try Response<[String: Int]>.decode(from: success)
- if response.isSuccess {
- getPostComment(postId: postId)
- }
- } catch {
- // Failed to parse data
- }
- case .failure(let failure):
- // Request failed:
- print("Request failed:", failure)
- }
- }
- }
- }
-
- private func getPostComment(postId: Int) {
- if !appState.deviceMac.isEmpty {
- let map: [String: Any] = [
- ValueConstant.postId: postId,
- ValueConstant.mac: appState.deviceMac,
- ValueConstant.page: 1,
- ValueConstant.pageSize: 30,
- ]
-
- Networking.shared.get(pathUrl: Urls.postCommentGet,parameters: map) { result in
- switch result {
- case .success(let success):
- do {
- let response = try Response.decode(from: success)
- if response.isSuccess,let list = response.data?.list {
- for index in posts.indices {
- if posts[index].id == postId {
- posts[index].postCommentList = list
- break
- }
- }
- }
- } catch {
- // Failed to parse data
- }
- case .failure(let failure):
- // Request failed:
- print("Request failed:", failure)
- }
- }
-
- }
-
-
- }
-
- private func addPost(post: Post) {
- let map: [String:Any] = [
- ValueConstant.mac: appState.deviceMac,
- ValueConstant.content_text: post.contentText ?? "",
- ValueConstant.content_image: post.contentImage ?? "",
- ]
- Networking.shared.post(pathUrl: Urls.postAdd, parameters: map) { result in
- switch result {
- case .success(let success):
- do {
- let response = try Response<[String:Int]>.decode(from: success)
- if response.isSuccess {
- // Refresh posts
- page = 1
- posts.removeAll()
- getPost()
- }
- } catch {
- // Failed to parse data
- }
- case .failure(let failure):
- // Request failed:
- print("Request failed:", failure)
- }
- }
- }
-
- /// Delete a post
- private func deletePost(_ post: Post) {
- let map: [String: Any] = [
- ValueConstant.id: post.id
- ]
- Networking.shared.delete(pathUrl: Urls.postDelete, parameters: map) { result in
- switch result {
- case .success(let success):
- do {
- let response = try Response.decode(from: success)
- if response.isSuccess {
- // Remove post locally
- withAnimation {
- posts.removeAll { $0.id == post.id }
- }
- }
- } catch {
- // Failed to parse data
- }
- case .failure(let failure):
- // Delete failed:
- print("Delete failed:", failure)
- }
- }
- }
-
- /// Fetch post list
- private func getPost() {
- isLoadingMore = true
- let map:[String:Any] = [
- ValueConstant.page: page,
- ValueConstant.pageSize: pageSize
- ]
- Networking.shared.get(pathUrl: Urls.postGet,parameters: map) { result in
- isLoadingMore = false
- switch result {
- case .success(let success):
- do {
- let response = try Response<[Post]>.decode(from: success)
- if response.isSuccess,let list = response.data {
- withAnimation {
- if list.count < pageSize {
- hasMore = false
- }
- posts.append(contentsOf: list)
- }
- }
- } catch {
-
- // Failed to parse data
- }
- case .failure(let failure):
- // Request failed:
- print("Request failed:", failure)
- }
- }
- }
-
- private func loadMoreIfNeeded() {
- guard !isLoadingMore, hasMore else { return }
- page += 1
- getPost()
- }
-
-}
-
-
-
-struct AddMoment : View {
-
- @Binding var showAddMoment: Bool
-
- var callBack: ((Post) -> Void)?
-
- @State private var post: Post = Post(id: 0)
- @State private var photoItem: PhotosPickerItem?
- @State private var isUploading: Bool = false
-
- @EnvironmentObject var appState: AppState
-
- var body: some View {
- NavigationStack {
- List {
- Section("Text") {
- TextField("Please enter the post content", text: Binding(
- get: {
- post.contentText ?? ""
- },
- set: {
- post.contentText = $0
- }
- ), axis: .vertical)
- .textFieldStyle(.plain)
- }
- Section("image") {
- PhotosPicker(selection: $photoItem, matching: .images) {
- if isUploading {
- ProgressView("Uploading...")
- } else {
- HStack {
- Spacer()
- if let urlString = post.contentImage,
- let url = URL(string: urlString) {
- AsyncImage(url: url) { phase in
- switch phase {
- case .empty:
- ProgressView()
- .frame(width: 200, height: 200)
- case .success(let image):
- image
- .resizable()
- .scaledToFit()
- .frame(maxHeight: 300)
- case .failure:
- Image(systemName: "photo")
- .frame(width: 200, height: 200)
- @unknown default:
- EmptyView()
- }
- }
- } else {
- Label("Select Image", systemImage: "plus.circle")
- }
- Spacer()
- }
- }
- }
- .onChange(of: photoItem) { _ in
- updateImage()
- }
- }
- }
- .navigationTitle("Add Post")
- .toolbar {
- ToolbarItem(placement: .cancellationAction) {
- Button {
- self.showAddMoment = false
- } label: {
- Label("Cancel", systemImage: "xmark")
- }
- }
- ToolbarItem(placement: .confirmationAction) {
- Button {
- callBack?(post)
- self.showAddMoment = false
- } label: {
- Label("Confirm", systemImage: "checkmark")
- }
- }
- }
- }
- }
-
- /// Upload file
- private func updateImage() {
- guard let photoItem else { return }
- isUploading = true
- Task {
- do {
- let data = try await photoItem.loadTransferable(type: Data.self)
- guard var imageData = data else {
- // Failed to get image data
- print("Failed to get image data")
- isUploading = false
- return
- }
-
- // Compress the image to no more than 2MB
- if let uiImage = UIImage(data: imageData),
- let compressedData = uiImage.compress(toMemorySize: 2.0) {
- imageData = compressedData
- }
-
- let map: [String:Any] = [
- ValueConstant.file: imageData,
- ValueConstant.directory: ValueConstant.moments,
- ValueConstant.name: UUID().uuidString + ".jpg",
- ]
- Networking.shared.postFromData(pathUrl: Urls.uploadFile,parameters: map) { result in
- isUploading = false
- switch result {
- case .success(let success):
- do {
- let response = try Response.decode(from: success)
- if response.isSuccess, let url = response.data?.path {
- let fileUrl = Urls.getFileUrl() + url
- DispatchQueue.main.async {
- post.contentImage = fileUrl
- }
- }
- } catch {
- // Failed to parse data
- }
- case .failure(let failure):
- // Request failed:
- print("Request failed:", failure)
- }
- }
- } catch {
- isUploading = false
- // Failed to load image data:
- print("Failed to load image data:", error)
- }
- }
- }
-}
diff --git a/app/StackChan/View/Nearby.swift b/app/StackChan/View/Nearby.swift
deleted file mode 100644
index 4406b3a..0000000
--- a/app/StackChan/View/Nearby.swift
+++ /dev/null
@@ -1,405 +0,0 @@
-/*
- * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD
- *
- * SPDX-License-Identifier: MIT
- */
-
-import SwiftUI
-import MultipeerConnectivity
-import CoreBluetooth
-
-struct TextMessage : Codable {
- var name: String = ""
- var content: String = ""
-}
-
-struct Nearby: View {
-
- @State var deviceList: [DeviceInfo] = []
-
- @State var proxySize : CGSize = CGSize(width: 0, height: 0)
-
- @EnvironmentObject var appState: AppState
-
- @State var deviceMac: String = ""
-
- @State private var showCallPopup: Bool = false
-
- @State private var displayMode: Int = 1 // 1 star map mode, 2 list mode
-
- private let tag = "Nearby"
- @State private var callTitle: String = "Under request..."
-
- var body: some View {
- NavigationStack(path: $appState.nearbyPath) {
- ZStack {
- DazzlingBackground(backColors: [Color.accent.opacity(0.5), Color.pink.opacity(0.1),Color.blue.opacity(0.2)],background: Color(UIColor.systemBackground))
- .ignoresSafeArea()
-
- if displayMode == 1 {
- Canvas { context, size in
- let center = CGPoint(x: size.width / 2, y: size.height / 2)
-
- for device in deviceList {
- var path = Path()
- path.move(to: center)
- path.addLine(to: device.postion)
- context.stroke(
- path,
- with: .color(.white),
- lineWidth: 3
- )
- }
- }
-
- GeometryReader { proxy in
- Color.clear
- .onAppear {
- proxySize = proxy.size
- }
- .onChange(of: proxy.size) { newSize in
- proxySize = newSize
- }
- ForEach(deviceList, id: \.device.mac) { device in
- Menu {
- Button {
- // Hi button logic
- let textMessage = TextMessage(name:"App",content: "👋")
- sendMessage(device: device,msgType: .textMessage,data: textMessage.toJsonString())
- launchAnimation(device: device, text: "👋")
- } label: {
- Label("👋", systemImage: "hand.wave")
- }
- Button {
- // Heart button logic
- let textMessage = TextMessage(name:"App",content: "❤️")
- sendMessage(device: device,msgType: .textMessage,data: textMessage.toJsonString())
- launchAnimation(device: device, text:"❤️")
- } label: {
- Label("❤️", systemImage: "heart.fill")
- }
- Button {
- // Video call button logic
- sendMessage(device: device,msgType: .requestCall, data: "")
- // Show request popup animation
-
- } label: {
- Label("Video Call", systemImage: "video.fill")
- }
- } label: {
- let name = (device.device.name?.isEmpty == false) ? device.device.name! : "StackChan"
- AvatarView(name: name)
- .frame(width: 100)
- }
- .position(x: device.postion.x, y: device.postion.y)
- }
- }
- RippleDiffusion {
- AvatarView(name: appState.deviceInfo.name ?? "Me")
- }
- ForEach(flyingTexts) { flying in
- Text(flying.text)
- .font(.largeTitle)
- .position(x: flying.start.x + (flying.end.x - flying.start.x) * flying.progress,
- y: flying.start.y + (flying.end.y - flying.start.y) * flying.progress
- )
- .opacity(1 - flying.progress)
- }
- } else {
- ScrollView {
- LazyVStack(spacing: 12) {
- ForEach(deviceList, id: \.device.mac) { device in
- Menu {
- Button {
- // Hi button logic
- let textMessage = TextMessage(name:"App",content: "👋")
- sendMessage(device: device,msgType: .textMessage,data: textMessage.toJsonString())
- launchAnimation(device: device, text: "👋")
- } label: {
- Label("👋", systemImage: "hand.wave")
- }
- Button {
- // Heart button logic
- let textMessage = TextMessage(name:"App",content: "❤️")
- sendMessage(device: device,msgType: .textMessage,data: textMessage.toJsonString())
- launchAnimation(device: device, text:"❤️")
- } label: {
- Label("❤️", systemImage: "heart.fill")
- }
- Button {
- // Video call button logic
- sendMessage(device: device,msgType: .requestCall, data: "")
- // Show request popup animation
-
- } label: {
- Label("Video Call", systemImage: "video.fill")
- }
- } label: {
- Text(device.device.name ?? "Unknown")
- .frame(maxWidth: .infinity)
- .padding(12)
- .glassEffectRegular(cornerRadius: 25)
- }
- }
- }
- }
- .padding(12)
- }
- }
- .toolbar {
- ToolbarItem(placement: .primaryAction) {
- Button {
- if displayMode == 1 {
- displayMode = 2
- } else {
- displayMode = 1
- }
- } label: {
- Label {
- Text("Display Mode")
- } icon: {
- Image(systemName: displayMode == 1 ? "circle.hexagonpath" : "list.bullet")
- }
- }
- }
- }
- .navigationTitle("Nearby")
- .navigationDestination(for: PageType.self) { PageType in
- switch PageType {
- case .cameraPage:
- CameraPage()
- case .minicryEmotion:
- MimicryEmotion(deviceMac: $deviceMac)
- case .dance:
- Dance()
- }
- }
- .sheet(isPresented: $showCallPopup) {
- VStack(alignment:.center) {
- Spacer()
- Text(callTitle).font(.largeTitle)
- Spacer()
- HStack(alignment:.center) {
- Spacer()
- AvatarView(name: "Caller")
- Spacer()
- ProgressView()
- .scaleEffect(1.5)
- Spacer()
- AvatarView(name: "Receiver")
- Spacer()
- }
- Spacer()
- Button {
- showCallPopup = false
- appState.sendWebSocketMessage(.hangupCall)
- } label: {
- VStack {
- Color.clear.frame(
- height: 15
- )
- Image(systemName: "phone.down.fill")
- .font(.system(size: 50))
- .frame(width: 100, height: 100)
- .background(.red)
- .clipShape(Circle())
- .foregroundColor(.white)
- .shadow(color: Color.gray, radius: 10, x: 0, y: 0)
- Text("Hang up")
- .frame(height: 15)
- .foregroundColor(Color(UIColor.label))
- }
- .frame(width: 100)
- }
- Spacer()
- }
- .presentationDetents([.medium])
- .presentationBackgroundClear()
- .interactiveDismissDisabled(true)
- }
- .onAppear {
- // bleInit()
- getDeviceList()
- WebSocketUtil.shared.addObserver(for: tag) { message in
- switch message {
- case .data(let data):
- let result = appState.parseMessage(message: data)
- if let msgType = result.0, let _ = result.1 {
- switch msgType {
- case MsgType.agreeCall:
- // Agree to call
- showCallPopup = false
- DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
- appState.nearbyPath.append(.minicryEmotion)
- }
- case MsgType.refuseCall:
- // Refuse call
- callTitle = "The other party has refused."
- DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
- showCallPopup = false
- }
- case MsgType.hangupCall:
- showCallPopup = false
- // Hang up call
- default:
- break
- }
- }
- case .string(let text):
- print("收到普通消息: \(text)")
- @unknown default:
- break
- }
- }
- appState.getDeviceInfo()
- }
- .onDisappear {
- WebSocketUtil.shared.removeObserver(for: tag)
- }
- }
- }
-
- private func getDeviceList() {
- let map = [
- ValueConstant.mac: appState.deviceMac
- ]
- Networking.shared.get(pathUrl: Urls.deviceRandomList,parameters: map) { result in
- switch result {
- case .success(let success):
- do {
- let response = try Response<[Device]>.decode(from: success)
- if response.isSuccess, let list = response.data {
- deviceList.removeAll()
- for i in list {
- let existingPositions = self.deviceList.map { $0.postion }
- let newPosition = self.generateRandomPosition(existingPositions: existingPositions)
- let newDevice = DeviceInfo(device: i, postion: newPosition)
- withAnimation {
- self.deviceList.append(newDevice)
- }
- }
- }
- } catch {
- print("Data parsing failed")
- }
- case .failure(let failure):
- print("Request failed:", failure)
- }
- }
- }
-
- @State private var flyingTexts: [FlyingText] = []
-
- private func launchAnimation(device: DeviceInfo, text: String) {
- let mineCenter = CGPoint(x: proxySize.width/2, y: proxySize.height/2)
- let targetCenter = device.postion
- let id = UUID()
- let flyingText = FlyingText(id: id, text: text, start: mineCenter, end: targetCenter, progress: 0)
- flyingTexts.append(flyingText)
-
- withAnimation(.linear(duration: 1.0)) {
- if let index = flyingTexts.firstIndex(where: { $0.id == id }) {
- flyingTexts[index].progress = 1
- }
- }
-
- DispatchQueue.main.asyncAfter(deadline: .now() + 1.0,) {
- flyingTexts.removeAll{ $0.id == id }
- }
- }
-
- private func sendMessage(device: DeviceInfo,msgType: MsgType, data: String) {
- if msgType == .requestCall {
- deviceMac = device.device.mac
- showCallPopup = true
- callTitle = "Under request..."
- // Automatically hang up after 20 seconds
- DispatchQueue.main.asyncAfter(deadline: .now() + 20.0) {
- if showCallPopup {
- callTitle = "No one answered."
- DispatchQueue.main.asyncAfter(deadline: .now() + 1.0,) {
- showCallPopup = false
- appState.sendWebSocketMessage(.hangupCall)
- }
- }
- }
- }
- let dataString = device.device.mac + data
- appState.sendWebSocketMessage(msgType,dataString.toData())
- }
-
- struct FlyingText: Identifiable {
- let id: UUID
- let text: String
- let start: CGPoint
- let end: CGPoint
- var progress: CGFloat
- }
-
- private func bleInit() {
- }
-
- // Randomly generate a position, avoiding existing devices and the center area
- private func generateRandomPosition(existingPositions: [CGPoint]) -> CGPoint {
- let center = CGPoint(x: proxySize.width / 2, y: proxySize.height / 2)
- let selfSafeRadius: CGFloat = 100 // Avoid own avatar
- let otherSafeRadius: CGFloat = 100 // Avoid other devices
- let maxAttempts = 200 // Increased number of attempts
-
- for _ in 0.. otherSafeRadius }) {
- return candidate
- }
- }
- // If all attempts fail, randomly offset from the center to avoid overlap
- var offsetX = CGFloat.random(in: selfSafeRadius...(selfSafeRadius + 50))
- var offsetY = CGFloat.random(in: selfSafeRadius...(selfSafeRadius + 50))
- // Randomly determine direction
- offsetX *= Bool.random() ? 1 : -1
- offsetY *= Bool.random() ? 1 : -1
- return CGPoint(x: center.x + offsetX, y: center.y + offsetY)
- }
-}
-
-struct DeviceInfo {
- let device: Device
- let postion: CGPoint
-}
-
-struct AvatarView : View {
-
- let name: String
-
- var body: some View {
- VStack {
- Color.clear.frame(
- height: 15
- )
- Image("logo_icon")
- .resizable()
- .scaledToFit()
- .frame(width: 100, height: 100)
- .clipShape(Circle())
- .shadow(color: Color.gray, radius: 10, x: 0, y: 0)
- Text(name)
- .frame(height: 15)
- .foregroundColor(Color(UIColor.label))
- }
- }
-}
-
-
-struct NearbyPreview : PreviewProvider {
- static var previews: some View {
- Nearby()
- }
-}
diff --git a/app/StackChan/View/ScanView.swift b/app/StackChan/View/ScanView.swift
deleted file mode 100644
index a7ec508..0000000
--- a/app/StackChan/View/ScanView.swift
+++ /dev/null
@@ -1,213 +0,0 @@
-/*
- * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD
- *
- * SPDX-License-Identifier: MIT
- */
-
-import SwiftUI
-import AVFoundation
-
-struct ScanView : UIViewControllerRepresentable {
- func updateUIViewController(_ uiViewController: ScannerViewController, context: Context) {}
-
- typealias UIViewControllerType = ScannerViewController
-
- typealias ScanCompletion = (Result) -> Void
-
- var completion: ScanCompletion
-
- func makeUIViewController(context: Context) -> ScannerViewController {
- let vc = ScannerViewController()
- vc.completion = completion
- return vc
- }
-}
-
-class ScannerViewController: UIViewController, AVCaptureMetadataOutputObjectsDelegate {
- var completion: ScanView.ScanCompletion?
- var captureSession: AVCaptureSession!
- var previewLayer: AVCaptureVideoPreviewLayer!
-
- // Flag to control whether callbacks are allowed
- private var isProcessing = false
-
- @objc private func toggleFlashlight() {
- guard let device = AVCaptureDevice.default(for: .video),
- device.hasTorch else { return }
- do {
- try device.lockForConfiguration()
- if device.torchMode == .on {
- device.torchMode = .off
- } else {
- try device.setTorchModeOn(level: 1.0)
- }
- } catch {
- print("Failed to toggle flashlight: \(error)")
- }
- }
-
- override func viewDidLoad() {
- super.viewDidLoad()
- view.backgroundColor = UIColor.black
-
- // Initialize guide view
- let guideImageView = UIImageView(image: UIImage(systemName: "viewfinder"))
- guideImageView.tintColor = .white
- guideImageView.contentMode = .scaleAspectFit
- guideImageView.tag = 998
-
- // Add breathing animation
- let pulse = CABasicAnimation(keyPath: "transform.scale")
- pulse.fromValue = 0.9
- pulse.toValue = 1.1
- pulse.duration = 0.7
- pulse.autoreverses = true
- pulse.repeatCount = .infinity
- guideImageView.layer.add(pulse, forKey: "breathingAnimation")
-
- view.addSubview(guideImageView)
-
- // Initialize flashlight button
- let flashlightButton = UIButton(type: .system)
- flashlightButton.setImage(UIImage(systemName: "flashlight.off.fill"), for: .normal)
- flashlightButton.tintColor = .white
- flashlightButton.addTarget(self, action: #selector(toggleFlashlight), for: .touchUpInside)
- flashlightButton.tag = 999
- view.addSubview(flashlightButton)
-
- switch AVCaptureDevice.authorizationStatus(for: .video) {
- case .authorized:
- setupSession()
- case .notDetermined:
- AVCaptureDevice.requestAccess(for: .video) { granted in
- DispatchQueue.main.async {
- if granted {
- self.setupSession()
- } else {
- self.completion?(.failure(NSError(domain: "Camera access not authorized", code: 0)))
- }
- }
- }
- default:
- completion?(.failure(NSError(domain: "Camera access not authorized", code: 0)))
- }
- }
-
- private func setupSession() {
- captureSession = AVCaptureSession()
- captureSession.sessionPreset = .photo
- guard let videoCaptureDevice = AVCaptureDevice.default(for: .video),
- let videoInput = try? AVCaptureDeviceInput(device: videoCaptureDevice),
- captureSession.canAddInput(videoInput)
- else {
- completion?(.failure(NSError(domain: "Failed to initialize camera", code: 0)))
- return
- }
- captureSession.addInput(videoInput)
-
- let metadataOutput = AVCaptureMetadataOutput()
- guard captureSession.canAddOutput(metadataOutput) else {
- completion?(.failure(NSError(domain: "Unable to add capture output", code: 0)))
- return
- }
- captureSession.addOutput(metadataOutput)
- metadataOutput.setMetadataObjectsDelegate(self, queue: DispatchQueue.main)
- metadataOutput.metadataObjectTypes = [.qr, .ean13, .code128]
-
- previewLayer = AVCaptureVideoPreviewLayer(session: captureSession)
- previewLayer.videoGravity = .resizeAspectFill
- view.layer.insertSublayer(previewLayer, at: 0)
-
- DispatchQueue.global(qos: .userInitiated).async {
- self.captureSession.startRunning()
- }
- }
-
- override func viewDidLayoutSubviews() {
- super.viewDidLayoutSubviews()
- previewLayer?.frame = view.bounds
-
- if let connection = previewLayer?.connection, connection.isVideoOrientationSupported {
- let deviceOrientation = UIDevice.current.orientation
-
- switch deviceOrientation {
- case .portrait:
- connection.videoOrientation = .portrait
- case .portraitUpsideDown:
- connection.videoOrientation = .portraitUpsideDown
- case .landscapeLeft:
- connection.videoOrientation = .landscapeRight // Note: device left equals camera right
- case .landscapeRight:
- connection.videoOrientation = .landscapeLeft // Note: device right equals camera left
- default:
- connection.videoOrientation = .portrait
- }
- }
-
- let guideImageViewSize: CGFloat = min(view.bounds.width, view.bounds.height) / 2
- let buttonSize: CGFloat = 44
-
- if let guideImageView = view.viewWithTag(998) as? UIImageView {
- guideImageView.frame = CGRect(
- x: (view.bounds.width - guideImageViewSize) / 2,
- y: (view.bounds.height - guideImageViewSize) / 2,
- width: guideImageViewSize,
- height: guideImageViewSize
- )
- }
-
- if let flashlightButton = view.viewWithTag(999) as? UIButton {
- var targetX = CGFloat(0)
- var targetY = CGFloat(0)
-
- if view.bounds.width > view.bounds.height {
- // Landscape
- let guideRightX = (view.bounds.width + guideImageViewSize) / 2
- let rightEdgeX = view.bounds.width - buttonSize
- targetX = (guideRightX + rightEdgeX) / 2
- targetY = (view.bounds.height / 2) - (buttonSize / 2)
- } else {
- // Portrait
- let guideBottomY = (view.bounds.height + guideImageViewSize) / 2
- let bottomEdgeY = view.bounds.height - buttonSize
- targetX = (view.bounds.width - buttonSize) / 2
- targetY = (guideBottomY + bottomEdgeY) / 2
- }
- flashlightButton.frame = CGRect(
- x: targetX,
- y: targetY,
- width: buttonSize,
- height: buttonSize
- )
- }
- }
-
- func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) {
- guard !isProcessing else { return }
- isProcessing = true // Mark as processing to avoid duplicate triggers
-
- if let metadataObject = metadataObjects.first as? AVMetadataMachineReadableCodeObject,
- let code = metadataObject.stringValue {
- AudioServicesPlaySystemSound(SystemSoundID(1057))
- completion?(.success(code))
- // Do not stop captureSession immediately
- // Call stopScanning() after external processing is finished
- } else {
- completion?(.failure(NSError(domain: "No QR code detected", code: 0)))
- isProcessing = false // Allow next scan
- }
- }
-
- // Provide a method for external callers to stop scanning
- func stopScanning() {
- if captureSession.isRunning {
- captureSession.stopRunning()
- }
- isProcessing = false
- }
-
- override func viewWillDisappear(_ animated: Bool) {
- super.viewWillDisappear(animated)
- stopScanning()
- }
-}
diff --git a/app/StackChan/View/Settings.swift b/app/StackChan/View/Settings.swift
deleted file mode 100644
index 610753b..0000000
--- a/app/StackChan/View/Settings.swift
+++ /dev/null
@@ -1,119 +0,0 @@
-/*
- * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD
- *
- * SPDX-License-Identifier: MIT
- */
-
-import SwiftUI
-
-struct Settings : View {
-
- @EnvironmentObject var appState: AppState
-
- @State var showCjamgeNameAlert: Bool = false
- @State var newName: String = ""
-
- @State var deviceInfo: Device = Device()
-
- var body: some View {
- NavigationStack(path: $appState.settingsPath) {
- List {
- Section("conventional") {
- Button {
- if appState.deviceMac.isEmpty {
- appState.showBindingDeviceAlert = true
- } else {
- appState.showCjamgeNameAlert = true
- }
- } label: {
- HStack {
- Image(systemName: "person")
- .foregroundStyle(.white)
- .frame(width: 28, height: 28)
- .background(Color.blue)
- .cornerRadius(8)
- Text("Change Name")
- Spacer()
- Text(appState.deviceInfo.name ?? "")
- .foregroundStyle(.secondary)
- Image(systemName: "chevron.right")
- .foregroundStyle(.secondary)
- }
- }
- .foregroundStyle(.primary)
- Button {
- } label: {
- HStack {
- Image(systemName: "arrow.down.circle")
- .foregroundStyle(.white)
- .frame(width: 28, height: 28)
- .background(Color.green)
- .cornerRadius(8)
- Text("Online upgrade")
- Spacer()
- Image(systemName: "chevron.right")
- .foregroundStyle(.secondary)
- }
- }
- .foregroundStyle(.primary)
- }
-
- Section("system") {
- Button {
- } label: {
- HStack {
- Image(systemName: "exclamationmark.arrow.triangle.2.circlepath")
- .foregroundStyle(.white)
- .frame(width: 28, height: 28)
- .background(Color.red)
- .cornerRadius(8)
- Text("Factory data reset")
- Spacer()
- Image(systemName: "chevron.right")
- .foregroundStyle(.secondary)
- }
- }.foregroundStyle(.primary)
- Button {
- appState.forcedDisplayBindingDevice = false
- appState.showBindingDevice = true
- } label: {
- HStack {
- Image(systemName: "shuffle")
- .foregroundStyle(.white)
- .frame(width: 28, height: 28)
- .background(Color.blue)
- .cornerRadius(8)
- Text("Bind StachChan")
- Spacer()
- Image(systemName: "chevron.right")
- .foregroundStyle(.secondary)
- }
- }
- .foregroundStyle(.primary)
- }
- }
- .listStyle(.insetGrouped)
- .navigationTitle("Settings")
- .navigationDestination(for: PageType.self) { PageType in
- switch PageType {
- case .cameraPage:
- CameraPage()
- case .minicryEmotion:
- MimicryEmotion(deviceMac: $appState.deviceMac)
- case .dance:
- Dance()
- }
- }
- .onAppear {
- appState.getDeviceInfo()
- }
- }
- }
-}
-
-
-struct SettingPreview : PreviewProvider {
- static var previews: some View {
- Settings()
- }
-}
diff --git a/app/StackChan/View/StackChan.swift b/app/StackChan/View/StackChan.swift
deleted file mode 100644
index b3ef857..0000000
--- a/app/StackChan/View/StackChan.swift
+++ /dev/null
@@ -1,368 +0,0 @@
-/*
- * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD
- *
- * SPDX-License-Identifier: MIT
- */
-
-import SwiftUI
-
-struct StackChan : View {
-
- let gridHeight: CGFloat = 100
-
- @EnvironmentObject var appState: AppState
-
- private let imageSize: CGFloat = 200
-
- @State private var showAvatarMotionControl: Bool = false
-
- @State private var deviceMac: String = ""
-
- func getDeviceStatus() -> String {
- if appState.deviceMac == "" {
- return "Unbound device"
- } else {
- if appState.deviceIsOnline {
- return "Device Online"
- } else {
- return "Device Offline"
- }
- }
- }
-
- var body: some View {
- let radius = UIScreen.main.bounds.minDimension / 12
- NavigationStack(path: $appState.stackChanPath) {
- ZStack {
- LinearGradient(
- colors: [
- Color.accent.opacity(0.5),
- Color.pink.opacity(0.1),
- Color.blue.opacity(0.2)
- ],
- startPoint: .topLeading,
- endPoint: .bottomTrailing
- )
- .ignoresSafeArea()
- ScrollView {
- VStack(alignment: .trailing,spacing: 20) {
- StackChanRotaryRobot()
- .frame(width: imageSize,height: imageSize)
- Text(getDeviceStatus())
- Button {
- if appState.deviceMac.isEmpty {
- appState.showBindingDeviceAlert = true
- } else {
- appState.stackChanPath.append(.minicryEmotion)
- }
- } label: {
- HStack {
- Image(systemName: "face.smiling")
- .font(.system(size: 44))
- Spacer()
- Text("AVATAR")
- .font(.largeTitle)
- }
- .padding(.horizontal,20)
- .foregroundColor(Color(UIColor.systemBackground))
- .padding()
- .frame(maxWidth: .infinity)
- .frame(height: gridHeight)
- .background(.yellow)
- .clipShape(RoundedRectangle(cornerRadius: radius))
- .glassEffectRegular(cornerRadius: radius)
- }
-
- Button {
- if appState.deviceMac.isEmpty {
- appState.showBindingDeviceAlert = true
- } else {
- appState.stackChanPath.append(.cameraPage)
- }
- } label: {
- HStack {
- Image(systemName: "video")
- .font(.system(size: 44))
- Spacer()
- Text("SENTINEL")
- .font(.largeTitle)
- }
- .padding(.horizontal,20)
- .foregroundColor(Color(UIColor.systemBackground))
- .padding()
- .frame(maxWidth: .infinity)
- .frame(height: gridHeight)
- .background(Color(UIColor.label).opacity(0.8))
- .clipShape(RoundedRectangle(cornerRadius: radius))
- .glassEffectRegular(cornerRadius: radius)
- }
-
- Button {
- if appState.deviceMac.isEmpty {
- appState.showBindingDeviceAlert = true
- } else {
- showAvatarMotionControl = true
- }
- } label: {
- HStack {
- Image(
- systemName: "arrow.up.and.down.and.arrow.left.and.right"
- )
- .font(.system(size: 44))
- Spacer()
- Text("MOTION")
- .font(.largeTitle)
- }
- .padding(.horizontal,20)
- .foregroundColor(Color(UIColor.label))
- .padding()
- .frame(maxWidth: .infinity)
- .frame(height: gridHeight)
- .background(.gray.opacity(0.5))
- .clipShape(RoundedRectangle(cornerRadius: radius))
- .glassEffectRegular(cornerRadius: radius)
- }
-
- Button {
- if appState.deviceMac.isEmpty {
- appState.showBindingDeviceAlert = true
- } else {
- appState.stackChanPath.append(.dance)
- }
- } label: {
- HStack {
- Image(systemName: "figure.dance")
- .font(.system(size: 44))
- Spacer()
- Text("DANCE")
- .font(.largeTitle)
- }
- .padding(.horizontal,20)
- .foregroundColor(Color(UIColor.systemBackground))
- .padding()
- .frame(maxWidth: .infinity)
- .frame(height: gridHeight)
- .background(.orange)
- .clipShape(RoundedRectangle(cornerRadius: radius))
- .glassEffectRegular(cornerRadius: radius)
- }
- }
- .padding(20)
- }
- }
- .sheet(isPresented: $showAvatarMotionControl) {
- AvatarMotionControl()
- .presentationDetents([.medium,.large])
- .presentationBackgroundClear()
- .presentationDragIndicator(.visible)
- }
- .sheet(isPresented: $appState.showSwitchFace) {
- SelectBlufiDevice()
- .presentationDetents([.medium])
- .presentationBackgroundClear()
- }
- .navigationTitle("StackChan")
- .navigationDestination(for: PageType.self) { PageType in
- switch PageType {
- case .cameraPage:
- CameraPage()
- case .minicryEmotion:
- MimicryEmotion(deviceMac: $appState.deviceMac)
- case .dance:
- Dance()
- }
- }
- }
- }
-}
-
-
-
-struct SwitchFacePreview : PreviewProvider {
- static var previews: some View {
- SwitchFace()
- }
-}
-
-
-
-struct SwitchFace : View {
-
- private let faceList: [ExpressionData] = [
- ExpressionData(
- leftEye: ExpressionItem(
- x: 0,
- y: -50,
- rotation: 1400,
- weight: 60,
- size: 0
- ),
- rightEye: ExpressionItem(
- x: 0,
- y: -50,
- rotation: -1400,
- weight: 60,
- size: 0
- ),
- mouth: ExpressionItem(x: 0, y: 0, rotation: 0, weight: 50, size: 0)
- ),
- ExpressionData(
- leftEye: ExpressionItem(
- x: 0,
- y: 0,
- rotation: 0,
- weight: 100,
- size: 0
- ),
- rightEye: ExpressionItem(
- x: 0,
- y: 0,
- rotation: 0,
- weight: 100,
- size: 0
- ),
- mouth: ExpressionItem(x: 0, y: 0, rotation: 0, weight: 0, size: 0)
- ),
- ExpressionData(
- leftEye: ExpressionItem(
- x: 0,
- y: 0,
- rotation: 250,
- weight: 50,
- size: 0
- ),
- rightEye: ExpressionItem(
- x: 0,
- y: 0,
- rotation: -250,
- weight: 50,
- size: 0
- ),
- mouth: ExpressionItem(x: 0, y: 0, rotation: 0, weight: 0, size: 0)
- ),
- ExpressionData(
- leftEye: ExpressionItem(
- x: 0,
- y: 0,
- rotation: 0,
- weight: 15,
- size: 0
- ),
- rightEye: ExpressionItem(
- x: 0,
- y: 0,
- rotation: 0,
- weight: 15,
- size: 0
- ),
- mouth: ExpressionItem(x: 0, y: 0, rotation: 0, weight: 0, size: 0)
- ),
- ExpressionData(
- leftEye: ExpressionItem(
- x: 0,
- y: 0,
- rotation: 0,
- weight: 100,
- size: 50
- ),
- rightEye: ExpressionItem(
- x: 5,
- y: 0,
- rotation: 0,
- weight: 100,
- size: -50
- ),
- mouth: ExpressionItem(x: 0, y: 0, rotation: 0, weight: 0, size: 0)
- ),
- ExpressionData(
- leftEye: ExpressionItem(
- x: 0,
- y: -50,
- rotation: 0,
- weight: 100,
- size: 50
- ),
- rightEye: ExpressionItem(
- x: 0,
- y: -50,
- rotation: 0,
- weight: 100,
- size: 50
- ),
- mouth: ExpressionItem(x: 0, y: 0, rotation: 0, weight: 100, size: 0)
- )
- ]
-
- @State var selectedIndex: Int = 0
-
- private let columns = Array(repeating: GridItem(.flexible(),spacing: 20), count: 2)
-
- @EnvironmentObject var appState: AppState
-
- var body: some View {
- ScrollView {
- LazyVGrid(columns: columns, spacing: 20) {
- ForEach(0.. UIGraphicsImageRenderer {
- let format = UIGraphicsImageRendererFormat.default()
- format.scale = UIScreen.main.scale
- format.opaque = false
- return UIGraphicsImageRenderer(
- size: expressionLayer.bounds.size,
- format: format
- )
- }
-}
-
-struct StackChanSwitchFacePreview : PreviewProvider {
- static var previews: some View {
- SwitchFace()
- }
-}
-
-
diff --git a/app/StackChan/View/StackChanModelView.swift b/app/StackChan/View/StackChanModelView.swift
deleted file mode 100644
index f724053..0000000
--- a/app/StackChan/View/StackChanModelView.swift
+++ /dev/null
@@ -1,165 +0,0 @@
-/*
- * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD
- *
- * SPDX-License-Identifier: MIT
- */
-
-import SwiftUI
-import ARKit
-
-struct StackChanModelView: View {
-
- @Binding var expressionData: ExpressionData
- @Binding var headData: MotionData
-
- var body: some View {
- Canvas { context, size in
- let eyeSize = size.width / 10
-
- func drawEye(_ item: ExpressionItem, at point: CGPoint) {
- let visibleHeight = eyeSize * (CGFloat(item.weight) / 100)
-
- let eyeX = point.x + CGFloat(item.x / 10)
- let eyeY = point.y + CGFloat(item.y / 10)
-
- let eyeRect = CGRect(x: eyeX, y: eyeY, width: eyeSize, height: eyeSize)
- var eyePath = Path()
- eyePath.addEllipse(in: eyeRect)
-
- let rotationDegrees = Double(item.rotation) / 10.0
- let rotationAngle = Angle(degrees: rotationDegrees)
-
- let maskRect = CGRect(
- x: eyeX,
- y: eyeY + eyeSize - visibleHeight,
- width: eyeSize,
- height: visibleHeight
- )
-
- context.drawLayer { context in
- let center = CGPoint(x: eyeRect.midX, y: eyeRect.midY)
- context.translateBy(x: center.x, y: center.y)
- context.rotate(by: rotationAngle)
- context.translateBy(x: -center.x, y: -center.y)
-
- context.clip(to: Path(maskRect))
- context.fill(
- Path(ellipseIn: eyeRect),
- with: .color(.white)
- )
- }
- }
-
-
- let eyeY = (size.height * 0.35) - (eyeSize / 2)
- let leftEyePoint = CGPoint(x: (size.width / 3) - (eyeSize / 2) ,y: eyeY)
- let rightEyePoint = CGPoint(x: (size.width / 3 * 2) - (eyeSize / 2) ,y: eyeY)
-
- drawEye(expressionData.leftEye, at: leftEyePoint)
- drawEye(expressionData.rightEye, at: rightEyePoint)
-
-
- context.drawLayer { context in
-
- let width = size.width * 0.3 - CGFloat(expressionData.mouth.weight / 10)
- let height = 3 + CGFloat(expressionData.mouth.weight) * 0.2
- let x = ((size.width - width) / 2) + CGFloat(expressionData.mouth.x / 10)
- let y = (size.height * 0.65) + CGFloat(expressionData.mouth.y / 10)
-
- let rotationDegrees = Double(expressionData.mouth.rotation) / 10.0
- let rotationAngle = Angle(degrees: rotationDegrees)
-
- let mouthRect = CGRect(x: x, y: y, width: width, height: height)
- let mouthPath = Path(roundedRect: mouthRect, cornerRadius: height / 2)
-
- let center = CGPoint(x: mouthRect.midX, y: mouthRect.midY)
- context.translateBy(x: center.x, y: center.y)
- context.rotate(by: rotationAngle)
- context.translateBy(x: -center.x, y: -center.y)
-
- context.fill(mouthPath, with: .color(.white))
- }
- }
- }
-}
-
-
-
-
-
-
-
-
-struct SceneKitView: UIViewRepresentable {
- @Binding var expressionData: ExpressionData
-
- private let planeNodeName = "expressionPlane"
-
- @State var expressionLayer = ExpressionLayer(data: ExpressionData(leftEye: ExpressionItem(), rightEye: ExpressionItem(), mouth: ExpressionItem()))
-
- func makeUIView(context: Context) -> SCNView {
- let scnView = SCNView()
- let scene = SCNScene()
- scnView.scene = scene
- scnView.allowsCameraControl = true
- scnView.autoenablesDefaultLighting = true
- scnView.backgroundColor = .clear
-
- expressionLayer.data = expressionData
- expressionLayer.frame = CGRect(origin: .zero, size: CGSize(width: 250, height: 200))
- expressionLayer.setNeedsDisplay()
-
- DispatchQueue.main.async {
- let plane = SCNPlane(width: 0.08, height: 0.06)
- let material = SCNMaterial()
- material.diffuse.contents = expressionLayer
- material.isDoubleSided = true
- plane.materials = [material]
-
- let planeNode = SCNNode(geometry: plane)
- planeNode.name = planeNodeName
-
- var position = SCNVector3()
- position.x += 0.02
- position.y += 0.015
- position.z += 0.01
-
- planeNode.position = position
-
- scene.rootNode.addChildNode(planeNode)
-
- if let position = scnView.scene?.rootNode.position {
- scnView.scene?.rootNode.position.z = position.z - 0.03
- }
- }
-
- return scnView
- }
-
- func updateUIView(_ uiView: SCNView, context: Context) {
- guard let scene = uiView.scene,
- let planeNode = scene.rootNode.childNode(withName: planeNodeName, recursively: true),
- let plane = planeNode.geometry as? SCNPlane,
- let material = plane.materials.first else {
- return
- }
- expressionLayer.data = expressionData
- expressionLayer.setNeedsDisplay()
- material.diffuse.contents = expressionLayer
- }
-
-
-}
-
-
-struct SceneKitViewPreview : PreviewProvider {
-
- static var previews: some View {
- SceneKitView(
- expressionData: .constant(
- ExpressionData(leftEye: ExpressionItem(), rightEye: ExpressionItem(), mouth: ExpressionItem())
- )
- )
- .frame(maxWidth: 400,maxHeight: 400)
- }
-}
diff --git a/app/StackChan/View/StackChanRobot.swift b/app/StackChan/View/StackChanRobot.swift
deleted file mode 100644
index ef93d6c..0000000
--- a/app/StackChan/View/StackChanRobot.swift
+++ /dev/null
@@ -1,360 +0,0 @@
-/*
- * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD
- *
- * SPDX-License-Identifier: MIT
- */
-
-import SwiftUI
-import SceneKit
-import Combine
-
-struct StackChanRobot : UIViewRepresentable {
-
- var data: DanceData
-
- var allowsCameraControl: Bool = false
-
- @State private var expressionLayer = ExpressionLayer(data: ExpressionData(leftEye: ExpressionItem(weight: 100), rightEye: ExpressionItem(weight: 100), mouth: ExpressionItem()))
-
- private let planeNodeName = "expressionPlane"
-
- private let rotateKey = "autoRotate"
-
- func makeUIView(context: Context) -> SCNView {
- let sceneView = SCNView()
-
- if let scene = SCNScene(named: "stackChanModel.scn") {
- scene.rootNode.eulerAngles = SCNVector3Zero
- scene.rootNode.eulerAngles.x = -Float.pi / 2
- scene.rootNode.position.y = scene.rootNode.position.y + 25
- scene.rootNode.position.z = scene.rootNode.position.z - 35
-
- let plane = SCNPlane(width: 42, height: 32)
- let magnification: CGFloat = 5
- let size = CGSize(width: magnification * plane.width, height: magnification * plane.height)
- expressionLayer.frame = CGRect(origin: .zero, size: size)
- expressionLayer.setNeedsDisplay()
-
- let material = SCNMaterial()
- plane.materials = [material]
- let planeNode = SCNNode(geometry: plane)
- planeNode.name = planeNodeName
- planeNode.position = SCNVector3(0, -16, 0)
- planeNode.eulerAngles = SCNVector3(Float.pi / 2, 0, 0)
- scene.rootNode.addChildNode(planeNode)
-
- sceneView.scene = scene
- } else {
- print("Model not found")
- }
-
- sceneView.autoenablesDefaultLighting = true
- sceneView.allowsCameraControl = allowsCameraControl
- sceneView.backgroundColor = UIColor.clear
- setData(sceneView)
- return sceneView
- }
-
- func updateUIView(_ uiView: SCNView, context: Context) {
- setData(uiView)
- }
-
- /// Refresh model position and expression
- private func setData(_ uiView: SCNView) {
- if let stackNode = uiView.scene?.rootNode {
- /// Set pitch angle (0–900)
- let clampedPitch = max(0, min(900, data.pitchServo.angle))
- let pitchRatio = Float(clampedPitch) / 900.0
- let pitchAngle = -Float.pi / 2 * (1 + pitchRatio)
- stackNode.eulerAngles.x = pitchAngle
-
- // Cancel previous auto-rotation
- stackNode.removeAction(forKey: rotateKey)
-
- if data.yawServo.rotate == 0 {
- /// Set yaw angle (-128 to 128, left to right)
- let clampedYaw = max(-1280, min(1280, data.yawServo.angle)) // Clamp to -128~128
- let yawAngle = Float(clampedYaw) * Float.pi / 1800 // Convert to radians
- stackNode.eulerAngles.y = yawAngle
- } else {
- let rotateSpeed = max(-1000, min(1000, data.yawServo.rotate))
- let radiansPerSecond = Float(rotateSpeed) / 1000.0 * Float.pi * 2
- // Rotate continuously using angular velocity (not a fixed-loop animation)
- let rotateAction = SCNAction.customAction(duration: .infinity) { node, _ in
- let deltaTime: Float = 1.0 / 60.0 // Approximate frame duration
- node.eulerAngles.y += radiansPerSecond * deltaTime
- }
- stackNode.runAction(rotateAction, forKey: rotateKey)
- }
- }
- // Find the plane node
- if let planeNode = uiView.scene?.rootNode.childNode(withName: planeNodeName, recursively: true),
- let plane = planeNode.geometry as? SCNPlane {
-
- // Render new expression image
- let expressionData = ExpressionData(leftEye: data.leftEye, rightEye: data.rightEye, mouth: data.mouth)
- expressionLayer.data = expressionData
- expressionLayer.setNeedsDisplay()
- let newImage = expressionRenderer().image { ctx in
- self.expressionLayer.render(in: ctx.cgContext)
- }
- plane.firstMaterial?.diffuse.contents = newImage
- }
- }
-
- static func dismantleUIView(_ uiView: SCNView, coordinator: ()) {
- // Remove all nodes from the scene
- uiView.scene?.rootNode.childNodes.forEach { $0.removeFromParentNode() }
-
- // Clean up scene and materials
- uiView.scene = nil
- uiView.delegate = nil
-
- // Stop any rendering or animations
- uiView.isPlaying = false
- uiView.scene?.isPaused = true
- }
-
- private func expressionRenderer() -> UIGraphicsImageRenderer {
- let format = UIGraphicsImageRendererFormat.default()
- format.scale = UIScreen.main.scale
- format.opaque = false
- return UIGraphicsImageRenderer(
- size: expressionLayer.bounds.size,
- format: format
- )
- }
-
-}
-
-
-
-struct StackChanRotaryRobot : UIViewRepresentable {
-
- private let expressionLayer = ExpressionLayer(data: ExpressionData(leftEye: ExpressionItem(weight: 100), rightEye: ExpressionItem(weight: 100), mouth: ExpressionItem()))
-
- func updateUIView(_ uiView: SCNView, context: Context) {
-
- }
-
- private func expressionRenderer() -> UIGraphicsImageRenderer {
- let format = UIGraphicsImageRendererFormat.default()
- format.scale = UIScreen.main.scale
- format.opaque = false
- return UIGraphicsImageRenderer(
- size: expressionLayer.bounds.size,
- format: format
- )
- }
-
- func makeUIView(context: Context) -> SCNView {
- let sceneView = SCNView()
-
- if let scene = SCNScene(named: "stackChanModel.scn") {
- scene.rootNode.eulerAngles = SCNVector3Zero
- scene.rootNode.eulerAngles.x = -Float.pi / 2
- scene.rootNode.position.y = scene.rootNode.position.y + 25
- scene.rootNode.position.z = scene.rootNode.position.z - 45
-
- let clampedPitch = max(0, min(900, 200))
- let pitchRatio = Float(clampedPitch) / 900.0
- let pitchAngle = -Float.pi / 2 * (1 + pitchRatio)
- scene.rootNode.eulerAngles.x = pitchAngle
-
- // Add plane
- let plane = SCNPlane(width: 42, height: 32)
- let magnification: CGFloat = 5
- let size = CGSize(width: magnification * plane.width, height: magnification * plane.height)
- expressionLayer.frame = CGRect(origin: .zero, size: size)
- expressionLayer.setNeedsDisplay()
- let newImage = expressionRenderer().image { ctx in
- self.expressionLayer.render(in: ctx.cgContext)
- }
- let material = SCNMaterial()
- material.diffuse.contents = newImage
- plane.materials = [material]
- let planeNode = SCNNode(geometry: plane)
- planeNode.position = SCNVector3(0, -16, 0)
- planeNode.eulerAngles = SCNVector3(Float.pi / 2, 0, 0)
- scene.rootNode.addChildNode(planeNode)
-
- // Add infinite rotation animation around Y axis
- let rotateAction = SCNAction.rotateBy(x: 0, y: CGFloat(2 * Double.pi), z: 0, duration: 5)
- let repeatAction = SCNAction.repeatForever(rotateAction)
- scene.rootNode.runAction(repeatAction)
-
- sceneView.scene = scene
- } else {
- print("Model not found")
- }
-
- sceneView.autoenablesDefaultLighting = true
- sceneView.allowsCameraControl = false
- sceneView.backgroundColor = UIColor.clear
- return sceneView
- }
-
- static func dismantleUIView(_ uiView: UIViewType, coordinator: ()) {
- // Remove all nodes from the scene
- uiView.scene?.rootNode.childNodes.forEach { $0.removeFromParentNode() }
-
- // Clean up scene and materials
- uiView.scene = nil
- uiView.delegate = nil
-
- // Stop any rendering or animations
- uiView.isPlaying = false
- uiView.scene?.isPaused = true
- }
-}
-
-
-
-struct StackChanRobotPreview : PreviewProvider {
-
- static var previews: some View {
- StackChanRotaryRobot()
- .frame(maxWidth: .infinity,maxHeight: 400)
- }
-}
-
-
-
-class ExpressionLayer: CALayer {
- var data: ExpressionData
-
- let reverse: Bool
-
- init(data: ExpressionData, reverse: Bool = false) {
- self.data = data
- self.reverse = reverse
- super.init()
- self.contentsScale = UIScreen.main.scale
- self.setNeedsDisplay()
- }
-
- override init(layer: Any) {
- if let layer = layer as? ExpressionLayer {
- self.data = layer.data
- self.reverse = layer.reverse
- } else {
- self.data = ExpressionData(leftEye: ExpressionItem(), rightEye: ExpressionItem(), mouth: ExpressionItem())
- self.reverse = false
- }
- super.init(layer: layer)
- }
-
- required init?(coder: NSCoder) {
- fatalError("init(coder:) has not been implemented")
- }
-
- override func draw(in ctx: CGContext) {
- let rect = self.frame
-
- // Background
- ctx.setFillColor(UIColor.black.withAlphaComponent(0.7).cgColor)
- ctx.fill(rect)
-
- let eyeSize = rect.width / 10
-
- func drawEye(_ item: ExpressionItem, at point: CGPoint) {
-
- // Calculate scale based on size (-100 to 100)
- // 0 -> 1.0 (keep current size)
- // -100 -> 0.5 (half normal radius)
- // 100 -> 2.0 (double normal radius)
- let clampedSize = max(-100, min(100, item.size))
- let sizeScale: CGFloat
- if clampedSize >= 0 {
- sizeScale = 1.0 + CGFloat(clampedSize) / 100.0
- } else {
- sizeScale = 1.0 + CGFloat(clampedSize) / 200.0
- }
-
- let scaledEyeSize = eyeSize * sizeScale
-
- let visibleHeight = scaledEyeSize * (CGFloat(item.weight) / 100)
-
- let centerX = point.x + CGFloat(item.x / 10) + eyeSize / 2
- let centerY = point.y + CGFloat(item.y / 10) + eyeSize / 2
- let eyeRect = CGRect(
- x: centerX - scaledEyeSize / 2,
- y: centerY - scaledEyeSize / 2,
- width: scaledEyeSize,
- height: scaledEyeSize
- )
-
- ctx.saveGState()
-
- // Rotation
- let rotationDegrees = CGFloat(item.rotation) / 10.0
- let center = CGPoint(x: eyeRect.midX, y: eyeRect.midY)
- ctx.translateBy(x: center.x, y: center.y)
- ctx.rotate(by: rotationDegrees * .pi / 180)
- ctx.translateBy(x: -center.x, y: -center.y)
-
- // Clip height
- let maskRect = CGRect(
- x: eyeRect.minX,
- y: eyeRect.maxY - visibleHeight,
- width: scaledEyeSize,
- height: visibleHeight
- )
- ctx.addRect(maskRect)
- ctx.clip()
-
- ctx.setFillColor(UIColor.white.cgColor)
- ctx.fillEllipse(in: eyeRect)
-
- ctx.restoreGState()
- }
-
- let eyeY = (rect.height * 0.35) - (eyeSize / 2)
- let leftEyePoint = CGPoint(x: (rect.width / 3) - (eyeSize / 2), y: eyeY)
- let rightEyePoint = CGPoint(x: (rect.width / 3 * 2) - (eyeSize / 2), y: eyeY)
-
-
- if reverse {
- // Temporarily swap rotation angles
- let leftEyeRotation = data.leftEye.rotation
- let rightEyeRotation = data.rightEye.rotation
-
- var leftEye = data.leftEye
- var rightEye = data.rightEye
-
- leftEye.rotation = rightEyeRotation
- rightEye.rotation = leftEyeRotation
-
- drawEye(leftEye, at: rightEyePoint)
- drawEye(rightEye, at: leftEyePoint)
- } else {
- drawEye(data.leftEye, at: leftEyePoint)
- drawEye(data.rightEye, at: rightEyePoint)
- }
-
-
-
-
- // Draw mouth
- ctx.saveGState()
-
- let width = rect.width * 0.3 - CGFloat(data.mouth.weight / 10)
- let height = 3 + CGFloat(data.mouth.weight) * 0.2
- let x = ((rect.width - width) / 2) + CGFloat(data.mouth.x / 10)
- let y = (rect.height * 0.65) + CGFloat(data.mouth.y / 10)
-
- let rotationDegrees = CGFloat(data.mouth.rotation) / 10.0
- let center = CGPoint(x: x + width / 2, y: y + height / 2)
- ctx.translateBy(x: center.x, y: center.y)
- ctx.rotate(by: rotationDegrees * .pi / 180)
- ctx.translateBy(x: -center.x, y: -center.y)
-
- let mouthRect = CGRect(x: x, y: y, width: width, height: height)
- let mouthPath = UIBezierPath(roundedRect: mouthRect, cornerRadius: height / 2)
- ctx.addPath(mouthPath.cgPath)
- ctx.setFillColor(UIColor.white.cgColor)
- ctx.fillPath()
-
- ctx.restoreGState()
- }
-}
diff --git a/app/analysis_options.yaml b/app/analysis_options.yaml
new file mode 100644
index 0000000..0d29021
--- /dev/null
+++ b/app/analysis_options.yaml
@@ -0,0 +1,28 @@
+# This file configures the analyzer, which statically analyzes Dart code to
+# check for errors, warnings, and lints.
+#
+# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
+# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
+# invoked from the command line by running `flutter analyze`.
+
+# The following line activates a set of recommended lints for Flutter apps,
+# packages, and plugins designed to encourage good coding practices.
+include: package:flutter_lints/flutter.yaml
+
+linter:
+ # The lint rules applied to this project can be customized in the
+ # section below to disable rules from the `package:flutter_lints/flutter.yaml`
+ # included above or to enable additional rules. A list of all available lints
+ # and their documentation is published at https://dart.dev/lints.
+ #
+ # Instead of disabling a lint rule for the entire project in the
+ # section below, it can also be suppressed for a single line of code
+ # or a specific dart file by using the `// ignore: name_of_lint` and
+ # `// ignore_for_file: name_of_lint` syntax on the line or in the file
+ # producing the lint.
+ rules:
+ # avoid_print: false # Uncomment to disable the `avoid_print` rule
+ # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
+
+# Additional information about this file can be found at
+# https://dart.dev/guides/language/analysis-options
diff --git a/app/android/.gitignore b/app/android/.gitignore
new file mode 100644
index 0000000..be3943c
--- /dev/null
+++ b/app/android/.gitignore
@@ -0,0 +1,14 @@
+gradle-wrapper.jar
+/.gradle
+/captures/
+/gradlew
+/gradlew.bat
+/local.properties
+GeneratedPluginRegistrant.java
+.cxx/
+
+# Remember to never publicly share your keystore.
+# See https://flutter.dev/to/reference-keystore
+key.properties
+**/*.keystore
+**/*.jks
diff --git a/app/android/app/build.gradle.kts b/app/android/app/build.gradle.kts
new file mode 100644
index 0000000..26fe94b
--- /dev/null
+++ b/app/android/app/build.gradle.kts
@@ -0,0 +1,87 @@
+/*
+SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD
+SPDX-License-Identifier: MIT
+*/
+
+import org.jetbrains.kotlin.gradle.dsl.JvmTarget
+
+plugins {
+ id("com.android.application")
+ id("kotlin-android")
+ id("dev.flutter.flutter-gradle-plugin")
+}
+
+android {
+ namespace = "com.m5stack.stackchan"
+ compileSdk = flutter.compileSdkVersion
+ ndkVersion = flutter.ndkVersion
+
+ packaging {
+ jniLibs {
+ pickFirsts.add("lib/arm64-v8a/libc++_shared.so")
+ pickFirsts.add("lib/armeabi-v7a/libc++_shared.so")
+ pickFirsts.add("lib/x86/libc++_shared.so")
+ pickFirsts.add("lib/x86_64/libc++_shared.so")
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_21
+ targetCompatibility = JavaVersion.VERSION_21
+ }
+
+ kotlin {
+ compilerOptions {
+ jvmTarget.set(JvmTarget.JVM_21)
+ }
+ }
+
+ signingConfigs {
+ create("release") {
+ storeFile = file("release.jks")
+ storePassword = "123456"
+ keyAlias = "key0"
+ keyPassword = "123456"
+ }
+ getByName("debug") {
+ storeFile = file("debug.jks")
+ storePassword = "123456"
+ keyAlias = "key0"
+ keyPassword = "123456"
+ }
+ }
+
+ defaultConfig {
+ applicationId = "com.m5stack.stackchan"
+ minSdk = 26
+ targetSdk = flutter.targetSdkVersion
+ versionCode = 1
+ versionName = "0.0.1"
+
+ ndk {
+ abiFilters.add("arm64-v8a")
+ }
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ isShrinkResources = false
+ signingConfig = signingConfigs.getByName("release")
+ }
+ debug {
+ isMinifyEnabled = false
+ isShrinkResources = false
+ signingConfig = signingConfigs.getByName("debug")
+ }
+ }
+}
+
+flutter {
+ source = "../.."
+}
+
+dependencies {
+ implementation("androidx.datastore:datastore-core:1.2.1")
+ //noinspection GradleDynamicVersion
+}
diff --git a/app/android/app/src/main/AndroidManifest.xml b/app/android/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..edeb93c
--- /dev/null
+++ b/app/android/app/src/main/AndroidManifest.xml
@@ -0,0 +1,75 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/android/app/src/main/kotlin/com/m5stack/stackchan/MainActivity.kt b/app/android/app/src/main/kotlin/com/m5stack/stackchan/MainActivity.kt
new file mode 100644
index 0000000..ededb6b
--- /dev/null
+++ b/app/android/app/src/main/kotlin/com/m5stack/stackchan/MainActivity.kt
@@ -0,0 +1,181 @@
+/*
+SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD
+SPDX-License-Identifier: MIT
+*/
+
+package com.m5stack.stackchan
+
+import android.Manifest
+import android.media.AudioFormat
+import android.media.AudioManager
+import android.media.AudioRecord
+import android.media.AudioTrack
+import android.util.Log
+import androidx.annotation.RequiresPermission
+import io.flutter.embedding.android.FlutterActivity
+import io.flutter.embedding.engine.FlutterEngine
+import io.flutter.plugin.common.BasicMessageChannel
+import io.flutter.plugin.common.BinaryCodec
+import io.flutter.plugin.common.EventChannel
+import io.flutter.plugin.common.MethodCall
+import io.flutter.plugin.common.MethodChannel
+import java.nio.ByteBuffer
+import java.util.concurrent.atomic.AtomicBoolean
+
+class MainActivity : FlutterActivity() {
+
+ private lateinit var channel: MethodChannel
+ private lateinit var audioPlayChannel: BasicMessageChannel
+ private lateinit var recordChannel: EventChannel
+
+ private val SAMPLE_RATE = 16000
+ private val CHANNEL_CONFIG = AudioFormat.CHANNEL_OUT_MONO
+ private val AUDIO_FORMAT = AudioFormat.ENCODING_PCM_16BIT
+ private var audioTrack: AudioTrack? = null
+
+ private val RECORD_CHANNEL_CONFIG = AudioFormat.CHANNEL_IN_MONO
+ private var audioRecord: AudioRecord? = null
+ private val isRecording = AtomicBoolean(false)
+ private var recordBufferSize = 0
+ private var eventSink: EventChannel.EventSink? = null
+
+ @RequiresPermission(Manifest.permission.RECORD_AUDIO)
+ override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
+ super.configureFlutterEngine(flutterEngine)
+
+ val messenger = flutterEngine.dartExecutor.binaryMessenger
+
+ // translated comment
+ channel = MethodChannel(
+ messenger,
+ "com.m5stack.stackchan/native"
+ )
+ channel.setMethodCallHandler { call, result ->
+ methodCallHandler(call, result)
+ }
+ audioPlayChannel = BasicMessageChannel(
+ messenger, "com.m5stack.stackchan/audio_play",
+ BinaryCodec.INSTANCE
+ )
+ audioPlayChannel.setMessageHandler { buffer, reply ->
+ buffer?.let { playAudio(it) }
+ reply.reply(null)
+ }
+ recordChannel = EventChannel(messenger, "com.m5stack.stackchan/record")
+ recordChannel.setStreamHandler(object : EventChannel.StreamHandler {
+ override fun onListen(
+ p0: Any?,
+ p1: EventChannel.EventSink?
+ ) {
+ eventSink = p1
+ }
+
+ override fun onCancel(p0: Any?) {
+ eventSink = null
+ stopRecording()
+ }
+ })
+ initAudioPlayer()
+ initAudioRecorder()
+ }
+
+ private fun initAudioPlayer() {
+ val bufferSize = AudioTrack.getMinBufferSize(SAMPLE_RATE, CHANNEL_CONFIG, AUDIO_FORMAT)
+ audioTrack = AudioTrack(
+ AudioManager.STREAM_MUSIC, SAMPLE_RATE, CHANNEL_CONFIG, AUDIO_FORMAT, bufferSize,
+ AudioTrack.MODE_STREAM
+ )
+ audioTrack?.play()
+ }
+
+ @RequiresPermission(Manifest.permission.RECORD_AUDIO)
+ private fun initAudioRecorder() {
+ recordBufferSize =
+ AudioRecord.getMinBufferSize(SAMPLE_RATE, RECORD_CHANNEL_CONFIG, AUDIO_FORMAT)
+ audioRecord = AudioRecord(
+ android.media.MediaRecorder.AudioSource.MIC,
+ SAMPLE_RATE,
+ RECORD_CHANNEL_CONFIG,
+ AUDIO_FORMAT,
+ recordBufferSize
+ )
+ }
+
+ private fun playAudio(buffer: ByteBuffer) {
+ val data = ByteArray(buffer.remaining())
+ buffer.get(data)
+ if (audioTrack?.playState != AudioTrack.PLAYSTATE_PLAYING) {
+ audioTrack?.play()
+ }
+ audioTrack?.write(data, 0, data.size)
+ }
+
+ // ====================== 1 ======================
+ @RequiresPermission(Manifest.permission.RECORD_AUDIO)
+ private fun startRecording() {
+ if (isRecording.get()) return
+
+ if (audioRecord == null || audioRecord?.state == AudioRecord.STATE_UNINITIALIZED) {
+ initAudioRecorder()
+ }
+
+ if (audioRecord?.state == AudioRecord.STATE_INITIALIZED) {
+ audioRecord?.startRecording()
+ isRecording.set(true)
+
+ Thread {
+ val buffer = ByteArray(recordBufferSize)
+ while (isRecording.get()) {
+ val readSize = audioRecord?.read(buffer, 0, buffer.size) ?: 0
+ if (readSize > 0) {
+ val data = buffer.copyOf(readSize)
+ // translated comment
+ runOnUiThread {
+ eventSink?.success(data)
+ }
+ }
+ }
+ }.start()
+ }
+ }
+
+ private fun stopRecording() {
+ isRecording.set(false)
+ audioRecord?.stop()
+ }
+
+ // ====================== 2 ======================
+ @RequiresPermission(Manifest.permission.RECORD_AUDIO)
+ private fun methodCallHandler(call: MethodCall, result: MethodChannel.Result) {
+ when (call.method) {
+ "stopPlayPCM" -> {
+ if (audioTrack?.playState == AudioTrack.PLAYSTATE_PLAYING) {
+ audioTrack?.pause()
+ }
+ result.success(null) // translated comment
+ }
+
+ "startRecording" -> {
+ startRecording()
+ result.success(null)
+ }
+
+ "stopRecording" -> {
+ stopRecording()
+ result.success(null)
+ }
+
+ else -> result.notImplemented() // translated comment
+ }
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ audioTrack?.stop()
+ audioTrack?.release()
+ audioTrack = null
+ stopRecording()
+ audioRecord?.release()
+ audioRecord = null
+ }
+}
\ No newline at end of file
diff --git a/app/android/app/src/main/kotlin/com/m5stack/stackchan/model/DanceData.kt b/app/android/app/src/main/kotlin/com/m5stack/stackchan/model/DanceData.kt
new file mode 100644
index 0000000..907dce2
--- /dev/null
+++ b/app/android/app/src/main/kotlin/com/m5stack/stackchan/model/DanceData.kt
@@ -0,0 +1,17 @@
+/*
+SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD
+SPDX-License-Identifier: MIT
+*/
+
+package com.m5stack.stackchan.model
+
+data class DanceData(
+ var leftEye: ExpressionItem,
+ var rightEye: ExpressionItem,
+ var mouth: ExpressionItem,
+ var yawServo: MotionDataItem,
+ var pitchServo: MotionDataItem,
+ var leftRgbColor: String = "#00000000",
+ var rightRgbColor: String = "#00000000",
+ var durationMs: Int = 1000
+)
\ No newline at end of file
diff --git a/app/android/app/src/main/kotlin/com/m5stack/stackchan/model/DanceList.kt b/app/android/app/src/main/kotlin/com/m5stack/stackchan/model/DanceList.kt
new file mode 100644
index 0000000..2985a4f
--- /dev/null
+++ b/app/android/app/src/main/kotlin/com/m5stack/stackchan/model/DanceList.kt
@@ -0,0 +1,12 @@
+/*
+SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD
+SPDX-License-Identifier: MIT
+*/
+
+package com.m5stack.stackchan.model
+
+data class DanceList(
+ var danceData: MutableList?,
+ var danceIndex: Int?,
+ var danceName: String?
+)
\ No newline at end of file
diff --git a/app/android/app/src/main/kotlin/com/m5stack/stackchan/model/ExpressionData.kt b/app/android/app/src/main/kotlin/com/m5stack/stackchan/model/ExpressionData.kt
new file mode 100644
index 0000000..f2bdc31
--- /dev/null
+++ b/app/android/app/src/main/kotlin/com/m5stack/stackchan/model/ExpressionData.kt
@@ -0,0 +1,13 @@
+/*
+SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD
+SPDX-License-Identifier: MIT
+*/
+
+package com.m5stack.stackchan.model
+
+data class ExpressionData(
+ var type: String = "bleAvatar",
+ var leftEye: ExpressionItem,
+ var rightEye: ExpressionItem,
+ var mouth: ExpressionItem
+)
\ No newline at end of file
diff --git a/app/android/app/src/main/kotlin/com/m5stack/stackchan/model/ExpressionItem.kt b/app/android/app/src/main/kotlin/com/m5stack/stackchan/model/ExpressionItem.kt
new file mode 100644
index 0000000..b344929
--- /dev/null
+++ b/app/android/app/src/main/kotlin/com/m5stack/stackchan/model/ExpressionItem.kt
@@ -0,0 +1,14 @@
+/*
+SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD
+SPDX-License-Identifier: MIT
+*/
+
+package com.m5stack.stackchan.model
+
+data class ExpressionItem(
+ var x: Int = 0,
+ var y: Int = 0,
+ var rotation: Int = 0,
+ var weight: Int = 0,
+ var size: Int = 0,
+)
\ No newline at end of file
diff --git a/app/android/app/src/main/kotlin/com/m5stack/stackchan/model/MotionData.kt b/app/android/app/src/main/kotlin/com/m5stack/stackchan/model/MotionData.kt
new file mode 100644
index 0000000..3e3b697
--- /dev/null
+++ b/app/android/app/src/main/kotlin/com/m5stack/stackchan/model/MotionData.kt
@@ -0,0 +1,12 @@
+/*
+SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD
+SPDX-License-Identifier: MIT
+*/
+
+package com.m5stack.stackchan.model
+
+data class MotionData(
+ var type: String = "bleMotion",
+ var pitchServo: MotionDataItem,
+ var yawServo: MotionDataItem,
+)
diff --git a/app/android/app/src/main/kotlin/com/m5stack/stackchan/model/MotionDataItem.kt b/app/android/app/src/main/kotlin/com/m5stack/stackchan/model/MotionDataItem.kt
new file mode 100644
index 0000000..820495a
--- /dev/null
+++ b/app/android/app/src/main/kotlin/com/m5stack/stackchan/model/MotionDataItem.kt
@@ -0,0 +1,12 @@
+/*
+SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD
+SPDX-License-Identifier: MIT
+*/
+
+package com.m5stack.stackchan.model
+
+data class MotionDataItem(
+ var angle: Int = 0,
+ var speed: Int = 0,
+ var rotate: Int = 0,
+)
\ No newline at end of file
diff --git a/app/android/app/src/main/res/drawable-v21/launch_background.xml b/app/android/app/src/main/res/drawable-v21/launch_background.xml
new file mode 100644
index 0000000..f74085f
--- /dev/null
+++ b/app/android/app/src/main/res/drawable-v21/launch_background.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
diff --git a/app/android/app/src/main/res/drawable/launch_background.xml b/app/android/app/src/main/res/drawable/launch_background.xml
new file mode 100644
index 0000000..304732f
--- /dev/null
+++ b/app/android/app/src/main/res/drawable/launch_background.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
diff --git a/app/android/app/src/main/res/layout/stack_chan_robot_fragment_layout.xml b/app/android/app/src/main/res/layout/stack_chan_robot_fragment_layout.xml
new file mode 100644
index 0000000..39043c7
--- /dev/null
+++ b/app/android/app/src/main/res/layout/stack_chan_robot_fragment_layout.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 0000000..db77bb4
Binary files /dev/null and b/app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 0000000..17987b7
Binary files /dev/null and b/app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 0000000..09d4391
Binary files /dev/null and b/app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 0000000..d5f1c8d
Binary files /dev/null and b/app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/app/StackChan/Assets.xcassets/AppIcon.appiconset/app_logo.jpg b/app/android/app/src/main/res/mipmap-xxxhdpi/app_logo.jpg
similarity index 100%
rename from app/StackChan/Assets.xcassets/AppIcon.appiconset/app_logo.jpg
rename to app/android/app/src/main/res/mipmap-xxxhdpi/app_logo.jpg
diff --git a/app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 0000000..4d6372e
Binary files /dev/null and b/app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/app/android/app/src/main/res/raw/stack_chan_model.glb b/app/android/app/src/main/res/raw/stack_chan_model.glb
new file mode 100644
index 0000000..d4042d6
Binary files /dev/null and b/app/android/app/src/main/res/raw/stack_chan_model.glb differ
diff --git a/app/android/app/src/main/res/values-night/styles.xml b/app/android/app/src/main/res/values-night/styles.xml
new file mode 100644
index 0000000..06952be
--- /dev/null
+++ b/app/android/app/src/main/res/values-night/styles.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
diff --git a/app/android/app/src/main/res/values/styles.xml b/app/android/app/src/main/res/values/styles.xml
new file mode 100644
index 0000000..9945555
--- /dev/null
+++ b/app/android/app/src/main/res/values/styles.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
diff --git a/app/android/build.gradle.kts b/app/android/build.gradle.kts
new file mode 100644
index 0000000..07dd865
--- /dev/null
+++ b/app/android/build.gradle.kts
@@ -0,0 +1,32 @@
+/*
+SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD
+SPDX-License-Identifier: MIT
+*/
+
+import com.android.build.gradle.BaseExtension
+
+allprojects {
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+val newBuildDir: Directory =
+ rootProject.layout.buildDirectory
+ .dir("../../build")
+ .get()
+rootProject.layout.buildDirectory.value(newBuildDir)
+
+subprojects {
+ val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
+ project.layout.buildDirectory.value(newSubprojectBuildDir)
+}
+
+subprojects {
+ project.evaluationDependsOn(":app")
+}
+
+tasks.register("clean") {
+ delete(rootProject.layout.buildDirectory)
+}
diff --git a/app/android/gradle.properties b/app/android/gradle.properties
new file mode 100644
index 0000000..df9fb4c
--- /dev/null
+++ b/app/android/gradle.properties
@@ -0,0 +1,14 @@
+# 原有JVM和AndroidX配置(保留不变)
+org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
+android.useAndroidX=true
+
+# 新增的代理配置(127.0.0.1:7897)
+# HTTP代理
+systemProp.http.proxyHost=127.0.0.1
+systemProp.http.proxyPort=7897
+# HTTPS代理(必须配置,Android依赖多为HTTPS)
+systemProp.https.proxyHost=127.0.0.1
+systemProp.https.proxyPort=7897
+# 可选:无需走代理的地址(本地/内网地址,按需调整)
+systemProp.http.nonProxyHosts=localhost,127.0.0.1,192.168.*,*.local
+systemProp.https.nonProxyHosts=localhost,127.0.0.1,192.168.*,*.local
\ No newline at end of file
diff --git a/app/android/gradle/wrapper/gradle-wrapper.jar b/app/android/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..13372ae
Binary files /dev/null and b/app/android/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/app/android/gradle/wrapper/gradle-wrapper.properties b/app/android/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..e4ef43f
--- /dev/null
+++ b/app/android/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,5 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip
diff --git a/app/android/gradlew b/app/android/gradlew
new file mode 100755
index 0000000..9d82f78
--- /dev/null
+++ b/app/android/gradlew
@@ -0,0 +1,160 @@
+#!/usr/bin/env bash
+
+##############################################################################
+##
+## Gradle start up script for UN*X
+##
+##############################################################################
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS=""
+
+APP_NAME="Gradle"
+APP_BASE_NAME=`basename "$0"`
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD="maximum"
+
+warn ( ) {
+ echo "$*"
+}
+
+die ( ) {
+ echo
+ echo "$*"
+ echo
+ exit 1
+}
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+case "`uname`" in
+ CYGWIN* )
+ cygwin=true
+ ;;
+ Darwin* )
+ darwin=true
+ ;;
+ MINGW* )
+ msys=true
+ ;;
+esac
+
+# Attempt to set APP_HOME
+# Resolve links: $0 may be a link
+PRG="$0"
+# Need this for relative symlinks.
+while [ -h "$PRG" ] ; do
+ ls=`ls -ld "$PRG"`
+ link=`expr "$ls" : '.*-> \(.*\)$'`
+ if expr "$link" : '/.*' > /dev/null; then
+ PRG="$link"
+ else
+ PRG=`dirname "$PRG"`"/$link"
+ fi
+done
+SAVED="`pwd`"
+cd "`dirname \"$PRG\"`/" >/dev/null
+APP_HOME="`pwd -P`"
+cd "$SAVED" >/dev/null
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD="$JAVA_HOME/jre/sh/java"
+ else
+ JAVACMD="$JAVA_HOME/bin/java"
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD="java"
+ which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+fi
+
+# Increase the maximum file descriptors if we can.
+if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then
+ MAX_FD_LIMIT=`ulimit -H -n`
+ if [ $? -eq 0 ] ; then
+ if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
+ MAX_FD="$MAX_FD_LIMIT"
+ fi
+ ulimit -n $MAX_FD
+ if [ $? -ne 0 ] ; then
+ warn "Could not set maximum file descriptor limit: $MAX_FD"
+ fi
+ else
+ warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
+ fi
+fi
+
+# For Darwin, add options to specify how the application appears in the dock
+if $darwin; then
+ GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
+fi
+
+# For Cygwin, switch paths to Windows format before running java
+if $cygwin ; then
+ APP_HOME=`cygpath --path --mixed "$APP_HOME"`
+ CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
+ JAVACMD=`cygpath --unix "$JAVACMD"`
+
+ # We build the pattern for arguments to be converted via cygpath
+ ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
+ SEP=""
+ for dir in $ROOTDIRSRAW ; do
+ ROOTDIRS="$ROOTDIRS$SEP$dir"
+ SEP="|"
+ done
+ OURCYGPATTERN="(^($ROOTDIRS))"
+ # Add a user-defined pattern to the cygpath arguments
+ if [ "$GRADLE_CYGPATTERN" != "" ] ; then
+ OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
+ fi
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ i=0
+ for arg in "$@" ; do
+ CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
+ CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
+
+ if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
+ eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
+ else
+ eval `echo args$i`="\"$arg\""
+ fi
+ i=$((i+1))
+ done
+ case $i in
+ (0) set -- ;;
+ (1) set -- "$args0" ;;
+ (2) set -- "$args0" "$args1" ;;
+ (3) set -- "$args0" "$args1" "$args2" ;;
+ (4) set -- "$args0" "$args1" "$args2" "$args3" ;;
+ (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
+ (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
+ (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
+ (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
+ (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
+ esac
+fi
+
+# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules
+function splitJvmOpts() {
+ JVM_OPTS=("$@")
+}
+eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS
+JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME"
+
+exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@"
diff --git a/app/android/gradlew.bat b/app/android/gradlew.bat
new file mode 100644
index 0000000..8a0b282
--- /dev/null
+++ b/app/android/gradlew.bat
@@ -0,0 +1,90 @@
+@if "%DEBUG%" == "" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS=
+
+set DIRNAME=%~dp0
+if "%DIRNAME%" == "" set DIRNAME=.
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if "%ERRORLEVEL%" == "0" goto init
+
+echo.
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto init
+
+echo.
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:init
+@rem Get command-line arguments, handling Windowz variants
+
+if not "%OS%" == "Windows_NT" goto win9xME_args
+if "%@eval[2+2]" == "4" goto 4NT_args
+
+:win9xME_args
+@rem Slurp the command line arguments.
+set CMD_LINE_ARGS=
+set _SKIP=2
+
+:win9xME_args_slurp
+if "x%~1" == "x" goto execute
+
+set CMD_LINE_ARGS=%*
+goto execute
+
+:4NT_args
+@rem Get arguments from the 4NT Shell from JP Software
+set CMD_LINE_ARGS=%$
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
+
+:end
+@rem End local scope for the variables with windows NT shell
+if "%ERRORLEVEL%"=="0" goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
+exit /b 1
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/app/android/local.properties b/app/android/local.properties
new file mode 100644
index 0000000..f96ad9a
--- /dev/null
+++ b/app/android/local.properties
@@ -0,0 +1,5 @@
+sdk.dir=/Users/m5stack/Library/Android/sdk
+flutter.sdk=/Users/m5stack/flutter
+flutter.buildMode=release
+flutter.versionName=1.1.0
+flutter.versionCode=6
\ No newline at end of file
diff --git a/app/android/settings.gradle.kts b/app/android/settings.gradle.kts
new file mode 100644
index 0000000..6ab688e
--- /dev/null
+++ b/app/android/settings.gradle.kts
@@ -0,0 +1,31 @@
+/*
+SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD
+SPDX-License-Identifier: MIT
+*/
+
+pluginManagement {
+ val flutterSdkPath =
+ run {
+ val properties = java.util.Properties()
+ file("local.properties").inputStream().use { properties.load(it) }
+ val flutterSdkPath = properties.getProperty("flutter.sdk")
+ require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
+ flutterSdkPath
+ }
+
+ includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
+
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+
+plugins {
+ id("dev.flutter.flutter-plugin-loader") version "1.0.0"
+ id("com.android.application") version "8.13.2" apply false
+ id("org.jetbrains.kotlin.android") version "2.3.0" apply false
+}
+
+include(":app")
diff --git a/app/assets/1.circle.fill.svg b/app/assets/1.circle.fill.svg
new file mode 100644
index 0000000..10ad557
--- /dev/null
+++ b/app/assets/1.circle.fill.svg
@@ -0,0 +1,13 @@
+
+
+
+
diff --git a/app/assets/2.circle.fill.svg b/app/assets/2.circle.fill.svg
new file mode 100644
index 0000000..ec11586
--- /dev/null
+++ b/app/assets/2.circle.fill.svg
@@ -0,0 +1,13 @@
+
+
+
+
diff --git a/app/assets/3.circle.fill.svg b/app/assets/3.circle.fill.svg
new file mode 100644
index 0000000..22d9c6c
--- /dev/null
+++ b/app/assets/3.circle.fill.svg
@@ -0,0 +1,13 @@
+
+
+
+
diff --git a/app/assets/arrow.down.circle.svg b/app/assets/arrow.down.circle.svg
new file mode 100644
index 0000000..0b12900
--- /dev/null
+++ b/app/assets/arrow.down.circle.svg
@@ -0,0 +1,15 @@
+
+
+
+
diff --git a/app/assets/arrow.up.and.down.and.arrow.left.and.right.svg b/app/assets/arrow.up.and.down.and.arrow.left.and.right.svg
new file mode 100644
index 0000000..5d8a871
--- /dev/null
+++ b/app/assets/arrow.up.and.down.and.arrow.left.and.right.svg
@@ -0,0 +1,11 @@
+
+
+
+
diff --git a/app/assets/avatar_icon.png b/app/assets/avatar_icon.png
new file mode 100644
index 0000000..3b0f2d8
Binary files /dev/null and b/app/assets/avatar_icon.png differ
diff --git a/app/assets/bubble.left.and.bubble.right.fill.svg b/app/assets/bubble.left.and.bubble.right.fill.svg
new file mode 100644
index 0000000..64eb8d6
--- /dev/null
+++ b/app/assets/bubble.left.and.bubble.right.fill.svg
@@ -0,0 +1,15 @@
+
+
+
+
diff --git a/app/assets/character.svg b/app/assets/character.svg
new file mode 100644
index 0000000..dd11a6f
--- /dev/null
+++ b/app/assets/character.svg
@@ -0,0 +1,13 @@
+
+
+
+
diff --git a/app/assets/checkmark.svg b/app/assets/checkmark.svg
new file mode 100644
index 0000000..936fed1
--- /dev/null
+++ b/app/assets/checkmark.svg
@@ -0,0 +1,13 @@
+
+
+
+
diff --git a/app/assets/chevron.right.svg b/app/assets/chevron.right.svg
new file mode 100644
index 0000000..9d6cee3
--- /dev/null
+++ b/app/assets/chevron.right.svg
@@ -0,0 +1,13 @@
+
+
+
+
diff --git a/app/assets/ellipsis.svg b/app/assets/ellipsis.svg
new file mode 100644
index 0000000..d87401d
--- /dev/null
+++ b/app/assets/ellipsis.svg
@@ -0,0 +1,17 @@
+
+
+
+
diff --git a/app/assets/exclamationmark.arrow.trianglehead.2.clockwise.rotate.90.svg b/app/assets/exclamationmark.arrow.trianglehead.2.clockwise.rotate.90.svg
new file mode 100644
index 0000000..6e1676d
--- /dev/null
+++ b/app/assets/exclamationmark.arrow.trianglehead.2.clockwise.rotate.90.svg
@@ -0,0 +1,12 @@
+
+
+
+
diff --git a/app/assets/figure.dance.svg b/app/assets/figure.dance.svg
new file mode 100644
index 0000000..e971c62
--- /dev/null
+++ b/app/assets/figure.dance.svg
@@ -0,0 +1,13 @@
+
+
+
+
diff --git a/app/assets/flashlight.off.fill.svg b/app/assets/flashlight.off.fill.svg
new file mode 100644
index 0000000..f8df2a4
--- /dev/null
+++ b/app/assets/flashlight.off.fill.svg
@@ -0,0 +1,13 @@
+
+
+
+
diff --git a/app/assets/gear.svg b/app/assets/gear.svg
new file mode 100644
index 0000000..f3e52c7
--- /dev/null
+++ b/app/assets/gear.svg
@@ -0,0 +1,11 @@
+
+
+
+
diff --git a/app/StackChan/Assets.xcassets/lateral_image.imageset/7.595.png b/app/assets/image1.png
similarity index 100%
rename from app/StackChan/Assets.xcassets/lateral_image.imageset/7.595.png
rename to app/assets/image1.png
diff --git a/app/assets/iphone.gen1.badge.play.svg b/app/assets/iphone.gen1.badge.play.svg
new file mode 100644
index 0000000..2261d60
--- /dev/null
+++ b/app/assets/iphone.gen1.badge.play.svg
@@ -0,0 +1,12 @@
+
+
+
+
diff --git a/app/assets/ipod.svg b/app/assets/ipod.svg
new file mode 100644
index 0000000..080cf12
--- /dev/null
+++ b/app/assets/ipod.svg
@@ -0,0 +1,12 @@
+
+
+
+
diff --git a/app/assets/lateral_image.png b/app/assets/lateral_image.png
new file mode 100644
index 0000000..e0d36e2
Binary files /dev/null and b/app/assets/lateral_image.png differ
diff --git a/app/assets/link.svg b/app/assets/link.svg
new file mode 100644
index 0000000..b252394
--- /dev/null
+++ b/app/assets/link.svg
@@ -0,0 +1,13 @@
+
+
+
+
diff --git a/app/assets/microphone.slash.svg b/app/assets/microphone.slash.svg
new file mode 100644
index 0000000..753022a
--- /dev/null
+++ b/app/assets/microphone.slash.svg
@@ -0,0 +1,12 @@
+
+
+
+
diff --git a/app/assets/microphone.svg b/app/assets/microphone.svg
new file mode 100644
index 0000000..0b14587
--- /dev/null
+++ b/app/assets/microphone.svg
@@ -0,0 +1,11 @@
+
+
+
+
diff --git a/app/assets/music.note.svg b/app/assets/music.note.svg
new file mode 100644
index 0000000..112a6c2
--- /dev/null
+++ b/app/assets/music.note.svg
@@ -0,0 +1,13 @@
+
+
+
+
diff --git a/app/assets/network.badge.shield.half.filled.svg b/app/assets/network.badge.shield.half.filled.svg
new file mode 100644
index 0000000..2b6b468
--- /dev/null
+++ b/app/assets/network.badge.shield.half.filled.svg
@@ -0,0 +1,15 @@
+
+
+
+
diff --git a/app/assets/pano.svg b/app/assets/pano.svg
new file mode 100644
index 0000000..e9f3681
--- /dev/null
+++ b/app/assets/pano.svg
@@ -0,0 +1,11 @@
+
+
+
+
diff --git a/app/assets/pencil.circle.svg b/app/assets/pencil.circle.svg
new file mode 100644
index 0000000..21861e3
--- /dev/null
+++ b/app/assets/pencil.circle.svg
@@ -0,0 +1,15 @@
+
+
+
+
diff --git a/app/assets/pencil.svg b/app/assets/pencil.svg
new file mode 100644
index 0000000..058857c
--- /dev/null
+++ b/app/assets/pencil.svg
@@ -0,0 +1,13 @@
+
+
+
+
diff --git a/app/assets/person.3.svg b/app/assets/person.3.svg
new file mode 100644
index 0000000..92429f8
--- /dev/null
+++ b/app/assets/person.3.svg
@@ -0,0 +1,13 @@
+
+
+
+
diff --git a/app/assets/person.svg b/app/assets/person.svg
new file mode 100644
index 0000000..55b6a4d
--- /dev/null
+++ b/app/assets/person.svg
@@ -0,0 +1,13 @@
+
+
+
+
diff --git a/app/assets/personalhotspot.slash.svg b/app/assets/personalhotspot.slash.svg
new file mode 100644
index 0000000..ccd2db7
--- /dev/null
+++ b/app/assets/personalhotspot.slash.svg
@@ -0,0 +1,12 @@
+
+
+
+
diff --git a/app/assets/play.fill.svg b/app/assets/play.fill.svg
new file mode 100644
index 0000000..9f04189
--- /dev/null
+++ b/app/assets/play.fill.svg
@@ -0,0 +1,13 @@
+
+
+
+
diff --git a/app/assets/plus.app.svg b/app/assets/plus.app.svg
new file mode 100644
index 0000000..dc4592c
--- /dev/null
+++ b/app/assets/plus.app.svg
@@ -0,0 +1,12 @@
+
+
+
+
diff --git a/app/assets/plus.circle.svg b/app/assets/plus.circle.svg
new file mode 100644
index 0000000..f62493f
--- /dev/null
+++ b/app/assets/plus.circle.svg
@@ -0,0 +1,15 @@
+
+
+
+
diff --git a/app/assets/plus.svg b/app/assets/plus.svg
new file mode 100644
index 0000000..e55a962
--- /dev/null
+++ b/app/assets/plus.svg
@@ -0,0 +1,13 @@
+
+
+
+
diff --git a/app/assets/questionmark.bubble.svg b/app/assets/questionmark.bubble.svg
new file mode 100644
index 0000000..4156b59
--- /dev/null
+++ b/app/assets/questionmark.bubble.svg
@@ -0,0 +1,15 @@
+
+
+
+
diff --git a/app/assets/record.circle.fill.svg b/app/assets/record.circle.fill.svg
new file mode 100644
index 0000000..eda2b28
--- /dev/null
+++ b/app/assets/record.circle.fill.svg
@@ -0,0 +1,13 @@
+
+
+
+
diff --git a/app/assets/rectangle.badge.sparkles.fill.svg b/app/assets/rectangle.badge.sparkles.fill.svg
new file mode 100644
index 0000000..e0c3fd3
--- /dev/null
+++ b/app/assets/rectangle.badge.sparkles.fill.svg
@@ -0,0 +1,12 @@
+
+
+
+
diff --git a/app/assets/repeat.1.svg b/app/assets/repeat.1.svg
new file mode 100644
index 0000000..dbae93c
--- /dev/null
+++ b/app/assets/repeat.1.svg
@@ -0,0 +1,12 @@
+
+
+
+
diff --git a/app/assets/repeat.svg b/app/assets/repeat.svg
new file mode 100644
index 0000000..83995e7
--- /dev/null
+++ b/app/assets/repeat.svg
@@ -0,0 +1,15 @@
+
+
+
+
diff --git a/app/assets/sensor.svg b/app/assets/sensor.svg
new file mode 100644
index 0000000..f69a213
--- /dev/null
+++ b/app/assets/sensor.svg
@@ -0,0 +1,13 @@
+
+
+
+
diff --git a/app/assets/setup1.mov b/app/assets/setup1.mov
new file mode 100644
index 0000000..e1eb874
Binary files /dev/null and b/app/assets/setup1.mov differ
diff --git a/app/assets/setup2.mov b/app/assets/setup2.mov
new file mode 100644
index 0000000..001235e
Binary files /dev/null and b/app/assets/setup2.mov differ
diff --git a/app/assets/shuffle.svg b/app/assets/shuffle.svg
new file mode 100644
index 0000000..215c832
--- /dev/null
+++ b/app/assets/shuffle.svg
@@ -0,0 +1,13 @@
+
+
+
+
diff --git a/app/assets/slash.circle.svg b/app/assets/slash.circle.svg
new file mode 100644
index 0000000..214ae47
--- /dev/null
+++ b/app/assets/slash.circle.svg
@@ -0,0 +1,15 @@
+
+
+
+
diff --git a/app/assets/stack_chan_model.glb b/app/assets/stack_chan_model.glb
new file mode 100644
index 0000000..d4042d6
Binary files /dev/null and b/app/assets/stack_chan_model.glb differ
diff --git a/app/assets/stop.circle.fill.svg b/app/assets/stop.circle.fill.svg
new file mode 100644
index 0000000..7c1fb92
--- /dev/null
+++ b/app/assets/stop.circle.fill.svg
@@ -0,0 +1,13 @@
+
+
+
+
diff --git a/app/assets/stop.fill.svg b/app/assets/stop.fill.svg
new file mode 100644
index 0000000..68a0b4d
--- /dev/null
+++ b/app/assets/stop.fill.svg
@@ -0,0 +1,13 @@
+
+
+
+
diff --git a/app/assets/video.svg b/app/assets/video.svg
new file mode 100644
index 0000000..8a38241
--- /dev/null
+++ b/app/assets/video.svg
@@ -0,0 +1,11 @@
+
+
+
+
diff --git a/app/assets/viewfinder.svg b/app/assets/viewfinder.svg
new file mode 100644
index 0000000..eae96b8
--- /dev/null
+++ b/app/assets/viewfinder.svg
@@ -0,0 +1,13 @@
+
+
+
+
diff --git a/app/assets/wifi.svg b/app/assets/wifi.svg
new file mode 100644
index 0000000..61e0cd0
--- /dev/null
+++ b/app/assets/wifi.svg
@@ -0,0 +1,17 @@
+
+
+
+
diff --git a/app/assets/xmark.svg b/app/assets/xmark.svg
new file mode 100644
index 0000000..54d029f
--- /dev/null
+++ b/app/assets/xmark.svg
@@ -0,0 +1,13 @@
+
+
+
+
diff --git a/app/devtools_options.yaml b/app/devtools_options.yaml
new file mode 100644
index 0000000..fa0b357
--- /dev/null
+++ b/app/devtools_options.yaml
@@ -0,0 +1,3 @@
+description: This file stores settings for Dart & Flutter DevTools.
+documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
+extensions:
diff --git a/app/ios/.gitignore b/app/ios/.gitignore
new file mode 100644
index 0000000..7a7f987
--- /dev/null
+++ b/app/ios/.gitignore
@@ -0,0 +1,34 @@
+**/dgph
+*.mode1v3
+*.mode2v3
+*.moved-aside
+*.pbxuser
+*.perspectivev3
+**/*sync/
+.sconsign.dblite
+.tags*
+**/.vagrant/
+**/DerivedData/
+Icon?
+**/Pods/
+**/.symlinks/
+profile
+xcuserdata
+**/.generated/
+Flutter/App.framework
+Flutter/Flutter.framework
+Flutter/Flutter.podspec
+Flutter/Generated.xcconfig
+Flutter/ephemeral/
+Flutter/app.flx
+Flutter/app.zip
+Flutter/flutter_assets/
+Flutter/flutter_export_environment.sh
+ServiceDefinitions.json
+Runner/GeneratedPluginRegistrant.*
+
+# Exceptions to above rules.
+!default.mode1v3
+!default.mode2v3
+!default.pbxuser
+!default.perspectivev3
diff --git a/app/ios/Flutter/AppFrameworkInfo.plist b/app/ios/Flutter/AppFrameworkInfo.plist
new file mode 100644
index 0000000..391a902
--- /dev/null
+++ b/app/ios/Flutter/AppFrameworkInfo.plist
@@ -0,0 +1,24 @@
+
+
+
+
+ CFBundleDevelopmentRegion
+ en
+ CFBundleExecutable
+ App
+ CFBundleIdentifier
+ io.flutter.flutter.app
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ App
+ CFBundlePackageType
+ FMWK
+ CFBundleShortVersionString
+ 1.0
+ CFBundleSignature
+ ????
+ CFBundleVersion
+ 1.0
+
+
diff --git a/app/ios/Flutter/Debug.xcconfig b/app/ios/Flutter/Debug.xcconfig
new file mode 100644
index 0000000..ec97fc6
--- /dev/null
+++ b/app/ios/Flutter/Debug.xcconfig
@@ -0,0 +1,2 @@
+#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
+#include "Generated.xcconfig"
diff --git a/app/ios/Flutter/Release.xcconfig b/app/ios/Flutter/Release.xcconfig
new file mode 100644
index 0000000..c4855bf
--- /dev/null
+++ b/app/ios/Flutter/Release.xcconfig
@@ -0,0 +1,2 @@
+#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
+#include "Generated.xcconfig"
diff --git a/app/ios/Podfile b/app/ios/Podfile
new file mode 100644
index 0000000..2353998
--- /dev/null
+++ b/app/ios/Podfile
@@ -0,0 +1,60 @@
+source 'https://mirrors.tuna.tsinghua.edu.cn/git/CocoaPods/Specs.git'
+
+# Uncomment this line to define a global platform for your project
+platform :ios, '16.6'
+
+$iOSVersion = '16.6'
+
+# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
+ENV['COCOAPODS_DISABLE_STATS'] = 'true'
+
+project 'Runner', {
+ 'Debug' => :debug,
+ 'Profile' => :release,
+ 'Release' => :release,
+}
+
+def flutter_root
+ generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__)
+ unless File.exist?(generated_xcode_build_settings_path)
+ raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first"
+ end
+
+ File.foreach(generated_xcode_build_settings_path) do |line|
+ matches = line.match(/FLUTTER_ROOT\=(.*)/)
+ return matches[1].strip if matches
+ end
+ raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get"
+end
+
+require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root)
+
+flutter_ios_podfile_setup
+
+target 'Runner' do
+ use_frameworks!
+
+ flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
+
+ target 'RunnerTests' do
+ inherit! :search_paths
+ end
+end
+
+post_install do |installer|
+ installer.pods_project.build_configurations.each do |config|
+ config.build_settings["EXCLUDED_ARCHS[sdk=*]"] = "armv7"
+ config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = $iOSVersion
+ end
+ installer.pods_project.targets.each do |target|
+ flutter_additional_ios_build_settings(target)
+ target.build_configurations.each do |config|
+ if Gem::Version.new($iOSVersion) > Gem::Version.new(config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'])
+ config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = $iOSVersion
+ end
+ end
+ target.build_configurations.each do |config|
+ config.build_settings['EXCLUDED_ARCHS[sdk=iphonesimulator*]'] = 'arm64'
+ end
+ end
+end
diff --git a/app/ios/Runner.xcodeproj/project.pbxproj b/app/ios/Runner.xcodeproj/project.pbxproj
new file mode 100644
index 0000000..8b615bb
--- /dev/null
+++ b/app/ios/Runner.xcodeproj/project.pbxproj
@@ -0,0 +1,867 @@
+// !$*UTF8*$!
+{
+ archiveVersion = 1;
+ classes = {
+ };
+ objectVersion = 54;
+ objects = {
+
+/* Begin PBXBuildFile section */
+ 0E2BFCBD2F2C51FE00E21BCA /* StackChanRobot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E2BFCBC2F2C51F600E21BCA /* StackChanRobot.swift */; };
+ 0E2BFCBF2F2C525E00E21BCA /* ViewFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E2BFCBE2F2C525400E21BCA /* ViewFactory.swift */; };
+ 0E2BFCC32F2C566600E21BCA /* DanceList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E2BFCC22F2C566600E21BCA /* DanceList.swift */; };
+ 0E2BFCC52F2C566B00E21BCA /* ExpressionData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E2BFCC42F2C566B00E21BCA /* ExpressionData.swift */; };
+ 0E2BFCC82F2C59AD00E21BCA /* Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E2BFCC72F2C59AD00E21BCA /* Extension.swift */; };
+ 0E2BFD962F2C908200E21BCA /* StackChanRotaryRobot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E2BFD952F2C907100E21BCA /* StackChanRotaryRobot.swift */; };
+ 0E319DC52F504DD600B8C136 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E319DC42F504DD600B8C136 /* SceneDelegate.swift */; };
+ 0E49C11D2F349BB300419137 /* StackChanArView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E49C11C2F349B9B00419137 /* StackChanArView.swift */; };
+ 0EF7F6292F2B40D7008CDEB0 /* NativeBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EF7F6282F2B40C6008CDEB0 /* NativeBridge.swift */; };
+ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
+ 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
+ 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
+ 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
+ 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
+ 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
+ 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
+ ABF1068D156BACD9687833B5 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 61533E6EB1B159D304737D3A /* Pods_RunnerTests.framework */; };
+ FEAAEB50E782E6F211E46BE8 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 69CC735B711452B07B9BC3CD /* Pods_Runner.framework */; };
+/* End PBXBuildFile section */
+
+/* Begin PBXContainerItemProxy section */
+ 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = {
+ isa = PBXContainerItemProxy;
+ containerPortal = 97C146E61CF9000F007C117D /* Project object */;
+ proxyType = 1;
+ remoteGlobalIDString = 97C146ED1CF9000F007C117D;
+ remoteInfo = Runner;
+ };
+/* End PBXContainerItemProxy section */
+
+/* Begin PBXFileReference section */
+ 014B92591A4905CFCFF58C45 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; };
+ 0E2BFCBC2F2C51F600E21BCA /* StackChanRobot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StackChanRobot.swift; sourceTree = ""; };
+ 0E2BFCBE2F2C525400E21BCA /* ViewFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewFactory.swift; sourceTree = ""; };
+ 0E2BFCC22F2C566600E21BCA /* DanceList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DanceList.swift; sourceTree = ""; };
+ 0E2BFCC42F2C566B00E21BCA /* ExpressionData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExpressionData.swift; sourceTree = ""; };
+ 0E2BFCC72F2C59AD00E21BCA /* Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Extension.swift; sourceTree = ""; };
+ 0E2BFD952F2C907100E21BCA /* StackChanRotaryRobot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StackChanRotaryRobot.swift; sourceTree = ""; };
+ 0E319DC42F504DD600B8C136 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; };
+ 0E49C11C2F349B9B00419137 /* StackChanArView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StackChanArView.swift; sourceTree = ""; };
+ 0EF7F6282F2B40C6008CDEB0 /* NativeBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeBridge.swift; sourceTree = ""; };
+ 0EF7F7242F2B4498008CDEB0 /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = ""; };
+ 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; };
+ 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; };
+ 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; };
+ 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
+ 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; };
+ 61533E6EB1B159D304737D3A /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
+ 69CC735B711452B07B9BC3CD /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
+ 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; };
+ 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; };
+ 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; };
+ 83DB239E5DAC7976B8DC7B7A /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; };
+ 86F9532C8B82DB90BB2B967F /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; };
+ 8D86E24B4CB62314C14A6BF4 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; };
+ 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; };
+ 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; };
+ 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
+ 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; };
+ 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; };
+ 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; };
+ 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; explicitFileType = text.xml; path = Info.plist; sourceTree = ""; };
+ 99E681E7B6E33A054304AA9A /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; };
+ D9756F4DAC76C779F6DADE83 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; };
+/* End PBXFileReference section */
+
+/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
+ 0E2BFD5D2F2C6AD200E21BCA /* Exceptions for "3DModel" folder in "Runner" target */ = {
+ isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
+ membershipExceptions = (
+ StackChanModel.scn,
+ );
+ target = 97C146ED1CF9000F007C117D /* Runner */;
+ };
+ 0E2BFD5E2F2C6AD200E21BCA /* Exceptions for "3DModel" folder in "RunnerTests" target */ = {
+ isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
+ membershipExceptions = (
+ StackChanModel.scn,
+ );
+ target = 331C8080294A63A400263BE5 /* RunnerTests */;
+ };
+/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
+
+/* Begin PBXFileSystemSynchronizedRootGroup section */
+ 0E2BFCC12F2C538400E21BCA /* 3DModel */ = {
+ isa = PBXFileSystemSynchronizedRootGroup;
+ exceptions = (
+ 0E2BFD5D2F2C6AD200E21BCA /* Exceptions for "3DModel" folder in "Runner" target */,
+ 0E2BFD5E2F2C6AD200E21BCA /* Exceptions for "3DModel" folder in "RunnerTests" target */,
+ );
+ explicitFileTypes = {
+ };
+ explicitFolders = (
+ );
+ path = 3DModel;
+ sourceTree = "";
+ };
+/* End PBXFileSystemSynchronizedRootGroup section */
+
+/* Begin PBXFrameworksBuildPhase section */
+ 15389107E87A87969889C15C /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ ABF1068D156BACD9687833B5 /* Pods_RunnerTests.framework in Frameworks */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 97C146EB1CF9000F007C117D /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ FEAAEB50E782E6F211E46BE8 /* Pods_Runner.framework in Frameworks */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXFrameworksBuildPhase section */
+
+/* Begin PBXGroup section */
+ 0E2BFCBB2F2C51EB00E21BCA /* View */ = {
+ isa = PBXGroup;
+ children = (
+ 0E49C11C2F349B9B00419137 /* StackChanArView.swift */,
+ 0E2BFD952F2C907100E21BCA /* StackChanRotaryRobot.swift */,
+ 0E2BFCBE2F2C525400E21BCA /* ViewFactory.swift */,
+ 0E2BFCBC2F2C51F600E21BCA /* StackChanRobot.swift */,
+ );
+ path = View;
+ sourceTree = "";
+ };
+ 0E2BFCC62F2C599E00E21BCA /* Utils */ = {
+ isa = PBXGroup;
+ children = (
+ 0E2BFCC72F2C59AD00E21BCA /* Extension.swift */,
+ 0EF7F6282F2B40C6008CDEB0 /* NativeBridge.swift */,
+ );
+ path = Utils;
+ sourceTree = "";
+ };
+ 0EF7F6242F2B3D35008CDEB0 /* Model */ = {
+ isa = PBXGroup;
+ children = (
+ 0E2BFCC42F2C566B00E21BCA /* ExpressionData.swift */,
+ 0E2BFCC22F2C566600E21BCA /* DanceList.swift */,
+ );
+ path = Model;
+ sourceTree = "";
+ };
+ 331C8082294A63A400263BE5 /* RunnerTests */ = {
+ isa = PBXGroup;
+ children = (
+ 331C807B294A618700263BE5 /* RunnerTests.swift */,
+ );
+ path = RunnerTests;
+ sourceTree = "";
+ };
+ 6A84775745706C15EDCCC2AC /* Frameworks */ = {
+ isa = PBXGroup;
+ children = (
+ 69CC735B711452B07B9BC3CD /* Pods_Runner.framework */,
+ 61533E6EB1B159D304737D3A /* Pods_RunnerTests.framework */,
+ );
+ name = Frameworks;
+ sourceTree = "";
+ };
+ 9740EEB11CF90186004384FC /* Flutter */ = {
+ isa = PBXGroup;
+ children = (
+ 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
+ 9740EEB21CF90195004384FC /* Debug.xcconfig */,
+ 7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
+ 9740EEB31CF90195004384FC /* Generated.xcconfig */,
+ );
+ name = Flutter;
+ sourceTree = "";
+ };
+ 97C146E51CF9000F007C117D = {
+ isa = PBXGroup;
+ children = (
+ 9740EEB11CF90186004384FC /* Flutter */,
+ 97C146F01CF9000F007C117D /* Runner */,
+ 97C146EF1CF9000F007C117D /* Products */,
+ 331C8082294A63A400263BE5 /* RunnerTests */,
+ F9C6E422CF624A714D6A795D /* Pods */,
+ 6A84775745706C15EDCCC2AC /* Frameworks */,
+ );
+ sourceTree = "";
+ };
+ 97C146EF1CF9000F007C117D /* Products */ = {
+ isa = PBXGroup;
+ children = (
+ 97C146EE1CF9000F007C117D /* Runner.app */,
+ 331C8081294A63A400263BE5 /* RunnerTests.xctest */,
+ );
+ name = Products;
+ sourceTree = "";
+ };
+ 97C146F01CF9000F007C117D /* Runner */ = {
+ isa = PBXGroup;
+ children = (
+ 0E319DC42F504DD600B8C136 /* SceneDelegate.swift */,
+ 0E2BFCC62F2C599E00E21BCA /* Utils */,
+ 0E2BFCC12F2C538400E21BCA /* 3DModel */,
+ 0E2BFCBB2F2C51EB00E21BCA /* View */,
+ 0EF7F7242F2B4498008CDEB0 /* Runner.entitlements */,
+ 0EF7F6242F2B3D35008CDEB0 /* Model */,
+ 97C146FA1CF9000F007C117D /* Main.storyboard */,
+ 97C146FD1CF9000F007C117D /* Assets.xcassets */,
+ 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,
+ 97C147021CF9000F007C117D /* Info.plist */,
+ 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
+ 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
+ 74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
+ 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
+ );
+ path = Runner;
+ sourceTree = "";
+ };
+ F9C6E422CF624A714D6A795D /* Pods */ = {
+ isa = PBXGroup;
+ children = (
+ 86F9532C8B82DB90BB2B967F /* Pods-Runner.debug.xcconfig */,
+ 014B92591A4905CFCFF58C45 /* Pods-Runner.release.xcconfig */,
+ 83DB239E5DAC7976B8DC7B7A /* Pods-Runner.profile.xcconfig */,
+ D9756F4DAC76C779F6DADE83 /* Pods-RunnerTests.debug.xcconfig */,
+ 99E681E7B6E33A054304AA9A /* Pods-RunnerTests.release.xcconfig */,
+ 8D86E24B4CB62314C14A6BF4 /* Pods-RunnerTests.profile.xcconfig */,
+ );
+ path = Pods;
+ sourceTree = "";
+ };
+/* End PBXGroup section */
+
+/* Begin PBXNativeTarget section */
+ 331C8080294A63A400263BE5 /* RunnerTests */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
+ buildPhases = (
+ 06679B67EF6ED2EE02C6AE1F /* [CP] Check Pods Manifest.lock */,
+ 331C807D294A63A400263BE5 /* Sources */,
+ 331C807F294A63A400263BE5 /* Resources */,
+ 15389107E87A87969889C15C /* Frameworks */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ 331C8086294A63A400263BE5 /* PBXTargetDependency */,
+ );
+ name = RunnerTests;
+ productName = RunnerTests;
+ productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */;
+ productType = "com.apple.product-type.bundle.unit-test";
+ };
+ 97C146ED1CF9000F007C117D /* Runner */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
+ buildPhases = (
+ 6B1F6EB6486B9A29A8A7AE57 /* [CP] Check Pods Manifest.lock */,
+ 9740EEB61CF901F6004384FC /* Run Script */,
+ 97C146EA1CF9000F007C117D /* Sources */,
+ 97C146EB1CF9000F007C117D /* Frameworks */,
+ 97C146EC1CF9000F007C117D /* Resources */,
+ 3B06AD1E1E4923F5004D2608 /* Thin Binary */,
+ BCD9052275896B9385CAEC4A /* [CP] Embed Pods Frameworks */,
+ 62354403C2A37F66E2DA6570 /* [CP] Copy Pods Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ );
+ name = Runner;
+ productName = Runner;
+ productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
+ productType = "com.apple.product-type.application";
+ };
+/* End PBXNativeTarget section */
+
+/* Begin PBXProject section */
+ 97C146E61CF9000F007C117D /* Project object */ = {
+ isa = PBXProject;
+ attributes = {
+ BuildIndependentTargetsInParallel = YES;
+ LastUpgradeCheck = 1510;
+ ORGANIZATIONNAME = "";
+ TargetAttributes = {
+ 331C8080294A63A400263BE5 = {
+ CreatedOnToolsVersion = 14.0;
+ TestTargetID = 97C146ED1CF9000F007C117D;
+ };
+ 97C146ED1CF9000F007C117D = {
+ CreatedOnToolsVersion = 7.3.1;
+ LastSwiftMigration = 1100;
+ };
+ };
+ };
+ buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */;
+ compatibilityVersion = "Xcode 9.3";
+ developmentRegion = en;
+ hasScannedForEncodings = 0;
+ knownRegions = (
+ en,
+ Base,
+ );
+ mainGroup = 97C146E51CF9000F007C117D;
+ productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
+ projectDirPath = "";
+ projectRoot = "";
+ targets = (
+ 97C146ED1CF9000F007C117D /* Runner */,
+ 331C8080294A63A400263BE5 /* RunnerTests */,
+ );
+ };
+/* End PBXProject section */
+
+/* Begin PBXResourcesBuildPhase section */
+ 331C807F294A63A400263BE5 /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 97C146EC1CF9000F007C117D /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */,
+ 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */,
+ 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,
+ 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXResourcesBuildPhase section */
+
+/* Begin PBXShellScriptBuildPhase section */
+ 06679B67EF6ED2EE02C6AE1F /* [CP] Check Pods Manifest.lock */ = {
+ isa = PBXShellScriptBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputFileListPaths = (
+ );
+ inputPaths = (
+ "${PODS_PODFILE_DIR_PATH}/Podfile.lock",
+ "${PODS_ROOT}/Manifest.lock",
+ );
+ name = "[CP] Check Pods Manifest.lock";
+ outputFileListPaths = (
+ );
+ outputPaths = (
+ "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt",
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
+ showEnvVarsInLog = 0;
+ };
+ 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
+ isa = PBXShellScriptBuildPhase;
+ alwaysOutOfDate = 1;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputPaths = (
+ "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}",
+ );
+ name = "Thin Binary";
+ outputPaths = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin\n";
+ };
+ 62354403C2A37F66E2DA6570 /* [CP] Copy Pods Resources */ = {
+ isa = PBXShellScriptBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputFileListPaths = (
+ "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
+ );
+ name = "[CP] Copy Pods Resources";
+ outputFileListPaths = (
+ "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
+ showEnvVarsInLog = 0;
+ };
+ 6B1F6EB6486B9A29A8A7AE57 /* [CP] Check Pods Manifest.lock */ = {
+ isa = PBXShellScriptBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputFileListPaths = (
+ );
+ inputPaths = (
+ "${PODS_PODFILE_DIR_PATH}/Podfile.lock",
+ "${PODS_ROOT}/Manifest.lock",
+ );
+ name = "[CP] Check Pods Manifest.lock";
+ outputFileListPaths = (
+ );
+ outputPaths = (
+ "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt",
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
+ showEnvVarsInLog = 0;
+ };
+ 9740EEB61CF901F6004384FC /* Run Script */ = {
+ isa = PBXShellScriptBuildPhase;
+ alwaysOutOfDate = 1;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputPaths = (
+ );
+ name = "Run Script";
+ outputPaths = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ shellScript = "export PATH=\"/opt/homebrew/bin:$PATH\"\nexport CMAKE=\"/opt/homebrew/bin/cmake\"\nexport FLUTTER_BUILD_NATIVE_ASSETS=\"false\"\n/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build\n";
+ };
+ BCD9052275896B9385CAEC4A /* [CP] Embed Pods Frameworks */ = {
+ isa = PBXShellScriptBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputFileListPaths = (
+ "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
+ );
+ name = "[CP] Embed Pods Frameworks";
+ outputFileListPaths = (
+ "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
+ showEnvVarsInLog = 0;
+ };
+/* End PBXShellScriptBuildPhase section */
+
+/* Begin PBXSourcesBuildPhase section */
+ 331C807D294A63A400263BE5 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 97C146EA1CF9000F007C117D /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 0E2BFCC82F2C59AD00E21BCA /* Extension.swift in Sources */,
+ 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
+ 0E2BFCC52F2C566B00E21BCA /* ExpressionData.swift in Sources */,
+ 0E2BFCBF2F2C525E00E21BCA /* ViewFactory.swift in Sources */,
+ 0E2BFD962F2C908200E21BCA /* StackChanRotaryRobot.swift in Sources */,
+ 0E49C11D2F349BB300419137 /* StackChanArView.swift in Sources */,
+ 0EF7F6292F2B40D7008CDEB0 /* NativeBridge.swift in Sources */,
+ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
+ 0E2BFCC32F2C566600E21BCA /* DanceList.swift in Sources */,
+ 0E2BFCBD2F2C51FE00E21BCA /* StackChanRobot.swift in Sources */,
+ 0E319DC52F504DD600B8C136 /* SceneDelegate.swift in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXSourcesBuildPhase section */
+
+/* Begin PBXTargetDependency section */
+ 331C8086294A63A400263BE5 /* PBXTargetDependency */ = {
+ isa = PBXTargetDependency;
+ target = 97C146ED1CF9000F007C117D /* Runner */;
+ targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */;
+ };
+/* End PBXTargetDependency section */
+
+/* Begin PBXVariantGroup section */
+ 97C146FA1CF9000F007C117D /* Main.storyboard */ = {
+ isa = PBXVariantGroup;
+ children = (
+ 97C146FB1CF9000F007C117D /* Base */,
+ );
+ name = Main.storyboard;
+ sourceTree = "";
+ };
+ 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = {
+ isa = PBXVariantGroup;
+ children = (
+ 97C147001CF9000F007C117D /* Base */,
+ );
+ name = LaunchScreen.storyboard;
+ sourceTree = "";
+ };
+/* End PBXVariantGroup section */
+
+/* Begin XCBuildConfiguration section */
+ 249021D3217E4FDB00AE95B9 /* Profile */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
+ CLANG_CXX_LIBRARY = "libc++";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = 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_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_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+ ENABLE_NS_ASSERTIONS = NO;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = NO;
+ EXCLUDED_ARCHS = "";
+ "EXCLUDED_ARCHS[sdk=*]" = armv7;
+ GCC_C_LANGUAGE_STANDARD = gnu99;
+ 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 = 13.0;
+ MTL_ENABLE_DEBUG_INFO = NO;
+ SDKROOT = iphoneos;
+ SUPPORTED_PLATFORMS = iphoneos;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ VALIDATE_PRODUCT = YES;
+ };
+ name = Profile;
+ };
+ 249021D4217E4FDB00AE95B9 /* Profile */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ CLANG_ENABLE_MODULES = YES;
+ CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
+ DEVELOPMENT_TEAM = NG678HLKHZ;
+ ENABLE_BITCODE = NO;
+ FRAMEWORK_SEARCH_PATHS = (
+ "$(inherited)",
+ "$(PROJECT_DIR)",
+ "$(PROJECT_DIR)/Runner",
+ );
+ INFOPLIST_FILE = Runner/Info.plist;
+ IPHONEOS_DEPLOYMENT_TARGET = 16.6;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = com.m5stack.StackChan;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
+ SUPPORTS_MACCATALYST = NO;
+ SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
+ SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ VERSIONING_SYSTEM = "apple-generic";
+ };
+ name = Profile;
+ };
+ 331C8088294A63A400263BE5 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = D9756F4DAC76C779F6DADE83 /* Pods-RunnerTests.debug.xcconfig */;
+ buildSettings = {
+ BUNDLE_LOADER = "$(TEST_HOST)";
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ GENERATE_INFOPLIST_FILE = YES;
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = com.m5stack.stackchan.RunnerTests;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
+ SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+ SWIFT_VERSION = 5.0;
+ TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
+ };
+ name = Debug;
+ };
+ 331C8089294A63A400263BE5 /* Release */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = 99E681E7B6E33A054304AA9A /* Pods-RunnerTests.release.xcconfig */;
+ buildSettings = {
+ BUNDLE_LOADER = "$(TEST_HOST)";
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ GENERATE_INFOPLIST_FILE = YES;
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = com.m5stack.stackchan.RunnerTests;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_VERSION = 5.0;
+ TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
+ };
+ name = Release;
+ };
+ 331C808A294A63A400263BE5 /* Profile */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = 8D86E24B4CB62314C14A6BF4 /* Pods-RunnerTests.profile.xcconfig */;
+ buildSettings = {
+ BUNDLE_LOADER = "$(TEST_HOST)";
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ GENERATE_INFOPLIST_FILE = YES;
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = com.m5stack.stackchan.RunnerTests;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_VERSION = 5.0;
+ TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
+ };
+ name = Profile;
+ };
+ 97C147031CF9000F007C117D /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
+ CLANG_CXX_LIBRARY = "libc++";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = 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_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_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = dwarf;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_TESTABILITY = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = NO;
+ EXCLUDED_ARCHS = "";
+ "EXCLUDED_ARCHS[sdk=*]" = armv7;
+ GCC_C_LANGUAGE_STANDARD = gnu99;
+ 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 = 13.0;
+ MTL_ENABLE_DEBUG_INFO = YES;
+ ONLY_ACTIVE_ARCH = YES;
+ SDKROOT = iphoneos;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Debug;
+ };
+ 97C147041CF9000F007C117D /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
+ CLANG_CXX_LIBRARY = "libc++";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = 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_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_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+ ENABLE_NS_ASSERTIONS = NO;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = NO;
+ EXCLUDED_ARCHS = "";
+ "EXCLUDED_ARCHS[sdk=*]" = armv7;
+ GCC_C_LANGUAGE_STANDARD = gnu99;
+ 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 = 13.0;
+ MTL_ENABLE_DEBUG_INFO = NO;
+ SDKROOT = iphoneos;
+ SUPPORTED_PLATFORMS = iphoneos;
+ SWIFT_COMPILATION_MODE = wholemodule;
+ SWIFT_OPTIMIZATION_LEVEL = "-O";
+ TARGETED_DEVICE_FAMILY = "1,2";
+ VALIDATE_PRODUCT = YES;
+ };
+ name = Release;
+ };
+ 97C147061CF9000F007C117D /* Debug */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ CLANG_ENABLE_MODULES = YES;
+ CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
+ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+ DEVELOPMENT_TEAM = NG678HLKHZ;
+ ENABLE_BITCODE = NO;
+ FRAMEWORK_SEARCH_PATHS = (
+ "$(inherited)",
+ "$(PROJECT_DIR)",
+ "$(PROJECT_DIR)/Runner",
+ );
+ INFOPLIST_FILE = Runner/Info.plist;
+ IPHONEOS_DEPLOYMENT_TARGET = 16.6;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = com.m5stack.StackChan;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
+ SUPPORTS_MACCATALYST = NO;
+ SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
+ SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
+ SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ VERSIONING_SYSTEM = "apple-generic";
+ };
+ name = Debug;
+ };
+ 97C147071CF9000F007C117D /* Release */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ CLANG_ENABLE_MODULES = YES;
+ CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
+ DEVELOPMENT_TEAM = NG678HLKHZ;
+ ENABLE_BITCODE = NO;
+ FRAMEWORK_SEARCH_PATHS = (
+ "$(inherited)",
+ "$(PROJECT_DIR)",
+ "$(PROJECT_DIR)/Runner",
+ );
+ INFOPLIST_FILE = Runner/Info.plist;
+ IPHONEOS_DEPLOYMENT_TARGET = 16.6;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = com.m5stack.StackChan;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
+ SUPPORTS_MACCATALYST = NO;
+ SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
+ SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ VERSIONING_SYSTEM = "apple-generic";
+ };
+ name = Release;
+ };
+/* End XCBuildConfiguration section */
+
+/* Begin XCConfigurationList section */
+ 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 331C8088294A63A400263BE5 /* Debug */,
+ 331C8089294A63A400263BE5 /* Release */,
+ 331C808A294A63A400263BE5 /* Profile */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 97C147031CF9000F007C117D /* Debug */,
+ 97C147041CF9000F007C117D /* Release */,
+ 249021D3217E4FDB00AE95B9 /* Profile */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 97C147061CF9000F007C117D /* Debug */,
+ 97C147071CF9000F007C117D /* Release */,
+ 249021D4217E4FDB00AE95B9 /* Profile */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+/* End XCConfigurationList section */
+ };
+ rootObject = 97C146E61CF9000F007C117D /* Project object */;
+}
diff --git a/app/StackChan.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/app/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata
similarity index 100%
rename from app/StackChan.xcodeproj/project.xcworkspace/contents.xcworkspacedata
rename to app/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata
diff --git a/app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
new file mode 100644
index 0000000..18d9810
--- /dev/null
+++ b/app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
@@ -0,0 +1,8 @@
+
+
+
+
+ IDEDidComputeMac32BitWarning
+
+
+
diff --git a/app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings
new file mode 100644
index 0000000..f9b0d7c
--- /dev/null
+++ b/app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings
@@ -0,0 +1,8 @@
+
+
+
+
+ PreviewsEnabled
+
+
+
diff --git a/app/StackChan.xcodeproj/xcshareddata/xcschemes/StackChan.xcscheme b/app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
similarity index 52%
rename from app/StackChan.xcodeproj/xcshareddata/xcschemes/StackChan.xcscheme
rename to app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
index a21a351..fa4cdb6 100644
--- a/app/StackChan.xcodeproj/xcshareddata/xcschemes/StackChan.xcscheme
+++ b/app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
@@ -1,11 +1,10 @@
+ LastUpgradeVersion = "1510"
+ version = "1.3">
+ buildImplicitDependencies = "YES">
+ BlueprintIdentifier = "97C146ED1CF9000F007C117D"
+ BuildableName = "Runner.app"
+ BlueprintName = "Runner"
+ ReferencedContainer = "container:Runner.xcodeproj">
@@ -27,32 +26,56 @@
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
- shouldUseLaunchSchemeArgsEnv = "YES"
- shouldAutocreateTestPlan = "YES">
+ customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
+ shouldUseLaunchSchemeArgsEnv = "YES">
+
+
+
+
+
+
+
+
+
+
+ BlueprintIdentifier = "97C146ED1CF9000F007C117D"
+ BuildableName = "Runner.app"
+ BlueprintName = "Runner"
+ ReferencedContainer = "container:Runner.xcodeproj">
+ BlueprintIdentifier = "97C146ED1CF9000F007C117D"
+ BuildableName = "Runner.app"
+ BlueprintName = "Runner"
+ ReferencedContainer = "container:Runner.xcodeproj">
diff --git a/app/ios/Runner.xcworkspace/contents.xcworkspacedata b/app/ios/Runner.xcworkspace/contents.xcworkspacedata
new file mode 100644
index 0000000..21a3cc1
--- /dev/null
+++ b/app/ios/Runner.xcworkspace/contents.xcworkspacedata
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
diff --git a/app/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/app/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
new file mode 100644
index 0000000..18d9810
--- /dev/null
+++ b/app/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
@@ -0,0 +1,8 @@
+
+
+
+
+ IDEDidComputeMac32BitWarning
+
+
+
diff --git a/app/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/app/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings
new file mode 100644
index 0000000..f9b0d7c
--- /dev/null
+++ b/app/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings
@@ -0,0 +1,8 @@
+
+
+
+
+ PreviewsEnabled
+
+
+
diff --git a/app/ios/Runner/3DModel/StackChanModel.scn b/app/ios/Runner/3DModel/StackChanModel.scn
new file mode 100644
index 0000000..ee2b347
Binary files /dev/null and b/app/ios/Runner/3DModel/StackChanModel.scn differ
diff --git a/app/ios/Runner/AppDelegate.swift b/app/ios/Runner/AppDelegate.swift
new file mode 100644
index 0000000..8670089
--- /dev/null
+++ b/app/ios/Runner/AppDelegate.swift
@@ -0,0 +1,22 @@
+/*
+SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD
+SPDX-License-Identifier: MIT
+*/
+
+import Flutter
+import CoreLocation
+
+@main
+@objc class AppDelegate: FlutterAppDelegate,FlutterImplicitEngineDelegate {
+
+ func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
+ GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
+ }
+
+ override func application(
+ _ application: UIApplication,
+ didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
+ ) -> Bool {
+ return super.application(application, didFinishLaunchingWithOptions: launchOptions)
+ }
+}
diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json
new file mode 100644
index 0000000..827687d
--- /dev/null
+++ b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -0,0 +1,14 @@
+{
+ "images" : [
+ {
+ "filename" : "app_logo.jpg",
+ "idiom" : "universal",
+ "platform" : "ios",
+ "size" : "1024x1024"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/app/StackChan/Assets.xcassets/logo_icon.imageset/stackChanLogo.jpg b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/app_logo.jpg
similarity index 100%
rename from app/StackChan/Assets.xcassets/logo_icon.imageset/stackChanLogo.jpg
rename to app/ios/Runner/Assets.xcassets/AppIcon.appiconset/app_logo.jpg
diff --git a/app/StackChan/Assets.xcassets/logo_icon.imageset/Contents.json b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json
similarity index 58%
rename from app/StackChan/Assets.xcassets/logo_icon.imageset/Contents.json
rename to app/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json
index 12483b1..0bedcf2 100644
--- a/app/StackChan/Assets.xcassets/logo_icon.imageset/Contents.json
+++ b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json
@@ -1,21 +1,23 @@
{
"images" : [
{
- "filename" : "stackChanLogo.jpg",
"idiom" : "universal",
+ "filename" : "LaunchImage.png",
"scale" : "1x"
},
{
"idiom" : "universal",
+ "filename" : "LaunchImage@2x.png",
"scale" : "2x"
},
{
"idiom" : "universal",
+ "filename" : "LaunchImage@3x.png",
"scale" : "3x"
}
],
"info" : {
- "author" : "xcode",
- "version" : 1
+ "version" : 1,
+ "author" : "xcode"
}
}
diff --git a/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png
new file mode 100644
index 0000000..9da19ea
Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ
diff --git a/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png
new file mode 100644
index 0000000..9da19ea
Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ
diff --git a/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png
new file mode 100644
index 0000000..9da19ea
Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ
diff --git a/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md
new file mode 100644
index 0000000..89c2725
--- /dev/null
+++ b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md
@@ -0,0 +1,5 @@
+# Launch Screen Assets
+
+You can customize the launch screen with your own desired assets by replacing the image files in this directory.
+
+You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images.
\ No newline at end of file
diff --git a/app/ios/Runner/Base.lproj/LaunchScreen.storyboard b/app/ios/Runner/Base.lproj/LaunchScreen.storyboard
new file mode 100644
index 0000000..f2e259c
--- /dev/null
+++ b/app/ios/Runner/Base.lproj/LaunchScreen.storyboard
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/ios/Runner/Base.lproj/Main.storyboard b/app/ios/Runner/Base.lproj/Main.storyboard
new file mode 100644
index 0000000..08eb481
--- /dev/null
+++ b/app/ios/Runner/Base.lproj/Main.storyboard
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/ios/Runner/GeneratedPluginRegistrant.h b/app/ios/Runner/GeneratedPluginRegistrant.h
new file mode 100644
index 0000000..ead0996
--- /dev/null
+++ b/app/ios/Runner/GeneratedPluginRegistrant.h
@@ -0,0 +1,24 @@
+/*
+SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD
+SPDX-License-Identifier: MIT
+*/
+
+//
+// Generated file. Do not edit.
+//
+
+// clang-format off
+
+#ifndef GeneratedPluginRegistrant_h
+#define GeneratedPluginRegistrant_h
+
+#import
+
+NS_ASSUME_NONNULL_BEGIN
+
+@interface GeneratedPluginRegistrant : NSObject
++ (void)registerWithRegistry:(NSObject*)registry;
+@end
+
+NS_ASSUME_NONNULL_END
+#endif /* GeneratedPluginRegistrant_h */
diff --git a/app/ios/Runner/GeneratedPluginRegistrant.m b/app/ios/Runner/GeneratedPluginRegistrant.m
new file mode 100644
index 0000000..f92de13
--- /dev/null
+++ b/app/ios/Runner/GeneratedPluginRegistrant.m
@@ -0,0 +1,159 @@
+/*
+SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD
+SPDX-License-Identifier: MIT
+*/
+
+//
+// Generated file. Do not edit.
+//
+
+// clang-format off
+
+#import "GeneratedPluginRegistrant.h"
+
+#if __has_include()
+#import
+#else
+@import audio_session;
+#endif
+
+#if __has_include()
+#import
+#else
+@import camera_avfoundation;
+#endif
+
+#if __has_include()
+#import
+#else
+@import ffmpeg_kit_flutter_new;
+#endif
+
+#if __has_include()
+#import
+#else
+@import file_picker;
+#endif
+
+#if __has_include()
+#import
+#else
+@import flutter_angle;
+#endif
+
+#if __has_include()
+#import
+#else
+@import flutter_blue_plus_darwin;
+#endif
+
+#if __has_include()
+#import
+#else
+@import geolocator_apple;
+#endif
+
+#if __has_include()
+#import
+#else
+@import google_mlkit_commons;
+#endif
+
+#if __has_include()
+#import
+#else
+@import google_mlkit_face_detection;
+#endif
+
+#if __has_include()
+#import
+#else
+@import just_audio;
+#endif
+
+#if __has_include()
+#import
+#else
+@import mobile_scanner;
+#endif
+
+#if __has_include()
+#import
+#else
+@import music_feature_analyzer;
+#endif
+
+#if __has_include()
+#import
+#else
+@import network_info_plus;
+#endif
+
+#if __has_include()
+#import
+#else
+@import opus_codec_ios;
+#endif
+
+#if __has_include()
+#import
+#else
+@import package_info_plus;
+#endif
+
+#if __has_include()
+#import
+#else
+@import permission_handler_apple;
+#endif
+
+#if __has_include()
+#import
+#else
+@import shared_preferences_foundation;
+#endif
+
+#if __has_include()
+#import
+#else
+@import three_js_sensors;
+#endif
+
+#if __has_include()
+#import
+#else
+@import video_player_avfoundation;
+#endif
+
+#if __has_include()
+#import
+#else
+@import webview_flutter_wkwebview;
+#endif
+
+@implementation GeneratedPluginRegistrant
+
++ (void)registerWithRegistry:(NSObject*)registry {
+ [AudioSessionPlugin registerWithRegistrar:[registry registrarForPlugin:@"AudioSessionPlugin"]];
+ [CameraPlugin registerWithRegistrar:[registry registrarForPlugin:@"CameraPlugin"]];
+ [FFmpegKitFlutterPlugin registerWithRegistrar:[registry registrarForPlugin:@"FFmpegKitFlutterPlugin"]];
+ [FilePickerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FilePickerPlugin"]];
+ [FlutterAnglePlugin registerWithRegistrar:[registry registrarForPlugin:@"FlutterAnglePlugin"]];
+ [FlutterBluePlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"FlutterBluePlusPlugin"]];
+ [GeolocatorPlugin registerWithRegistrar:[registry registrarForPlugin:@"GeolocatorPlugin"]];
+ [GoogleMlKitCommonsPlugin registerWithRegistrar:[registry registrarForPlugin:@"GoogleMlKitCommonsPlugin"]];
+ [GoogleMlKitFaceDetectionPlugin registerWithRegistrar:[registry registrarForPlugin:@"GoogleMlKitFaceDetectionPlugin"]];
+ [JustAudioPlugin registerWithRegistrar:[registry registrarForPlugin:@"JustAudioPlugin"]];
+ [MobileScannerPlugin registerWithRegistrar:[registry registrarForPlugin:@"MobileScannerPlugin"]];
+ [MusicFeatureAnalyzerPlugin registerWithRegistrar:[registry registrarForPlugin:@"MusicFeatureAnalyzerPlugin"]];
+ [FPPNetworkInfoPlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"FPPNetworkInfoPlusPlugin"]];
+ [OpusFlutterIosPlugin registerWithRegistrar:[registry registrarForPlugin:@"OpusFlutterIosPlugin"]];
+ [FPPPackageInfoPlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"FPPPackageInfoPlusPlugin"]];
+ [PermissionHandlerPlugin registerWithRegistrar:[registry registrarForPlugin:@"PermissionHandlerPlugin"]];
+ [SharedPreferencesPlugin registerWithRegistrar:[registry registrarForPlugin:@"SharedPreferencesPlugin"]];
+ [TJSSensorsPlugin registerWithRegistrar:[registry registrarForPlugin:@"TJSSensorsPlugin"]];
+ [FVPVideoPlayerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FVPVideoPlayerPlugin"]];
+ [WebViewFlutterPlugin registerWithRegistrar:[registry registrarForPlugin:@"WebViewFlutterPlugin"]];
+}
+
+@end
diff --git a/app/ios/Runner/Info.plist b/app/ios/Runner/Info.plist
new file mode 100644
index 0000000..b496942
--- /dev/null
+++ b/app/ios/Runner/Info.plist
@@ -0,0 +1,108 @@
+
+
+
+
+ CADisableMinimumFrameDurationOnPhone
+
+ CFBundleDevelopmentRegion
+ $(DEVELOPMENT_LANGUAGE)
+ CFBundleDisplayName
+ StackChan World
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIdentifier
+ com.m5stack.StackChan
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ stackchan
+ CFBundlePackageType
+ APPL
+ CFBundleShortVersionString
+ 1.1.4
+ CFBundleSignature
+ ????
+ CFBundleVersion
+ 11
+ ITSAppUsesNonExemptEncryption
+
+ LSRequiresIPhoneOS
+
+ NSAppTransportSecurity
+
+ NSAllowsArbitraryLoads
+
+
+ NSBluetoothAlwaysUsageDescription
+ The Bluetooth permission is required for searching and connecting to the StackChan device.
+ NSBluetoothPeripheralUsageDescription
+ The Bluetooth permission is required for searching and connecting to the StackChan device.
+ NSBonjourServices
+
+ _stackchan-mpc._tcp
+
+ NSCameraUsageDescription
+ We need access to the camera to take photos and record videos.
+ NSLocalNetworkUsageDescription
+ This app requires access to the local network to communicate with devices.
+ NSMicrophoneUsageDescription
+ We need access to the microphone to capture audio data.
+ NSPhotoLibraryAddUsageDescription
+ Save the photo to the album
+ NSPhotoLibraryUsageDescription
+ We need access to the photo library to save your photos and videos.
+ UIApplicationSceneManifest
+
+ UIApplicationSupportsMultipleScenes
+
+ UISceneConfigurations
+
+ UIWindowSceneSessionRoleApplication
+
+
+ UISceneClassName
+ UIWindowScene
+ UISceneConfigurationName
+ flutter
+ UISceneDelegateClassName
+ $(PRODUCT_MODULE_NAME).SceneDelegate
+ UISceneStoryboardFile
+ Main
+
+
+
+
+ UIApplicationSupportsIndirectInputEvents
+
+ UILaunchStoryboardName
+ LaunchScreen
+ UIMainStoryboardFile
+ Main
+ UIRequiredDeviceCapabilities
+
+ arm64
+ bluetooth-le
+ arkit
+
+ UISupportedInterfaceOrientations
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+ UISupportedInterfaceOrientations~ipad
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationPortraitUpsideDown
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+ com.apple.developer.networking.wifi-info
+
+
+ NSLocationWhenInUseUsageDescription
+ You need to grant location permission to view the name of the WiFi you are currently connected to.
+ NSLocationAlwaysAndWhenInUseUsageDescription
+ You need to grant location permission to view the name of the WiFi you are currently connected to.
+
+
diff --git a/app/ios/Runner/Model/DanceList.swift b/app/ios/Runner/Model/DanceList.swift
new file mode 100644
index 0000000..c23ab32
--- /dev/null
+++ b/app/ios/Runner/Model/DanceList.swift
@@ -0,0 +1,111 @@
+/*
+SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD
+SPDX-License-Identifier: MIT
+*/
+
+//
+// Dance.swift
+// StackChan
+//
+// Created by on 2026/1/16.
+//
+
+import Foundation
+
+struct DanceList: Codable, Identifiable {
+ var danceData: [DanceData]?
+ var danceIndex: Int?
+ var danceName: String?
+
+ var id: String = UUID().uuidString
+
+ enum CodingKeys: String, CodingKey {
+ case danceData
+ case danceIndex
+ case danceName
+ }
+}
+
+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 leftRgbColor: String = "#00000000"
+ var rightRgbColor: String = "#00000000"
+
+ var durationMs: Int // Duration in milliseconds, default 1000
+ var id: String = UUID().uuidString
+
+ enum CodingKeys: String, CodingKey {
+ case leftEye, rightEye, mouth, yawServo, pitchServo, leftRgbColor, rightRgbColor, durationMs
+ }
+
+ static func from(jsonString: String) -> DanceData? {
+ guard !jsonString.isEmpty else {
+ return nil
+ }
+ guard let jsonData = jsonString.data(using: .utf8) else {
+ return nil
+ }
+
+ do {
+ let decoder = JSONDecoder()
+ let danceData = try decoder.decode(DanceData.self, from: jsonData)
+ return danceData
+ } catch {
+ return nil
+ }
+ }
+
+ init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ leftEye = try container.decode(ExpressionItem.self, forKey: .leftEye)
+ rightEye = try container.decode(ExpressionItem.self, forKey: .rightEye)
+ mouth = try container.decode(ExpressionItem.self, forKey: .mouth)
+ yawServo = try container.decode(MotionDataItem.self, forKey: .yawServo)
+ pitchServo = try container.decode(MotionDataItem.self, forKey: .pitchServo)
+ leftRgbColor = try container.decodeIfPresent(String.self, forKey: .leftRgbColor) ?? "#00000000"
+ rightRgbColor = try container.decodeIfPresent(String.self, forKey: .rightRgbColor) ?? "#00000000"
+ durationMs = try container.decode(Int.self, forKey: .durationMs)
+ id = UUID().uuidString
+ }
+
+ init(
+ leftEye: ExpressionItem,
+ rightEye: ExpressionItem,
+ mouth: ExpressionItem,
+ yawServo: MotionDataItem,
+ pitchServo: MotionDataItem,
+ leftRgbColor: String = "#00000000",
+ rightRgbColor: String = "#00000000",
+ durationMs: Int,
+ id: String = UUID().uuidString
+ ) {
+ self.leftEye = leftEye
+ self.rightEye = rightEye
+ self.mouth = mouth
+ self.yawServo = yawServo
+ self.pitchServo = pitchServo
+ self.leftRgbColor = leftRgbColor
+ self.rightRgbColor = rightRgbColor
+ self.durationMs = durationMs
+ self.id = id
+ }
+
+ func copy() -> DanceData {
+ DanceData(
+ leftEye: self.leftEye.copy(),
+ rightEye: self.rightEye.copy(),
+ mouth: self.mouth.copy(),
+ yawServo: self.yawServo.copy(),
+ pitchServo: self.pitchServo.copy(),
+ leftRgbColor: self.leftRgbColor,
+ rightRgbColor: self.rightRgbColor,
+ durationMs: self.durationMs,
+ id: UUID().uuidString
+ )
+ }
+}
diff --git a/app/StackChan/Model/ExpressionData.swift b/app/ios/Runner/Model/ExpressionData.swift
similarity index 100%
rename from app/StackChan/Model/ExpressionData.swift
rename to app/ios/Runner/Model/ExpressionData.swift
diff --git a/app/ios/Runner/Runner-Bridging-Header.h b/app/ios/Runner/Runner-Bridging-Header.h
new file mode 100644
index 0000000..dfe4124
--- /dev/null
+++ b/app/ios/Runner/Runner-Bridging-Header.h
@@ -0,0 +1,6 @@
+/*
+SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD
+SPDX-License-Identifier: MIT
+*/
+
+#import "GeneratedPluginRegistrant.h"
diff --git a/app/StackChan/StackChan.entitlements b/app/ios/Runner/Runner.entitlements
similarity index 100%
rename from app/StackChan/StackChan.entitlements
rename to app/ios/Runner/Runner.entitlements
diff --git a/app/ios/Runner/SceneDelegate.swift b/app/ios/Runner/SceneDelegate.swift
new file mode 100644
index 0000000..99d0226
--- /dev/null
+++ b/app/ios/Runner/SceneDelegate.swift
@@ -0,0 +1,83 @@
+/*
+SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD
+SPDX-License-Identifier: MIT
+*/
+
+import Flutter
+import NetworkExtension
+import CoreLocation
+import UIKit
+
+class SceneDelegate: FlutterSceneDelegate,CLLocationManagerDelegate {
+
+ private let locationManager = CLLocationManager()
+
+ override func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
+ if let controller = window?.rootViewController as? FlutterViewController {
+ NativeBridge.shared.setup(with: controller)
+ NativeBridge.shared.setMethodCallHandler { [weak self] call, result in
+ self?.handleMethodCall(call: call, result: result)
+ }
+ registerNativeViews(with: controller)
+ }
+ locationManager.delegate = self
+ }
+
+ private func registerNativeViews(with controller: FlutterViewController) {
+ guard let registrar = controller.registrar(forPlugin: "stack_chan") else {
+ return
+ }
+ let robotFactory =
+ StackChanRobotViewFactory(messenger: controller.binaryMessenger)
+ registrar.register(robotFactory, withId: "stackchan_robot_view")
+
+ let rotaryFactory =
+ StackChanRotaryRobotViewFactory(messenger: controller.binaryMessenger)
+ registrar.register(rotaryFactory, withId: "stackchan_rotary_robot_view")
+
+ let arViewFactory = StackChanArViewFactory(messenger: controller.binaryMessenger)
+ registrar.register(arViewFactory, withId: "stackchan_ar_view")
+ }
+
+ private func handleMethodCall(call: FlutterMethodCall, result: @escaping FlutterResult) {
+ let method = Method.fromString(call.method)
+ switch method {
+ case .wifiName:
+ getWifiName()
+ case .stopPlayPCM:
+ NativeBridge.shared.stopPlayPCM()
+ default:
+ break
+ }
+ }
+
+ private func getWifiName() {
+ switch locationManager.authorizationStatus {
+ case .authorizedWhenInUse, .authorizedAlways:
+ fetchWifiInfo()
+ break
+ case .denied, .restricted:
+ break
+ case .notDetermined:
+ locationManager.requestWhenInUseAuthorization()
+ break
+ default:
+ break
+ }
+ }
+
+ private func fetchWifiInfo() {
+ NEHotspotNetwork.fetchCurrent { network in
+ if let wifiName = network?.ssid {
+ NativeBridge.shared.sendMessage(method: .wifiName, wifiName)
+ }
+ }
+ }
+
+ func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
+ if manager.authorizationStatus == .authorizedWhenInUse ||
+ manager.authorizationStatus == .authorizedAlways {
+ fetchWifiInfo()
+ }
+ }
+}
diff --git a/app/StackChan/Utils/Extension.swift b/app/ios/Runner/Utils/Extension.swift
similarity index 68%
rename from app/StackChan/Utils/Extension.swift
rename to app/ios/Runner/Utils/Extension.swift
index 7274859..4abfe1a 100644
--- a/app/StackChan/Utils/Extension.swift
+++ b/app/ios/Runner/Utils/Extension.swift
@@ -14,46 +14,78 @@ extension UIApplication {
}
}
+
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
+
+ switch hexString.count {
+ case 3: // RGB
+ let chars = Array(hexString)
+ r = Double(strtoul(String([chars[0], chars[0]]), nil, 16)) / 255
+ g = Double(strtoul(String([chars[1], chars[1]]), nil, 16)) / 255
+ b = Double(strtoul(String([chars[2], chars[2]]), nil, 16)) / 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
+
+ case 4: // RGBA
+ let chars = Array(hexString)
+ r = Double(strtoul(String([chars[0], chars[0]]), nil, 16)) / 255
+ g = Double(strtoul(String([chars[1], chars[1]]), nil, 16)) / 255
+ b = Double(strtoul(String([chars[2], chars[2]]), nil, 16)) / 255
+ a = Double(strtoul(String([chars[3], chars[3]]), nil, 16)) / 255
+
+ case 6: // RRGGBB
+ var value: UInt64 = 0
+ Scanner(string: hexString).scanHexInt64(&value)
+ r = Double((value & 0xFF0000) >> 16) / 255
+ g = Double((value & 0x00FF00) >> 8) / 255
+ b = Double(value & 0x0000FF) / 255
+ a = 1.0
+
+ case 8: // AARRGGBB
+ var value: UInt64 = 0
+ Scanner(string: hexString).scanHexInt64(&value)
+ a = Double((value & 0xFF000000) >> 24) / 255
+ r = Double((value & 0x00FF0000) >> 16) / 255
+ g = Double((value & 0x0000FF00) >> 8) / 255
+ b = Double(value & 0x000000FF) / 255
+
+ default:
+ return nil
}
+
self.init(red: r, green: g, blue: b, opacity: a)
}
- func toHex() -> String? {
+ 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))
+ guard uiColor.getRed(&r, green: &g, blue: &b, alpha: &a) else { return "#00000000" }
+
+ if a < 1.0 {
+ return String(format: "#%02X%02X%02X%02X",
+ Int(a * 255),
+ Int(r * 255),
+ Int(g * 255),
+ Int(b * 255))
+ } else {
+ 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)
@@ -132,21 +164,16 @@ extension UIImage {
extension String {
func jsonPrint() {
guard let data = self.data(using: .utf8) else {
- print(self)
- return
+ 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)
- }
+ } else {
+ }
} catch {
- print("❌ Invalid JSON format: \(error.localizedDescription)")
- print(self)
- }
+ }
}
func leftPadding(toLength: Int, withPad character: Character) -> String {
@@ -181,6 +208,14 @@ extension String {
return self.data(using: .utf8)
}
+ func toColor() -> Color {
+ let hexString = self.trimmingCharacters(in: .whitespacesAndNewlines)
+ if let color = Color(hex: hexString) {
+ return color
+ }
+ return .clear
+ }
+
}
extension Encodable {
@@ -213,8 +248,7 @@ extension Encodable {
let data = try encoder.encode(self)
return String(data: data, encoding: .utf8) ?? "{}"
} catch {
- print("❌ JSON serialization failed: \(error)")
- return "{}"
+ return "{}"
}
}
@@ -223,9 +257,53 @@ extension Encodable {
do {
return try encoder.encode(self)
} catch {
- print("❌ Failed to convert JSON to Data: \(error.localizedDescription)")
+ return nil
+ }
+ }
+}
+
+
+extension UIColor {
+ convenience init?(hex: String) {
+ var hexString = hex.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
+ if hexString.hasPrefix("#") {
+ hexString.removeFirst()
+ }
+
+ let r, g, b, a: CGFloat
+
+ switch hexString.count {
+ case 3: // RGB
+ let chars = Array(hexString)
+ r = CGFloat(strtoul(String([chars[0], chars[0]]), nil, 16)) / 255
+ g = CGFloat(strtoul(String([chars[1], chars[1]]), nil, 16)) / 255
+ b = CGFloat(strtoul(String([chars[2], chars[2]]), nil, 16)) / 255
+ a = 1.0
+ case 4: // RGBA
+ let chars = Array(hexString)
+ r = CGFloat(strtoul(String([chars[0], chars[0]]), nil, 16)) / 255
+ g = CGFloat(strtoul(String([chars[1], chars[1]]), nil, 16)) / 255
+ b = CGFloat(strtoul(String([chars[2], chars[2]]), nil, 16)) / 255
+ a = CGFloat(strtoul(String([chars[3], chars[3]]), nil, 16)) / 255
+ case 6: // RRGGBB
+ var value: UInt64 = 0
+ Scanner(string: hexString).scanHexInt64(&value)
+ r = CGFloat((value & 0xFF0000) >> 16) / 255
+ g = CGFloat((value & 0x00FF00) >> 8) / 255
+ b = CGFloat(value & 0x0000FF) / 255
+ a = 1.0
+ case 8: // AARRGGBB
+ var value: UInt64 = 0
+ Scanner(string: hexString).scanHexInt64(&value)
+ a = CGFloat((value & 0xFF000000) >> 24) / 255
+ r = CGFloat((value & 0x00FF0000) >> 16) / 255
+ g = CGFloat((value & 0x0000FF00) >> 8) / 255
+ b = CGFloat(value & 0x000000FF) / 255
+ default:
return nil
}
+
+ self.init(red: r, green: g, blue: b, alpha: a)
}
}
@@ -276,7 +354,7 @@ extension View {
self
.background(
RoundedRectangle(cornerRadius: cornerRadius)
- .fill(.thinMaterial)
+ .fill(.ultraThinMaterial)
)
}
}
@@ -295,7 +373,16 @@ extension View {
if #available(iOS 26.0, *) {
self.buttonStyle(.glass)
} else {
- self.buttonStyle(.bordered)
+ self
+ }
+ }
+
+ @ViewBuilder
+ func glassProminentButtonStyle() -> some View {
+ if #available(iOS 26.0, *) {
+ self.buttonStyle(.glassProminent)
+ } else {
+ self.buttonStyle(.borderedProminent)
}
}
diff --git a/app/ios/Runner/Utils/NativeBridge.swift b/app/ios/Runner/Utils/NativeBridge.swift
new file mode 100644
index 0000000..bd7a041
--- /dev/null
+++ b/app/ios/Runner/Utils/NativeBridge.swift
@@ -0,0 +1,186 @@
+/*
+SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD
+SPDX-License-Identifier: MIT
+*/
+
+import Foundation
+import Flutter
+import AVFoundation
+
+class NativeBridge {
+ static let shared = NativeBridge()
+
+ private var channel: FlutterMethodChannel?
+ private var audioPlayChannel: FlutterBasicMessageChannel?
+ private weak var flutterViewController: FlutterViewController?
+
+ private let channelName = "com.m5stack.stackchan/native"
+ private let audioPlayChannelName = "com.m5stack.stackchan/audio_play"
+
+ private var audioEngine: AVAudioEngine?
+ private var audioPlayerNode: AVAudioPlayerNode?
+ private let sampleRate: Double = 16000.0
+ private let channels: AVAudioChannelCount = 1
+ private var isAudioInitialized = false
+
+ private let audioQueue = DispatchQueue(label: "com.stackchan.audio", qos: .userInitiated)
+ private let audioFormat: AVAudioFormat? = AVAudioFormat(
+ commonFormat: .pcmFormatFloat32,
+ sampleRate: 16_000,
+ channels: 1,
+ interleaved: true
+ )
+
+ private init() {}
+
+ func setup(with viewController: FlutterViewController) {
+ self.flutterViewController = viewController
+ let binaryMessenger = viewController.binaryMessenger
+
+ channel = FlutterMethodChannel(name: channelName, binaryMessenger: binaryMessenger)
+ audioPlayChannel = FlutterBasicMessageChannel(
+ name: audioPlayChannelName,
+ binaryMessenger: binaryMessenger,
+ codec: FlutterBinaryCodec()
+ )
+
+ audioPlayChannel?.setMessageHandler { [weak self] message, reply in
+ guard let self = self, let data = message as? Data else {
+ reply(nil)
+ return
+ }
+ self.audioQueue.async { [weak self] in
+ self?.playAudio(pcmData: data)
+ }
+ reply(nil)
+ }
+ }
+
+ private func playAudio(pcmData: Data) {
+ guard let audioFormat = audioFormat else {
+ return
+ }
+
+ if !isAudioInitialized {
+ guard setupAudioSession() else {
+ return
+ }
+ guard setupAudioEngine() else {
+ return
+ }
+ isAudioInitialized = true
+ }
+
+ guard let engine = audioEngine, let playerNode = audioPlayerNode else {
+ resetAudio()
+ return
+ }
+
+ if !engine.isRunning {
+ do {
+ try engine.start()
+ } catch {
+ resetAudio()
+ return
+ }
+ }
+
+ var floatBuffer = pcmData.withUnsafeBytes { (bytes: UnsafeRawBufferPointer) -> [Float] in
+ let int16Buffer = bytes.bindMemory(to: Int16.self)
+ var floats = [Float](repeating: 0, count: int16Buffer.count)
+ //3
+ for i in 0...size)
+
+ playerNode.scheduleBuffer(buffer)
+ if !playerNode.isPlaying {
+ playerNode.play()
+ }
+ }
+
+ // MARK: - (-50)
+ private func setupAudioSession() -> Bool {
+ do {
+ let session = AVAudioSession.sharedInstance()
+ try session.setCategory(.playback, mode: .default)
+ try session.setActive(true)
+ return true
+ } catch {
+ let nsError = error as NSError
+ return false
+ }
+ }
+
+ // MARK: - (-10868)
+ private func setupAudioEngine() -> Bool {
+ guard let audioFormat = audioFormat else {
+ return false
+ }
+
+ let engine = AVAudioEngine()
+ let playerNode = AVAudioPlayerNode()
+ engine.attach(playerNode)
+
+ // translated comment
+ engine.connect(playerNode, to: engine.mainMixerNode, format: audioFormat)
+
+ do {
+ try engine.start()
+ } catch {
+ return false
+ }
+
+ self.audioEngine = engine
+ self.audioPlayerNode = playerNode
+ return true
+ }
+
+ private func resetAudio() {
+ audioPlayerNode?.stop()
+ audioEngine?.stop()
+ audioEngine = nil
+ audioPlayerNode = nil
+ isAudioInitialized = false
+ }
+
+ func stopPlayPCM() {
+ audioQueue.async { [weak self] in
+ self?.resetAudio()
+ }
+ }
+
+ func sendMessage(method: Method,_ arguments: Any? = nil,_ completion: ((Any?) -> Void)? = nil) {
+ guard method != .unknown else {
+ completion?(nil)
+ return
+ }
+ channel?.invokeMethod(method.rawValue, arguments: arguments) { result in
+ if let error = result as? FlutterError {
+ }
+ completion?(result)
+ }
+ }
+
+ func setMethodCallHandler(handler: @escaping FlutterMethodCallHandler) {
+ channel?.setMethodCallHandler(handler)
+ }
+}
+
+enum Method: String, CaseIterable {
+ case wifiName
+ case unknown
+ case stopPlayPCM
+
+ static func fromString(_ name: String) -> Method {
+ return Method(rawValue: name) ?? .unknown
+ }
+}
diff --git a/app/StackChan/View/MimicryEmotion.swift b/app/ios/Runner/View/StackChanArView.swift
similarity index 51%
rename from app/StackChan/View/MimicryEmotion.swift
rename to app/ios/Runner/View/StackChanArView.swift
index c558401..4930d8c 100644
--- a/app/StackChan/View/MimicryEmotion.swift
+++ b/app/ios/Runner/View/StackChanArView.swift
@@ -1,447 +1,157 @@
/*
- * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD
- *
- * SPDX-License-Identifier: MIT
- */
+SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD
+SPDX-License-Identifier: MIT
+*/
-import SwiftUI
-import AVFoundation
+//
+// StackChanArView.swift
+// Runner
+//
+// Created by on 2026/2/5.
+//
+
+import RealityKit
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
+class StackChanArView : NSObject, FlutterPlatformView, ARSessionDelegate, ARSCNViewDelegate {
+ private var arView: ARSCNView
+ private var channel: FlutterMethodChannel?
+ private var expressionChannel: FlutterEventChannel?
+ private var frameChannel: FlutterEventChannel?
+ private var expressionStreamHandler: ExpressionStreamHandler?
+ private var frameStreamHandler: FrameStreamHandler?
+ private var decorate: Int = 0
+ private var captureScreen: Bool = false
+ private let methodChannelName = "com.stackchan.ar.view"
+ private var lastCaptureTime: TimeInterval = 0
private let stackChanTargetSize = CGSize(width: 320, height: 240)
+ private var faceAnchorNode: SCNNode?
+ private var currentDecorationNode: SCNNode?
+ private var expressionLayer = ExpressionLayer(data: ExpressionData(leftEye: ExpressionItem(), rightEye: ExpressionItem(), mouth: ExpressionItem()),reverse: true)
+ private let emotionThresholds = EmotionThresholds()
+ private var lastSendTime: Date = Date(timeIntervalSince1970: 0)
- 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)
+ func view() -> UIView {
+ return arView
}
- // 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)
+
+ init(
+ frame: CGRect,
+ viewId: Int64,
+ messenger: FlutterBinaryMessenger,
+ args: Any?
+ ) {
+ arView = ARSCNView(frame: frame)
+ arView.contentMode = .scaleAspectFit
+ arView.autoresizingMask = [.flexibleWidth,.flexibleHeight]
+ super.init()
+ initializeChannels(viewId: viewId, messenger: messenger)
+ setupARSession()
+ }
+
+ private func initializeChannels(viewId: Int64, messenger: FlutterBinaryMessenger) {
+ let methodChannelName = "\(methodChannelName)_\(viewId)"
+ channel = FlutterMethodChannel(name: methodChannelName, binaryMessenger: messenger)
+ channel?.setMethodCallHandler { [weak self] call, result in
+ self?.handleMethodCall(call, result: result)
+ }
+
+ // translated comment
+ let expressionChannelName = "\(methodChannelName)_expression"
+ expressionStreamHandler = ExpressionStreamHandler()
+ expressionChannel = FlutterEventChannel(name: expressionChannelName, binaryMessenger: messenger)
+ expressionChannel?.setStreamHandler(expressionStreamHandler)
+
+ // translated comment
+ let frameChannelName = "\(methodChannelName)_frame"
+ frameStreamHandler = FrameStreamHandler()
+ frameChannel = FlutterEventChannel(name: frameChannelName, binaryMessenger: messenger)
+ frameChannel?.setStreamHandler(frameStreamHandler)
+ }
+
+ private func setupARSession() {
+ guard ARFaceTrackingConfiguration.isSupported else {
+ return
+ }
+ let configuration = ARFaceTrackingConfiguration()
+ configuration.isLightEstimationEnabled = true
+ if let format = ARFaceTrackingConfiguration.supportedVideoFormats.last {
+ configuration.videoFormat = format
+ }
+ configuration.videoHDRAllowed = true
+ arView.automaticallyUpdatesLighting = true
+ arView.session.delegate = self
+ arView.delegate = self
+ arView.session.run(configuration, options: [.resetTracking,.removeExistingAnchors])
+ }
+
+ private func handleMethodCall(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
+ switch call.method {
+ case "dispose":
+ dispose()
+ result(nil)
+ case "setDecorate":
+ if let decorateValue = call.arguments as? Int {
+ decorate = decorateValue
+ if let faceNode = self.faceAnchorNode {
+ self.updateDecorationOnNode(node: faceNode, decorate: decorate)
+ }
+ }
+ result(nil)
+ case "setCaptureScreen":
+ if let captureValue = call.arguments as? Bool {
+ captureScreen = captureValue
+ }
+ result(nil)
+ default:
+ result(FlutterMethodNotImplemented)
}
}
- // 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))
+ //translated comment
+ func session(_ session: ARSession, didUpdate anchors: [ARAnchor]) {
+ DispatchQueue.main.async {
+ self.emotionDetection(session: session, anchors: anchors)
}
-
- // 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] = []
+ private func emotionDetection(session: ARSession, anchors: [ARAnchor]) {
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
- }
+ self.updateDecoration(expressionData: faceData)
- /// Send data via Bluetooth
let now = Date()
- if now.timeIntervalSince(lastSendTime) >= 0.5 {
- self.sendExpressionData(data: faceData)
- self.sendHeadData(data: headData)
+ if now.timeIntervalSince(self.lastSendTime) >= 0.5 {
+ let danceData = DanceData(leftEye: faceData.leftEye, rightEye: faceData.rightEye, mouth: faceData.mouth, yawServo: headData.yawServo, pitchServo: headData.pitchServo, durationMs: 1000)
+ let jsonString = danceData.toJsonString()
+ self.expressionStreamHandler?.sendExpressionData(jsonString)
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 detectHeadData(session: ARSession,faceAnchor: ARFaceAnchor) -> MotionData {
+ let faceTransform = faceAnchor.transform
+ guard let cameraTransform = session.currentFrame?.camera.transform else {
+ return MotionData(pitchServo: MotionDataItem(angle: 0, speed: 500),
+ yawServo: MotionDataItem(angle: 0, speed: 500))
+ }
+ let relativeTransform = simd_mul(simd_inverse(cameraTransform), faceTransform)
+ let relativeMatrix = SCNMatrix4(relativeTransform)
+ let pitch = atan2(relativeMatrix.m31, relativeMatrix.m33) // Vertical rotation (pitch)
+ let yaw = asin(-relativeMatrix.m32) // Horizontal rotation (yaw)
+ let pitchDeg = pitch * 180.0 / .pi
+ let yawDeg = yaw * 180.0 / .pi
+ let yawServoAngle = max(-1280, min(1280, Int(-yawDeg * 20)))
+ 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)
}
- 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
@@ -458,7 +168,52 @@ struct MimicryEmotion: View {
return isEyesWide && isBrowRaised && isMouthAction
}
- /// Angry emotion detection
+ private func buildExpressionData(faceAnchor: ARFaceAnchor) -> ExpressionData {
+ let blendShapes = faceAnchor.blendShapes
+ let eyeBlinkLeft = blendShapes[.eyeBlinkLeft]?.floatValue ?? 0
+ let leftEyeWeight = max(0, min(100, Int((1.0 - eyeBlinkLeft) * 100)))
+ let eyeBlinkRight = blendShapes[.eyeBlinkRight]?.floatValue ?? 0
+ let rightEyeWeight = max(0, min(100, Int((1.0 - eyeBlinkRight) * 100)))
+ 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
+ )
+ let jawOpen = blendShapes[.jawOpen]?.floatValue ?? 0
+ let mouthSmileLeft = blendShapes[.mouthSmileLeft]?.floatValue ?? 0
+ let mouthSmileRight = blendShapes[.mouthSmileRight]?.floatValue ?? 0
+ let mouthX = max(-100, min(100, Int((mouthSmileRight - mouthSmileLeft) * 100)))
+ 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)
+ if isHappy(blendShapes: blendShapes) {
+ expressionData.leftEye.weight -= 35
+ expressionData.leftEye.rotation = -2150
+ expressionData.rightEye.weight -= 35
+ expressionData.rightEye.rotation = 2150
+ }
+ if isAnger(blendShapes: blendShapes) {
+ expressionData.leftEye.rotation = 450
+ expressionData.rightEye.rotation = -450
+ }
+ return expressionData
+ }
+
+
private func isAnger(blendShapes: [ARFaceAnchor.BlendShapeLocation: NSNumber]) -> Bool {
// Brow features
let browDownLeft = blendShapes[.browDownLeft]?.floatValue ?? 0
@@ -499,7 +254,6 @@ struct MimicryEmotion: View {
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
@@ -515,9 +269,133 @@ struct MimicryEmotion: View {
return eyesClosed
}
- // MARK: - Helper Functions
+ 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)
+ }
+
+ 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) // translated comment
+ 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)
+ }
+
+ func renderer(_ renderer: any 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
+ }
+
+ private 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
+ }
+ }
+
+ private 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
+ }
+
+ private 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
+ }
+
+ private func createStackChanModel() -> SCNNode {
+ guard let scene = SCNScene(named: "StackChanModel.scn"),
+ let modelNode = scene.rootNode.childNodes.first else {
+ 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
+
+ //translated comment
+ if let foundation = modelNode.childNode(withName: "_00_stackchan450_3",recursively: false),let centralComponent = modelNode.childNode(withName: "_00_stackchan450_2", recursively: false) {
+ foundation.opacity = 0
+ centralComponent.opacity = 0
+ }
+
+ return modelNode
+ }
- /// Get gaze direction
private func getGazeDirection(faceAnchor: ARFaceAnchor) -> String {
let lookAtPoint = faceAnchor.lookAtPoint
var direction = ""
@@ -537,7 +415,6 @@ struct MimicryEmotion: View {
return direction.isEmpty ? "Looking Forward" : direction + " Look"
}
- /// Get head direction
private func getHeadDirection(faceAnchor: ARFaceAnchor) -> String {
let transform = faceAnchor.transform
let rotation = SCNMatrix4(transform)
@@ -566,9 +443,114 @@ struct MimicryEmotion: View {
return "Head Facing " + vertical + horizontal
}
}
+
+ 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
+ )
+ }()
+
+ //translated comment
+ func renderer(_ renderer: any SCNSceneRenderer, updateAtTime time: TimeInterval) {
+ if captureScreen {
+ if time - lastCaptureTime >= 0.5 {
+ lastCaptureTime = time
+ DispatchQueue.main.async { [weak self] in
+ guard let self = self else { return }
+ let renderedImage = arView.snapshot()
+ if let jpedData = renderedImage.compress(to: stackChanTargetSize,memorySize: 0.02,cropCenter: true) {
+ self.frameStreamHandler?.sendFrameData(jpedData)
+ }
+ }
+ }
+ }
+ }
+
+ //translated comment
+ private func updateDecoration(expressionData: ExpressionData) {
+ DispatchQueue.main.async {
+ if self.decorate == 1 {
+ let scene = self.arView.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
+ }
+ }
+ }
+
+ private func dispose() {
+ arView.session.pause()
+ channel?.setMethodCallHandler(nil)
+ expressionChannel?.setStreamHandler(nil)
+ frameChannel?.setStreamHandler(nil)
+ arView.scene.rootNode.childNodes.forEach { $0.removeFromParentNode() }
+ }
+}
+
+
+
+class ExpressionStreamHandler: NSObject,FlutterStreamHandler {
+
+ private var eventSink: FlutterEventSink?
+
+ func sendExpressionData(_ data: String) {
+ guard let sink = eventSink else { return }
+ DispatchQueue.main.async {
+ sink(data)
+ }
+ }
+
+ func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? {
+ eventSink = events
+ return nil
+ }
+
+ func onCancel(withArguments arguments: Any?) -> FlutterError? {
+ eventSink = nil
+ return nil
+ }
+}
+
+class FrameStreamHandler: NSObject, FlutterStreamHandler {
+
+ private var eventSink: FlutterEventSink?
+
+ func sendFrameData(_ data: Data) {
+ guard let sink = eventSink else { return }
+
+ // translated comment
+ DispatchQueue.main.async {
+ sink(FlutterStandardTypedData(bytes: data))
+ }
+ }
+
+ func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? {
+ eventSink = events
+ return nil
+ }
+
+ func onCancel(withArguments arguments: Any?) -> FlutterError? {
+ eventSink = nil
+ return nil
+ }
}
-// MARK: - Threshold Configuration
private struct EmotionThresholds {
// Happy emotion thresholds
diff --git a/app/ios/Runner/View/StackChanRobot.swift b/app/ios/Runner/View/StackChanRobot.swift
new file mode 100644
index 0000000..e574966
--- /dev/null
+++ b/app/ios/Runner/View/StackChanRobot.swift
@@ -0,0 +1,490 @@
+/*
+SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD
+SPDX-License-Identifier: MIT
+*/
+
+//
+// StackChanRobot.swift
+// Runner
+//
+// Created by on 2026/1/30.
+//
+import SceneKit
+
+class StackChanRobot: NSObject, FlutterPlatformView, FlutterStreamHandler {
+
+ private let sceneView: SCNView
+ private let methodChannel: FlutterMethodChannel
+
+ private var currentDanceData: DanceData?
+ private let expressionLayer = ExpressionLayer(data: ExpressionData(leftEye: ExpressionItem(weight: 100), rightEye: ExpressionItem(weight: 100), mouth: ExpressionItem()))
+ private let planeNodeName = "expressionPlane"
+ private let rotateKey = "autoRotate"
+ private var topLook: Bool = false
+ private let methodChannelName = "com.stackchan.robot.method"
+
+ private var defaultCameraNode: SCNNode?
+ private var topCameraNode: SCNNode?
+
+ init(
+ frame: CGRect,
+ viewId: Int64,
+ messenger: FlutterBinaryMessenger,
+ args: Any?
+ ) {
+ self.sceneView = SCNView(frame: frame)
+ self.methodChannel = FlutterMethodChannel(
+ name: methodChannelName + "_\(viewId)",
+ binaryMessenger: messenger
+ )
+ super.init()
+ methodChannel.setMethodCallHandler(handleMethodCall)
+ setupSceneView()
+ setupInitialScene()
+ }
+
+ func view() -> UIView {
+ return sceneView
+ }
+
+ func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? {
+ return nil
+ }
+
+ func onCancel(withArguments arguments: Any?) -> FlutterError? {
+ return nil
+ }
+
+ private func handleMethodCall(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
+ switch call.method {
+ case "updateDanceData":
+ if let json = call.arguments as? String {
+ updateDanceData(from: json)
+ result(nil)
+ } else {
+ result(FlutterError(
+ code: "INVALID_ARGS",
+ message: "Expected JSON string",
+ details: nil
+ ))
+ }
+ case "setTopLook":
+ if let topLook = call.arguments as? Bool {
+ self.topLook = topLook
+ setupCamera()
+ result(nil)
+ } else {
+ result(FlutterError(
+ code: "INVALID_ARGS",
+ message: "Expected boolean value",
+ details: nil
+ ))
+ }
+ case "setAllowsCameraControl":
+ if let allowsControl = call.arguments as? Bool {
+ sceneView.allowsCameraControl = allowsControl
+ result(nil)
+ } else {
+ result(FlutterError(
+ code: "INVALID_ARGS",
+ message: "Expected boolean value",
+ details: nil
+ ))
+ }
+ case "dispose":
+ cleanup()
+ result(nil)
+ default:
+ result(FlutterMethodNotImplemented)
+ }
+ }
+
+ private func setupSceneView() {
+ sceneView.antialiasingMode = .multisampling4X
+ sceneView.autoenablesDefaultLighting = true
+ sceneView.allowsCameraControl = false
+ sceneView.backgroundColor = .clear
+ sceneView.isPlaying = true
+ }
+
+ private func setupInitialScene() {
+ guard let scene = SCNScene(named: "StackChanModel.scn") else {
+ return
+ }
+ 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
+
+ if let rootNode = scene.rootNode.childNodes.first {
+ setupRobotHierarchy(rootNode: rootNode, scene: scene)
+
+ if let defaultCamera = scene.rootNode.childNode(withName: "camera", recursively: true) {
+ defaultCameraNode = defaultCamera
+ } else {
+ defaultCameraNode = createDefaultCameraNode(rootNode: rootNode)
+ rootNode.addChildNode(defaultCameraNode!)
+ }
+ sceneView.pointOfView = defaultCameraNode
+ }
+ sceneView.scene = scene
+ }
+
+ private func createDefaultCameraNode(rootNode: SCNNode) -> SCNNode {
+ let cameraNode = SCNNode()
+ cameraNode.name = "defaultCamera"
+ let camera = SCNCamera()
+ camera.zFar = 200
+ cameraNode.camera = camera
+ cameraNode.position = SCNVector3(x: 0, y: -100, z: 0)
+ let lookAtConstraint = SCNLookAtConstraint(target: rootNode)
+ lookAtConstraint.isGimbalLockEnabled = true
+ cameraNode.constraints = [lookAtConstraint]
+ return cameraNode
+ }
+
+ private func createTopCameraNode(rootNode: SCNNode) -> SCNNode {
+ let cameraNode = SCNNode()
+ cameraNode.name = "leftTopCamera"
+ let camera = SCNCamera()
+ camera.zFar = 300
+ cameraNode.camera = camera
+ cameraNode.position = SCNVector3(x: 0, y: -100, z: 70)
+ let lookAtConstraint = SCNLookAtConstraint(target: rootNode)
+ lookAtConstraint.isGimbalLockEnabled = true
+ cameraNode.constraints = [lookAtConstraint]
+ return cameraNode
+ }
+
+ private func setupRobotHierarchy(rootNode: SCNNode, scene: SCNScene) {
+ guard let foundation = rootNode.childNode(withName: "_00_stackchan450_3", recursively: false),
+ let centralComponent = rootNode.childNode(withName: "_00_stackchan450_2", recursively: false),
+ let head = rootNode.childNode(withName: "_00_stackchan450_1", recursively: false) else {
+ return
+ }
+
+ let yawAxis = SCNNode()
+ yawAxis.name = "yawAxis"
+ let centralWorldPos = centralComponent.worldPosition
+ yawAxis.worldPosition.z = centralWorldPos.z + 15
+ foundation.addChildNode(yawAxis)
+
+ let centralWorldTransform = centralComponent.worldTransform
+ yawAxis.addChildNode(centralComponent)
+ centralComponent.setWorldTransform(centralWorldTransform)
+
+ // Setup pitch axis for head movement
+ let headWorldTransform = head.worldTransform
+ let pitchAxis = SCNNode()
+ pitchAxis.name = "pitchAxis"
+ pitchAxis.worldPosition.z = pitchAxis.worldPosition.z - 20
+ centralComponent.addChildNode(pitchAxis)
+ pitchAxis.addChildNode(head)
+ head.setWorldTransform(headWorldTransform)
+
+ // Add expression plane to head
+ addExpressionPlane(to: head)
+ }
+
+ private func addExpressionPlane(to head: SCNNode) {
+ 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 = head.position
+ planeNode.position.z = planeNode.position.z - 4.5
+
+ head.addChildNode(planeNode)
+ }
+
+ private func setupCamera() {
+ guard let scene = sceneView.scene,
+ let rootNode = scene.rootNode.childNodes.first else {
+ return
+ }
+ rootNode.childNodes.filter { $0.name == "leftTopCamera" }.forEach { $0.removeFromParentNode() }
+ if topLook {
+ if topCameraNode == nil {
+ topCameraNode = createTopCameraNode(rootNode: rootNode)
+ rootNode.addChildNode(topCameraNode!)
+ }
+ sceneView.pointOfView = topCameraNode
+ } else {
+ if defaultCameraNode == nil {
+ defaultCameraNode = createDefaultCameraNode(rootNode: rootNode)
+ rootNode.addChildNode(defaultCameraNode!)
+ }
+ sceneView.pointOfView = defaultCameraNode
+ }
+ }
+
+ private func updateDanceData(from json: String) {
+ guard let danceData = DanceData.from(jsonString: json) else {
+ return
+ }
+
+ currentDanceData = danceData
+
+ DispatchQueue.main.async {
+ self.applyDanceData(danceData)
+ }
+ }
+
+ private func applyDanceData(_ data: DanceData) {
+ guard let scene = sceneView.scene,
+ let rootNode = scene.rootNode.childNodes.first else {
+ return
+ }
+
+ // Update servo positions
+ updateServos(rootNode: rootNode, data: data)
+
+ // Update RGB color
+ updateRGBColor(rootNode: rootNode, data: data)
+
+ // Update expression
+ updateExpression(data: data)
+ }
+ private func updateServos(rootNode: SCNNode, data: DanceData) {
+
+ if let yawAxis = rootNode.childNode(withName: "yawAxis", recursively: true),
+ let pitchAxis = rootNode.childNode(withName: "pitchAxis", recursively: true) {
+
+ yawAxis.removeAction(forKey: rotateKey)
+
+ // Update yaw (rotation around Y axis)
+ if data.yawServo.rotate == 0 {
+ let clampedYaw = max(-128, min(128, data.yawServo.angle / 10))
+ let yawRadians = Float(clampedYaw) * Float.pi / 180.0
+ yawAxis.rotation = SCNVector4(0, 1, 0, yawRadians)
+ } else {
+ let rotateSpeed = max(-100, min(100, data.yawServo.rotate / 10))
+ let radiansPerSecond = Float(rotateSpeed) / 100.0 * Float.pi * 2
+
+ let rotateAction = SCNAction.customAction(duration: .infinity) { node, _ in
+ let deltaTime: Float = 1.0 / 60.0
+ node.eulerAngles.y += radiansPerSecond * deltaTime
+ }
+ yawAxis.runAction(rotateAction, forKey: rotateKey)
+ }
+
+ // Update pitch (head tilt)
+ let clampedPitch = max(0, min(90, data.pitchServo.angle / 10))
+ let pitchRadians = Float(clampedPitch) * Float.pi / 180.0
+ pitchAxis.eulerAngles.x = -pitchRadians
+ }
+ }
+
+ private func updateRGBColor(rootNode: SCNNode, data: DanceData) {
+ rootNode.enumerateChildNodes { node, _ in
+ if let materials = node.geometry?.materials {
+ for material in materials {
+ if material.name == "MTL12" {
+ if let color = UIColor(hex: data.leftRgbColor) {
+ material.emission.contents = color
+ }
+ break
+ }
+ }
+ }
+ }
+ }
+
+ private func updateExpression(data: DanceData) {
+ guard let planeNode = sceneView.scene?.rootNode.childNode(withName: planeNodeName, recursively: true),
+ let plane = planeNode.geometry as? SCNPlane else {
+ return
+ }
+
+ 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
+ }
+
+ private func expressionRenderer() -> UIGraphicsImageRenderer {
+ let format = UIGraphicsImageRendererFormat.default()
+ format.scale = UIScreen.main.scale
+ format.opaque = false
+ return UIGraphicsImageRenderer(
+ size: expressionLayer.bounds.size,
+ format: format
+ )
+ }
+
+ private func cleanup() {
+ // Stop all animations
+ sceneView.scene?.rootNode.childNodes.forEach { node in
+ node.removeAllActions()
+ node.removeFromParentNode()
+ }
+
+ // Clean up scene
+ sceneView.scene = nil
+ sceneView.isPlaying = false
+
+ // Remove method call handler
+ methodChannel.setMethodCallHandler(nil)
+ }
+
+ deinit {
+ cleanup()
+ }
+}
+
+
+
+
+
+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.4) - (eyeSize / 2)
+ let leftEyePoint = CGPoint(x: (rect.width / 4) - (eyeSize / 2), y: eyeY)
+ let rightEyePoint = CGPoint(x: (rect.width / 4 * 3) - (eyeSize / 2), y: eyeY)
+
+
+ if reverse {
+ // Temporarily swap rotation angles
+ let leftEyeRotation = data.leftEye.rotation
+ let rightEyeRotation = data.rightEye.rotation
+
+ var leftEye = data.leftEye
+ var rightEye = data.rightEye
+
+ leftEye.rotation = rightEyeRotation
+ rightEye.rotation = leftEyeRotation
+
+ drawEye(leftEye, at: rightEyePoint)
+ drawEye(rightEye, at: leftEyePoint)
+ } else {
+ drawEye(data.leftEye, at: leftEyePoint)
+ drawEye(data.rightEye, at: rightEyePoint)
+ }
+
+ // Draw mouth
+ ctx.saveGState()
+
+ let width = rect.width * 0.3 - CGFloat(data.mouth.weight / 10)
+ let height = 3 + CGFloat(data.mouth.weight) * 0.2
+ let x = ((rect.width - width) / 2) + CGFloat(data.mouth.x / 10)
+ let y = (rect.height * 0.65) + CGFloat(data.mouth.y / 10)
+
+ let rotationDegrees = CGFloat(data.mouth.rotation) / 10.0
+ let center = CGPoint(x: x + width / 2, y: y + height / 2)
+ ctx.translateBy(x: center.x, y: center.y)
+ ctx.rotate(by: rotationDegrees * .pi / 180)
+ ctx.translateBy(x: -center.x, y: -center.y)
+
+ let mouthRect = CGRect(x: x, y: y, width: width, height: height)
+ let mouthPath = UIBezierPath(roundedRect: mouthRect, cornerRadius: height / 2)
+ ctx.addPath(mouthPath.cgPath)
+ ctx.setFillColor(UIColor.white.cgColor)
+ ctx.fillPath()
+
+ ctx.restoreGState()
+ }
+}
diff --git a/app/ios/Runner/View/StackChanRotaryRobot.swift b/app/ios/Runner/View/StackChanRotaryRobot.swift
new file mode 100644
index 0000000..d48e435
--- /dev/null
+++ b/app/ios/Runner/View/StackChanRotaryRobot.swift
@@ -0,0 +1,140 @@
+/*
+SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD
+SPDX-License-Identifier: MIT
+*/
+
+//
+// StackChanRotaryRobot.swift
+// Runner
+//
+// Created by on 2026/1/30.
+//
+import SceneKit
+
+class StackChanRotaryRobot: NSObject, FlutterPlatformView, FlutterStreamHandler {
+
+ private let expressionLayer = ExpressionLayer(data: ExpressionData(leftEye: ExpressionItem(weight: 100), rightEye: ExpressionItem(weight: 100), mouth: ExpressionItem()))
+
+ private let planeNodeName = "expressionPlane"
+
+ private let sceneView: SCNView
+
+ func view() -> UIView {
+ return sceneView
+ }
+
+ func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? {
+ return nil
+ }
+
+ func onCancel(withArguments arguments: Any?) -> FlutterError? {
+ return nil
+ }
+
+ init(
+ frame: CGRect,
+ viewId: Int64,
+ messenger: FlutterBinaryMessenger,
+ args: Any?
+ ) {
+ self.sceneView = SCNView(frame: frame)
+ super.init()
+ setupSceneView()
+ setupInitialScene()
+ }
+
+
+ private func setupSceneView() {
+ sceneView.antialiasingMode = .multisampling4X
+ sceneView.autoenablesDefaultLighting = true
+ sceneView.allowsCameraControl = false
+ sceneView.backgroundColor = .clear
+ sceneView.isPlaying = true
+ }
+
+
+ private func setupInitialScene() {
+ guard let scene = SCNScene(named: "StackChanModel.scn") else {
+ return
+ }
+ 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
+
+ if let rootNode = scene.rootNode.childNodes.first {
+ setupRobotHierarchy(rootNode: rootNode, scene: scene)
+ }
+ sceneView.scene = scene
+ }
+
+ private func setupRobotHierarchy(rootNode: SCNNode, scene: SCNScene) {
+ guard let foundation = rootNode.childNode(withName: "_00_stackchan450_3", recursively: false),
+ let centralComponent = rootNode.childNode(withName: "_00_stackchan450_2", recursively: false),
+ let head = rootNode.childNode(withName: "_00_stackchan450_1", recursively: false) else {
+ return
+ }
+
+ let yawAxis = SCNNode()
+ yawAxis.name = "yawAxis"
+ let centralWorldPos = centralComponent.worldPosition
+ yawAxis.worldPosition.z = centralWorldPos.z + 15
+ foundation.addChildNode(yawAxis)
+
+ let centralWorldTransform = centralComponent.worldTransform
+ yawAxis.addChildNode(centralComponent)
+ centralComponent.setWorldTransform(centralWorldTransform)
+
+ // Setup pitch axis for head movement
+ let headWorldTransform = head.worldTransform
+ let pitchAxis = SCNNode()
+ pitchAxis.name = "pitchAxis"
+ pitchAxis.worldPosition.z = pitchAxis.worldPosition.z - 20
+ centralComponent.addChildNode(pitchAxis)
+ pitchAxis.addChildNode(head)
+ head.setWorldTransform(headWorldTransform)
+
+ // Add expression plane to head
+ addExpressionPlane(to: head)
+
+ // translated comment
+ let rotateAction = SCNAction.rotateBy(x: 0, y: CGFloat(2 * Double.pi), z: 0, duration: 5)
+ let repeatAction = SCNAction.repeatForever(rotateAction)
+ scene.rootNode.runAction(repeatAction)
+ }
+
+ private func addExpressionPlane(to head: SCNNode) {
+ 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.name = planeNodeName
+ planeNode.position = head.position
+ planeNode.position.z = planeNode.position.z - 4.5
+ head.addChildNode(planeNode)
+ }
+
+ private func expressionRenderer() -> UIGraphicsImageRenderer {
+ let format = UIGraphicsImageRendererFormat.default()
+ format.scale = UIScreen.main.scale
+ format.opaque = false
+ return UIGraphicsImageRenderer(
+ size: expressionLayer.bounds.size,
+ format: format
+ )
+ }
+}
+
diff --git a/app/ios/Runner/View/ViewFactory.swift b/app/ios/Runner/View/ViewFactory.swift
new file mode 100644
index 0000000..b2cb866
--- /dev/null
+++ b/app/ios/Runner/View/ViewFactory.swift
@@ -0,0 +1,69 @@
+/*
+SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD
+SPDX-License-Identifier: MIT
+*/
+
+//
+// ViewFactory.swift
+// Runner
+//
+// Created by on 2026/1/30.
+//
+
+import Flutter
+import UIKit
+
+class StackChanRobotViewFactory: NSObject, FlutterPlatformViewFactory {
+
+ private let messenger: FlutterBinaryMessenger
+
+ init(messenger: FlutterBinaryMessenger) {
+ self.messenger = messenger
+ super.init()
+ }
+
+ func create(withFrame frame: CGRect, viewIdentifier viewId: Int64, arguments args: Any?) -> any FlutterPlatformView {
+ return StackChanRobot(frame: frame, viewId: viewId, messenger: messenger, args: args)
+ }
+
+ func createArgsCodec() -> any FlutterMessageCodec & NSObjectProtocol {
+ return FlutterStandardMessageCodec.sharedInstance()
+ }
+}
+
+
+class StackChanRotaryRobotViewFactory: NSObject, FlutterPlatformViewFactory {
+ private let messenger: FlutterBinaryMessenger
+
+ init(messenger: FlutterBinaryMessenger) {
+ self.messenger = messenger
+ super.init()
+ }
+
+ func create(withFrame frame: CGRect, viewIdentifier viewId: Int64, arguments args: Any?) -> any FlutterPlatformView {
+ return StackChanRotaryRobot(frame: frame, viewId: viewId, messenger: messenger, args: args)
+ }
+
+ func createArgsCodec() -> any FlutterMessageCodec & NSObjectProtocol {
+ return FlutterStandardMessageCodec.sharedInstance()
+ }
+}
+
+
+class StackChanArViewFactory: NSObject, FlutterPlatformViewFactory {
+ private let messenger: FlutterBinaryMessenger
+
+ init(messenger: FlutterBinaryMessenger) {
+ self.messenger = messenger
+ super.init()
+ }
+
+ func create(withFrame frame: CGRect, viewIdentifier viewId: Int64, arguments args: Any?) -> any FlutterPlatformView {
+ return StackChanArView(frame: frame, viewId: viewId, messenger: messenger, args: args)
+ }
+
+ func createArgsCodec() -> any FlutterMessageCodec & NSObjectProtocol {
+ return FlutterStandardMessageCodec.sharedInstance()
+ }
+
+}
diff --git a/app/ios/RunnerTests/RunnerTests.swift b/app/ios/RunnerTests/RunnerTests.swift
new file mode 100644
index 0000000..0b224ee
--- /dev/null
+++ b/app/ios/RunnerTests/RunnerTests.swift
@@ -0,0 +1,17 @@
+/*
+SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD
+SPDX-License-Identifier: MIT
+*/
+
+import Flutter
+import UIKit
+import XCTest
+
+class RunnerTests: XCTestCase {
+
+ func testExample() {
+ // If you add code to the Runner application, consider adding tests here.
+ // See https://developer.apple.com/documentation/xctest for more information about using XCTest.
+ }
+
+}
diff --git a/app/lib/app_state.dart b/app/lib/app_state.dart
new file mode 100644
index 0000000..1bed214
--- /dev/null
+++ b/app/lib/app_state.dart
@@ -0,0 +1,467 @@
+/*
+SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD
+SPDX-License-Identifier: MIT
+*/
+
+import 'dart:typed_data';
+
+import 'package:dio/dio.dart' show Response;
+import 'package:flutter/cupertino.dart';
+import 'package:flutter/services.dart';
+import 'package:geolocator/geolocator.dart';
+import 'package:get/get.dart' hide Response;
+import 'package:package_info_plus/package_info_plus.dart';
+import 'package:permission_handler/permission_handler.dart';
+import 'package:shared_preferences/shared_preferences.dart';
+import 'package:stack_chan/model/device.dart';
+import 'package:stack_chan/model/model.dart';
+import 'package:stack_chan/network/http.dart';
+import 'package:stack_chan/util/blue_util.dart';
+import 'package:stack_chan/util/value_constant.dart';
+import 'package:stack_chan/view/popup/binding_device.dart';
+import 'package:stack_chan/view/popup/login_page.dart';
+import 'package:uuid/uuid.dart';
+
+import 'model/blue_device_info.dart';
+import 'model/msg_type.dart';
+import 'model/user_info.dart';
+import 'network/urls.dart';
+import 'network/web_socket_util.dart';
+
+class AppState extends GetxController {
+ static final AppState shared = Get.find();
+
+ static final asyncPrefs = SharedPreferencesAsync();
+
+ bool isInitialization = false;
+
+ PackageInfo? packageInfo;
+
+ AppState();
+
+ Future initData() async {
+ deviceMac = await asyncPrefs.getString(ValueConstant.deviceMac) ?? "";
+ _deviceId.value =
+ await asyncPrefs.getString(ValueConstant.deviceId) ?? uuid.v4();
+ _deviceControlMode.value =
+ await asyncPrefs.getInt(ValueConstant.deviceControlMode) ?? 0;
+ isInitialization = true;
+ isLogin.value = await asyncPrefs.getBool(ValueConstant.isLogin) ?? false;
+
+ ///Set status bar and navigation bar to transparent
+ SystemUiOverlayStyle style = SystemUiOverlayStyle(
+ statusBarColor: CupertinoColors.transparent,
+ );
+ SystemChrome.setSystemUIOverlayStyle(style);
+ SystemChrome.setEnabledSystemUIMode(.manual, overlays: [.top, .bottom]);
+
+ ///Lock screen orientation
+ SystemChrome.setPreferredOrientations([.portraitDown, .portraitUp]);
+
+ ///Initialize package manager
+ packageInfo = await PackageInfo.fromPlatform();
+ }
+
+ final uuid = Uuid();
+
+ final RxString _deviceId = "".obs;
+
+ String get deviceId => _deviceId.value;
+
+ final RxBool isLogin = RxBool(false);
+
+ Future setIsLogin(bool login) async {
+ await asyncPrefs.setBool(ValueConstant.isLogin, login);
+ isLogin.value = login;
+ }
+
+ final RxString _deviceMac = "".obs;
+
+ String get deviceMac => _deviceMac.value;
+
+ set deviceMac(String mac) {
+ _deviceMac.value = mac;
+ asyncPrefs.setString(ValueConstant.deviceMac, _deviceMac.value);
+ startSearchXiaoZhiConfig(mac);
+ }
+
+ bool get hasValidDeviceMac => _deviceMac.isNotEmpty;
+
+ final RxBool _deviceIsOnline = false.obs;
+
+ bool get deviceIsOnline => _deviceIsOnline.value;
+
+ set deviceIsOnline(bool deviceIsOnline) {
+ _deviceIsOnline.value = deviceIsOnline;
+ }
+
+ RxList blueDeviceList = RxList([]);
+
+ DateTime? manualShutdownTime;
+
+ bool popupState = false; //Popup state
+
+ Rxn deviceInfo = Rxn();
+
+ final RxInt _deviceControlMode = RxInt(1);
+
+ int get deviceControlMode => _deviceControlMode.value;
+
+ set deviceControlMode(int deviceControlMode) {
+ _deviceControlMode.value = deviceControlMode;
+ asyncPrefs.setInt(ValueConstant.deviceControlMode, deviceControlMode);
+ }
+
+ Function(String?)? toastFunction;
+
+ void showToast(String? msg) {
+ if (toastFunction != null) {
+ toastFunction!(msg);
+ }
+ }
+
+ Rxn userInfo = Rxn();
+
+ ///Location info
+ final Rxn currentLocation = Rxn();
+ final RxBool isLocationAvailable = false.obs;
+
+ bool showBlueDevicesSetStep = false;
+
+ Future logout() async {
+ await setIsLogin(false);
+ await asyncPrefs.remove(ValueConstant.token);
+ userInfo.value = null;
+ deviceInfo.value = null;
+ deviceMac = "";
+ devices.value = [];
+ }
+
+ Future getUserInfo() async {
+ final response = await Http.instance.get(Urls.user);
+ if (response.data != null) {
+ Model responseData = Model.fromJsonT(
+ response.data,
+ factory: (value) => UserInfo.fromJson(value),
+ );
+ if (responseData.isSuccess()) {
+ userInfo.value = responseData.data;
+ }
+ }
+ }
+
+ (MsgType?, Uint8List?) parseMessage(Uint8List message) {
+ if (message.length < 5) {
+ return (null, null);
+ }
+
+ final int typeByte = message[0] & 0xFF;
+ MsgType? msgType;
+ try {
+ msgType = MsgType.values.firstWhere((e) => e.value == typeByte);
+ } on StateError {
+ msgType = null;
+ }
+ if (msgType == null) {
+ return (null, null);
+ }
+
+ int dataLength = 0;
+ for (int i = 1; i <= 4; i++) {
+ dataLength = (dataLength << 8) | (message[i] & 0xFF);
+ }
+
+ final int totalRequiredLength = 5 + dataLength;
+ if (message.length < totalRequiredLength || dataLength < 0) {
+ return (null, null);
+ }
+
+ final Uint8List payload = Uint8List.sublistView(
+ message,
+ 5,
+ totalRequiredLength,
+ );
+ return (msgType, payload);
+ }
+
+ void sendWebSocketMessage(MsgType msgType, {Uint8List? data}) {
+ final payload = data ?? Uint8List(0);
+ final buffer = BytesBuilder();
+
+ buffer.add([msgType.value & 0xFF]);
+
+ final int dataLen = payload.length;
+ final Uint32List lenBytes = Uint32List(1);
+ lenBytes[0] = dataLen;
+ buffer.add([
+ (lenBytes[0] >> 24) & 0xFF,
+ (lenBytes[0] >> 16) & 0xFF,
+ (lenBytes[0] >> 8) & 0xFF,
+ lenBytes[0] & 0xFF,
+ ]);
+
+ buffer.add(payload);
+ WebSocketUtil.shared.send(buffer.toBytes());
+ }
+
+ void webSocketMessageMonitoring() {
+ WebSocketUtil.shared.addObserver("App", (message) {
+ if (message is Uint8List) {
+ final result = parseMessage(message);
+ final msgType = result.$1;
+ if (msgType != null) {
+ switch (msgType) {
+ case .deviceOnline:
+ deviceIsOnline = true;
+ break;
+ case .deviceOffline:
+ deviceIsOnline = false;
+ break;
+ default:
+ break;
+ }
+ }
+ } else if (message is String) {
+ }
+ });
+ }
+
+ void connectWebSocket() {
+ final webSocketUrl =
+ "${Urls.getWebSocketUrl()}?deviceType=App&deviceId=${AppState.shared.deviceId}";
+ WebSocketUtil.shared.connect(webSocketUrl);
+ }
+
+ ///Filter devices for pairing only
+ List screeningDevices(List devices) {
+ List newDevices = [];
+ for (final deviceInfo in devices) {
+ final List? serviceUuids =
+ deviceInfo.advertisementData[ValueConstant.serviceUuids];
+ if (serviceUuids == null || serviceUuids.isEmpty) {
+ continue;
+ }
+ final bool containsTargetUUID = serviceUuids
+ .map((uuid) => uuid.toString().toUpperCase())
+ .contains(BlueUtil.targetServiceUUID.toUpperCase());
+ if (containsTargetUUID) {
+ newDevices.add(deviceInfo);
+ }
+ }
+ return newDevices;
+ }
+
+ ///Upgrade device info
+ Future updateDeviceInfo() async {
+ final Map map = {
+ ValueConstant.mac: deviceMac,
+ ValueConstant.name: deviceInfo.value?.name,
+ };
+ Response response = await Http.instance.put(Urls.deviceInfo, data: map);
+ if (response.data != null) {
+ Model data = Model.fromJsonT(response.data);
+ if (data.isSuccess()) {
+ showToast("Update successful");
+ deviceInfo.refresh();
+ getDevices();
+ } else {
+ showToast("Failed to parse data");
+ }
+ }
+ }
+
+ Future getDeviceInfo() async {
+ final Map map = {ValueConstant.mac: deviceMac};
+ Response response = await Http.instance.get(Urls.deviceInfo, data: map);
+ if (response.data != null) {
+ Model model = Model.fromJsonT(
+ response.data,
+ factory: (value) => Device.fromJson(value),
+ );
+ if (model.isSuccess() && model.data != null) {
+ deviceInfo.value = model.data!;
+ }
+ }
+ }
+
+ //Show bind window
+ void showBindingDevice(BuildContext context) async {
+ if (AppState.shared.popupState) {
+ return;
+ }
+ if (AppState.shared.isLogin.value) {
+ BlueUtil.shared.blueMode = 3;
+ AppState.shared.popupState = true;
+ await showCupertinoSheet(
+ useNestedNavigation: true,
+ context: context,
+ builder: (context) {
+ return BindingDevice();
+ },
+ );
+ BlueUtil.shared.blueMode = 1;
+ AppState.shared.popupState = false;
+ AppState.shared.showBlueDevicesSetStep = false;
+ } else {
+ await showLoginPopup(context);
+ }
+ }
+
+ Future showLoginPopup(BuildContext context) async {
+ AppState.shared.popupState = true;
+ await showCupertinoSheet(
+ context: context,
+ useNestedNavigation: true,
+ enableDrag: false,
+ builder: (context) {
+ return LoginPage();
+ },
+ );
+ AppState.shared.popupState = false;
+ }
+
+ //Query XiaoZhi configuration status
+ Future startSearchXiaoZhiConfig(String mac) async {}
+
+ RxList devices = RxList([]);
+
+ //Get bound device list
+ Future getDevices() async {
+ final response = await Http.instance.get(Urls.devices);
+ if (response.data != null) {
+ Model> responseData = Model.fromJsonT(
+ response.data,
+ factory: (value) => Device.fromListJson(value),
+ );
+ if (responseData.isSuccess() && responseData.data != null) {
+ devices.value = responseData.data!;
+ if (devices.isEmpty) {
+ deviceMac = "";
+ deviceInfo.value = null;
+ } else {
+ if (deviceMac.isEmpty) {
+ switchDevice(devices.first);
+ } else {
+ for (final device in devices) {
+ if (device.mac == deviceMac) {
+ deviceInfo.value = device;
+ break;
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ void switchDevice(Device device) async {
+ deviceInfo.value = device;
+ deviceMac = device.mac;
+ connectWebSocket();
+ }
+
+ Future bindDevice(String mac) async {
+ final map = {ValueConstant.mac: mac};
+ final response = await Http.instance.post(Urls.v2deviceBind, data: map);
+ if (response.data != null) {
+ Model responseData = Model.fromJsonT(response.data);
+ if (responseData.isSuccess()) {
+ showToast("Device binding successful");
+ getDevices();
+ getDeviceInfo();
+
+ ///Proactively reset device default configuration
+ Http.instance.post(Urls.deviceAgentRestore, data: map);
+ return true;
+ }
+ }
+ return false;
+ }
+
+ Future unbindDevice(String mac) async {
+ final map = {ValueConstant.mac: mac};
+ final response = await Http.instance.post(Urls.v2deviceUnbind, data: map);
+ if (response.data != null) {
+ Model responseData = Model.fromJsonT(response.data);
+ if (responseData.isSuccess()) {
+ AppState.shared.showToast("Device unbinding successful");
+ AppState.shared.deviceMac = "";
+ AppState.shared.deviceInfo.value = null;
+ getDevices();
+ } else {
+ AppState.shared.showToast(responseData.message);
+ }
+ }
+ }
+
+ ///Get current phone location
+ Future obtainLocation() async {
+ try {
+ //Check if location service is enabled
+ bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
+ if (!serviceEnabled) {
+ showToast(
+ "Location service is not enabled. Please enable the location service first",
+ );
+ isLocationAvailable.value = false;
+ return;
+ }
+
+ //Check location permission
+ LocationPermission permission = await Geolocator.checkPermission();
+ if (permission == LocationPermission.denied) {
+ permission = await Geolocator.requestPermission();
+ if (permission == LocationPermission.denied) {
+ showToast(
+ "The location permission was denied, and thus the location information could not be obtained",
+ );
+ isLocationAvailable.value = false;
+ //Guide user to settings page to enable permission
+ await openAppSettings();
+ return;
+ }
+ }
+
+ if (permission == LocationPermission.deniedForever) {
+ showToast(
+ "The location permission has been permanently denied. Please enable the location permission in the settings",
+ );
+ isLocationAvailable.value = false;
+ await openAppSettings();
+ return;
+ }
+
+ //Get current location
+ Position position = await Geolocator.getCurrentPosition(
+ locationSettings: const LocationSettings(
+ accuracy: LocationAccuracy.high,
+ distanceFilter: 100,
+ ),
+ );
+
+ currentLocation.value = position;
+ isLocationAvailable.value = true;
+ } catch (e) {
+ showToast("Failed to obtain location: ${e.toString()}");
+ isLocationAvailable.value = false;
+ }
+ }
+
+ ///Continuously listen for location changes
+ void startLocationUpdates() {
+ Geolocator.getPositionStream(
+ locationSettings: const LocationSettings(
+ accuracy: LocationAccuracy.high,
+ distanceFilter: 100,
+ ),
+ ).listen(
+ (Position position) {
+ currentLocation.value = position;
+ isLocationAvailable.value = true;
+ },
+ onError: (e) {
+ isLocationAvailable.value = false;
+ },
+ );
+ }
+}
diff --git a/app/lib/main.dart b/app/lib/main.dart
new file mode 100644
index 0000000..0ed62f2
--- /dev/null
+++ b/app/lib/main.dart
@@ -0,0 +1,20 @@
+/*
+SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD
+SPDX-License-Identifier: MIT
+*/
+
+import 'package:flutter/cupertino.dart';
+import 'package:get/get_core/src/get_main.dart';
+import 'package:get/get_instance/src/extension_instance.dart';
+import 'package:stack_chan/util/audio_engine_manager.dart';
+import 'package:stack_chan/view/app.dart';
+
+import 'app_state.dart';
+
+void main() async {
+ WidgetsFlutterBinding.ensureInitialized();
+ Get.put(AppState());
+ await AppState.shared.initData();
+ AudioEngineManager.shared.init();
+ runApp(App());
+}
diff --git a/app/lib/model/XiaoZhi/License.dart b/app/lib/model/XiaoZhi/License.dart
new file mode 100644
index 0000000..3459af0
--- /dev/null
+++ b/app/lib/model/XiaoZhi/License.dart
@@ -0,0 +1,69 @@
+/*
+SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD
+SPDX-License-Identifier: MIT
+*/
+
+///licenseentityClass
+
+
+class License {
+ int? id;
+ int? productId;
+ String? serialNumber;
+ String? licenseAlgorithm;
+ String? licenseKey;
+ String? status;
+ String? createdAt;
+ String? updatedAt;
+ dynamic activateAt;
+ String? seed;
+ String? billingType;
+ dynamic macAddress;
+
+ License({
+ this.id,
+ this.productId,
+ this.serialNumber,
+ this.licenseAlgorithm,
+ this.licenseKey,
+ this.status,
+ this.createdAt,
+ this.updatedAt,
+ this.activateAt,
+ this.seed,
+ this.billingType,
+ this.macAddress,
+ });
+
+ ///JSON to License object
+ factory License.fromJson(Map json) => License(
+ id: json['id'] as int?,
+ productId: json['product_id'] as int?,
+ serialNumber: json['serial_number'] as String?,
+ licenseAlgorithm: json['license_algorithm'] as String?,
+ licenseKey: json['license_key'] as String?,
+ status: json['status'] as String?,
+ createdAt: json['created_at'] as String?,
+ updatedAt: json['updated_at'] as String?,
+ activateAt: json['activate_at'],
+ seed: json['seed'] as String?,
+ billingType: json['billing_type'] as String?,
+ macAddress: json['mac_address'],
+ );
+
+ ///objectto JSON
+ Map toJson() => {
+ 'id': id,
+ 'product_id': productId,
+ 'serial_number': serialNumber,
+ 'license_algorithm': licenseAlgorithm,
+ 'license_key': licenseKey,
+ 'status': status,
+ 'created_at': createdAt,
+ 'updated_at': updatedAt,
+ 'activate_at': activateAt,
+ 'seed': seed,
+ 'billing_type': billingType,
+ 'mac_address': macAddress,
+ };
+}
diff --git a/app/lib/model/XiaoZhi/agent.dart b/app/lib/model/XiaoZhi/agent.dart
new file mode 100644
index 0000000..ed5dffe
--- /dev/null
+++ b/app/lib/model/XiaoZhi/agent.dart
@@ -0,0 +1,171 @@
+/*
+SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD
+SPDX-License-Identifier: MIT
+*/
+
+import '../XiaoZhi/device.dart';
+
+class Agent {
+ int? id;
+ int? user_id;
+ String? agent_name;
+ String? tts_voice;
+ String? llm_model;
+ String? assistant_name;
+ String? user_name;
+ String? created_at;
+ String? updated_at;
+ String? memory;
+ String? character;
+ int? long_memory_switch;
+ String? lang_code;
+ String? language;
+ String? tts_speech_speed;
+ String? asr_speed;
+ int? is_deleted;
+ int? tts_pitch;
+ int? agent_template_id;
+ List? knowledge_base_ids;
+ String? memory_updated_at;
+ int? share_agent_id;
+ String? source;
+ List? mcp_endpoints;
+ String? memory_type;
+ int? max_message_count;
+ int? deviceCount;
+ LastDevice? lastDevice;
+
+ //Constructorfunction
+ Agent({
+ this.id,
+ this.user_id,
+ this.agent_name,
+ this.tts_voice,
+ this.llm_model,
+ this.assistant_name,
+ this.user_name,
+ this.created_at,
+ this.updated_at,
+ this.memory,
+ this.character,
+ this.long_memory_switch,
+ this.lang_code,
+ this.language,
+ this.tts_speech_speed,
+ this.asr_speed,
+ this.is_deleted,
+ this.tts_pitch,
+ this.agent_template_id,
+ this.knowledge_base_ids,
+ this.memory_updated_at,
+ this.share_agent_id,
+ this.source,
+ this.mcp_endpoints,
+ this.memory_type,
+ this.max_message_count,
+ this.deviceCount,
+ this.lastDevice,
+ });
+
+ //fromsingle JSON objectDeserialize
+ factory Agent.fromJson(Map json) {
+ return Agent(
+ id: json['id'] as int?,
+ user_id: json['user_id'] as int?,
+ agent_name: json['agent_name'] as String?,
+ tts_voice: json['tts_voice'] as String?,
+ llm_model: json['llm_model'] as String?,
+ assistant_name: json['assistant_name'] as String?,
+ user_name: json['user_name'] as String?,
+ created_at: json['created_at'] as String?,
+ updated_at: json['updated_at'] as String?,
+ memory: json['memory'] as String?,
+ character: json['character'] as String?,
+ long_memory_switch: json['long_memory_switch'] as int?,
+ lang_code: json['lang_code'] as String?,
+ language: json['language'] as String?,
+ tts_speech_speed: json['tts_speech_speed'] as String?,
+ asr_speed: json['asr_speed'] as String?,
+ is_deleted: json['is_deleted'] as int?,
+ tts_pitch: json['tts_pitch'] as int?,
+ agent_template_id: json['agent_template_id'] as int?,
+ knowledge_base_ids: json['knowledge_base_ids'] != null
+ ? List.from(json['knowledge_base_ids'] as List)
+ : null,
+ memory_updated_at: json['memory_updated_at'] as String?,
+ share_agent_id: json['share_agent_id'] as int?,
+ source: json['source'] as String?,
+ mcp_endpoints: json['mcp_endpoints'] != null
+ ? List.from(json['mcp_endpoints'] as List)
+ : null,
+ memory_type: json['memory_type'] as String?,
+ max_message_count: json['max_message_count'] as int?,
+ deviceCount: json['deviceCount'] as int?,
+ lastDevice: json['lastDevice'] != null && json['lastDevice'] is Map
+ ? LastDevice.fromJson(json['lastDevice'] as Map)
+ : null,
+ );
+ }
+
+ //newIncrease / Add:from JSON arrayconvertas List
+ static List fromListJson(List? jsonList) {
+ //nullFallback:ifPassed null/Nullarray,returnNulllist
+ if (jsonList == null || jsonList.isEmpty) {
+ return [];
+ }
+ //iteratearray,one by oneconvertas Agent object
+ return jsonList.map((jsonItem) {
+ //Fallback / Error handlinghandle:EnsureeachElementis Map type
+ if (jsonItem is Map) {
+ return Agent.fromJson(jsonItem);
+ } else {
+ //Non- Map typereturnNull Agent(oraccording toThrows/skip)
+ return Agent();
+ }
+ }).toList();
+ }
+
+ //Serializeassingle JSON object
+ Map toJson() {
+ final Map data = {};
+ data['id'] = id;
+ data['user_id'] = user_id;
+ data['agent_name'] = agent_name;
+ data['tts_voice'] = tts_voice;
+ data['llm_model'] = llm_model;
+ data['assistant_name'] = assistant_name;
+ data['user_name'] = user_name;
+ data['created_at'] = created_at;
+ data['updated_at'] = updated_at;
+ data['memory'] = memory;
+ data['character'] = character;
+ data['long_memory_switch'] = long_memory_switch;
+ data['lang_code'] = lang_code;
+ data['language'] = language;
+ data['tts_speech_speed'] = tts_speech_speed;
+ data['asr_speed'] = asr_speed;
+ data['is_deleted'] = is_deleted;
+ data['tts_pitch'] = tts_pitch;
+ data['agent_template_id'] = agent_template_id;
+ data['knowledge_base_ids'] = knowledge_base_ids;
+ data['memory_updated_at'] = memory_updated_at;
+ data['share_agent_id'] = share_agent_id;
+ data['source'] = source;
+ data['mcp_endpoints'] = mcp_endpoints;
+ data['memory_type'] = memory_type;
+ data['max_message_count'] = max_message_count;
+ data['deviceCount'] = deviceCount;
+ if (lastDevice != null) {
+ data['lastDevice'] = lastDevice!.toJson();
+ }
+ return data;
+ }
+
+ //optionalExtension:will List convertas JSON array
+ static List