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: + +| ![](https://m5stack-doc.oss-cn-shenzhen.aliyuncs.com/1205/avatar_stack_chan.jpg) | ![](https://m5stack-doc.oss-cn-shenzhen.aliyuncs.com/1205/avatar_takao.jpg) | +| -------------------------------------------------------------------------------- | --------------------------------------------------------------------------- | +| [@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 + +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) +[![Flutter](https://img.shields.io/badge/Flutter-3.0+-blue.svg)](https://flutter.dev/) +[![Dart](https://img.shields.io/badge/Dart-3.0+-blue.svg)](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> toListJson(List? agentList) { + if (agentList == null || agentList.isEmpty) { + return []; + } + return agentList.map((agent) => agent.toJson()).toList(); + } +} diff --git a/app/lib/model/XiaoZhi/agent_create.dart b/app/lib/model/XiaoZhi/agent_create.dart new file mode 100644 index 0000000..09291e8 --- /dev/null +++ b/app/lib/model/XiaoZhi/agent_create.dart @@ -0,0 +1,78 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +class AgentCreate { + String? agent_name; + String? assistant_name; + String? llm_model; + String? tts_voice; + String? tts_speech_speed; + int? tts_pitch; + String? asr_speed; + String? language; + String? character; + String? memory; + String? memory_type; + List? mcp_endpoints; + List? product_mcp_endpoints; + + //Constructorfunction + AgentCreate({ + this.agent_name, + this.assistant_name, + this.llm_model, + this.tts_voice, + this.tts_speech_speed, + this.tts_pitch, + this.asr_speed, + this.language, + this.character, + this.memory, + this.memory_type, + this.mcp_endpoints, + this.product_mcp_endpoints, + }); + + //from JSON Deserialize(factory Factorymethod) + factory AgentCreate.fromJson(Map json) { + return AgentCreate( + agent_name: json['agent_name'] as String?, + assistant_name: json['assistant_name'] as String?, + llm_model: json['llm_model'] as String?, + tts_voice: json['tts_voice'] as String?, + tts_speech_speed: json['tts_speech_speed'] as String?, + tts_pitch: json['tts_pitch'] as int?, + //Count / NumberValuetypeSeparatelyhandle + asr_speed: json['asr_speed'] as String?, + language: json['language'] as String?, + character: json['character'] as String?, + memory: json['memory'] as String?, + memory_type: json['memory_type'] as String?, + //handledynamicarray,directCast(List Compatible withanyarraytype) + mcp_endpoints: json['mcp_endpoints'] as List?, + product_mcp_endpoints: json['product_mcp_endpoints'] as List?, + ); + } + + //Serializeas JSON(return Map),onlyContainsNot emptyfield + Map toJson() { + final Map data = {}; + //only whenfieldValueNotas null when,Only thenaddto Map in + if (agent_name != null) data['agent_name'] = agent_name; + if (assistant_name != null) data['assistant_name'] = assistant_name; + if (llm_model != null) data['llm_model'] = llm_model; + if (tts_voice != null) data['tts_voice'] = tts_voice; + if (tts_speech_speed != null) data['tts_speech_speed'] = tts_speech_speed; + if (tts_pitch != null) data['tts_pitch'] = tts_pitch; + if (asr_speed != null) data['asr_speed'] = asr_speed; + if (language != null) data['language'] = language; + if (character != null) data['character'] = character; + if (memory != null) data['memory'] = memory; + if (memory_type != null) data['memory_type'] = memory_type; + data['mcp_endpoints'] = mcp_endpoints; + data['product_mcp_endpoints'] = product_mcp_endpoints; + return data; + } +} diff --git a/app/lib/model/XiaoZhi/agent_template.dart b/app/lib/model/XiaoZhi/agent_template.dart new file mode 100644 index 0000000..63df22e --- /dev/null +++ b/app/lib/model/XiaoZhi/agent_template.dart @@ -0,0 +1,97 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +class AgentTemplate { + int? id; + int? developer_id; + String? agent_name; + List? tts_voices; + String? default_tts_voice; + String? llm_model; + String? assistant_name; + String? user_name; + String? created_at; + String? updated_at; + String? character; + String? tts_speech_speed; + String? asr_speed; + int? tts_pitch; + List? knowledge_base_ids; + String? xiaozhi_version; + String? tts_voice_name; + + //defaultConstructorfunction + AgentTemplate({ + this.id, + this.developer_id, + this.agent_name, + this.tts_voices, + this.default_tts_voice, + this.llm_model, + this.assistant_name, + this.user_name, + this.created_at, + this.updated_at, + this.character, + this.tts_speech_speed, + this.asr_speed, + this.tts_pitch, + this.knowledge_base_ids, + this.xiaozhi_version, + this.tts_voice_name, + }); + + //fromJSONparseFactorymethod + factory AgentTemplate.fromJson(Map json) { + return AgentTemplate( + id: json['id'] as int?, + developer_id: json['developer_id'] as int?, + agent_name: json['agent_name'] as String?, + //handlelisttype,avoidnullorNon-listtypeCauseCrash + tts_voices: json['tts_voices'] != null + ? List.from(json['tts_voices'] as List) + : null, + default_tts_voice: json['default_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?, + character: json['character'] as String?, + tts_speech_speed: json['tts_speech_speed'] as String?, + asr_speed: json['asr_speed'] as String?, + tts_pitch: json['tts_pitch'] as int?, + knowledge_base_ids: json['knowledge_base_ids'], + //dynamictypedirectAssignValue + xiaozhi_version: json['xiaozhi_version'] as String?, + tts_voice_name: json['tts_voice_name'] as String?, + ); + } + + //convertasJSONmethod + Map toJson() { + final Map data = {}; + if (id != null) data['id'] = id; + if (developer_id != null) data['developer_id'] = developer_id; + if (agent_name != null) data['agent_name'] = agent_name; + if (tts_voices != null) data['tts_voices'] = tts_voices; + if (default_tts_voice != null) + data['default_tts_voice'] = default_tts_voice; + if (llm_model != null) data['llm_model'] = llm_model; + if (assistant_name != null) data['assistant_name'] = assistant_name; + if (user_name != null) data['user_name'] = user_name; + if (created_at != null) data['created_at'] = created_at; + if (updated_at != null) data['updated_at'] = updated_at; + if (character != null) data['character'] = character; + if (tts_speech_speed != null) data['tts_speech_speed'] = tts_speech_speed; + if (asr_speed != null) data['asr_speed'] = asr_speed; + if (tts_pitch != null) data['tts_pitch'] = tts_pitch; + if (knowledge_base_ids != null) + data['knowledge_base_ids'] = knowledge_base_ids; + if (xiaozhi_version != null) data['xiaozhi_version'] = xiaozhi_version; + if (tts_voice_name != null) data['tts_voice_name'] = tts_voice_name; + return data; + } +} diff --git a/app/lib/model/XiaoZhi/agents_device.dart b/app/lib/model/XiaoZhi/agents_device.dart new file mode 100644 index 0000000..8f048fe --- /dev/null +++ b/app/lib/model/XiaoZhi/agents_device.dart @@ -0,0 +1,83 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +class AgentsDevice { + int? id; + int? user_id; + String? mac_address; + String? created_at; + String? updated_at; + String? last_connected_at; + int? auto_update; + dynamic board; + String? alias; + String? agent_code; + int? agent_id; + String? app_version; + int? is_deleted; + String? board_name; + String? serial_number; + + //Constructorfunction + AgentsDevice({ + this.id, + this.user_id, + this.mac_address, + this.created_at, + this.updated_at, + this.last_connected_at, + this.auto_update, + this.board, + this.alias, + this.agent_code, + this.agent_id, + this.app_version, + this.is_deleted, + this.board_name, + this.serial_number, + }); + + //from JSON convert + factory AgentsDevice.fromJson(Map json) { + return AgentsDevice( + id: json['id'], + user_id: json['user_id'], + mac_address: json['mac_address'], + created_at: json['created_at'], + updated_at: json['updated_at'], + last_connected_at: json['last_connected_at'], + auto_update: json['auto_update'], + board: json['board'], + alias: json['alias'], + agent_code: json['agent_code'], + agent_id: json['agent_id'], + app_version: json['app_version'], + is_deleted: json['is_deleted'], + board_name: json['board_name'], + serial_number: json['serial_number'], + ); + } + + //convertas JSON + Map toJson() { + final Map data = {}; + data['id'] = id; + data['user_id'] = user_id; + data['mac_address'] = mac_address; + data['created_at'] = created_at; + data['updated_at'] = updated_at; + data['last_connected_at'] = last_connected_at; + data['auto_update'] = auto_update; + data['board'] = board; + data['alias'] = alias; + data['agent_code'] = agent_code; + data['agent_id'] = agent_id; + data['app_version'] = app_version; + data['is_deleted'] = is_deleted; + data['board_name'] = board_name; + data['serial_number'] = serial_number; + return data; + } +} diff --git a/app/lib/model/XiaoZhi/agents_devices_activate.dart b/app/lib/model/XiaoZhi/agents_devices_activate.dart new file mode 100644 index 0000000..63ac725 --- /dev/null +++ b/app/lib/model/XiaoZhi/agents_devices_activate.dart @@ -0,0 +1,53 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + + +import 'agent.dart'; +import 'agents_device.dart'; + +class AgentsDevicesActivate { + String? macAddress; + String? serialNumber; + int? agentId; + Agent? agent; + AgentsDevice? device; + + //Constructorfunction + AgentsDevicesActivate({ + this.macAddress, + this.serialNumber, + this.agentId, + this.agent, + this.device, + }); + + //from JSON convert + factory AgentsDevicesActivate.fromJson(Map json) { + return AgentsDevicesActivate( + macAddress: json['macAddress'], + serialNumber: json['serialNumber'], + agentId: json['agentId'], + agent: json['agent'] != null ? Agent.fromJson(json['agent']) : null, + device: json['device'] != null + ? AgentsDevice.fromJson(json['device']) + : null, + ); + } + + //convertas JSON + Map toJson() { + final Map data = {}; + data['macAddress'] = macAddress; + data['serialNumber'] = serialNumber; + data['agentId'] = agentId; + if (agent != null) { + data['agent'] = agent!.toJson(); + } + if (device != null) { + data['device'] = device!.toJson(); + } + return data; + } +} \ No newline at end of file diff --git a/app/lib/model/XiaoZhi/common_mcp_tool.dart b/app/lib/model/XiaoZhi/common_mcp_tool.dart new file mode 100644 index 0000000..feb19a0 --- /dev/null +++ b/app/lib/model/XiaoZhi/common_mcp_tool.dart @@ -0,0 +1,48 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +class CommonMcpTool { + String? endpoint_id; + String? name; + String? language; + + //Constructorfunction + CommonMcpTool({this.endpoint_id, this.name, this.language}); + + //1. singleobjectfrom JSON Deserialize + factory CommonMcpTool.fromJson(Map json) { + return CommonMcpTool( + endpoint_id: json['endpoint_id'] as String?, + name: json['name'] as String?, + language: json['language'] as String?, + ); + } + + //2. singleobjectSerializeas JSON + Map toJson() { + final Map data = {}; + data['endpoint_id'] = endpoint_id; + data['name'] = name; + data['language'] = language; + return data; + } + + //3. JSON arrayconvertas CommonMcpTool list(core FromListJson Implement) + static List fromListJson(List jsonList) { + //iterate JSON array,one by oneconvertas CommonMcpTool object + return jsonList + .map( + (jsonItem) => + CommonMcpTool.fromJson(jsonItem as Map), + ) + .toList(); + } + + //optionalExtension:CommonMcpTool listconvertas JSON array + static List> toListJson(List? toolList) { + if (toolList == null) return []; + return toolList.map((tool) => tool.toJson()).toList(); + } +} diff --git a/app/lib/model/XiaoZhi/conversation.dart b/app/lib/model/XiaoZhi/conversation.dart new file mode 100644 index 0000000..8e8f6c8 --- /dev/null +++ b/app/lib/model/XiaoZhi/conversation.dart @@ -0,0 +1,93 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +class Conversation { + int? id; + int? user_id; + String? created_at; + int? device_id; + int? msg_count; + int? agent_id; + String? model; + int? token_count; + int? duration; + ChatSummary? chat_summary; + + //Constructorfunction + Conversation({ + this.id, + this.user_id, + this.created_at, + this.device_id, + this.msg_count, + this.agent_id, + this.model, + this.token_count, + this.duration, + this.chat_summary, + }); + + //fromJSONconvert + factory Conversation.fromJson(Map json) { + return Conversation( + id: json['id'] as int?, + user_id: json['user_id'] as int?, + created_at: json['created_at'] as String?, + device_id: json['device_id'] as int?, + msg_count: json['msg_count'] as int?, + agent_id: json['agent_id'] as int?, + model: json['model'] as String?, + token_count: json['token_count'] as int?, + duration: json['duration'] as int?, + //nestedobjectconvert + chat_summary: json['chat_summary'] != null + ? ChatSummary.fromJson(json['chat_summary'] as Map) + : null, + ); + } + + //convertasJSON + Map toJson() { + final Map data = {}; + data['id'] = id; + data['user_id'] = user_id; + data['created_at'] = created_at; + data['device_id'] = device_id; + data['msg_count'] = msg_count; + data['agent_id'] = agent_id; + data['model'] = model; + data['token_count'] = token_count; + data['duration'] = duration; + //handlenestedobjectJSONconvert + if (chat_summary != null) { + data['chat_summary'] = chat_summary!.toJson(); + } + return data; + } +} + +class ChatSummary { + String? title; + String? summary; + + //Constructorfunction + ChatSummary({this.title, this.summary}); + + //fromJSONconvert + factory ChatSummary.fromJson(Map json) { + return ChatSummary( + title: json['title'] as String?, + summary: json['summary'] as String?, + ); + } + + //convertasJSON + Map toJson() { + final Map data = {}; + data['title'] = title; + data['summary'] = summary; + return data; + } +} diff --git a/app/lib/model/XiaoZhi/conversation_message_data.dart b/app/lib/model/XiaoZhi/conversation_message_data.dart new file mode 100644 index 0000000..ece4e07 --- /dev/null +++ b/app/lib/model/XiaoZhi/conversation_message_data.dart @@ -0,0 +1,105 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +class ConversationMessageData { + int? id; + int? user_id; + int? chat_id; + String? role; + String? content; + String? voice_embedding_id; + String? created_at; + String? name; + int? prompt_tokens; + int? total_tokens; + int? completion_tokens; + int? prompt_ms; + int? total_ms; + int? completion_ms; + String? model; + String? url; + + //optionalparameterConstructorfunction + ConversationMessageData({ + this.id, + this.user_id, + this.chat_id, + this.role, + this.content, + this.voice_embedding_id, + this.created_at, + this.name, + this.prompt_tokens, + this.total_tokens, + this.completion_tokens, + this.prompt_ms, + this.total_ms, + this.completion_ms, + this.model, + this.url, + }); + + //fromJSONconvert(Factorymethod,handlenullandtypesafe) + factory ConversationMessageData.fromJson(Map json) { + return ConversationMessageData( + id: json['id'] is int ? json['id'] as int : null, + user_id: json['user_id'] is int ? json['user_id'] as int : null, + chat_id: json['chat_id'] is int ? json['chat_id'] as int : null, + role: json['role'] is String ? json['role'] as String : null, + content: json['content'] is String ? json['content'] as String : null, + voice_embedding_id: json['voice_embedding_id'] is String + ? json['voice_embedding_id'] as String + : null, + created_at: json['created_at'] is String + ? json['created_at'] as String + : null, + name: json['name'] is String ? json['name'] as String : null, + prompt_tokens: json['prompt_tokens'] is int + ? json['prompt_tokens'] as int + : null, + total_tokens: json['total_tokens'] is int + ? json['total_tokens'] as int + : null, + completion_tokens: json['completion_tokens'] is int + ? json['completion_tokens'] as int + : null, + prompt_ms: json['prompt_ms'] is int ? json['prompt_ms'] as int : null, + total_ms: json['total_ms'] is int ? json['total_ms'] as int : null, + completion_ms: json['completion_ms'] is int + ? json['completion_ms'] as int + : null, + model: json['model'] is String ? json['model'] as String : null, + url: json['url'] is String ? json['url'] as String : null, + ); + } + + //convertasJSON(handlenull,avoidnullfieldPollute / Add unnecessaryJSON) + Map toJson() { + final Map data = {}; + + //onlyaddNot emptyfield,ReduceJSONSize + if (id != null) data['id'] = id; + if (user_id != null) data['user_id'] = user_id; + if (chat_id != null) data['chat_id'] = chat_id; + if (role != null) data['role'] = role; + if (content != null) data['content'] = content; + if (voice_embedding_id != null) { + data['voice_embedding_id'] = voice_embedding_id; + } + if (created_at != null) data['created_at'] = created_at; + if (name != null) data['name'] = name; + if (prompt_tokens != null) data['prompt_tokens'] = prompt_tokens; + if (total_tokens != null) data['total_tokens'] = total_tokens; + if (completion_tokens != null) { + data['completion_tokens'] = completion_tokens; + } + if (prompt_ms != null) data['prompt_ms'] = prompt_ms; + if (total_ms != null) data['total_ms'] = total_ms; + if (completion_ms != null) data['completion_ms'] = completion_ms; + if (model != null) data['model'] = model; + if (url != null) data['url'] = url; + return data; + } +} diff --git a/app/lib/model/XiaoZhi/device.dart b/app/lib/model/XiaoZhi/device.dart new file mode 100644 index 0000000..03e0cec --- /dev/null +++ b/app/lib/model/XiaoZhi/device.dart @@ -0,0 +1,139 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +class Device { + int? device_id; + int? agent_id; + int? id; + int? product_id; + String? seed; + String? serial_number; + String? activate_at; + String? product_name; + String? mac_address; + String? app_version; + String? board_name; + String? client_id; + String? iccid; + String? imei; + bool? online; + + //Constructorfunction + Device({ + this.device_id, + this.agent_id, + this.id, + this.product_id, + this.seed, + this.serial_number, + this.activate_at, + this.product_name, + this.mac_address, + this.app_version, + this.board_name, + this.client_id, + this.iccid, + this.imei, + this.online, + }); + + //fromJSONparse + factory Device.fromJson(Map json) { + return Device( + device_id: json['device_id'] as int?, + agent_id: json['agent_id'] as int?, + id: json['id'] as int?, + product_id: json['product_id'] as int?, + seed: json['seed'] as String?, + serial_number: json['serial_number'] as String?, + activate_at: json['activate_at'] as String?, + product_name: json['product_name'] as String?, + mac_address: json['mac_address'] as String?, + app_version: json['app_version'] as String?, + board_name: json['board_name'] as String?, + client_id: json['client_id'] as String?, + iccid: json['iccid'] as String?, + imei: json['imei'] as String?, + online: json['online'] as bool?, + ); + } + + //convertasJSON + Map toJson() { + final Map data = {}; + if (device_id != null) data['device_id'] = device_id; + if (agent_id != null) data['agent_id'] = agent_id; + if (id != null) data['id'] = id; + if (product_id != null) data['product_id'] = product_id; + if (seed != null) data['seed'] = seed; + if (serial_number != null) data['serial_number'] = serial_number; + if (activate_at != null) data['activate_at'] = activate_at; + if (product_name != null) data['product_name'] = product_name; + if (mac_address != null) data['mac_address'] = mac_address; + if (app_version != null) data['app_version'] = app_version; + if (board_name != null) data['board_name'] = board_name; + if (client_id != null) data['client_id'] = client_id; + if (iccid != null) data['iccid'] = iccid; + if (imei != null) data['imei'] = imei; + if (online != null) data['online'] = online; + return data; + } +} + +class LastDevice { + int? id; + int? user_id; + String? mac_address; + String? created_at; + String? updated_at; + String? last_connected_at; + int? auto_update; + String? alias; + int? agent_id; + + //Constructorfunction(allfieldasoptionalparameter,Nullsafe) + LastDevice({ + this.id, + this.user_id, + this.mac_address, + this.created_at, + this.updated_at, + this.last_connected_at, + this.auto_update, + this.alias, + this.agent_id, + }); + + //from JSON Deserialize(factory Factorymethod,) + factory LastDevice.fromJson(Map json) { + return LastDevice( + id: json['id'] as int?, + user_id: json['user_id'] as int?, + mac_address: json['mac_address'] as String?, + created_at: json['created_at'] as String?, + updated_at: json['updated_at'] as String?, + last_connected_at: json['last_connected_at'] as String?, + auto_update: json['auto_update'] as int?, + alias: json['alias'] as String?, + agent_id: json['agent_id'] as int?, + ); + } + + //Serializeas JSON(return Map,Candirectfor jsonEncode) + Map toJson() { + final Map data = {}; + //one by onemapfield,nullWillautoSerializeas null( JSON standard) + data['id'] = id; + data['user_id'] = user_id; + data['mac_address'] = mac_address; + data['created_at'] = created_at; + data['updated_at'] = updated_at; + data['last_connected_at'] = last_connected_at; + data['auto_update'] = auto_update; + data['alias'] = alias; + data['agent_id'] = agent_id; + return data; + } +} diff --git a/app/lib/model/XiaoZhi/endpoints_response.dart b/app/lib/model/XiaoZhi/endpoints_response.dart new file mode 100644 index 0000000..76d2a5f --- /dev/null +++ b/app/lib/model/XiaoZhi/endpoints_response.dart @@ -0,0 +1,103 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +///responsemodel +class EndpointsResponse { + final List endpoints; + + EndpointsResponse({required this.endpoints}); + + factory EndpointsResponse.fromJson(Map json) { + return EndpointsResponse( + endpoints: + (json['endpoints'] as List?) + ?.map((e) => Endpoint.fromJson(e as Map)) + .toList() ?? + [], + ); + } +} + +///singleendpointinfo +class Endpoint { + final String endpointId; + final int connectionCount; + final String status; + final int rpm; + final dynamic lastRequestTime; + final int totalRequests; + final List tools; + final List brokers; + + Endpoint({ + required this.endpointId, + required this.connectionCount, + required this.status, + required this.rpm, + required this.lastRequestTime, + required this.totalRequests, + required this.tools, + required this.brokers, + }); + + factory Endpoint.fromJson(Map json) { + return Endpoint( + endpointId: json['endpointId'] ?? '', + connectionCount: json['connectionCount'] ?? 0, + status: json['status'] ?? '', + rpm: json['rpm'] ?? 0, + lastRequestTime: json['lastRequestTime'], + totalRequests: json['totalRequests'] ?? 0, + tools: + (json['tools'] as List?) + ?.map((e) => Tool.fromJson(e as Map)) + .toList() ?? + [], + brokers: + (json['brokers'] as List?) + ?.map((e) => e.toString()) + .toList() ?? + [], + ); + } +} + +///toolinfo +class Tool { + final String name; + final String description; + final InputSchema inputSchema; + + Tool({ + required this.name, + required this.description, + required this.inputSchema, + }); + + factory Tool.fromJson(Map json) { + return Tool( + name: json['name'] ?? '', + description: json['description'] ?? '', + inputSchema: InputSchema.fromJson(json['inputSchema'] ?? {}), + ); + } +} + +///input schema +class InputSchema { + final String type; + final Map? properties; + + InputSchema({required this.type, this.properties}); + + factory InputSchema.fromJson(Map json) { + return InputSchema( + type: json['type'] ?? 'object', + properties: json['properties'] != null + ? Map.from(json['properties']) + : null, + ); + } +} diff --git a/app/lib/model/XiaoZhi/generateLicense.dart b/app/lib/model/XiaoZhi/generateLicense.dart new file mode 100644 index 0000000..4c6fb7f --- /dev/null +++ b/app/lib/model/XiaoZhi/generateLicense.dart @@ -0,0 +1,53 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +class GenerateLicense { + //fieldDartnamingstandard,parsewhenmapAPIreturnDownnaming + String? productName; + String? boardName; + String? serialNumber; + String? licenseKey; + String? licenseAlgorithm; + String? createdAt; + dynamic firmware; + + //defaultConstructorfunction + GenerateLicense({ + this.productName, + this.boardName, + this.serialNumber, + this.licenseKey, + this.licenseAlgorithm, + this.createdAt, + this.firmware, + }); + + //core:fromJsonFactorymethod,parseJSONdatatoobject + factory GenerateLicense.fromJson(Map json) { + return GenerateLicense( + //mapAPIreturnDownfieldtoDartfield + productName: json['product_name'] as String?, + boardName: json['board_name'] as String?, + serialNumber: json['serial_number'] as String?, + licenseKey: json['license_key'] as String?, + licenseAlgorithm: json['license_algorithm'] as String?, + createdAt: json['created_at'] as String?, + firmware: json['firmware'], //dynamictypedirect + ); + } + + //optional:addtoJsonmethod(For easylaterSerialize,Local) + Map toJson() { + return { + 'product_name': productName, + 'board_name': boardName, + 'serial_number': serialNumber, + 'license_key': licenseKey, + 'license_algorithm': licenseAlgorithm, + 'created_at': createdAt, + 'firmware': firmware, + }; + } +} diff --git a/app/lib/model/XiaoZhi/mcp_endpoints.dart b/app/lib/model/XiaoZhi/mcp_endpoints.dart new file mode 100644 index 0000000..1dcb014 --- /dev/null +++ b/app/lib/model/XiaoZhi/mcp_endpoints.dart @@ -0,0 +1,64 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +class McpEndpoints { + int? id; + int? developerId; //namingstandard:snake_case camelCase + String? name; + String? description; + int? enabled; + String? createdAt; //namingstandard:snake_case camelCase + String? updatedAt; //namingstandard:snake_case camelCase + + //defaultConstructorfunction + McpEndpoints({ + this.id, + this.developerId, + this.name, + this.description, + this.enabled, + this.createdAt, + this.updatedAt, + }); + + ///fromsingle JSON objectconvertas McpEndpoints instance + factory McpEndpoints.fromJson(Map json) { + return McpEndpoints( + id: json['id'] as int?, + developerId: json['developer_id'] as int?, + //map snake_case field + name: json['name'] as String?, + description: json['description'] as String?, + enabled: json['enabled'] as int?, + createdAt: json['created_at'] as String?, + //map snake_case field + updatedAt: json['updated_at'] as String?, //map snake_case field + ); + } + + ///from JSON arrayconvertas McpEndpoints list + static List fromListJson(List jsonList) { + //nullhandle + typeverify,avoidCrash + if (jsonList.isEmpty) return []; + + return jsonList + .where((item) => item is Map) //filter Map type + .map((item) => McpEndpoints.fromJson(item as Map)) + .toList(); + } + + ///convertas JSON object(forNetworkrequest/) + Map toJson() { + return { + 'id': id, + 'developer_id': developerId, // snake_case adaptafter + 'name': name, + 'description': description, + 'enabled': enabled, + 'created_at': createdAt, // snake_case adaptafter + 'updated_at': updatedAt, // snake_case adaptafter + }; + } +} \ No newline at end of file diff --git a/app/lib/model/XiaoZhi/pagination.dart b/app/lib/model/XiaoZhi/pagination.dart new file mode 100644 index 0000000..0a98907 --- /dev/null +++ b/app/lib/model/XiaoZhi/pagination.dart @@ -0,0 +1,52 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +class Pagination { + int? total; + int? current; + int? pageSize; + bool? hasMore; + int? page; + int? limit; + int? totaPages; //:fieldiserror,is totalPages + + //Constructorfunction + Pagination({ + this.total, + this.current, + this.pageSize, + this.hasMore, + this.page, + this.limit, + this.totaPages, + }); + + //fromJSONparse + factory Pagination.fromJson(Map json) { + return Pagination( + total: json['total'] as int?, + current: json['current'] as int?, + pageSize: json['pageSize'] as int?, + hasMore: json['hasMore'] as bool?, + page: json['page'] as int?, + limit: json['limit'] as int?, + totaPages: + json['totaPages'] as int? ?? json['totalPages'] as int?, //field + ); + } + + //convertasJSON + Map toJson() { + final Map data = {}; + if (total != null) data['total'] = total; + if (current != null) data['current'] = current; + if (pageSize != null) data['pageSize'] = pageSize; + if (hasMore != null) data['hasMore'] = hasMore; + if (page != null) data['page'] = page; + if (limit != null) data['limit'] = limit; + if (totaPages != null) data['totaPages'] = totaPages; + return data; + } +} diff --git a/app/lib/model/XiaoZhi/product.dart b/app/lib/model/XiaoZhi/product.dart new file mode 100644 index 0000000..856fb94 --- /dev/null +++ b/app/lib/model/XiaoZhi/product.dart @@ -0,0 +1,121 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +///deviceinfoentityClass +class Product { + int? id; + int? developerId; + String? boardName; + String? productType; + String? productName; + String? productDescription; + String? serialNumberPrefix; + String? licenseAlgorithm; + String? createdAt; + String? updatedAt; + McpConfig? mcpConfig; + String? remark; + int? latestFirmwareId; + int? testingFirmwareId; + int? licenseType; + String? voiceCloneStatus; + int? agentTemplateId; + dynamic hotwords; + String? xiaozhiVersion; + String? payMethod; + dynamic asrModel; + int? toolConciseMode; + + Product({ + this.id, + this.developerId, + this.boardName, + this.productType, + this.productName, + this.productDescription, + this.serialNumberPrefix, + this.licenseAlgorithm, + this.createdAt, + this.updatedAt, + this.mcpConfig, + this.remark, + this.latestFirmwareId, + this.testingFirmwareId, + this.licenseType, + this.voiceCloneStatus, + this.agentTemplateId, + this.hotwords, + this.xiaozhiVersion, + this.payMethod, + this.asrModel, + this.toolConciseMode, + }); + + ///JSON toobject + factory Product.fromJson(Map json) => Product( + id: json['id'] as int?, + developerId: json['developer_id'] as int?, + boardName: json['board_name'] as String?, + productType: json['product_type'] as String?, + productName: json['product_name'] as String?, + productDescription: json['product_description'] as String?, + serialNumberPrefix: json['serial_number_prefix'] as String?, + licenseAlgorithm: json['license_algorithm'] as String?, + createdAt: json['created_at'] as String?, + updatedAt: json['updated_at'] as String?, + mcpConfig: json['mcp_config'] == null + ? null + : McpConfig.fromJson(json['mcp_config'] as Map), + remark: json['remark'], + latestFirmwareId: json['latest_firmware_id'] as int?, + testingFirmwareId: json['testing_firmware_id'], + licenseType: json['license_type'] as int?, + voiceCloneStatus: json['voice_clone_status'] as String?, + agentTemplateId: json['agent_template_id'] as int?, + hotwords: json['hotwords'], + xiaozhiVersion: json['xiaozhi_version'] as String?, + payMethod: json['pay_method'] as String?, + asrModel: json['asr_model'], + toolConciseMode: json['tool_concise_mode'] as int?, + ); + + ///objectto JSON + Map toJson() => { + 'id': id, + 'developer_id': developerId, + 'board_name': boardName, + 'product_type': productType, + 'product_name': productName, + 'product_description': productDescription, + 'serial_number_prefix': serialNumberPrefix, + 'license_algorithm': licenseAlgorithm, + 'created_at': createdAt, + 'updated_at': updatedAt, + 'mcp_config': mcpConfig?.toJson(), + 'remark': remark, + 'latest_firmware_id': latestFirmwareId, + 'testing_firmware_id': testingFirmwareId, + 'license_type': licenseType, + 'voice_clone_status': voiceCloneStatus, + 'agent_template_id': agentTemplateId, + 'hotwords': hotwords, + 'xiaozhi_version': xiaozhiVersion, + 'pay_method': payMethod, + 'asr_model': asrModel, + 'tool_concise_mode': toolConciseMode, + }; +} + +///MCP configentityClass +class McpConfig { + List? endpointIds; + + McpConfig({this.endpointIds}); + + factory McpConfig.fromJson(Map json) => + McpConfig(endpointIds: json['endpoint_ids'] as List?); + + Map toJson() => {'endpoint_ids': endpointIds}; +} diff --git a/app/lib/model/XiaoZhi/tts_list.dart b/app/lib/model/XiaoZhi/tts_list.dart new file mode 100644 index 0000000..a1a12ca --- /dev/null +++ b/app/lib/model/XiaoZhi/tts_list.dart @@ -0,0 +1,94 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +class TTsList { + //languagelist + List languages; + + //coreModify:anylanguage -> correspondinglist,supportalllanguagetype + Map>? ttsVoices; + + //Constructorfunction(NullsafedefaultValue) + TTsList({this.languages = const [], this.ttsVoices}); + + //JSON tomodel + factory TTsList.fromJson(Map json) { + //parselanguagelist + final languageList = json['languages'] is List + ? List.from(json['languages'].map((x) => x.toString())) + : []; + + //parsedynamiclanguage Map(core:autoadaptalllanguage key) + Map>? voiceMap; + if (json['tts_voices'] != null) { + voiceMap = {}; + final jsonMap = json['tts_voices'] as Map; + jsonMap.forEach((key, value) { + if (value is List) { + voiceMap![key.toString()] = value + .map((item) => TTsVoice.fromJson(item as Map)) + .toList(); + } + }); + } + + return TTsList(languages: languageList, ttsVoices: voiceMap); + } + + //modelto JSON + Map toJson() { + final data = {}; + data['languages'] = languages; + if (ttsVoices != null) { + data['tts_voices'] = ttsVoices!.map( + (key, value) => + MapEntry(key, value.map((item) => item.toJson()).toList()), + ); + } + return data; + } +} + +class TTsVoice { + bool? top; + String? voiceId; + String? voiceName; + String? language; + String? createdAt; + String? voiceDemo; + + TTsVoice({ + this.top, + this.voiceId, + this.voiceName, + this.language, + this.createdAt, + this.voiceDemo, + }); + + //JSON tomodel + factory TTsVoice.fromJson(Map json) { + return TTsVoice( + top: json['top'] as bool?, + voiceId: json['voice_id']?.toString(), + voiceName: json['voice_name']?.toString(), + language: json['language']?.toString(), + createdAt: json['created_at']?.toString(), + voiceDemo: json['voice_demo']?.toString(), + ); + } + + //modelto JSON + Map toJson() { + final data = {}; + data['top'] = top; + data['voice_id'] = voiceId; + data['voice_name'] = voiceName; + data['language'] = language; + data['created_at'] = createdAt; + data['voice_demo'] = voiceDemo; + return data; + } +} diff --git a/app/lib/model/XiaoZhi/xiaozhi_model.dart b/app/lib/model/XiaoZhi/xiaozhi_model.dart new file mode 100644 index 0000000..ebef0c0 --- /dev/null +++ b/app/lib/model/XiaoZhi/xiaozhi_model.dart @@ -0,0 +1,70 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +class XiaoZhiModel { + List? modelList; + + //Constructorfunction + XiaoZhiModel({this.modelList}); + + //from JSON Deserialize + factory XiaoZhiModel.fromJson(Map json) { + //handlearraytype modelList + var modelListJson = json['modelList'] as List?; + List? modelList; + if (modelListJson != null) { + modelList = modelListJson + .map((item) => ModelData.fromJson(item as Map)) + .toList(); + } + + return XiaoZhiModel(modelList: modelList); + } + + //Serializeas JSON + Map toJson() { + final Map data = {}; + //handlearraytype modelList + if (modelList != null) { + data['modelList'] = modelList!.map((v) => v.toJson()).toList(); + } + return data; + } +} + +class ModelData { + String? name; + String? description; + List? xiaozhi_version; + List? role; + + //Constructorfunction + ModelData({this.name, this.description, this.xiaozhi_version, this.role}); + + //from JSON Deserialize + factory ModelData.fromJson(Map json) { + return ModelData( + name: json['name'] as String?, + description: json['description'] as String?, + //handleStringarray + xiaozhi_version: json['xiaozhi_version'] != null + ? List.from(json['xiaozhi_version'] as List) + : null, + role: json['role'] != null + ? List.from(json['role'] as List) + : null, + ); + } + + //Serializeas JSON + Map toJson() { + final Map data = {}; + data['name'] = name; + data['description'] = description; + data['xiaozhi_version'] = xiaozhi_version; + data['role'] = role; + return data; + } +} diff --git a/app/lib/model/blue_device_info.dart b/app/lib/model/blue_device_info.dart new file mode 100644 index 0000000..1c50481 --- /dev/null +++ b/app/lib/model/blue_device_info.dart @@ -0,0 +1,30 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +import 'package:flutter_blue_plus/flutter_blue_plus.dart'; + +class BlueDeviceInfo { + final BluetoothDevice device; + final Map advertisementData; + final int rssi; + DateTime lastSeen; + + BlueDeviceInfo({ + required this.device, + required this.advertisementData, + required this.rssi, + required this.lastSeen, + }); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is BlueDeviceInfo && + runtimeType == other.runtimeType && + device.remoteId == other.device.remoteId; + + @override + int get hashCode => device.remoteId.hashCode; +} diff --git a/app/lib/model/blue_model.dart b/app/lib/model/blue_model.dart new file mode 100644 index 0000000..de92de0 --- /dev/null +++ b/app/lib/model/blue_model.dart @@ -0,0 +1,142 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +import 'dart:convert'; + +class BlueWifi { + String? ssid; + String? password; + + BlueWifi({this.ssid, this.password}); + + Map toMap() { + return {'ssid': ssid, 'password': password}; + } + + factory BlueWifi.fromMap(Map map) { + return BlueWifi(ssid: map['ssid'], password: map['password']); + } +} + +class BlueEncryptionDecryption { + String? cmd; + String? data; + + //defaultConstructorfunction + BlueEncryptionDecryption({this.cmd, this.data}); + + //fromJson Constructorfunction:from JSON mapcreateinstance + BlueEncryptionDecryption.fromJson(Map json) { + //safefrom JSON inValue,avoidtypeerror + cmd = json['cmd'] as String?; + data = json['data'] as String?; + } + + //optional:toJson method(Convenientwillobjecttoas JSON) + Map toJson() { + final Map json = {}; + if (cmd != null) { + json['cmd'] = cmd; + } + if (data != null) { + json['data'] = data; + } + return json; + } + + //toString method:Stringoutput + @override + String toString() { + return '{\n' + ' cmd: $cmd,\n' + ' data: $data\n' + '}'; + } + + //optional:enhance toString(output,Debug) + String toStringFormatted() { + return 'BlueEncryptionDecryption {\n' + ' cmd: "${cmd ?? 'null'}"\n' + ' data: "${data ?? 'null'}"\n' + '}'; + } +} + +class BlueWifiModel { + String? cmd; + BlueWifi? data; + + BlueWifiModel({this.cmd, this.data}); + + Map toMap() { + return {'cmd': cmd, 'data': data?.toMap()}; + } + + String? toJson() { + try { + return const JsonEncoder.withIndent(' ').convert(toMap()); + } catch (_) { + return null; + } + } + + static BlueWifiModel? fromJson(String json) { + try { + final map = jsonDecode(json) as Map; + return BlueWifiModel( + cmd: map['cmd'], + data: map['data'] != null ? BlueWifi.fromMap(map['data']) : null, + ); + } catch (_) { + return null; + } + } +} + +class BlueNotifyState { + int? type; + String? state; + + BlueNotifyState({this.type, this.state}); + + Map toMap() { + return {'type': type, 'state': state}; + } + + factory BlueNotifyState.fromMap(Map map) { + return BlueNotifyState(type: map['type'], state: map['state']); + } +} + +class BlueNotifyStateModel { + String? cmd; + BlueNotifyState? data; + + BlueNotifyStateModel({this.cmd, this.data}); + + Map toMap() { + return {'cmd': cmd, 'data': data?.toMap()}; + } + + String? toJson() { + try { + return const JsonEncoder.withIndent(' ').convert(toMap()); + } catch (_) { + return null; + } + } + + static BlueNotifyStateModel? fromJson(String json) { + try { + final map = jsonDecode(json) as Map; + return BlueNotifyStateModel( + cmd: map['cmd'], + data: map['data'] != null ? BlueNotifyState.fromMap(map['data']) : null, + ); + } catch (_) { + return null; + } + } +} diff --git a/app/lib/model/dance_list.dart b/app/lib/model/dance_list.dart new file mode 100644 index 0000000..d4876d8 --- /dev/null +++ b/app/lib/model/dance_list.dart @@ -0,0 +1,150 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +import 'package:stack_chan/util/music_util.dart'; +import 'package:uuid/uuid.dart'; + +import 'expression_data.dart'; + +const _uuid = Uuid(); + +class DanceList { + List danceData = []; + int? danceIndex; + String? danceName; + int? id; + String? musicUrl; + + MusicInfo? musicInfo; + + bool isLoading = false; + + DanceList({ + this.danceData = const [], + this.danceIndex, + this.danceName, + this.musicUrl, + this.id, + }); + + factory DanceList.fromJson(Map json) { + return DanceList( + danceData: json['danceData'] != null + ? (json['danceData'] as List) + .map((e) => DanceData.fromJson(e)) + .toList() + : [], + danceIndex: json['danceIndex'], + danceName: json['danceName'], + id: json['id'], + musicUrl: json['musicUrl'], + ); + } + + static List fromListJson(List jsonList) { + if (jsonList.isEmpty) return []; + return jsonList.map((json) => DanceList.fromJson(json)).toList(); + } + + List> danceDataToJson() { + if (danceData.isEmpty) { + return []; + } + return danceData.map((danceDataItem) => danceDataItem.toJson()).toList(); + } + + Map toJson() { + return { + 'danceData': danceData.map((e) => e.toJson()).toList(), + 'danceIndex': danceIndex, + 'danceName': danceName, + 'id': id, + 'musicUrl': musicUrl, + }; + } +} + +class DanceData { + ExpressionItem leftEye; // default weight = 100 + ExpressionItem rightEye; // default weight = 100 + ExpressionItem mouth; // default weight = 0 + MotionDataItem yawServo; // (-1280 ~ 1280) + MotionDataItem pitchServo; // (0 ~ 900) + + String leftRgbColor; + String rightRgbColor; + + int durationMs; + String id; + + static DanceData init() { + return DanceData( + leftEye: ExpressionItem(weight: 100), + rightEye: ExpressionItem(weight: 100), + mouth: ExpressionItem(weight: 0), + yawServo: MotionDataItem(angle: 0), + pitchServo: MotionDataItem(angle: 0), + durationMs: 1000, + ); + } + + DanceData({ + required this.leftEye, + required this.rightEye, + required this.mouth, + required this.yawServo, + required this.pitchServo, + this.leftRgbColor = "#000000", + this.rightRgbColor = "#000000", + required this.durationMs, + String? id, + }) : id = id ?? _uuid.v4(); + + /// Swift init(from decoder:) + factory DanceData.fromJson(Map json) { + return DanceData( + leftEye: ExpressionItem.fromJson(json['leftEye']), + rightEye: ExpressionItem.fromJson(json['rightEye']), + mouth: ExpressionItem.fromJson(json['mouth']), + yawServo: MotionDataItem.fromJson(json['yawServo']), + pitchServo: MotionDataItem.fromJson(json['pitchServo']), + leftRgbColor: json['leftRgbColor'] ?? "#000000", + rightRgbColor: json['rightRgbColor'] ?? "#000000", + durationMs: json['durationMs'], + ); + } + + Map toJson() { + return { + 'leftEye': leftEye.toJson(), + 'rightEye': rightEye.toJson(), + 'mouth': mouth.toJson(), + 'yawServo': yawServo.toJson(), + 'pitchServo': pitchServo.toJson(), + 'leftRgbColor': leftRgbColor, + 'rightRgbColor': rightRgbColor, + 'durationMs': durationMs, + }; + } + + /// Swift copy() + DanceData copy() { + return DanceData( + leftEye: leftEye.copy(), + rightEye: rightEye.copy(), + mouth: mouth.copy(), + yawServo: yawServo.copy(), + pitchServo: pitchServo.copy(), + leftRgbColor: leftRgbColor, + rightRgbColor: rightRgbColor, + durationMs: durationMs, + ); + } + + static List> listToJson(List list) { + if (list.isEmpty) return []; + return list.map((danceData) => danceData.toJson()).toList(); + } +} diff --git a/app/lib/model/device.dart b/app/lib/model/device.dart new file mode 100644 index 0000000..7697bed --- /dev/null +++ b/app/lib/model/device.dart @@ -0,0 +1,37 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +import 'package:uuid/v4.dart'; + +class Device { + String mac; + String? name; + int? uid; + String? bindTime; + + Device({required this.mac, this.name, this.uid, this.bindTime}); + + String getDisplayName() { + if (name != null && name != "") { + return name!; + } else { + return mac; + } + } + + Device.fromJson(Map json) + : mac = json['mac'] ?? UuidV4().toString(), + uid = json['uid'] as int?, + name = json['name']?.toString(), + bindTime = json["bind_time"]?.toString(); + + Map toJson() { + return {'mac': mac, 'name': name, 'uid': uid, 'bind_time': bindTime}; + } + + static List fromListJson(List list) { + return list.map((i) => Device.fromJson(i as Map)).toList(); + } +} diff --git a/app/lib/model/expression_data.dart b/app/lib/model/expression_data.dart new file mode 100644 index 0000000..84be765 --- /dev/null +++ b/app/lib/model/expression_data.dart @@ -0,0 +1,171 @@ +// SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +// SPDX-License-Identifier: MIT + +import 'dart:convert'; + +class ExpressionData { + String type; + ExpressionItem leftEye; + ExpressionItem rightEye; + ExpressionItem mouth; + + ExpressionData({ + this.type = "bleAvatar", + required this.leftEye, + required this.rightEye, + required this.mouth, + }); + + factory ExpressionData.fromJson(Map json) => ExpressionData( + type: json['type'] ?? "bleAvatar", + leftEye: ExpressionItem.fromJson(json['leftEye']), + rightEye: ExpressionItem.fromJson(json['rightEye']), + mouth: ExpressionItem.fromJson(json['mouth']), + ); + + @override + String toString() { + return jsonEncode(toJson()); + } + + Map toJson() => { + 'type': type, + 'leftEye': leftEye.toJson(), + 'rightEye': rightEye.toJson(), + 'mouth': mouth.toJson(), + }; +} + +class ExpressionItem { + int x; + int y; + int rotation; + int weight; + int size; + + ExpressionItem({ + this.x = 0, + this.y = 0, + this.rotation = 0, + this.weight = 0, + this.size = 0, + }); + + ExpressionItem copy() => ExpressionItem( + x: x, + y: y, + rotation: rotation, + weight: weight, + size: size, + ); + + factory ExpressionItem.fromJson(Map json) => ExpressionItem( + x: json['x'] ?? 0, + y: json['y'] ?? 0, + rotation: json['rotation'] ?? 0, + weight: json['weight'] ?? 0, + size: json['size'] ?? 0, + ); + + Map toJson() => { + 'x': x, + 'y': y, + 'rotation': rotation, + 'weight': weight, + 'size': size, + }; +} + +class MotionData { + String type; + MotionDataItem pitchServo; + MotionDataItem yawServo; + + MotionData({ + this.type = "bleMotion", + required this.pitchServo, + required this.yawServo, + }); + + @override + String toString() { + return jsonEncode(toJson()); + } + + factory MotionData.fromJson(Map json) => MotionData( + type: json['type'] ?? "bleMotion", + pitchServo: MotionDataItem.fromJson(json['pitchServo']), + yawServo: MotionDataItem.fromJson(json['yawServo']), + ); + + Map toJson() => { + 'type': type, + 'pitchServo': pitchServo.toJson(), + 'yawServo': yawServo.toJson(), + }; +} + +class MotionDataItem { + int angle; + int speed; + int rotate; + + MotionDataItem({this.angle = 0, this.speed = 500, this.rotate = 0}); + + MotionDataItem copy() => + MotionDataItem(angle: angle, speed: speed, rotate: rotate); + + factory MotionDataItem.fromJson(Map json) { + return MotionDataItem( + angle: json['angle'] ?? 0, + speed: json['speed'] ?? 500, + rotate: json['rotate'] ?? 0, + ); + } + + Map toJson() { + if (angle != 0) { + return {'angle': angle, 'speed': speed}; + } else if (rotate != 0) { + return {'rotate': rotate, 'speed': speed}; + } else { + return {'angle': angle, 'speed': speed}; + } + } +} + +class RgbData { + String? leftRgbColor = "#FFFFFF"; + double? leftRgbDuration = 0.0; + String? rightRgbColor = "#FFFFFF"; + double? rightRgbDuration = 0.0; + + RgbData({ + this.leftRgbColor, + this.leftRgbDuration, + this.rightRgbColor, + this.rightRgbDuration, + }); + + RgbData.fromJson(Map json) { + leftRgbColor = json['leftRgbColor'] ?? "#FFFFFF"; + leftRgbDuration = (json['leftRgbDuration'] ?? 0.0).toDouble(); + rightRgbColor = json['rightRgbColor'] ?? "#FFFFFF"; + rightRgbDuration = (json['rightRgbDuration'] ?? 0.0).toDouble(); + } + + //Serializeas JSON + Map toJson() { + final Map data = {}; + data['leftRgbColor'] = leftRgbColor; + data['leftRgbDuration'] = leftRgbDuration; + data['rightRgbColor'] = rightRgbColor; + data['rightRgbDuration'] = rightRgbDuration; + return data; + } + + @override + String toString() { + return jsonEncode(toJson()); + } +} diff --git a/app/lib/model/model.dart b/app/lib/model/model.dart new file mode 100644 index 0000000..c2990ac --- /dev/null +++ b/app/lib/model/model.dart @@ -0,0 +1,34 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +import 'dart:convert'; + +class Model { + int? code; + String? message; + T? data; + + Model.fromJson(Map map, {T Function(dynamic)? factory}) { + if (map["code"] != null && map["code"] != "null") { + code = map["code"]; + } + if (map["data"] != null && map["data"] != "null" && factory != null) { + data = factory(map["data"]); + } else { + data = map["data"]; + } + if (map["message"] != null && map["message"] != "null") { + message = map["message"]; + } + } + + Model.fromJsonT(dynamic data, {T Function(dynamic)? factory}) + : this.fromJson(data is String ? jsonDecode(data) : data, factory: factory); + + Model.fromJsonString(String? jsonString, {T Function(dynamic)? factory}) + : this.fromJson(jsonDecode(jsonString ?? ""), factory: factory); + + bool isSuccess() => code == 0; +} diff --git a/app/lib/model/msg_type.dart b/app/lib/model/msg_type.dart new file mode 100644 index 0000000..4c8239e --- /dev/null +++ b/app/lib/model/msg_type.dart @@ -0,0 +1,45 @@ +// SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +// +// SPDX-License-Identifier: MIT + +enum MsgType { + opus(0x01), + jpeg(0x02), + controlAvatar(0x03), + controlMotion(0x04), + onCamera(0x05), + offCamera(0x06), + textMessage(0x07), + requestCall(0x09), + refuseCall(0x0A), + agreeCall(0x0B), + hangupCall(0x0C), + updateDeviceName(0x0D), + getDeviceName(0x0E), + ping(0x10), + pong(0x11), + onPhoneScreen(0x12), + offPhoneScreen(0x13), + dance(0x14), + getAvatarPosture(0x15), + deviceOffline(0x16), + deviceOnline(0x17), + onAudio(0x18), + offAudio(0x19), + aimedTakePhoto(0x1A); + + final int value; //custom,andiOSrawValuefullyfor + + const MsgType(this.value); + + //CurrentlySerializelogic:valueAnd / WhileNon-index + String toJson() => value.toString(); + + static MsgType fromJson(String json) { + final int value = int.parse(json); + return MsgType.values.firstWhere( + (e) => e.value == value, + orElse: () => throw ArgumentError('Invalid MsgType value: $json'), + ); + } +} diff --git a/app/lib/model/registration_response.dart b/app/lib/model/registration_response.dart new file mode 100644 index 0000000..728653a --- /dev/null +++ b/app/lib/model/registration_response.dart @@ -0,0 +1,72 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +class RegistrationResponse { + int? uid; + String? username; + String? userslug; + String? email; + int? emailConfirmed; + int? joinDate; + int? lastOnline; + dynamic picture; + String? iconBgColor; + dynamic fullname; + String? displayname; + String? iconText; + String? userStatus; + + RegistrationResponse({ + this.uid, + this.username, + this.userslug, + this.email, + this.emailConfirmed, + this.joinDate, + this.lastOnline, + this.picture, + this.iconBgColor, + this.fullname, + this.displayname, + this.iconText, + this.userStatus, + }); + + ///from JSON toobject + RegistrationResponse.fromJson(Map json) { + uid = json['uid']; + username = json['username']; + userslug = json['userslug']; + email = json['email']; + emailConfirmed = json['email:confirmed']; + joinDate = json['joindate']; + lastOnline = json['lastonline']; + picture = json['picture']; + iconBgColor = json['icon:bgColor']; + fullname = json['fullname']; + displayname = json['displayname']; + iconText = json['icon:text']; + userStatus = json['status']; + } + + ///objectto JSON + Map toJson() { + final map = {}; + map['uid'] = uid; + map['username'] = username; + map['userslug'] = userslug; + map['email'] = email; + map['email:confirmed'] = emailConfirmed; + map['joindate'] = joinDate; + map['lastonline'] = lastOnline; + map['picture'] = picture; + map['icon:bgColor'] = iconBgColor; + map['fullname'] = fullname; + map['displayname'] = displayname; + map['icon:text'] = iconText; + map['status'] = userStatus; + return map; + } +} diff --git a/app/lib/model/stack_chan_music_info.dart b/app/lib/model/stack_chan_music_info.dart new file mode 100644 index 0000000..695a807 --- /dev/null +++ b/app/lib/model/stack_chan_music_info.dart @@ -0,0 +1,11 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +class StackChanMusicInfo { + String name; + String url; + + StackChanMusicInfo({required this.name, required this.url}); +} diff --git a/app/lib/model/upload_file_data.dart b/app/lib/model/upload_file_data.dart new file mode 100644 index 0000000..2f659fe --- /dev/null +++ b/app/lib/model/upload_file_data.dart @@ -0,0 +1,14 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +class UploadFile { + final String? path; + + UploadFile({this.path}); + + factory UploadFile.fromJson(Map json) { + return UploadFile(path: json['path']); + } +} diff --git a/app/lib/model/user_info.dart b/app/lib/model/user_info.dart new file mode 100644 index 0000000..c219ddb --- /dev/null +++ b/app/lib/model/user_info.dart @@ -0,0 +1,60 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +class UserInfo { + int? uid; + String? username; + String? userslug; + String? displayName; + String? iconText; + String? iconBgColor; + int? emailConfirmed; + int? joinDate; + int? lastOnline; + String? userStatus; + + UserInfo({ + this.uid, + this.username, + this.userslug, + this.displayName, + this.iconText, + this.iconBgColor, + this.emailConfirmed, + this.joinDate, + this.lastOnline, + this.userStatus, + }); + + factory UserInfo.fromJson(Map json) { + return UserInfo( + uid: json['uid'] as int?, + username: json['username']?.toString(), + userslug: json['userslug']?.toString(), + displayName: json['displayName']?.toString(), + iconText: json['iconText']?.toString(), + iconBgColor: json['iconBgColor']?.toString(), + emailConfirmed: json['emailConfirmed'] as int?, + joinDate: json['joinDate'] as int?, + lastOnline: json['lastOnline'] as int?, + userStatus: json['userStatus']?.toString(), + ); + } + + Map toJson() { + return { + 'uid': uid, + 'username': username, + 'userslug': userslug, + 'displayName': displayName, + 'iconText': iconText, + 'iconBgColor': iconBgColor, + 'emailConfirmed': emailConfirmed, + 'joinDate': joinDate, + 'lastOnline': lastOnline, + 'userStatus': userStatus, + }; + } +} diff --git a/app/lib/network/http.dart b/app/lib/network/http.dart new file mode 100644 index 0000000..6ac75f4 --- /dev/null +++ b/app/lib/network/http.dart @@ -0,0 +1,151 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +import 'package:dio/dio.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:stack_chan/app_state.dart'; +import 'package:stack_chan/network/urls.dart'; +import 'package:stack_chan/network/web_socket_util.dart'; + +import '../util/rsa_util.dart'; +import '../util/value_constant.dart'; + +void logPrint(Object? object) { + if (object == null) return; + String log = object.toString(); + const int chunkSize = 800; //800(limit) + //ifcontent,directPrint + if (log.length <= chunkSize) { + return; + } + + //contentPrint + for (int i = 0; i < log.length; i += chunkSize) { + int end = i + chunkSize; + if (end > log.length) end = log.length; + //usedebugPrint(print,supportcontent) + } +} + +class Http { + static final Http instance = Http._init(); + + late final Dio _dio; + + late final BaseOptions _baseOptions; + + final List _interceptors = [ + LogInterceptor(responseBody: true, logPrint: logPrint), + InterceptorsWrapper( + onRequest: + (RequestOptions options, RequestInterceptorHandler handler) async { + /// v1 mac + final encryptedToken = RsaUtil.encrypt( + WebSocketUtil.shared.getAuthorization(AppState.shared.deviceMac), + ); + options.headers[ValueConstant.authorization] = encryptedToken; + + /// v2 token + final token = await AppState.asyncPrefs.getString( + ValueConstant.token, + ); + if (token != null) { + options.headers[ValueConstant.token] = token; + } + + /// App Version + String? version = AppState.shared.packageInfo?.version; + if (version != null) { + options.headers[ValueConstant.appVersion] = version; + } + + return handler.next(options); + }, + onResponse: + (Response response, ResponseInterceptorHandler handler) async { + if (response.statusCode == 401) { + await AppState.shared.logout(); + } + return handler.next(response); + }, + onError: (DioException error, ErrorInterceptorHandler handler) async { + if (error.response?.statusCode == 401) { + await AppState.shared.logout(); + } + return handler.next(error); + }, + ), + ]; + + Http._init() { + _baseOptions = BaseOptions( + baseUrl: Urls.getBaseUrl(), + connectTimeout: const Duration(seconds: 20), + receiveTimeout: const Duration(seconds: 20), + ); + _dio = Dio(_baseOptions); + _dio.interceptors.addAll(_interceptors); + } + + Future get( + String pathUrl, { + dynamic data, + Options? options, + String? baseUrlString, + }) async { + if (baseUrlString != null) { + _dio.options.baseUrl = baseUrlString; + } + return await _dio.get(pathUrl, queryParameters: data, options: options); + } + + Future post( + String pathUrl, { + dynamic data, + Options? options, + String? baseUrlString, + }) async { + if (baseUrlString != null) { + _dio.options.baseUrl = baseUrlString; + } + return await _dio.post(pathUrl, data: data, options: options); + } + + Future put( + String pathUrl, { + dynamic data, + Options? options, + String? baseUrlString, + }) async { + if (baseUrlString != null) { + _dio.options.baseUrl = baseUrlString; + } + return await _dio.put(pathUrl, data: data, options: options); + } + + Future delete( + String pathUrl, { + dynamic data, + Options? options, + String? baseUrlString, + }) async { + if (baseUrlString != null) { + _dio.options.baseUrl = baseUrlString; + } + return await _dio.delete(pathUrl, data: data, options: options); + } + + Future postFormData( + String pathUrl, + FormData data, { + Options? options, + String? baseUrlString, + }) async { + if (baseUrlString != null) { + _dio.options.baseUrl = baseUrlString; + } + return await _dio.post(pathUrl, data: data, options: options); + } +} diff --git a/app/lib/network/urls.dart b/app/lib/network/urls.dart new file mode 100644 index 0000000..e92a2c2 --- /dev/null +++ b/app/lib/network/urls.dart @@ -0,0 +1,148 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +/// API endpoint configuration for the StackChan backend server +/// +/// This class contains all the base URL configurations and API endpoint paths +/// for communicating with the StackChan backend server. +/// +/// Backend Configuration: +/// - Update the [url] constant to point to your backend server address +/// - The base URL is constructed as: http://:/stackChan/ +/// - WebSocket endpoint uses: ws://:/stackChan/ws +class Urls { + /// Backend server base address configuration + /// + /// IMPORTANT: Update this to your actual backend server address + /// Format: "server-ip:port/" + /// Example: "192.168.1.100:8080/" or "api.example.com/" + /// + /// For development, you can use the commented local IP below + static const String url = "00.000.000.000:0000/"; + + + /// Get the HTTP base URL for API requests + /// + /// Returns: "http:///stackChan/" + static String getBaseUrl() { + return "http://$url" + "stackChan/"; + } + + /// Get the HTTP base URL for file operations (uploads and downloads) + /// + /// Returns: "http:///" + static String getFileUrl() { + return "http://$url"; + } + + /// Get the WebSocket URL for real-time communication + /// + /// Returns: "ws:///stackChan/ws" + static String getWebSocketUrl() { + return "ws://$url" + "stackChan/ws"; + } + + // =========================================================================== + // Device Management Endpoints + // =========================================================================== + + /// Device registration endpoint + /// Register a new device using its MAC address + static const String registerMac = "api/v2/device/registerMac"; + + /// Device information endpoint + /// Retrieve device details and status + static const String deviceInfo = "device/info"; + + // =========================================================================== + // Dance Choreography Endpoints + // =========================================================================== + + /// Dance data endpoint (v1 legacy) + static const String dance = "dance"; + + /// Dance data endpoint (v2) + /// Create, retrieve, update, and delete dance choreographies + static const String v2dance = "v2/dance"; + + // =========================================================================== + // Social & Content Endpoints + // =========================================================================== + + /// Get random list of devices for discovery + static const String deviceRandomList = "device/randomList"; + + /// File upload endpoint for media (images, videos, dance files) + static const String uploadFile = "uploadFile"; + + /// Create a new social post + static const String postAdd = "post/add"; + + /// Retrieve post details + static const String postGet = "post/get"; + + /// Delete a social post + static const String postDelete = "post/delete"; + + /// Create a comment on a post + static const String postCommentCreate = "post/comment/create"; + + /// Delete a comment from a post + static const String postCommentDelete = "post/comment/delete"; + + /// Retrieve comments for a post + static const String postCommentGet = "post/comment/get"; + + /// Panoramic image or 360 view content endpoint + static const String pano = "pano"; + + // =========================================================================== + // Authentication & User Endpoints + // =========================================================================== + + /// User login endpoint + static const String login = "v2/user/login"; + + /// User profile management endpoint + static const String user = "v2/user"; + + /// User devices management endpoint + static const String devices = "v2/devices"; + + /// User registration endpoint + static const String registration = "v2/user/registration"; + + // =========================================================================== + // Device-User Binding Endpoints + // =========================================================================== + + /// Bind a device to a user account + static const String v2deviceBind = "v2/device/bind"; + + /// Unbind a device from a user account + static const String v2deviceUnbind = "v2/device/unbind"; + + /// Restore device agent configuration + static const String deviceAgentRestore = "v2/device/agent/restore"; + + // =========================================================================== + // XiaoZhi AI Service Endpoints + // =========================================================================== + + /// XiaoZhi AI authentication token endpoint + /// Retrieve token for AI service access + static const String xiaozhiToken = "xiaozhi/token"; + + /// XiaoZhi AI token refresh endpoint + /// Refresh expired authentication tokens + static const String xiaozhiTokenRefresh = "xiaozhi/token/refresh"; + + /// Generate license token for device activation + /// Used for StackChan device licensing and activation + static const String xiaozhiGenerateLicenseToken = "xiaozhi/generateLicenseToken"; +} \ No newline at end of file diff --git a/app/lib/network/web_socket_util.dart b/app/lib/network/web_socket_util.dart new file mode 100644 index 0000000..996e5c5 --- /dev/null +++ b/app/lib/network/web_socket_util.dart @@ -0,0 +1,217 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +import 'dart:async'; +import 'dart:io'; +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:flutter/cupertino.dart'; + +//Assume theseisYoucustomDepends on(according toActual situationAdjust) +import '../app_state.dart'; +import '../model/msg_type.dart'; +import '../util/rsa_util.dart'; +import '../util/value_constant.dart'; + +class WebSocketUtil { + WebSocketUtil._internal(); + + static final WebSocketUtil shared = WebSocketUtil._internal(); + + WebSocket? _socket; + StreamSubscription? _subscription; + + //newIncrease / Add:Recordcurrentconnectstate,avoidRepeatPrint + bool _isConnected = false; + + Function()? connectionSuccessful; + + final Map _observers = {}; + + String _urlString = ''; + + /* ======================= + * Authorization + * ======================= */ + String getAuthorization(String mac) { + final rand = Random(); + final randomPart = List.generate( + mac.length, + (_) => + ValueConstant.characters[rand.nextInt( + ValueConstant.characters.length, + )], + ).join(); + + final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000; + return '$mac|$randomPart|$timestamp'; + } + + /* ======================= + * Connect + * ======================= */ + Future connect(String urlString) async { + //ifalreadyexistconnect,First / PreviouslyDisconnect + if (_socket != null) { + disconnect(); + } + + _urlString = urlString; + + if (AppState.shared.deviceMac.isEmpty) { + return; + } + + //Printconnectstartlog + + try { + final encryptedToken = RsaUtil.encrypt( + getAuthorization(AppState.shared.deviceMac), + ); + final headers = {ValueConstant.authorization: encryptedToken}; + _socket = await WebSocket.connect(urlString, headers: headers); + + //connectsuccesslog(ContainstimeandURL) + _isConnected = true; + final connectTime = DateTime.now().toString().split('.').first; + + _subscription = _socket!.listen( + _handleMessage, + onError: _handleError, + onDone: _handleDone, + cancelOnError: true, + ); + + if (connectionSuccessful != null) { + connectionSuccessful!(); + } + } catch (e) { + _isConnected = false; + //connectfaillog(ContainsSpecificerrorinfo) + final errorTime = DateTime.now().toString().split('.').first; + _scheduleReconnect(); + } + } + + /* ======================= + * Message Handling + * ======================= */ + void _handleMessage(dynamic message) { + final isPing = replyPong(message); + if (!isPing) { + _notifyObservers(message); + } + } + + void _handleError(Object error) { + //errorlog(DistinguishconnecterrorandRunning / Runtimewhenerror) + _isConnected = false; + _scheduleReconnect(); + } + + void _handleDone() { + //connectcloselog(ContainscloseoriginalBecause) + _isConnected = false; + final closeTime = DateTime.now().toString().split('.').first; + _scheduleReconnect(); + } + + bool replyPong(dynamic message) { + if (message is Uint8List) { + final result = AppState.shared.parseMessage(message); + final msgType = result.$1; + + if (msgType != null) { + switch (msgType) { + case MsgType.ping: + AppState.shared.sendWebSocketMessage(.pong); + return true; + default: + return false; + } + } + } + return false; + } + + /* ======================= + * Send + * ======================= */ + void sendString(String message) { + if (_socket == null) { + return; + } + + try { + _socket!.add(message); + } catch (e) { + _isConnected = false; + _scheduleReconnect(); + } + } + + void send(Uint8List data) { + if (_socket == null) { + return; + } + + //debugPrint('📤 send2Basemessage: length=${data.length} Byte'); + try { + _socket!.add(data); + } catch (e) { + _isConnected = false; + _scheduleReconnect(); + } + } + + /* ======================= + * Reconnect + * ======================= */ + void _scheduleReconnect() async { + if (_urlString.isEmpty) return; + + //reconnectlog(avoidFrequentlyRepeatPrint) + if (!_isConnected) { + } + + await Future.delayed(const Duration(seconds: 1)); + await connect(_urlString); + } + + /* ======================= + * Disconnect + * ======================= */ + void disconnect() { + _subscription?.cancel(); + _socket?.close(WebSocketStatus.goingAway, '主动断开连接'); + _isConnected = false; + _socket = null; + } + + /* ======================= + * Observer + * ======================= */ + void addObserver(String key, void Function(dynamic message) observer) { + _observers[key] = observer; + } + + void removeObserver(String key) { + _observers.remove(key); + } + + void removeAllObservers() { + _observers.clear(); + } + + void _notifyObservers(dynamic message) { + for (final observer in _observers.values) { + observer(message); + } + } + + //newIncrease / Add:Getcurrentconnectstatemethod(ConvenientExternalquery) + bool get isConnected => _isConnected && _socket?.readyState == WebSocket.open; +} diff --git a/app/lib/util/XiaoZhi_util.dart b/app/lib/util/XiaoZhi_util.dart new file mode 100644 index 0000000..912c2b4 --- /dev/null +++ b/app/lib/util/XiaoZhi_util.dart @@ -0,0 +1,837 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +import 'dart:convert'; + +import 'package:dio/dio.dart'; +import 'package:flutter/widgets.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:stack_chan/model/model.dart'; +import 'package:stack_chan/network/http.dart'; +import 'package:stack_chan/network/urls.dart'; +import 'package:stack_chan/util/mac_address_validator.dart'; +import 'package:stack_chan/util/value_constant.dart'; + +import '../model/XiaoZhi/License.dart'; +import '../model/XiaoZhi/XiaoZhi_model.dart'; +import '../model/XiaoZhi/agent.dart'; +import '../model/XiaoZhi/agent_create.dart'; +import '../model/XiaoZhi/agent_template.dart'; +import '../model/XiaoZhi/agents_devices_activate.dart'; +import '../model/XiaoZhi/common_mcp_tool.dart'; +import '../model/XiaoZhi/conversation.dart'; +import '../model/XiaoZhi/conversation_message_data.dart'; +import '../model/XiaoZhi/device.dart'; +import '../model/XiaoZhi/endpoints_response.dart'; +import '../model/XiaoZhi/generateLicense.dart'; +import '../model/XiaoZhi/mcp_endpoints.dart'; +import '../model/XiaoZhi/pagination.dart'; +import '../model/XiaoZhi/product.dart'; +import '../model/XiaoZhi/tts_list.dart'; + +class XiaoZhiUtil { + static final XiaoZhiUtil shared = XiaoZhiUtil._internal(); + + XiaoZhiUtil._internal() { + _dio.options.baseUrl = "https://XiaoZhi.me/"; + _dio.options.connectTimeout = const Duration(seconds: 10); + _dio.options.receiveTimeout = const Duration(seconds: 10); + _dio.options.validateStatus = (state) { + return state != null && state >= 200 && state < 500; + }; + _dio.interceptors.add( + LogInterceptor(responseBody: true, logPrint: logPrint), + ); + _dio.interceptors.add( + InterceptorsWrapper( + onRequest: (options, handler) async { + final token = await getToken(); + if (token != null) { + options.headers['Authorization'] = 'Bearer $token'; + } + options.headers['Accept'] = 'application/json'; + options.headers['Content-Type'] = 'application/json'; + handler.next(options); + }, + onResponse: (response, handler) async { + if (response.statusCode == 401) { + await _asyncPrefs.remove(_tokenKay); + final newToken = await getTokenFromServer(); + if (newToken != null) { + //Resend request + final Options newOptions = Options( + method: response.requestOptions.method, + headers: { + ...response.requestOptions.headers, + 'Authorization': 'Bearer $newToken', + 'Accept': 'application/json', + }, + ); + try { + final newResponse = await _dio.request( + response.requestOptions.path, + options: newOptions, + queryParameters: response.requestOptions.queryParameters, + data: response.requestOptions.data, + ); + handler.resolve(newResponse); + return; + } catch (e) { + handler.next(response); + return; + } + } + } + + //Handle session expiration response + if (response.data != null) { + try { + XiaozhiResponse xiaozhiResponse = XiaozhiResponse.fromJsonT( + response.data, + ); + if (!xiaozhiResponse.success && + xiaozhiResponse.message == "Session expired or logged out") { + //Refresh token + final newToken = await refreshXiaoZhiToken(); + if (newToken != null) { + //Resend request + final Options newOptions = Options( + method: response.requestOptions.method, + headers: { + ...response.requestOptions.headers, + 'Authorization': 'Bearer $newToken', + 'Accept': 'application/json', + }, + ); + try { + final newResponse = await _dio.request( + response.requestOptions.path, + options: newOptions, + queryParameters: response.requestOptions.queryParameters, + data: response.requestOptions.data, + ); + handler.resolve(newResponse); + return; + } catch (e) { + } + } + } + } catch (e) { + } + } + + handler.next(response); + }, + ), + ); + } + + final SharedPreferencesAsync _asyncPrefs = SharedPreferencesAsync(); + final String _tokenKay = "XiaoZhiToken"; + final Dio _dio = Dio(); + + Future getToken() async { + String? token = await _asyncPrefs.getString(_tokenKay); + if (token == null || token.isEmpty) { + return await getTokenFromServer(); + } + return token; + } + + Future getTokenFromServer() async { + final response = await Http.instance.get(Urls.xiaozhiToken); + Model responseData = Model.fromJsonT(response.data); + if (responseData.isSuccess()) { + String? token = responseData.data; + if (token != null) { + await _asyncPrefs.setString(_tokenKay, token); + return token; + } + } + return null; + } + + ///Refresh XiaoZhi token + Future refreshXiaoZhiToken() async { + final response = await Http.instance.get(Urls.xiaozhiTokenRefresh); + Model responseData = Model.fromJsonT(response.data); + if (responseData.isSuccess()) { + String? token = responseData.data; + if (token != null) { + await _asyncPrefs.setString(_tokenKay, token); + return token; + } + } + return null; + } + + ///Agent template + Future> agentTemplatesList(int page, int pageSize) async { + Map map = {"page": page, "pageSize": pageSize}; + final response = await _dio.get( + "api/developers/agent-templates/list", + data: map, + ); + if (response.data != null) { + XiaozhiResponse> xiaozhiResponse = + XiaozhiResponse.fromJsonT( + response.data, + factory: (value) => ListData.fromJson( + value, + (value) => AgentTemplate.fromJson(value), + ), + ); + if (xiaozhiResponse.success) { + return xiaozhiResponse.data?.list ?? []; + } + } + return []; + } + + ///Query device by serial number + Future serialNumberGetDevice(String serialNumber) async { + final map = {'serial_number': serialNumber}; + final response = await _dio.get( + "api/developers/devices", + queryParameters: map, + ); + + if (response.data != null) { + XiaozhiResponse> xiaozhiResponse = + XiaozhiResponse.fromJsonT( + response.data, + factory: (value) => + ListData.fromJson(value, (value) => Device.fromJson(value)), + ); + if (xiaozhiResponse.success) { + final list = xiaozhiResponse.data?.list ?? []; + //Safe access: check if list is empty first + return list.isNotEmpty ? list.first : null; + } + } + return null; + } + + //Get device list + Future> getDevice(String macAddress) async { + final map = {'mac_address': MacAddressValidator.formatMac(macAddress)}; + try { + final response = await _dio.get( + "api/developers/devices", + queryParameters: map, + ); + if (response.data != null) { + XiaozhiResponse> xiaozhiResponse = + XiaozhiResponse.fromJsonT( + response.data, + factory: (value) => + ListData.fromJson(value, (value) => Device.fromJson(value)), + ); + if (xiaozhiResponse.success) { + final list = xiaozhiResponse.data?.list ?? []; + if (list.isEmpty) { + return await getCapitalLettersMacDevice(macAddress); + } else { + return list; + } + } else { + throw Exception('查询设备失败'); + } + } + return []; + } catch (e) { + return []; + } + } + + ///Get authorization list + Future getLicenses(String serialNumber, String productId) async { + final map = {"query": serialNumber}; + final response = await _dio.get( + "api/developers/products/$productId/licenses", + queryParameters: map, + ); + if (response.data != null) { + XiaozhiResponse xiaozhiResponse = XiaozhiResponse.fromJsonT( + response.data, + factory: (value) => Licenses.fromJson(value), + ); + if (xiaozhiResponse.success) { + return xiaozhiResponse.data?.licenses.first; + } + } + return null; + } + + Future getProductsList() async { + final response = await _dio.get("api/developers/products/list"); + if (response.data != null) { + XiaozhiResponse> xiaozhiResponse = + XiaozhiResponse.fromJsonT( + response.data, + factory: (value) => + ListData.fromJson(value, (value) => Product.fromJson(value)), + ); + if (xiaozhiResponse.success) { + return xiaozhiResponse.data?.list.first; + } + } + return null; + } + + Future> getCapitalLettersMacDevice(String macAddress) async { + final map = { + 'mac_address': MacAddressValidator.formatLowerCaseMac(macAddress), + }; + try { + final response = await _dio.get( + "api/developers/devices", + queryParameters: map, + ); + if (response.data != null) { + XiaozhiResponse> xiaozhiResponse = + XiaozhiResponse.fromJsonT( + response.data, + factory: (value) => + ListData.fromJson(value, (value) => Device.fromJson(value)), + ); + if (xiaozhiResponse.success) { + return xiaozhiResponse.data?.list ?? []; + } + } + return []; + } catch (e) { + return []; + } + } + + ///Get voice list + Future getTtsList() async { + final response = await _dio.get("api/user/tts-list"); + if (response.data != null) { + XiaozhiResponse xiaozhiResponse = XiaozhiResponse.fromJsonT( + response.data, + factory: (value) => TTsList.fromJson(value), + ); + if (xiaozhiResponse.success) { + return xiaozhiResponse.data; + } + } + return null; + } + + ///Get model list + Future> getModelList() async { + final response = await _dio.get("api/roles/model-list"); + if (response.data != null) { + XiaozhiResponse xiaozhiResponse = XiaozhiResponse.fromJsonT( + response.data, + factory: (value) => XiaoZhiModel.fromJson(value), + ); + if (xiaozhiResponse.success) { + return xiaozhiResponse.data?.modelList ?? []; + } + } + return []; + } + + ///Get official MCP tools + Future> getCommonMcpTool() async { + final response = await _dio.get("api/agents/common-mcp-tool/list"); + if (response.data != null) { + XiaozhiResponse> xiaozhiResponse = + XiaozhiResponse.fromJsonT( + response.data, + factory: (value) => CommonMcpTool.fromListJson(value), + ); + if (xiaozhiResponse.success) { + return xiaozhiResponse.data ?? []; + } + } + return []; + } + + ///Create agent + Future createAgent(AgentCreate agentParams) async { + final response = await _dio.post("api/agents", data: agentParams.toJson()); + if (response.data != null) { + XiaozhiResponse xiaozhiResponse = XiaozhiResponse.fromJsonT( + response.data, + ); + if (xiaozhiResponse.success) { + int? id = xiaozhiResponse.data["id"] as int?; + return id; + } + } + return null; + } + + ///Get agent list + Future> getAgents({ + int page = 1, + int pageSize = 24, + String? keyword, + }) async { + final Map params = { + "page": page, + "pageSize": pageSize, + if (keyword != null && keyword.isNotEmpty) "keyword": keyword, + }; + final response = await _dio.get("api/agents", queryParameters: params); + if (response.data != null) { + XiaozhiResponse> xiaozhiResponse = XiaozhiResponse.fromJsonT( + response.data, + factory: (value) => Agent.fromListJson(value), + ); + if (xiaozhiResponse.success) { + return xiaozhiResponse.data ?? []; + } + } + return []; + } + + ///Get agent details + Future getAgentDetail(int agentId) async { + final response = await _dio.get("api/agents/$agentId"); + if (response.data != null) { + XiaozhiResponse> xiaozhiResponse = + XiaozhiResponse.fromJsonT(response.data); + if (xiaozhiResponse.success && xiaozhiResponse.data != null) { + if (xiaozhiResponse.data!["agent"] != null) { + final agent = Agent.fromJson(xiaozhiResponse.data!["agent"]); + return agent; + } + } + } + return null; + } + + ///Update agent + Future updateAgent(int agentId, AgentCreate agentParams) async { + final response = await _dio.post( + "api/agents/$agentId/config", + data: agentParams.toJson(), + ); + if (response.data != null) { + XiaozhiResponse xiaozhiResponse = XiaozhiResponse.fromJsonT( + response.data, + ); + if (xiaozhiResponse.success) { + return true; + } + } + return false; + } + + ///Bind device to agent (core: add device to specified agent + Future bindDeviceToAgent(int agentId, String verificationCode) async { + final response = await _dio.post( + "api/agents/$agentId/devices", + data: {"verificationCode": verificationCode}, + ); + if (response.data != null) { + XiaozhiResponse xiaozhiResponse = XiaozhiResponse.fromJsonT( + response.data, + ); + if (xiaozhiResponse.success) { + return true; + } + } + return false; + } + + ///Unbind device + Future unbindDevice(int deviceId) async { + final response = await _dio.post( + "api/developers/unbind-device", + data: {"device_id": deviceId}, + ); + if (response.data != null) { + XiaozhiResponse xiaozhiResponse = XiaozhiResponse.fromJsonT( + response.data, + ); + if (xiaozhiResponse.success) { + return true; + } + } + return false; + } + + ///Generate device authorization license + ///[macAddress]: Device MAC address (replaces seed parameter) + ///Returns: Authorization info (includes serial number), null on failure + Future generateLicense(String macAddress) async { + try { + // Get generateLicenseToken License + final generateResponse = await Http.instance.get( + Urls.xiaozhiGenerateLicenseToken, + ); + if (generateResponse.data == null) { + return null; + } + Model generateLicenseModel = Model.fromJsonT( + generateResponse.data, + ); + if (!generateLicenseModel.isSuccess() || + generateLicenseModel.data == null) { + return null; + } + + final Map queryParams = { + ValueConstant.token: generateLicenseModel.data, + ValueConstant.seed: macAddress, + }; + final response = await _dio.get( + "api/developers/generate-license", + queryParameters: queryParams, + ); + + if (response.data != null) { + XiaozhiResponse xiaozhiResponse = + XiaozhiResponse.fromJsonT( + response.data, + factory: (value) => GenerateLicense.fromJson(value), + ); + return xiaozhiResponse.data; + } + return null; + } catch (e) { + return null; + } + } + + ///Enterprise device activation API + Future agentsDevicesActivate( + String serialNumber, + String macAddress, + ) async { + final Map map = { + "serial_number": serialNumber, + "mac_address": macAddress, + }; + final response = await _dio.post("api/agents/devices/activate", data: map); + if (response.statusCode == 200) { + if (response.data != null) { + XiaozhiResponse xiaozhiResponse = + XiaozhiResponse.fromJsonT( + response.data, + factory: (value) => AgentsDevicesActivate.fromJson(value), + ); + + if (xiaozhiResponse.success) { + //Activation successful + return true; + } + } + } else if (response.statusCode == 400) { + ///Already added + XiaozhiResponse xiaozhiResponse = XiaozhiResponse.fromJsonT( + response.data, + ); + if (xiaozhiResponse.message == "该设备已经添加过,请不要重复添加") { + return true; + } + } + return false; + } + + ///Get MCP endpoint list + Future> mcpEndpoints() async { + final response = await _dio.get("api/developers/mcp-endpoints"); + if (response.data != null) { + XiaozhiResponse> xiaozhiResponse = + XiaozhiResponse.fromJsonT( + response.data, + factory: (value) => McpEndpoints.fromListJson(value), + ); + if (xiaozhiResponse.success) { + return xiaozhiResponse.data ?? []; + } + } + return []; + } + + ///Create mcp endpoint + Future createMcpEndpoints( + String name, + String description, + bool enabled, + ) async { + final Map map = { + "name": name, + "description": description, + "enabled": enabled, + }; + final response = await _dio.post("api/developers/mcp-endpoints", data: map); + if (response.data != null) { + XiaozhiResponse res = XiaozhiResponse.fromJsonT(response.data); + return res.success; + } + return false; + } + + ///Get agent MCP endpoint + Future generateMcpEndpointToken(int id) async { + String url = "api/agents/$id/generate-mcp-endpoint-token"; + final response = await _dio.post(url); + if (response.data != null) { + XiaozhiResponse xiaozhiResponse = XiaozhiResponse.fromJsonT( + response.data, + ); + if (xiaozhiResponse.success) { + return xiaozhiResponse.token; + } + } + return null; + } + + ///Get endpoint token + Future getEndpointToken(int id) async { + String url = "api/developers/mcp-endpoints/$id/generate-endpoint-token"; + final response = await _dio.post(url); + if (response.data != null) { + XiaozhiResponse xiaozhiResponse = XiaozhiResponse.fromJsonT( + response.data, + ); + if (xiaozhiResponse.success) { + return xiaozhiResponse.token; + } + } + return null; + } + + Future endpointsList(int endpointIds) async { + String url = "https://api.XiaoZhi.me/mcp/endpoints/list"; + final map = {"endpoint_ids": "agent_$endpointIds"}; + final response = await _dio.get(url, queryParameters: map); + if (response.data != null) { + EndpointsResponse data = EndpointsResponse.fromJson(response.data); + return data; + } + return null; + } + + ///Edit MCP endpoint information + Future editEndpoints( + int id, { + String? name, + String? description, + bool? enabled, + }) async { + final Map map = {}; + if (name != null) { + map["name"] = name; + } + if (description != null) { + map["description"] = description; + } + if (enabled != null) { + map["enabled"] = enabled; + } + String url = "api/developers/mcp-endpoints/$id"; + final response = await _dio.post(url, data: map); + if (response.data != null) { + XiaozhiResponse res = XiaozhiResponse.fromJsonT(response.data); + return res.success; + } + return false; + } + + ///Delete MCP endpoint + Future deleteEndpoints(int id) async { + String url = "api/developers/mcp-endpoints/$id"; + final response = await _dio.delete(url); + if (response.data != null) { + XiaozhiResponse xiaozhiResponse = XiaozhiResponse.fromJsonT( + response.data, + ); + if (xiaozhiResponse.success) { + return true; + } + } + return false; + } + + Future> getConversationList( + String startDate, + int? deviceId, + int? page, + int? pageSize, + int? agentId, + ) async { + final response = await _dio.get( + "api/chats/list", + queryParameters: { + "startDate": startDate, + "deviceId": deviceId, + "page": page, + "pageSize": pageSize, + "agentId": agentId, + }, + ); + if (response.data != null) { + XiaozhiResponse> xiaozhiResponse = + XiaozhiResponse.fromJsonT( + response.data, + factory: (value) => ListData.fromJson( + value, + (value) => Conversation.fromJson(value), + ), + ); + if (xiaozhiResponse.success) { + final list = xiaozhiResponse.data?.list; + if (list != null) { + return list; + } + } + } + return []; + } + + ///Delete conversation + Future deleteConversation(int agentId, int id) async { + String url = "api/agents/$agentId/chats/$id"; + final response = await _dio.delete(url); + if (response.data != null) { + XiaozhiResponse xiaozhiResponse = XiaozhiResponse.fromJsonT( + response.data, + ); + if (xiaozhiResponse.success) { + return true; + } + } + return false; + } + + ///Get message list + Future> getChatsMessages( + Map data, + ) async { + final response = await _dio.get( + "api/chats/messages", + queryParameters: data, + ); + if (response.data != null) { + XiaozhiResponse> xiaozhiResponse = + XiaozhiResponse.fromJsonT( + response.data, + factory: (value) => ListData.fromJson( + value, + (value) => ConversationMessageData.fromJson(value), + ), + ); + if (xiaozhiResponse.success) { + return xiaozhiResponse.data?.list ?? []; + } + } + return []; + } +} + +class XiaozhiResponse { + bool success = false; + T? data; + String? message; + Pagination? pagination; + String? token; + + XiaozhiResponse({ + required this.success, + this.data, + this.message, + this.pagination, + this.token, + }); + + XiaozhiResponse.fromJson( + Map map, { + T Function(dynamic)? factory, + }) { + if (map["data"] != null && map["data"] != "null" && factory != null) { + data = factory(map["data"]); + } else { + data = map["data"]; + } + if (map["message"] != null && map["message"] != "null") { + message = map["message"]; + } + if (map["success"] != null && map["success"] != "null") { + success = map["success"]; + } + if (map["pagination"] != null && map["pagination"] != "null") { + pagination = Pagination.fromJson(map["pagination"]); + } + if (map["token"] != null && map["token"] != "null") { + token = map["token"]; + } + } + + XiaozhiResponse.fromJsonT(dynamic data, {T Function(dynamic)? factory}) + : this.fromJson(data is String ? jsonDecode(data) : data, factory: factory); + + XiaozhiResponse.fromJsonString( + String? jsonString, { + T Function(dynamic)? factory, + }) : this.fromJson(jsonDecode(jsonString ?? ""), factory: factory); +} + +class ListData { + List list; + Pagination? pagination; + + //Fix constructor: Use generic list instead of fixed Device type + ListData({required this.list, this.pagination}); + + //Generic factory method: support parsing any type list + factory ListData.fromJson( + Map json, + T Function(Map) fromJsonT, //Type conversion function + ) { + return ListData( + list: json['list'] != null + ? List.from( + (json['list'] as List).map( + (x) => fromJsonT(x as Map), + ), + ) + : [], + pagination: json['pagination'] != null + ? Pagination.fromJson(json['pagination'] as Map) + : null, + ); + } + + //Convert to JSON: Support any generic type serialization + Map toJson(dynamic Function(T) toJsonT) { + final Map data = {}; + data['list'] = list.map((v) => toJsonT(v)).toList(); + if (pagination != null) { + data['pagination'] = pagination!.toJson(); + } + return data; + } +} + +class Licenses { + final List licenses; + final Pagination? pagination; + + Licenses({required this.licenses, this.pagination}); + + factory Licenses.fromJson(Map json) { + //Core fix: Correctly call License.fromJson(x) + final licenseList = json['licenses'] as List? ?? []; + final licenses = licenseList + .map((x) => License.fromJson(x as Map)) + .toList(); + + return Licenses( + licenses: licenses, + pagination: json['pagination'] != null + ? Pagination.fromJson(json['pagination'] as Map) + : null, + ); + } +} diff --git a/app/lib/util/app_toast.dart b/app/lib/util/app_toast.dart new file mode 100644 index 0000000..dbdca2c --- /dev/null +++ b/app/lib/util/app_toast.dart @@ -0,0 +1,121 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +import 'dart:async'; + +import 'package:flutter/cupertino.dart'; +import 'package:stack_chan/app_state.dart'; + +class AppToast extends StatefulWidget { + const AppToast({super.key, this.child}); + + final Widget? child; + + @override + State createState() => _AppToastState(); +} + +class _AppToastState extends State { + String _toastText = ""; + bool _isShowToast = false; + + //Replace with standard Timer, fix timer invalidation issue + Timer? _hideTimer; + + static const _duration = Duration(milliseconds: 1500); + static const _fadeDuration = Duration(milliseconds: 200); + + @override + void initState() { + super.initState(); + //listenAndsaveSubscribe + AppState.shared.toastFunction = (toastText) { + if (toastText == null || toastText.isEmpty) { + _hideToast(); + } else { + _updateToast(toastText); + } + }; + } + + @override + void dispose() { + AppState.shared.toastFunction = null; + _hideTimer?.cancel(); + super.dispose(); + } + + ///Update Toast text and show + void _updateToast(String text) { + //First / Previouslycanceltimer,avoidRepeatwhen + _hideTimer?.cancel(); + setState(() { + _toastText = text; + _isShowToast = true; + }); + //startnewautohidetimer + _hideTimer = Timer(_duration, _hideToast); + } + + ///Hide Toast + void _hideToast() { + if (mounted) { + //Avoid setState error when page is already disposed + setState(() { + _isShowToast = false; + }); + } + _hideTimer?.cancel(); + _hideTimer = null; + } + + ///Build Toast style (remove redundant Visibility, use only AnimatedOpacity) + Widget _buildToastWidget() { + return Positioned( + top: 80, + left: 0, + right: 0, + child: Center( + child: AnimatedOpacity( + opacity: _isShowToast ? 1.0 : 0.0, + duration: _fadeDuration, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + decoration: BoxDecoration( + color: CupertinoColors.secondarySystemGroupedBackground + .resolveFrom(context) + .withValues(alpha: 0.7), + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: CupertinoColors.systemBackground + .resolveFrom(context) + .withValues(alpha: 0.1), + blurRadius: 12, + offset: const Offset(0, 2), + ), + ], + ), + child: Text( + _toastText, + style: TextStyle( + color: CupertinoColors.label.resolveFrom(context), + fontSize: 16, + ), + textAlign: TextAlign.center, + ), + ), + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Stack( + children: [if (widget.child != null) widget.child!, _buildToastWidget()], + ); + } +} diff --git a/app/lib/util/audio_engine_manager.dart b/app/lib/util/audio_engine_manager.dart new file mode 100644 index 0000000..090ed73 --- /dev/null +++ b/app/lib/util/audio_engine_manager.dart @@ -0,0 +1,149 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +import 'dart:async'; +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:flutter/cupertino.dart'; +import 'package:opus_codec/opus_codec.dart' as opus_flutter; +import 'package:opus_codec_dart/opus_codec_dart.dart'; +import 'package:permission_handler/permission_handler.dart'; + +import 'native_bridge.dart'; + +class AudioEngineManager { + static final AudioEngineManager shared = AudioEngineManager._internal(); + + AudioEngineManager._internal(); + + late SimpleOpusEncoder simpleOpusEncoder; + late SimpleOpusDecoder simpleOpusDecoder; + + bool _isInitialized = false; + Function(Uint8List)? onAudioData; + Function(double)? onDecibel; + + //🔥 Fixed Opus standard parameters (DO NOT CHANGE) + static const int sampleRate = 16000; + static const int channels = 1; + static const int frameSamples = 320; + + // autoBufferqueue(fixcore) + final Int16List _pcmBuffer = Int16List(frameSamples * 10); + int _bufferWriteIndex = 0; + + Future init() async { + try { + WidgetsFlutterBinding.ensureInitialized(); + await opus_flutter.load(); + initOpus(await opus_flutter.load()); + + // StandardencodeController + simpleOpusEncoder = SimpleOpusEncoder( + sampleRate: sampleRate, + channels: channels, + application: Application.voip, + ); + simpleOpusDecoder = SimpleOpusDecoder( + sampleRate: sampleRate, + channels: channels, + ); + + _isInitialized = true; + + //listenoriginalRecorddata + // NativeBridge.shared.recordChannel.receiveBroadcastStream().listen((data) { + // if (data is Uint8List) _processPcm(data); + // }); + } catch (e) { + rethrow; + } + } + + //======================== 🔥 Core fix: Auto frame assembly ======================== + void _processPcm(Uint8List pcm8) { + try { + final pcm16 = Int16List.view(pcm8.buffer); + + //writeBuffer + for (int sample in pcm16) { + if (_bufferWriteIndex < _pcmBuffer.length) { + _pcmBuffer[_bufferWriteIndex++] = sample; + } + + //Accumulate 320 samples → encode one frame + if (_bufferWriteIndex == frameSamples) { + final frame = _pcmBuffer.sublist(0, frameSamples); + final opusData = simpleOpusEncoder.encode(input: frame); + + //output + if (onAudioData != null) onAudioData!(opusData); + if (onDecibel != null) onDecibel!(_getDecibel(frame)); + + //resetBuffer + _bufferWriteIndex = 0; + } + } + } catch (e) { + } + } + + //decibelcalculate + double _getDecibel(Int16List pcm) { + num sum = 0; + for (int s in pcm) { + sum += s * s; + } + final rms = sqrt(sum / pcm.length); + final db = 20 * log(rms / 32768.0) / ln10; + return db.isFinite ? db : -60; + } + + //======================== play ======================== + Future playOpus(Uint8List opusData) async { + try { + if (!_isInitialized) return; + final pcm16 = simpleOpusDecoder.decode(input: opusData); + final byteData = ByteData(pcm16.length * 2); + for (int i = 0; i < pcm16.length; i++) { + byteData.setInt16(i * 2, pcm16[i], Endian.little); + } + NativeBridge.shared.sendAudioStream(byteData); + } catch (e) { + } + } + + Future stopPlayOpus() async { + NativeBridge.shared.sendMessage(.stopPlayPCM); + } + + //====================== startRecord ====================== + Future startRecording() async { + if (!_isInitialized) return false; + + //requestMicrophonepermission + final perm = await Permission.microphone.request(); + if (!perm.isGranted) { + return false; + } + + NativeBridge.shared.sendMessage(.startRecording); + return true; + } + + //====================== stopRecord ====================== + Future stopRecording() async { + NativeBridge.shared.sendMessage(.stopRecording); + } + + Future dispose() async { + if (_isInitialized) { + simpleOpusEncoder.destroy(); + simpleOpusDecoder.destroy(); + _isInitialized = false; + } + } +} diff --git a/app/lib/util/blue_util.dart b/app/lib/util/blue_util.dart new file mode 100644 index 0000000..a319911 --- /dev/null +++ b/app/lib/util/blue_util.dart @@ -0,0 +1,720 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter_blue_plus/flutter_blue_plus.dart'; +import 'package:permission_handler/permission_handler.dart'; +import 'package:stack_chan/app_state.dart'; +import 'package:stack_chan/model/expression_data.dart'; +import 'package:stack_chan/util/value_constant.dart'; +import 'package:stack_chan/view/app.dart'; +import 'package:stack_chan/view/popup/device_wifi_config.dart'; + +import '../model/blue_device_info.dart'; +import '../model/dance_list.dart'; + +class BlueUtil { + static final BlueUtil shared = BlueUtil._internal(); + + BlueUtil._internal() { + _initialize(); + } + + static const String danceTargetServiceUUID = + "e2e5e5e0-1234-5678-1234-56789abcdef0"; + + //MARK: - Core constants (align with iOS) + static const String targetServiceUUID = + "e2e5e5ff-1234-5678-1234-56789abcdef0"; + static const String headCharacteristicUUID = + "0000ffe1-0000-1000-8000-00805f9b34fb"; + static const String wifiSetCharacteristicUUID = + "e2e5e5e3-1234-5678-1234-56789abcdef0"; + static const String expressionCharacteristicUUID = + "0000ffe3-0000-1000-8000-00805f9b34fb"; + static const String writeCharacteristicUUID = + "0000ffe4-0000-1000-8000-00805f9b34fb"; + + //MARK: - Core properties (align with iOS) + List discoveredDevices = []; + bool blueSwitch = false; + final bool autoReconnect = true; + BluetoothDevice? currentPeripheral; + + //Auto scan enabled by default + bool automaticScanning = true; + + //Feature object + BluetoothCharacteristic? writeCharacteristic; + BluetoothCharacteristic? writeExpressionCharacteristic; + BluetoothCharacteristic? writeHeadCharacteristic; + BluetoothCharacteristic? writeWifiSetCharacteristic; + + //MARK: - Callback closures + Function(List)? blufDevicesMonitoring; + Function(BluetoothAdapterState)? centralManagerDidUpdateState; + Function(BluetoothDevice, BluetoothCharacteristic)? characteristicCallback; + Function(BluetoothDevice, bool)? connectionStateChanged; + Function(List)? wifiSetCharacteristicCall; + + //MARK: - Private properties + StreamSubscription? _scanSubscription; + StreamSubscription? _adapterStateSubscription; + StreamSubscription? _connectionStateSubscription; + Timer? _cleanupTimer; + final Duration _deviceTimeout = const Duration(seconds: 3); + + static const String motionCharacteristicUUID = + "e2e5e5e1-1234-5678-1234-56789abcdef0"; + static const String avatarCharacteristicUUID = + "e2e5e5e2-1234-5678-1234-56789abcdef0"; + static const String configCharacteristicUUID = + "e2e5e5e3-1234-5678-1234-56789abcdef0"; + static const String rgbCharacteristicUUID = + "e2e5e5e4-1234-5678-1234-56789abcdef0"; + BluetoothCharacteristic? writeMotionCharacteristic; + BluetoothCharacteristic? writeAvatarCharacteristic; + BluetoothCharacteristic? writeRGBCharacteristic; + + int blueMode = 1; //1 WiFi mode 2 Dance mode 3 Pairing mode + + ///Cache scanned device list for change comparison + List cachedDeviceMacs = []; + + //MARK: - Initialization (fix Android first timing issue: permission → listen → enable Bluetooth) + void _initialize() { + //[Fix] Request permission first, init listener and Bluetooth after permission granted + _requestBluetoothPermissions(); + } + + //MARK: - Request Bluetooth permission (permission_handler) + Future _requestBluetoothPermissions() async { + try { + Map statuses; + + if (Platform.isAndroid) { + //Android 12+ permissions + statuses = await [ + Permission.bluetooth, + Permission.bluetoothScan, + Permission.bluetoothConnect, + Permission.location, + ].request(); + } else if (Platform.isIOS) { + //iOS permissions + statuses = await [ + Permission.bluetooth, + Permission.bluetoothScan, + Permission.bluetoothConnect, + Permission.location, + ].request(); + } else { + return; + } + + bool allGranted = true; + statuses.forEach((permission, status) { + if (!status.isGranted) { + allGranted = false; + } + }); + + //Execute enable Bluetooth command regardless of success to fix some plugin bugs + if (allGranted) { + _registerBluetoothStateListener(); + await _tryTurnOnBluetooth(); + } else { + _registerBluetoothStateListener(); + await _tryTurnOnBluetooth(); + } + } catch (e) { + _registerBluetoothStateListener(); + await _tryTurnOnBluetooth(); + } + } + + void _registerBluetoothStateListener() { + _adapterStateSubscription?.cancel(); + _adapterStateSubscription = FlutterBluePlus.adapterState.listen((state) { + centralManagerDidUpdateState?.call(state); + _centralManagerDidUpdateState(state); + }); + } + + //MARK: - [New] Auto enable Bluetooth + Future _tryTurnOnBluetooth() async { + try { + final currentState = FlutterBluePlus.adapterStateNow; + + if (currentState == BluetoothAdapterState.off) { + await FlutterBluePlus.turnOn(); + } else if (currentState == BluetoothAdapterState.on) { + //[fixkey]Androidfirstpermissionsuccess+BluetoothAlreadyenable → proactivetriggerscan + blueSwitch = true; + if (automaticScanning) { + startScan(); + } + if (autoReconnect) { + reconnect(); + } + } + } catch (e) { + } + } + + //MARK: - Bluetooth status update (auto scan, auto reconnect) + void _centralManagerDidUpdateState(BluetoothAdapterState state) { + switch (state) { + case BluetoothAdapterState.unknown: + break; + case BluetoothAdapterState.unavailable: + break; + case BluetoothAdapterState.unauthorized: + break; + case BluetoothAdapterState.turningOn: + break; + case BluetoothAdapterState.on: + blueSwitch = true; + //Bluetoothenable autostartscan + if (automaticScanning) { + startScan(); + } + //autoreconnect + if (autoReconnect) { + reconnect(); + } + break; + case BluetoothAdapterState.turningOff: + break; + case BluetoothAdapterState.off: + blueSwitch = false; + //closeafterautoTryreOpen + _tryTurnOnBluetooth(); + break; + } + } + + //MARK: - Scan related (auto execute) + void startScan() { + if (FlutterBluePlus.adapterStateNow != BluetoothAdapterState.on) { + //stateNotMeet / Satisfy,autoOpenBluetooth + _tryTurnOnBluetooth(); + return; + } + + discoveredDevices.clear(); + + FlutterBluePlus.startScan( + withServices: [Guid(targetServiceUUID), Guid(danceTargetServiceUUID)], + continuousUpdates: true, + removeIfGone: _deviceTimeout, + ); + + _scanSubscription = FlutterBluePlus.scanResults.listen( + (results) { + for (var result in results) { + _centralManagerDidDiscoverPeripheral(result); + } + }, + onError: (e) { + }, + ); + + _startCleanupTimer(); + } + + //Corresponds to iOS centralManager didDiscover peripheral method + void _centralManagerDidDiscoverPeripheral(ScanResult result) { + final advertisementDataMap = { + ValueConstant.advName: result.advertisementData.advName, + ValueConstant.txPowerLevel: result.advertisementData.txPowerLevel, + ValueConstant.connectable: result.advertisementData.connectable, + ValueConstant.serviceUuids: result.advertisementData.serviceUuids + .map((g) => g.toString()) + .toList(), + ValueConstant.serviceData: result.advertisementData.serviceData.map( + (k, v) => MapEntry(k.toString(), v), + ), + ValueConstant.manufacturerData: result.advertisementData.manufacturerData, + }; + + final deviceInfo = BlueDeviceInfo( + device: result.device, + advertisementData: advertisementDataMap, + rssi: result.rssi, + lastSeen: DateTime.now(), + ); + + final index = discoveredDevices.indexWhere( + (d) => d.device.remoteId == result.device.remoteId, + ); + if (index == -1) { + discoveredDevices.add(deviceInfo); + } else { + discoveredDevices[index] = deviceInfo; + } + + //[Fix]WhenbounddeviceCurrentlyinconnectorconnectedwhen,NotexecutedevicediscoverStreamProcess / Thread + if (currentPeripheral != null) { + return; + } + + //Determine behavior based on blueMode + switch (blueMode) { + case 1: + //Change WiFi mode: Check device changes, auto connect bound devices + _checkDeviceChanges(); + break; + case 2: + //Dance mode: Only connect own device (requires deviceControlMode == 1) + if (AppState.shared.deviceControlMode == 1) { + screenMyDevice(discoveredDevices); + } + break; + case 3: + //pairingmode:callbackdevicelistFor / ToUIshow + blufDevicesMonitoring?.call(discoveredDevices); + break; + } + } + + ///checkdevicelistchange,hasnewdevicewhencheckwhetherbound + Future _checkDeviceChanges() async { + final newDate = DateTime.now(); + if (AppState.shared.manualShutdownTime != null) { + final Duration difference = newDate.difference( + AppState.shared.manualShutdownTime!, + ); + if (difference.inSeconds < 6) { + return; + } + } + + //Get MAC addresses of all current devices + List currentMacs = []; + for (final device in discoveredDevices) { + final mac = _getDeviceId(device); + if (mac != null) { + currentMacs.add(mac.toUpperCase()); + } + } + //comparewhetherhasnewdeviceAppear / Occur + bool hasNewDevice = false; + for (final mac in currentMacs) { + if (!cachedDeviceMacs.contains(mac)) { + hasNewDevice = true; + break; + } + } + + //updatecache + cachedDeviceMacs = currentMacs; + //ifhasnewdevice,AnduserAlreadylogin,checkwhetherisbounddevice + if (hasNewDevice && AppState.shared.isLogin.value) { + //Getbounddevicelist + await AppState.shared.getDevices(); + + //checkcurrentscantodevicewhetherinboundlistin + for (final deviceInfo in discoveredDevices) { + final String? deviceMac = _getDeviceId(deviceInfo); + if (deviceMac == null) continue; + + final upperMac = deviceMac.toUpperCase(); + //checkwhetherisbounddevice + final isBound = AppState.shared.devices.any( + (device) => device.mac.toUpperCase() == upperMac, + ); + + if (isBound && currentPeripheral == null) { + currentPeripheral = deviceInfo.device; + //[Fix]First / Previouslymarkconnectin,connectsuccessafterAgainpopup + await connect(deviceInfo.device); + if (AppState.shared.popupState) { + return; + } + AppState.shared.popupState = true; + await showCupertinoSheet( + context: App.appContext(), + builder: (context) { + return DeviceWifiConfig(); + }, + ); + AppState.shared.popupState = false; + break; + } + } + } + } + + String? _getDeviceId(BlueDeviceInfo blueDeviceInfo) { + final Map> manufacturerDataMap = + blueDeviceInfo.advertisementData[ValueConstant.manufacturerData]; + if (manufacturerDataMap.isNotEmpty) { + final MapEntry> firstEntry = + manufacturerDataMap.entries.first; + final List customData = firstEntry.value; + final address = customData.map((byte) { + return byte.toRadixString(16).padLeft(2, '0').toUpperCase(); + }).join(); + return address; + } + return null; + } + + void screenMyDevice(List devices) { + if (AppState.shared.deviceMac.isEmpty) { + return; + } + for (final deviceInfo in devices) { + final String? deviceMac = _getDeviceId(deviceInfo); + if (deviceMac == null) { + continue; + } + final String targetMac = AppState.shared.deviceMac.toUpperCase(); + if (deviceMac.toUpperCase() == targetMac) { + currentPeripheral = deviceInfo.device; + connect(deviceInfo.device); + break; + } + } + } + + void _startCleanupTimer() { + _cleanupTimer?.cancel(); + _cleanupTimer = Timer.periodic(const Duration(seconds: 2), (timer) { + final now = DateTime.now(); + final originalCount = discoveredDevices.length; + + discoveredDevices.removeWhere((d) { + return now.difference(d.lastSeen) > _deviceTimeout; + }); + + if (discoveredDevices.length != originalCount) { + blufDevicesMonitoring?.call(discoveredDevices); + } + }); + } + + void stopScan() { + FlutterBluePlus.stopScan(); + _scanSubscription?.cancel(); + _cleanupTimer?.cancel(); + } + + //MARK: - Connection related + Future connect(BluetoothDevice peripheral) async { + try { + _connectionStateSubscription?.cancel(); + _connectionStateSubscription = peripheral.connectionState.listen((state) { + _handleConnectionState(peripheral, state); + }); + _resetCharacteristics(); + await peripheral.connect( + license: License.free, + timeout: const Duration(seconds: 15), + autoConnect: false, + ); + } catch (e) { + String errorMsg = e is FlutterBluePlusException + ? "${e.description} (code: ${e.code})" + : e.toString(); + connectionStateChanged?.call(peripheral, false); + } + } + + void _handleConnectionState( + BluetoothDevice peripheral, + BluetoothConnectionState state, + ) { + switch (state) { + case BluetoothConnectionState.connected: + currentPeripheral = peripheral; + _peripheralDidConnect(peripheral); + connectionStateChanged?.call(peripheral, true); + break; + case BluetoothConnectionState.disconnected: + final disconnectReason = peripheral.disconnectReason; + currentPeripheral = null; + _resetCharacteristics(); + connectionStateChanged?.call(peripheral, false); + + //Remove disconnected device MAC from cache so it's treated as new when scanned after reboot + for (final deviceInfo in discoveredDevices) { + if (deviceInfo.device.remoteId == peripheral.remoteId) { + final mac = _getDeviceId(deviceInfo); + if (mac != null) { + cachedDeviceMacs.remove(mac.toUpperCase()); + } + break; + } + } + + if (autoReconnect) { + reconnect(); + } + break; + default: + break; + } + } + + Future _peripheralDidConnect(BluetoothDevice peripheral) async { + await _discoverServices(peripheral); + } + + Future _discoverServices(BluetoothDevice peripheral) async { + try { + if (peripheral.isDisconnected) { + return; + } + + //CallSystemmethoddiscoverservice + final services = await peripheral.discoverServices(timeout: 35); + + for (var s in services) { + } + + //iteratediscoverfeature + for (var service in services) { + await _discoverCharacteristics(peripheral, service); + } + } catch (e, stack) { + //Add stack print trace + } + } + + Future _discoverCharacteristics( + BluetoothDevice peripheral, + BluetoothService service, + ) async { + try { + final characteristics = service.characteristics; + + for (var characteristic in characteristics) { + characteristicCallback?.call(peripheral, characteristic); + await _setupCharacteristicListener(peripheral, characteristic); + _saveCharacteristicReference(characteristic); + } + } catch (e) { + } + } + + Future _setupCharacteristicListener( + BluetoothDevice peripheral, + BluetoothCharacteristic characteristic, + ) async { + final uuid = characteristic.uuid.toString(); + + const List needNotifyUuids = [wifiSetCharacteristicUUID]; + + if (!needNotifyUuids.map((e) => e.toLowerCase()).contains(uuid)) { + return; + } + + //onlyhasinwhitelistInsidefeatureValue,Only thenexecuteDownSurface / Sidelistenlogic + if (characteristic.properties.notify || + characteristic.properties.indicate) { + try { + bool notifySuccess = await characteristic.setNotifyValue(true); + if (notifySuccess) { + } else { + } + } catch (e) { + } + } + //listendatareceive + characteristic.lastValueStream.listen((value) { + if (value.isEmpty) return; + if (uuid == wifiSetCharacteristicUUID.toLowerCase()) { + wifiSetCharacteristicCall?.call(value); + } + }); + } + + void _saveCharacteristicReference(BluetoothCharacteristic characteristic) { + final uuid = characteristic.uuid.toString(); + switch (uuid) { + case headCharacteristicUUID: + writeHeadCharacteristic = characteristic; + break; + case wifiSetCharacteristicUUID: + writeWifiSetCharacteristic = characteristic; + break; + case expressionCharacteristicUUID: + writeExpressionCharacteristic = characteristic; + break; + case writeCharacteristicUUID: + writeCharacteristic = characteristic; + break; + case motionCharacteristicUUID: + writeMotionCharacteristic = characteristic; + break; + case avatarCharacteristicUUID: + writeAvatarCharacteristic = characteristic; + break; + case rgbCharacteristicUUID: + writeRGBCharacteristic = characteristic; + break; + } + } + + Future disconnectCurrentPeripheral() async { + final peripheral = currentPeripheral; + if (peripheral == null) { + return; + } + + try { + await peripheral.disconnect(timeout: 35, queue: true, androidDelay: 2000); + _resetCharacteristics(); + } catch (e) { + } + } + + void _resetCharacteristics() { + writeWifiSetCharacteristic = null; + writeHeadCharacteristic = null; + writeExpressionCharacteristic = null; + writeCharacteristic = null; + } + + //MARK: - Data sending + Future sendHeadData(String data) async { + await _sendData(data, writeHeadCharacteristic, "Head data"); + } + + Future sendWifiSetData(String data) async { + return await _sendData(data, writeWifiSetCharacteristic, "WiFi set data"); + } + + Future sendExpressionData(String data) async { + await _sendData(data, writeExpressionCharacteristic, "Expression data"); + } + + Future sendData(String data) async { + await _sendData(data, writeCharacteristic, "Data"); + } + + Future sendDanceData(DanceData data) async { + if (writeMotionCharacteristic != null) { + final motion = MotionData( + pitchServo: data.pitchServo, + yawServo: data.yawServo, + ); + final motionData = utf8.encode(motion.toString()); + writeMotionCharacteristic!.write( + motionData, + withoutResponse: false, + allowLongWrite: true, + ); + } + if (writeAvatarCharacteristic != null) { + final avatar = ExpressionData( + leftEye: data.leftEye, + rightEye: data.rightEye, + mouth: data.mouth, + ); + final avatarData = utf8.encode(avatar.toString()); + writeAvatarCharacteristic!.write( + avatarData, + withoutResponse: false, + allowLongWrite: true, + ); + } + if (writeRGBCharacteristic != null) { + final rgb = RgbData( + leftRgbColor: data.leftRgbColor, + leftRgbDuration: 0.3, + rightRgbColor: data.rightRgbColor, + rightRgbDuration: 0.3, + ); + final rgbData = utf8.encode(rgb.toString()); + writeRGBCharacteristic!.write( + rgbData, + withoutResponse: false, + allowLongWrite: true, + ); + } + } + + Future _sendData( + String data, + BluetoothCharacteristic? characteristic, + String type, + ) async { + if (characteristic == null) { + return false; + } + + final dataToSend = utf8.encode(data); + if (dataToSend.isEmpty) { + return false; + } + + try { + + await characteristic.write( + dataToSend, + withoutResponse: false, + allowLongWrite: true, + ); + + return true; + } catch (e) { + + //Send failed = connection broken → can reconnect here + if (e.toString().contains("Timed out")) { + //cantriggerreconnectlogic + } + return false; + } + } + + Future reconnect() async { + if (currentPeripheral != null && currentPeripheral!.isDisconnected) { + _resetCharacteristics(); + await connect(currentPeripheral!); + onReconnectSuccess?.call(currentPeripheral!); + } + } + + Function(BluetoothDevice)? onReconnectSuccess; + + Future readRssi(BluetoothDevice device) async { + if (device.isDisconnected) return null; + try { + return await device.readRssi(timeout: 15); + } catch (e) { + return null; + } + } + + //MARK: - Resource release + void dispose() { + _scanSubscription?.cancel(); + _adapterStateSubscription?.cancel(); + _connectionStateSubscription?.cancel(); + _cleanupTimer?.cancel(); + stopScan(); + disconnectCurrentPeripheral(); + } +} + +extension ScanResultListExtension on List { + void addOrUpdate(ScanResult result) { + final index = indexWhere( + (element) => element.device.remoteId == result.device.remoteId, + ); + if (index == -1) { + add(result); + } else { + this[index] = result; + } + } +} diff --git a/app/lib/util/custom_colors.dart b/app/lib/util/custom_colors.dart new file mode 100644 index 0000000..54b2475 --- /dev/null +++ b/app/lib/util/custom_colors.dart @@ -0,0 +1,12 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +import 'dart:ui'; + +abstract final class CustomColors { + static const Color transparent = Color(0x00FFFFFF); + + static const Color ff659c = Color(0xFFFF659C); +} diff --git a/app/lib/util/extension.dart b/app/lib/util/extension.dart new file mode 100644 index 0000000..e2c02d6 --- /dev/null +++ b/app/lib/util/extension.dart @@ -0,0 +1,238 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +import 'dart:convert'; +import 'dart:ui' as ui; + +import 'package:flex_color_picker/flex_color_picker.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:image/image.dart' as img; + +extension HexExtension on Uint8List { + String toHexString() { + return map( + (byte) => byte.toRadixString(16).padLeft(2, '0'), + ).join().toUpperCase(); + } +} + +// NeedReplaceKeyValueFor +final projectStringReplacement = { + "小智": "Xiaozhi", + "Qwen3 实时": "Qwen3 235B (Fast)", + "DeepSeek V3.1": "DeepSeek V3.1 (Powerful)", + "DouBao Seed 1.6": "Doubao Seed 1.6 (Delayed)", + "GLM 4.7(内测)": "GLM 4.7(Internal Test)", + "Kimi-K2(内测)": "Kimi-K2(Internal Test)", + "Doubao 2.0(内测)": "Doubao 2.0(Internal Test)", + "Qwen3.5 397B(内测)": "Qwen3.5 397B(Internal Test)", +}; + +extension StringTool on String? { + /// RegexReplaceString + /// projectStringReplacement ThenReplaceAllMatchContent + String? regularExpressionSubstitution() { + // 1. NullValueDirectlyReturns null + if (this == null) { + return null; + } + + // 2. Non-NullString + String result = this!; + + // 3. IterateReplaceDictionary,ReplaceAllMatchItem + for (final entry in projectStringReplacement.entries) { + // Escape,AvoidRegex(,) + final pattern = RegExp.escape(entry.key); + // GlobalReplaceAllMatchContent + result = result.replaceAll(RegExp(pattern), entry.value); + } + + return result; + } +} + +extension StringToUint8List on String? { + ///Convert String? to Uint8List + Uint8List toUint8List() { + if (this == null || this!.isEmpty) { + return Uint8List(0); + } + return Uint8List.fromList(utf8.encode(this!)); + } + + ///Convert Hex string to Color object + ///Supported formats: "0xFFFFFFFF", "#FFFFFF", "FFFFFF" + Color hex() { + if (this == null || this!.isEmpty) return CupertinoColors.transparent; + + String hexString = this!.toUpperCase().replaceAll("#", ""); + if (hexString.startsWith("0X")) { + hexString = hexString.substring(2); + } + + if (hexString.length == 6) { + hexString = "FF$hexString"; + } + + final intValue = int.tryParse(hexString, radix: 16); + return Color(intValue ?? 0x00000000); + } +} + +extension ColorExtension on Color? { + ///Convert Color to hex string (e.g., #RRGGBB) + String hexString() { + if (this == null) return "#000000"; + + //Extract RGB channels and convert to hex, ignore Alpha to match standard color codes + String r = this!.red8bit.toRadixString(16).padLeft(2, '0'); + String g = this!.green8bit.toRadixString(16).padLeft(2, '0'); + String b = this!.blue8bit.toRadixString(16).padLeft(2, '0'); + + return "#$r$g$b".toUpperCase(); + } +} + +extension ImageExtension on Uint8List { + Future compress({ + ui.Size? resolutionSize, + double? memorySize, + bool cropCenter = false, + }) async { + //Use compute isolation to avoid blocking UI thread when processing large images + return compute( + _compressImage, + _CompressParams( + bytes: this, + resolutionSize: resolutionSize, + memorySize: memorySize, + cropCenter: cropCenter, + ), + ); + } + + Future compressToMemorySize(double memorySize) async { + return compress( + resolutionSize: null, + memorySize: memorySize, + cropCenter: false, + ); + } +} + +//Compression parameter wrapper (for compute isolation) +class _CompressParams { + final Uint8List bytes; + final ui.Size? resolutionSize; + final double? memorySize; + final bool cropCenter; + + _CompressParams({ + required this.bytes, + this.resolutionSize, + this.memorySize, + required this.cropCenter, + }); +} + +//Core compression logic (top-level function for compute isolation) +Future _compressImage(_CompressParams params) async { + try { + //1. Decode original image + img.Image? originalImage = img.decodeImage(params.bytes); + if (originalImage == null) return null; //Return null on decode failure + + img.Image processedImage = originalImage; + + //2. Handle resolution scaling/cropping (align with iOS logic) + if (params.resolutionSize != null) { + final targetWidth = params.resolutionSize!.width.toInt(); + final targetHeight = params.resolutionSize!.height.toInt(); + + if (params.cropCenter) { + //CropCenter=true: Scale to cover target size then center crop (Aspect-Fill) + final scaleX = targetWidth / originalImage.width; + final scaleY = targetHeight / originalImage.height; + final scale = scaleX > scaleY + ? scaleX + : scaleY; //Take larger scale ratio + + //Scale image to cover target size + final scaledWidth = (originalImage.width * scale).toInt(); + final scaledHeight = (originalImage.height * scale).toInt(); + final scaledImage = img.copyResize( + originalImage, + width: scaledWidth, + height: scaledHeight, + ); + + //Calculate center crop offset + final cropX = (scaledWidth - targetWidth) ~/ 2; + final cropY = (scaledHeight - targetHeight) ~/ 2; + + //Execute center crop + processedImage = img.copyCrop( + scaledImage, + x: cropX, + y: cropY, + width: targetWidth, + height: targetHeight, + ); + } else { + //CropCenter=false: Aspect-Fit scaling, draw to target size canvas + final scaleX = targetWidth / originalImage.width; + final scaleY = targetHeight / originalImage.height; + final scale = scaleX < scaleY + ? scaleX + : scaleY; //Take smaller scale ratio + + //Scale image to fit target size + final newWidth = (originalImage.width * scale).toInt(); + final newHeight = (originalImage.height * scale).toInt(); + final scaledImage = img.copyResize( + originalImage, + width: newWidth, + height: newHeight, + ); + + //Create target size canvas, draw scaled image at top-left (align with iOS draw logic) + final canvas = img.Image(width: targetWidth, height: targetHeight); + //Critical fix: Use direct blend mode (normal overlay, no color blending) + img.compositeImage( + canvas, + scaledImage, + dstX: 0, + dstY: 0, + blend: img.BlendMode.direct, + ); + processedImage = canvas; + } + } + + //3. Handle memory size compression (JPEG quality adjustment) + if (params.memorySize == null) { + //No memory limit, return 100% quality JPEG + return img.encodeJpg(processedImage, quality: 100); + } + + //Calculate max bytes (MB → Bytes) + final maxBytes = (params.memorySize! * 1024 * 1024).toInt(); + int quality = 100; //Corresponds to iOS compressionQuality=1.0 + //Null safety fix: encodeJpg may return null, need ? and handling + Uint8List compressedData = img.encodeJpg(processedImage, quality: quality); + //Gradually reduce quality (×0.7 each time) until size limit met or quality below 1% + while (compressedData.length > maxBytes && quality > 1) { + quality = (quality * 0.7).round(); + if (quality < 1) quality = 1; //Minimum quality limit is 1% + compressedData = img.encodeJpg(processedImage, quality: quality); + } + + return compressedData; + } catch (e) { + return null; + } +} diff --git a/app/lib/util/mac_address_validator.dart b/app/lib/util/mac_address_validator.dart new file mode 100644 index 0000000..b38207c --- /dev/null +++ b/app/lib/util/mac_address_validator.dart @@ -0,0 +1,101 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +/// Utility class for MAC address validation and formatting +/// +/// Supports two common MAC address formats: +/// 1. Standard colon-separated format: 00:11:22:33:44:55 +/// 2. Hyphen-separated format: 00-11-22-33-44-55 +/// 3. Pure 12-character format: 001122334455 +class MacAddressValidator { + /// Validate MAC address format + /// + /// Supports: + /// - Pure 12 hex characters (uppercase or lowercase) + /// - Colon-separated format (XX:XX:XX:XX:XX:XX) + /// - Hyphen-separated format (XX-XX-XX-XX-XX-XX) + /// + /// Returns true if the MAC address is valid, false otherwise + static bool isValidMac(String? mac) { + if (mac == null || mac.isEmpty) return false; + + // Pattern 1: With separators (colon or hyphen) + // Matches: 00:11:22:33:44:55 or 00-11-22-33-44-55 + final macWithSeparator = RegExp( + r'^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$', + ); + + // Pattern 2: Pure 12 hex characters (no separators) + final macWithoutSeparator = RegExp(r'^[0-9A-Fa-f]{12}$'); + + return macWithSeparator.hasMatch(mac) || macWithoutSeparator.hasMatch(mac); + } + + /// Format MAC address to standard colon-separated uppercase format + /// + /// Converts any valid MAC address to: XX:XX:XX:XX:XX:XX + /// Returns null if the input MAC is invalid + static String? formatMac(String? mac) { + if (mac == null || mac.isEmpty) return null; + + // Remove all separators and convert to uppercase + String cleanMac = mac.replaceAll(RegExp(r'[:-]'), '').toUpperCase(); + + // Validate before formatting + if (cleanMac.length != 12) return null; + + // Insert colon every 2 characters + List parts = []; + for (int i = 0; i < cleanMac.length; i += 2) { + parts.add(cleanMac.substring(i, i + 2)); + } + + return parts.join(':'); + } + + /// Format MAC address to colon-separated lowercase format + /// + /// Converts any valid MAC address to: xx:xx:xx:xx:xx:xx + /// Useful for case-sensitive comparisons or storage + static String? formatLowerCaseMac(String? mac) { + if (mac == null || mac.isEmpty) return null; + + // Remove all separators and convert to lowercase + String cleanMac = mac.replaceAll(RegExp(r'[:-]'), '').toLowerCase(); + + // Validate before formatting + if (cleanMac.length != 12) return null; + + // Insert colon every 2 characters + List parts = []; + for (int i = 0; i < cleanMac.length; i += 2) { + parts.add(cleanMac.substring(i, i + 2)); + } + + return parts.join(':'); + } + + /// Remove all separators from MAC address + /// + /// Returns pure 12-character hex string in uppercase + static String? toPureMac(String? mac) { + if (mac == null || mac.isEmpty) return null; + return mac.replaceAll(RegExp(r'[:-]'), '').toUpperCase(); + } + + /// Normalize MAC address for comparison + /// + /// Converts to lowercase without separators for consistent comparison + static String? normalize(String? mac) { + if (mac == null || mac.isEmpty) return null; + return mac.replaceAll(RegExp(r'[:-]'), '').toLowerCase(); + } + + /// Check if two MAC addresses are equal (case and separator insensitive) + static bool areEqual(String? mac1, String? mac2) { + return normalize(mac1) == normalize(mac2); + } +} \ No newline at end of file diff --git a/app/lib/util/ml_kit_util.dart b/app/lib/util/ml_kit_util.dart new file mode 100644 index 0000000..5781e39 --- /dev/null +++ b/app/lib/util/ml_kit_util.dart @@ -0,0 +1,66 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +import 'package:camera/camera.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:google_mlkit_face_detection/google_mlkit_face_detection.dart'; + +class MlKitUtil { + MlKitUtil._internal(); + + static final MlKitUtil shared = MlKitUtil._internal(); + + late final FaceDetector _faceDetector = FaceDetector( + options: FaceDetectorOptions( + enableClassification: true, + enableTracking: true, + performanceMode: .accurate, + ), + ); + + bool _isProcessing = false; + + Future testing( + CameraImage image, + int sensorOrientation, + Function(List) onFacesDetected, + ) async { + if (_isProcessing) return; + _isProcessing = true; + try { + final inputImage = _convertToInputImage(image, sensorOrientation); + final faces = await _faceDetector.processImage(inputImage); + onFacesDetected(faces); + } catch (e) { + } finally { + _isProcessing = false; + } + } + + InputImage _convertToInputImage(CameraImage image, int sensorOrientation) { + return InputImage.fromBytes( + bytes: image.planes.first.bytes, + metadata: InputImageMetadata( + size: Size(image.width.toDouble(), image.height.toDouble()), + rotation: _getRotation(sensorOrientation), + format: .nv21, + bytesPerRow: image.planes[0].bytesPerRow, + ), + ); + } + + InputImageRotation _getRotation(int degrees) { + switch (degrees) { + case 90: + return InputImageRotation.rotation90deg; + case 180: + return InputImageRotation.rotation180deg; + case 270: + return InputImageRotation.rotation270deg; + default: + return InputImageRotation.rotation0deg; + } + } +} diff --git a/app/lib/util/music_util.dart b/app/lib/util/music_util.dart new file mode 100644 index 0000000..10cc06c --- /dev/null +++ b/app/lib/util/music_util.dart @@ -0,0 +1,562 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +import 'dart:async'; +import 'dart:io'; +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:ffmpeg_kit_flutter_new/ffmpeg_kit.dart'; +import 'package:ffmpeg_kit_flutter_new/return_code.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:just_audio/just_audio.dart'; +import 'package:music_feature_analyzer/music_feature_analyzer.dart'; +import 'package:path/path.dart' as path; +import 'package:path_provider/path_provider.dart'; + +class MusicInfo { + int duration; //translated comment + String filePath; + String? title; + String? artist; + String? album; + String? artwork; + String? lyrics; + + MusicInfo( + this.duration, + this.filePath, { + this.title, + this.artist, + this.album, + this.artwork, + this.lyrics, + }); + + ///loadmusicfileBytedata + Future loadData() async { + try { + final file = File(filePath); + if (!await file.exists()) { + throw FileSystemException("音乐文件不存在", file.path); + } + return await file.readAsBytes(); + } catch (e) { + throw Exception("加载音乐文件失败: $e"); + } + } + + String get mimeType { + final ext = path.extension(filePath).toLowerCase(); + switch (ext) { + case '.wav': + return 'audio/wav'; + case '.m4a': + return 'audio/mp4'; + case '.flac': + return 'audio/flac'; + case '.mp3': + default: + return 'audio/mpeg'; + } + } + + Future> getProgressData({int targetSampleCount = 100}) async { + if (targetSampleCount <= 0) { + throw ArgumentError("目标采样点数必须大于0: $targetSampleCount"); + } + final audioFile = File(filePath); + if (!await audioFile.exists()) { + throw FileSystemException("音频文件不存在", filePath); + } + + final tempDir = await getTemporaryDirectory(); + final pcmFileName = + "audio_waveform_${DateTime.now().microsecondsSinceEpoch}.pcm"; + final pcmFilePath = "${tempDir.path}/$pcmFileName"; + + try { + final command = + '-loglevel error -hide_banner -i "$filePath" -f s16le -ac 1 -ar 16000 -vn "$pcmFilePath"'; + final session = await FFmpegKit.execute(command); + final returnCode = await session.getReturnCode(); + if (!ReturnCode.isSuccess(returnCode)) { + final error = await session.getAllLogsAsString(); + throw Exception("FFmpeg转换失败: 码=$returnCode, 错误=$error"); + } + + //Process PCM file, calculate chunk volume (RMS) + final volumeData = await _processPcmFileForVolume( + pcmFilePath, + targetSampleCount, + ); + return volumeData; + } catch (e) { + throw Exception("获取音频波动数据失败: $e"); + } finally { + try { + final pcmFile = File(pcmFilePath); + if (await pcmFile.exists()) { + await pcmFile.delete(); + } + } catch (e) { + //onlyPrintdeletefaillog,NotinterruptMainStreamProcess / Thread + } + } + } + + ///Process PCM file, calculate chunk volume (RMS/decibel) + Future> _processPcmFileForVolume( + String pcmPath, + int targetSampleCount, + ) async { + final file = File(pcmPath); + final bytes = await file.readAsBytes(); + const sampleSize = 2; //16-bit PCM = 2 bytes/sample + final totalSamples = bytes.length ~/ sampleSize; + + //Boundary: return all zeros when no samples + if (totalSamples == 0) { + return List.filled(targetSampleCount, 0.0); + } + + final byteData = ByteData.view(bytes.buffer); + final volumeValues = []; + + //calculateeachBlockShouldContainssampleCount / Number + final samplesPerBlock = (totalSamples / targetSampleCount).ceil(); + + //Calculate volume in chunks (RMS) + for (int blockIndex = 0; blockIndex < targetSampleCount; blockIndex++) { + //calculatecurrentBlocksamplerange + final startSample = blockIndex * samplesPerBlock; + final endSample = ((blockIndex + 1) * samplesPerBlock).clamp( + 0, + totalSamples, + ); + final blockSampleCount = endSample - startSample; + + //Boundary: chunk with no samples, volume is 0 + if (blockSampleCount <= 0) { + volumeValues.add(0.0); + continue; + } + + //Calculate RMS of current chunk: reflects average volume in this period + double sumOfSquares = 0.0; + for (int i = startSample; i < endSample; i++) { + //Read 16-bit little-endian PCM sample (range: -32768 ~ 32767) + final int16Value = byteData.getInt16(i * sampleSize, Endian.little); + //calculateSquareand + sumOfSquares += (int16Value * int16Value).toDouble(); + } + + //RMS = sqrt(sum of squares / sample count) + final rms = sqrt(sumOfSquares / blockSampleCount); + //Normalize to 0~1 range (32767 is max value for 16-bit signed integer) + final normalizedRms = (rms / 32767.0).clamp(0.0, 1.0); + + //Optional: Convert to decibels (dB) (closer to human perception, range: 0~1) + //Decibel formula: 20 * log10(RMS / 32767), but handle 0 to avoid log(0) + // final db = normalizedRms > 0 ? 20 * log10(normalizedRms) : -100; + //final normalizedDb = (db + 100) / 100; // Map to 0~1 + // volumeValues.add(normalizedDb.clamp(0.0, 1.0)); + + //Use normalized RMS directly (simpler, linear volume representation) + volumeValues.add(normalizedRms); + } + + return volumeValues; + } +} + +///Custom byte stream audio source (adapt just_audio) +class BytesAudioSource extends StreamAudioSource { + final Uint8List bytes; + final String contentType; + final String? id; + + BytesAudioSource(this.bytes, {this.contentType = 'audio/mpeg', this.id}); + + @override + Future request([int? start, int? end]) async { + start ??= 0; + end ??= bytes.length; + return StreamAudioResponse( + sourceLength: bytes.length, + contentLength: end - start, + offset: start, + stream: Stream.value(bytes.sublist(start, end)), + contentType: contentType, + ); + } +} + +class MusicUtil { + //Singletonmode + MusicUtil._internal() { + _initAnalyzer(); + _setupPlayerListener(); //beforeinitlistener,avoid + } + + static final MusicUtil shared = MusicUtil._internal(); + + //Core player instance (just_audio) + final AudioPlayer _audioPlayer = AudioPlayer(); + + //playcompletecallback + void Function()? _playbackCompletion; + + //musicduration(Second(s)) + double _musicDuration = 0.0; + + //currentplayprogress(Second(s)) + double _currentPosition = 0.0; + + //currentplaymusicinfo + MusicInfo? _currentMusicInfo; + + ///initmusicAnalyzer + Future _initAnalyzer() async { + try { + await MusicFeatureAnalyzer.initialize(); + } catch (e) { + } + } + + ///configplayerlistener(System1Managerstate) + void _setupPlayerListener() { + _audioPlayer.setVolume(1.0); + + //playerstatelisten(Containsplaystateandhandlestate) + _audioPlayer.playerStateStream.listen((PlayerState state) { + + //Playback completion check (handle completed status) + if (state.processingState == ProcessingState.completed) { + _currentPosition = 0.0; //resetprogress + + //SingleloopThenreplay,elseexecutecompletecallback + if (_audioPlayer.loopMode == LoopMode.one && + _currentMusicInfo != null) { + _audioPlayer.seek(Duration.zero); + _audioPlayer.play(); + } else { + _playbackCompletion?.call(); + _playbackCompletion = null; + } + } + + //stopstateresetprogress + if (state.processingState == ProcessingState.idle) { + _currentPosition = 0.0; + } + }); + + //durationChangelisten + _audioPlayer.durationStream.listen((Duration? duration) { + if (duration != null) { + _musicDuration = duration.inMilliseconds / 1000.0; + } + }); + + //playprogresslisten + _audioPlayer.positionStream.listen((Duration position) { + _currentPosition = position.inMilliseconds / 1000.0; + //preventprogressExceeds totalduration + if (_currentPosition > _musicDuration && _musicDuration > 0) { + _currentPosition = _musicDuration; + } + }); + + //errorlisten + _audioPlayer.errorStream.listen((PlayerException? e) { + if (e != null) { + } + }); + } + + ///playBytedataFormat / Formmusic + Future playMusicData( + Uint8List data, { + String contentType = 'audio/mpeg', + }) async { + try { + await stopMusic(); + _musicDuration = 0.0; + _currentPosition = 0.0; + + //usecustomByteStreamaudioSourceload + + final audioSource = BytesAudioSource(data, contentType: contentType); + await _audioPlayer.setAudioSource(audioSource); + await _audioPlayer.play(); + + } on PlayerException catch (e) { + throw Exception("播放失败: ${e.message}"); + } catch (e) { + throw Exception("播放失败: $e"); + } + } + + ///playSinglemusic(playcompleteafterexecutecallback) + Future playMusicOnce(MusicInfo musicInfo, Function() completion) async { + _playbackCompletion = completion; + await playMusic(musicInfo, isLoop: false); //playcloseloop + } + + ///PlayOnlineMusic1Time(s),RepeatCallThenStopFrontFrom beginningPlay + Future playUrlMusicOnce(String? url, {Function()? completion}) async { + if (url == null) { + return; + } + try { + // First / PreviouslyStopFrontPlay + await stopMusic(); + + // SetComplete / DoneCallback + _playbackCompletion = completion; + + // SetIsNotLoop + _audioPlayer.setLoopMode(LoopMode.off); + + // DirectlyUse setUrl Load URL + await _audioPlayer.setUrl(url); + await _audioPlayer.play(); + + } on PlayerException catch (e) { + throw Exception("播放失败: ${e.message}"); + } catch (e) { + throw Exception("播放失败: $e"); + } + } + + ///coreplaymethod(supportloop) + Future playMusic(MusicInfo? musicInfo, {bool isLoop = false}) async { + if (musicInfo == null) { + return; + } + + //Recordcurrentplaymusicinfo + _currentMusicInfo = musicInfo; + + //Set loop mode (just_audio LoopMode) + _audioPlayer.setLoopMode(isLoop ? LoopMode.one : LoopMode.off); + + try { + final data = await musicInfo.loadData(); + final contentType = musicInfo.mimeType; + await playMusicData(data, contentType: contentType); + } on PlayerException catch (e) { + throw Exception("播放失败: ${e.message}"); + } catch (e) { + throw Exception("播放失败: $e"); + } + } + + ///stopplay + Future stopMusic() async { + await _audioPlayer.stop(); + await _audioPlayer.seek(Duration.zero); //resetprogressto + + _currentPosition = 0.0; + _playbackCompletion = null; + _currentMusicInfo = null; + } + + ///pauseplay + Future pauseMusic() async { + if (_audioPlayer.playing) { + await _audioPlayer.pause(); + } + } + + ///resumeplay + Future resumeMusic() async { + if (!_audioPlayer.playing && _currentMusicInfo != null) { + await _audioPlayer.play(); + } + } + + ///setloopplaystate + void setMusicLoop(bool isLoop) { + final loopMode = isLoop ? LoopMode.one : LoopMode.off; + _audioPlayer.setLoopMode(loopMode); + } + + ///jumpplayprogress + Future seekTo(double seconds) async { + if (seconds < 0 || seconds > _musicDuration) { + return; + } + await _audioPlayer.seek(Duration(seconds: seconds.toInt())); + _currentPosition = seconds; + } + + ///Set volume (0.0 ~ 1.0) + Future setVolume(double volume) async { + if (volume < 0.0 || volume > 1.0) { + return; + } + await _audioPlayer.setVolume(volume); + } + + ///setplayspeed + Future setPlaybackSpeed(double speed) async { + if (speed <= 0) { + return; + } + await _audioPlayer.setSpeed(speed); + } + + ///Getcurrentplayprogress(Second(s)) + double getCurrentPosition() => _currentPosition; + + ///GetmusicTotalduration(Second(s)) + double getMusicDuration() => _musicDuration; + + ///Getcurrentloopstate + bool getIsLoop() => _audioPlayer.loopMode == LoopMode.one; + + ///GetcurrentplayerwhetherCurrentlyinplay + bool isPlaying() => _audioPlayer.playing; + + ///releaseplayerAsset / ResourceSource(pagedisposewhenCall) + Future dispose() async { + await stopMusic(); + await _audioPlayer.dispose(); + _currentMusicInfo = null; + _playbackCompletion = null; + } + + ///improveaftermusicinfoparse(With / CarryVerboselog+cacheverify) + Future getMusicInfoAsync(String urlString) async { + const tag = "MusicUtil/getMusicInfoAsync"; + try { + + //1. Parse URL + final uri = Uri.parse(urlString); + if (!uri.isAbsolute) { + return null; + } + + //2. Generate cache file info + final extension = path.extension(uri.path); + if (extension.isEmpty || + ![ + '.mp3', + '.wav', + '.m4a', + '.flac', + ].contains(extension.toLowerCase())) { + return null; + } + final fileName = '${uri.hashCode.toRadixString(16)}$extension'; + //useDocumentDirectoryAnd / WhileNotisWhenwhenDirectory,avoidSystemautocleancachefile + final cacheDir = await getApplicationDocumentsDirectory(); + final musicCacheDir = Directory(path.join(cacheDir.path, 'music_cache')); + if (!await musicCacheDir.exists()) { + await musicCacheDir.create(recursive: true); + } + final filePath = path.join(musicCacheDir.path, fileName); + + //3. Check cache file + final file = File(filePath); + if (await file.exists()) { + final stat = await file.stat(); + final fileSizeKB = stat.size / 1024; + if (fileSizeKB < 10) { + await file.delete(); + } else { + return await _extractMetadataFromFile(filePath, uri); + } + } + + //4. Download file + await _downloadFile(uri, file); + final stat = await file.stat(); + final fileSizeKB = stat.size / 1024; + if (fileSizeKB < 10) { + return null; + } + + //5. Extract metadata + return await _extractMetadataFromFile(filePath, uri); + } catch (e, stackTrace) { + return null; + } + } + + ///DownLoadfiletoLocalcache + Future _downloadFile(Uri uri, File file) async { + const tag = "MusicUtil/_downloadFile"; + final httpClient = HttpClient(); + try { + final request = await httpClient.getUrl(uri); + final response = await request.close(); + + if (response.statusCode != HttpStatus.ok) { + throw Exception("下载失败:状态码 ${response.statusCode},URL=$uri"); + } + + await response.pipe(file.openWrite()); + } catch (e) { + rethrow; + } finally { + httpClient.close(); + } + } + + ///fromfileExtractmusicMetadata / Metadata + Future _extractMetadataFromFile(String filePath, Uri uri) async { + const tag = "MusicUtil/_extractMetadataFromFile"; + try { + final song = await MusicFeatureAnalyzer.metadata(filePath); + if (song == null) { + return null; + } + + final durationSec = song.duration ~/ 1000; //convertas + + return MusicInfo( + durationSec, + filePath, + title: song.title, + artist: song.artist, + album: song.album, + artwork: song.albumArt, + ); + } catch (e, stackTrace) { + return null; + } + } + + ///cleanExpiredmusiccache(optional:Periodicallyclean) + Future clearExpiredCache({ + Duration maxAge = const Duration(days: 7), + }) async { + const tag = "MusicUtil/clearExpiredCache"; + try { + final cacheDir = await getTemporaryDirectory(); + final files = await cacheDir.list().toList(); + final now = DateTime.now(); + int deletedCount = 0; + + for (final file in files) { + if (file is File) { + final extension = path.extension(file.path).toLowerCase(); + if (['.mp3', '.wav', '.m4a', '.flac'].contains(extension)) { + final stat = await file.stat(); + final fileAge = now.difference(stat.modified); + if (fileAge > maxAge) { + await file.delete(); + deletedCount++; + } + } + } + } + } catch (e) { + } + } +} diff --git a/app/lib/util/native_bridge.dart b/app/lib/util/native_bridge.dart new file mode 100644 index 0000000..55feb4b --- /dev/null +++ b/app/lib/util/native_bridge.dart @@ -0,0 +1,115 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +import 'dart:async'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/services.dart'; + +class NativeBridge { + static final NativeBridge shared = NativeBridge._internal(); + + NativeBridge._internal() { + _channel.setMethodCallHandler(_handleMethodCall); + } + + final MethodChannel _channel = MethodChannel("com.m5stack.stackchan/native"); + + final BasicMessageChannel _audioPlayChannel = const BasicMessageChannel( + "com.m5stack.stackchan/audio_play", + BinaryCodec(), + ); + + // final EventChannel recordChannel = const EventChannel( + // "com.m5stack.stackchan/record", + // ); + + final Map Function(MethodCall)> _handlers = {}; + + /// Register a handler for a specific native method + void registerHandler( + Method method, + Future Function(MethodCall) handler, + ) { + _handlers[method] = handler; + } + + /// Register multiple handlers at once + void registerHandlers( + Map Function(MethodCall)> handlers, + ) { + _handlers.addAll(handlers); + } + + /// Unregister a handler for a specific native method + void unregisterHandler(Method method) { + _handlers.remove(method); + } + + /// Unregister multiple handlers at once + void unregisterHandlers(List methods) { + for (final method in methods) { + _handlers.remove(method); + } + } + + /// Unregister all handlers + void unregisterAllHandlers() { + _handlers.clear(); + } + + /// Check if a handler is registered for a specific method + bool hasHandler(Method method) { + return _handlers.containsKey(method); + } + + /// Get all registered method names + List get registeredMethods => _handlers.keys.toList(); + + Future _handleMethodCall(MethodCall call) async { + try { + final method = Method.fromString(call.method); + if (method != Method.unknown && _handlers.containsKey(method)) { + return await _handlers[method]!(call); + } else { + return null; + } + } catch (e) { + return null; + } + } + + /// Send message to native side with optional arguments + Future sendMessage(Method method, [dynamic arguments]) async { + try { + return await _channel.invokeMethod(method.name, arguments); + } catch (e) { + return null; + } + } + + /// Send PCM audio stream to native side for playback + Future sendAudioStream(ByteData pcmData) async { + try { + await _audioPlayChannel.send(pcmData); + } catch (e) { + } + } +} + +enum Method { + wifiName, + stopPlayPCM, + startRecording, + stopRecording, + unknown; + + static Method fromString(String name) { + return Method.values.firstWhere( + (m) => m.name == name, + orElse: () => Method.unknown, + ); + } +} diff --git a/app/lib/util/rsa_util.dart b/app/lib/util/rsa_util.dart new file mode 100644 index 0000000..5103a83 --- /dev/null +++ b/app/lib/util/rsa_util.dart @@ -0,0 +1,47 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +import 'package:encrypt/encrypt.dart'; +import 'package:pointycastle/asymmetric/api.dart'; +import 'package:stack_chan/util/value_constant.dart'; + +class RsaUtil { + static final _encrypter = Encrypter( + RSA( + publicKey: + RSAKeyParser().parse(ValueConstant.serverPublicKey) as RSAPublicKey, + privateKey: + RSAKeyParser().parse(ValueConstant.clientPrivateKey) as RSAPrivateKey, + encoding: RSAEncoding.OAEP, + digest: RSADigest.SHA256, + ), + ); + + ///RSA Encrypt(OAEP + SHA-256) + static String encrypt(String plainText) { + final encrypted = _encrypter.encrypt(plainText); + return encrypted.base64; + } + + ///RSA Decrypt(OAEP + SHA-256) + static String decrypt(String cipherText) { + final encrypted = Encrypted.fromBase64(cipherText); + return _encrypter.decrypt(encrypted); + } + + static String decryptStackChanBlue(String cipherText) { + final stackChanBlueEncrypter = Encrypter( + RSA( + privateKey: + RSAKeyParser().parse(ValueConstant.stackChanBluePrivateKey) + as RSAPrivateKey, + encoding: RSAEncoding.OAEP, + digest: RSADigest.SHA256, + ), + ); + final encrypted = Encrypted.fromBase64(cipherText); + return stackChanBlueEncrypter.decrypt(encrypted); + } +} diff --git a/app/lib/util/status_bar_management.dart b/app/lib/util/status_bar_management.dart new file mode 100644 index 0000000..4594589 --- /dev/null +++ b/app/lib/util/status_bar_management.dart @@ -0,0 +1,198 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +import 'package:flutter/services.dart'; + +///createtime:2024/1/8 +///Author: +///Description:status barManagertoolClass + +class StatusBarManagement { + static StatusBarManagement? _statusBarManagement; + + static StatusBarManagement getInstance() { + return _statusBarManagement ?? StatusBarManagement(); + } + + ///setstatus barfontandicon + void setStatusBarImmerse(Brightness statusBarBrightness) { + SystemUiOverlayStyle style = SystemUiOverlayStyle( + statusBarColor: const Color(0x00000000), + statusBarIconBrightness: statusBarBrightness, + ); + SystemChrome.setSystemUIOverlayStyle(style); + SystemChrome.setEnabledSystemUIMode( + SystemUiMode.edgeToEdge, + overlays: [SystemUiOverlay.top], + ); + } + + ///setstatus barcolorfontandicon + void setStatusColor( + Color color, + Brightness statusBarBrightness, { + Color? navigationColor, + Brightness? navigationBrightness, + }) { + Brightness status; + if (statusBarBrightness == Brightness.light) { + status = Brightness.dark; + } else { + status = Brightness.light; + } + Brightness navigation; + if (navigationBrightness == Brightness.light) { + navigation = Brightness.dark; + } else { + navigation = Brightness.light; + } + + SystemUiOverlayStyle style = SystemUiOverlayStyle( + statusBarColor: color, + systemNavigationBarColor: navigationColor, + systemNavigationBarIconBrightness: navigation, + statusBarIconBrightness: status, + statusBarBrightness: Brightness.light, + ); + SystemChrome.setSystemUIOverlayStyle(style); + SystemChrome.setEnabledSystemUIMode( + SystemUiMode.manual, + overlays: [SystemUiOverlay.top, SystemUiOverlay.bottom], + ); + } + + void setBrightness( + Brightness statusBarBrightness, + Brightness navigationBrightness, + ) { + Brightness status; + if (statusBarBrightness == Brightness.light) { + status = Brightness.dark; + } else { + status = Brightness.light; + } + Brightness navigation; + if (navigationBrightness == Brightness.light) { + navigation = Brightness.dark; + } else { + navigation = Brightness.light; + } + SystemUiOverlayStyle style = SystemUiOverlayStyle( + statusBarIconBrightness: status, + systemNavigationBarIconBrightness: navigation, + ); + SystemChrome.setSystemUIOverlayStyle(style); + } + + ///setstatus barandnavigation barfontandicon + void setStatusBarAndNavigationBarDark( + Color statusBarColor, + Color navigationColor, + Brightness statusBarBrightness, + Brightness navigationBrightness, + ) { + Brightness status; + if (statusBarBrightness == Brightness.light) { + status = Brightness.dark; + } else { + status = Brightness.light; + } + Brightness navigation; + if (navigationBrightness == Brightness.light) { + navigation = Brightness.dark; + } else { + navigation = Brightness.light; + } + SystemUiOverlayStyle style = SystemUiOverlayStyle( + statusBarColor: statusBarColor, + systemStatusBarContrastEnforced: false, + statusBarIconBrightness: status, + statusBarBrightness: Brightness.light, + systemNavigationBarColor: navigationColor, + systemNavigationBarIconBrightness: navigation, + systemNavigationBarContrastEnforced: false, + ); + SystemChrome.setSystemUIOverlayStyle(style); + SystemChrome.setEnabledSystemUIMode( + SystemUiMode.manual, + overlays: [SystemUiOverlay.top, SystemUiOverlay.bottom], + ); + } + + ///setstatus barandnavigation barfontandicon + void setStatusBarAndNavigationBarImmerseDark( + Brightness statusBarBrightness, + Brightness navigationBrightness, + ) { + Brightness status; + if (statusBarBrightness == Brightness.light) { + status = Brightness.dark; + } else { + status = Brightness.light; + } + Brightness navigation; + if (navigationBrightness == Brightness.light) { + navigation = Brightness.dark; + } else { + navigation = Brightness.light; + } + + SystemUiOverlayStyle style = SystemUiOverlayStyle( + statusBarColor: const Color(0x00000000), + systemNavigationBarColor: const Color(0x00000000), + systemNavigationBarIconBrightness: navigation, + systemNavigationBarContrastEnforced: false, + statusBarIconBrightness: status, + statusBarBrightness: Brightness.light, + systemStatusBarContrastEnforced: false, + ); + SystemChrome.setSystemUIOverlayStyle(style); + SystemChrome.setEnabledSystemUIMode( + SystemUiMode.edgeToEdge, + overlays: [SystemUiOverlay.top, SystemUiOverlay.bottom], + ); + } + + ///setstatus barandnavigation barcustom + void setStatusBarAndNavigationBarCustom({ + bool isImmerse = false, + Color? statusBackColor, + Color? navigationBackColor, + bool statusIsLight = false, + }) { + Brightness statusBarIconBrightness = Brightness.dark; + Brightness statusBarBrightness = Brightness.light; + if (statusIsLight) { + statusBarIconBrightness = Brightness.light; + } else { + statusBarIconBrightness = Brightness.dark; + } + if (statusIsLight) { + statusBarBrightness = Brightness.dark; + } else { + statusBarBrightness = Brightness.light; + } + SystemUiOverlayStyle style = SystemUiOverlayStyle( + systemNavigationBarColor: navigationBackColor ?? const Color(0x00000000), + systemNavigationBarContrastEnforced: false, + systemStatusBarContrastEnforced: false, + statusBarColor: statusBackColor ?? const Color(0x00000000), + statusBarIconBrightness: statusBarIconBrightness, + statusBarBrightness: statusBarBrightness, + ); + SystemChrome.setSystemUIOverlayStyle(style); + if (isImmerse) { + SystemChrome.setEnabledSystemUIMode( + SystemUiMode.edgeToEdge, + overlays: [SystemUiOverlay.top, SystemUiOverlay.bottom], + ); + } else { + SystemChrome.setEnabledSystemUIMode( + SystemUiMode.manual, + overlays: [SystemUiOverlay.top, SystemUiOverlay.bottom], + ); + } + } +} diff --git a/app/lib/util/value_constant.dart b/app/lib/util/value_constant.dart new file mode 100644 index 0000000..5a87ad2 --- /dev/null +++ b/app/lib/util/value_constant.dart @@ -0,0 +1,313 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +/// Application-wide constant values and configuration +/// +/// This class contains: +/// - API response keys for JSON parsing +/// - Language support configuration +/// - RSA encryption keys for secure communication +/// - Character sets for random generation +/// - Bluetooth encryption keys +/// +/// Backend Configuration Notes: +/// - Update serverPublicKey and clientPrivateKey for your backend server +/// - These keys are used for RSA-OAEP encryption with SHA-256 +/// - For production, use environment variables instead of hardcoding +class ValueConstant { + // =========================================================================== + // API Response Keys - Used for JSON parsing from backend responses + // =========================================================================== + + /// Authentication token key for HTTP headers and storage + static const String token = "token"; + + /// Device-specific token key for device authentication + static const String deviceToken = "deviceToken"; + + /// Authorization header key for Bearer token authentication + static const String authorization = "Authorization"; + + /// Device type identifier key + static const String deviceType = "deviceType"; + + /// MAC address key for device identification + static const String mac = "mac"; + + /// Index key for pagination or list positioning + static const String index = "index"; + + /// Data payload key in API responses + static const String data = "data"; + + /// List key for array responses + static const String list = "list"; + + /// File upload/download identifier key + static const String file = "file"; + + /// Directory path key for file operations + static const String directory = "directory"; + + /// Moments or timeline content key + static const String moments = "moments"; + + /// Name field key for various entities + static const String name = "name"; + + /// Dance choreography data key + static const String danceData = "danceData"; + + /// Dance name/title key + static const String danceName = "danceName"; + + /// Music file URL key + static const String musicUrl = "musicUrl"; + + /// Text content key for messages or posts + static const String contentText = "content_text"; + + /// Image content key for media attachments + static const String contentImage = "content_image"; + + /// Page number key for pagination + static const String page = "page"; + + /// Page size key for pagination results + static const String pageSize = "pageSize"; + + /// Generic ID key for various entities + static const String id = "id"; + + /// Post identifier key for social content + static const String postId = "postId"; + + /// Content body key + static const String content = "content"; + + /// Comment identifier key + static const String commentId = "commentId"; + + /// Random seed key for deterministic operations + static const String seed = "seed"; + + /// Device control mode configuration key + static const String deviceControlMode = "deviceControlMode"; + + /// Login status flag key + static const String isLogin = "isLogin"; + + /// Username field key for authentication + static const String username = "username"; + + /// Password field key for authentication + static const String password = "password"; + + /// Email address field key + static const String email = "email"; + + /// Application version identifier key + static const String appVersion = "app_version"; + + // =========================================================================== + // Bluetooth BLE Advertisement Data Keys + // =========================================================================== + + /// Manufacturer specific data key in BLE advertisement + static const String manufacturerData = "manufacturerData"; + + /// Service data key in BLE advertisement + static const String serviceData = "serviceData"; + + /// Service UUID list key in BLE advertisement + static const String serviceUuids = "serviceUuids"; + + /// Connectable flag key in BLE advertisement + static const String connectable = "connectable"; + + /// Transmit power level key in BLE advertisement + static const String txPowerLevel = "txPowerLevel"; + + /// Advertised device name key + static const String advName = "advName"; + + /// Device serial number key for identification + static const String serialNumber = "serialNumber"; + + /// Device MAC address key (alternative) + static const String deviceMac = "deviceMac"; + + /// Device unique identifier key + static const String deviceId = "deviceId"; + + // =========================================================================== + // Language Support Configuration + // =========================================================================== + + /// Supported languages map + /// Key: Language code (ISO 639-1) + /// Value: Display name in English and native language + static const Map languages = { + "en": "English", + "zh": "Chinese Mandarin 普通话", + "yue": "Cantonese 粵語", + "ja": "Japanese 日本語", + "ko": "Korean 한국어", + "ru": "Russian Русский", + "es": "Spanish Español", + "ar": "Arabic العربية", + "fr": "French Français", + "vi": "Vietnamese Tiếng Việt", + "it": "Italian Italiano", + "id": "Indonesian Bahasa Indonesia", + "hi": "Hindi हिन्दी", + "fi": "Finnish Suomi", + "th": "Thai ไทย", + "de": "German Deutsch", + "pt": "Portuguese Português", + "uk": "Ukrainian Українська", + "tr": "Turkish Türkçe", + "cs": "Czech Čeština", + "pl": "Polish Polski", + "ro": "Romanian Română", + "ca": "Catalan Català", + "nl": "Dutch Nederlands", + "sv": "Swedish Svenska", + "da": "Danish Dansk", + "no": "Norwegian Norsk", + "et": "Estonian Eesti", + "lv": "Latvian Latviešu", + "lt": "Lithuanian Lietuvių", + "is": "Icelandic Íslenska", + "ms": "Malay Bahasa Melayu", + "sl": "Slovenian Slovenščina", + "bg": "Bulgarian Български", + "he": "Hebrew עברית", + "sk": "Slovak Slovenčina", + "hr": "Croatian Hrvatski", + "hu": "Hungarian Magyar", + "fa": "Persian فارسی", + "el": "Greek Ελληνικά", + "fil": "Filipino Filipino", + }; + + // =========================================================================== + // RSA Encryption Keys - Backend Configuration + // =========================================================================== + + /// Server RSA Public Key for encrypting outgoing requests + /// + /// IMPORTANT BACKEND CONFIGURATION: + /// - This key must match the public key from your backend server + /// - Used for RSA-OAEP encryption with SHA-256 padding + /// - Encrypt sensitive data before sending to server + /// + /// To replace with your backend key: + /// 1. Generate RSA key pair on your server (2048-bit recommended) + /// 2. Extract the public key in PEM format + /// 3. Replace the content below with your server's public key + static const String serverPublicKey = ''' +'''; + + /// Client RSA Private Key for decrypting incoming responses + /// + /// IMPORTANT SECURITY NOTE: + /// - In production, DO NOT hardcode this key! + /// - Use secure storage, environment variables, or key management service + /// - This private key should ONLY be used for development/testing + /// + /// Key Usage: + /// - Decrypt data encrypted with the corresponding public key + /// - Used for secure server-to-client communication + static const String clientPrivateKey = ''' +'''; + + // =========================================================================== + // Character Sets + // =========================================================================== + + /// Alphanumeric character set for random string generation + /// Contains: uppercase, lowercase letters, and digits 0-9 + static const List characters = [ + 'a', + 'b', + 'c', + 'd', + 'e', + 'f', + 'g', + 'h', + 'i', + 'j', + 'k', + 'l', + 'm', + 'n', + 'o', + 'p', + 'q', + 'r', + 's', + 't', + 'u', + 'v', + 'w', + 'x', + 'y', + 'z', + 'A', + 'B', + 'C', + 'D', + 'E', + 'F', + 'G', + 'H', + 'I', + 'J', + 'K', + 'L', + 'M', + 'N', + 'O', + 'P', + 'Q', + 'R', + 'S', + 'T', + 'U', + 'V', + 'W', + 'X', + 'Y', + 'Z', + '0', + '1', + '2', + '3', + '4', + '5', + '6', + '7', + '8', + '9', + ]; + + // =========================================================================== + // Bluetooth Encryption Key + // =========================================================================== + + /// StackChan Bluetooth RSA Private Key + /// + /// This key is used for secure communication with StackChan hardware devices + /// over Bluetooth Low Energy (BLE). It enables encrypted communication + /// between the mobile app and the StackChan robot. + /// + /// Note: Each StackChan device should have a unique key pair in production. + /// This is a default development key for testing purposes. + static const stackChanBluePrivateKey = ''' +'''; +} \ No newline at end of file diff --git a/app/lib/view/app.dart b/app/lib/view/app.dart new file mode 100644 index 0000000..567c323 --- /dev/null +++ b/app/lib/view/app.dart @@ -0,0 +1,102 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:stack_chan/view/popup/cupertino_popup_windows.dart'; + +import '../util/app_toast.dart'; +import 'home/home.dart'; + +class App extends StatelessWidget { + static const isRelease = false; + + static final GlobalKey navigatorKey = GlobalKey(); + + static BuildContext appContext() { + return navigatorKey.currentState!.context; + } + + static void showDialog(String title) { + showCupertinoDialog( + context: appContext(), + builder: (context) { + return CupertinoAlertDialog( + title: Text(title), + actions: [ + CupertinoDialogAction( + isDefaultAction: true, + child: Text("Confirm"), + onPressed: () { + Navigator.of(context).pop(); + }, + ), + ], + ); + }, + ); + } + + static void showAppModalPopup( + WidgetBuilder builder, { + bool barrierDismissible = true, + bool useRootNavigator = true, + SheetSize sheetSize = .medium, + }) { + showCupertinoSheet( + context: appContext(), + builder: builder, + useNestedNavigation: useRootNavigator, + ); + // showCupertinoPopupWindows( + // context: appContext(), + // builder: builder, + // useRootNavigator: useRootNavigator, + // barrierDismissible: barrierDismissible, + // sheetSize: sheetSize, + // ); + } + + static Future showAppSheet( + WidgetBuilder builder, { + bool useNestedNavigation = false, + bool enableDrag = true, + bool showDragHandle = false, + }) async { + return showCupertinoSheet( + context: appContext(), + builder: builder, + useNestedNavigation: useNestedNavigation, + enableDrag: enableDrag, + showDragHandle: showDragHandle, + ); + } + + static final RouteObserver routeObserver = + RouteObserver(); + + const App({super.key}); + + @override + Widget build(BuildContext context) { + return CupertinoApp( + navigatorKey: navigatorKey, + home: const Home(), + builder: (context, child) { + return AppToast(child: child); + }, + debugShowCheckedModeBanner: false, + theme: CupertinoThemeData( + primaryColor: CupertinoColors.systemOrange.resolveFrom(context), + ), + localizationsDelegates: const [ + DefaultMaterialLocalizations.delegate, + DefaultCupertinoLocalizations.delegate, + DefaultWidgetsLocalizations.delegate, + ], + navigatorObservers: [routeObserver], + ); + } +} diff --git a/app/lib/view/home/add_music.dart b/app/lib/view/home/add_music.dart new file mode 100644 index 0000000..7eafa3a --- /dev/null +++ b/app/lib/view/home/add_music.dart @@ -0,0 +1,283 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:dio/dio.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter_svg/svg.dart'; +import 'package:path/path.dart' as path; +import 'package:uuid/uuid.dart'; + +import '../../app_state.dart'; +import '../../model/model.dart'; +import '../../model/stack_chan_music_info.dart'; +import '../../model/upload_file_data.dart'; +import '../../network/http.dart'; +import '../../network/urls.dart'; +import '../../util/value_constant.dart'; + +class AddMusic extends StatefulWidget { + const AddMusic({super.key, required this.onResult}); + + final Function(String url) onResult; + + @override + State createState() => _AddMusicState(); +} + +class _AddMusicState extends State { + String musicURL = ""; + + final Dio _dio = Dio(); + + final List _musicList = [ + StackChanMusicInfo( + name: "StackChan on My Desk", + url: "${Urls.getFileUrl()}file/music/stackchan_music.mp3", + ), + ]; + + @override + void dispose() { + if (mounted) { + FocusScope.of(context).unfocus(); + } + super.dispose(); + } + + void _completeSelection(String url) { + widget.onResult(url); + Navigator.pop(context); + } + + void _checkLink() { + final url = musicURL; + if (url.isEmpty) { + AppState.shared.showToast("Please enter the music link."); + return; + } + + if (!Uri.parse(url).isAbsolute) { + AppState.shared.showToast("Please provide a valid URL link."); + return; + } + _downloadAndUploadFile(Uri.parse(url)); + } + + Future _downloadAndUploadFile(Uri url) async { + try { + final response = await _dio.getUri( + url, + options: Options( + responseType: ResponseType.bytes, + followRedirects: true, + maxRedirects: 5, + headers: {"Accept": "audio/mpeg,audio/*,*/*;q=0.9"}, + ), + ); + if (response.statusCode != 200) { + AppState.shared.showToast( + "Download failed, status code: ${response.statusCode}", + ); + return; + } + + if (response.data == null || response.data is! Uint8List) { + AppState.shared.showToast("Download failed: Invalid response data"); + return; + } + + final fileName = _generateUUIDFileName(url.path); + await _uploadFile(response.data as Uint8List, fileName); + } catch (e) { + AppState.shared.showToast("Download failed: ${e.toString()}"); + } + } + + Future _pickLocalFile() async { + try { + final result = await FilePicker.pickFiles( + type: FileType.custom, + allowedExtensions: ['mp3', 'wav', 'm4a'], + dialogTitle: "Select audio file", + ); + + if (result == null || result.files.isEmpty) return; + + final file = result.files.first; + Uint8List? fileData; + + if (file.path != null) { + fileData = File(file.path!).readAsBytesSync(); + } else { + fileData = file.bytes; + } + + if (fileData == null) { + AppState.shared.showToast("No file data was found"); + return; + } + final fileName = _generateUUIDFileName(file.name); + await _uploadFile(fileData, fileName); + } catch (e) { + AppState.shared.showToast("File selection failed: ${e.toString()}"); + } + } + + Future _uploadFile(Uint8List data, String fileName) async { + try { + FormData formData = FormData.fromMap({ + ValueConstant.file: MultipartFile.fromBytes( + data, + filename: fileName, + contentType: DioMediaType.parse("audio/mpeg"), + ), + ValueConstant.directory: ValueConstant.moments, + ValueConstant.name: fileName, + }); + + final response = await Http.instance.postFormData( + Urls.uploadFile, + formData, + ); + + if (response.data != null) { + Model responseData = Model.fromJsonT( + response.data, + factory: (data) => UploadFile.fromJson(data), + ); + if (responseData.isSuccess()) { + String? url = responseData.data?.path; + if (url != null) { + final fileUrl = Urls.getFileUrl() + url; + _completeSelection(fileUrl); + } else { + AppState.shared.showToast("Upload failed: File path is empty"); + } + } else { + AppState.shared.showToast(responseData.message ?? "Upload failed"); + } + } else { + AppState.shared.showToast("Upload failed: Empty response"); + } + } catch (e) { + AppState.shared.showToast("Upload failed: ${e.toString()}"); + } + } + + String _generateUUIDFileName(String originalPath) { + final fileExtension = path.extension(originalPath).isEmpty + ? 'mp3' + : path.extension(originalPath).replaceFirst('.', ''); + return "${const Uuid().v4()}.$fileExtension"; + } + + @override + Widget build(BuildContext context) { + final theme = CupertinoTheme.of(context); + + return CupertinoPageScaffold( + backgroundColor: CupertinoColors.systemGroupedBackground.resolveFrom( + context, + ), + navigationBar: CupertinoNavigationBar.large( + largeTitle: Text("Add Music"), + trailing: CupertinoButton( + sizeStyle: .medium, + child: Icon(CupertinoIcons.xmark), + onPressed: () => Navigator.of(context).pop(), + ), + ), + child: ListView( + children: [ + CupertinoListSection.insetGrouped( + header: Text("URL"), + children: [ + CupertinoListTile( + leading: SvgPicture.asset( + "assets/music.note.svg", + colorFilter: .mode(theme.primaryColor, .srcIn), + width: 15, + height: 15, + ), + trailing: Row( + mainAxisSize: .min, + children: [ + SizedBox( + width: 250, + child: CupertinoTextField( + onChanged: (value) { + musicURL = value; + }, + placeholder: "Enter the music link", + textAlign: .end, + decoration: BoxDecoration(), + ), + ), + CupertinoButton( + padding: .zero, + minimumSize: .zero, + child: SvgPicture.asset( + "assets/checkmark.svg", + colorFilter: .mode(theme.primaryColor, .srcIn), + width: 15, + height: 15, + ), + onPressed: () { + _checkLink(); + }, + ), + ], + ), + title: SizedBox.shrink(), + ), + ], + ), + CupertinoListSection.insetGrouped( + header: Text("File"), + children: [ + CupertinoListTile( + onTap: () => _pickLocalFile(), + title: Text("Select local music files"), + leading: SvgPicture.asset( + "assets/music.note.svg", + colorFilter: .mode(theme.primaryColor, .srcIn), + width: 15, + height: 15, + ), + trailing: SvgPicture.asset( + "assets/chevron.right.svg", + colorFilter: .mode(CupertinoColors.secondaryLabel, .srcIn), + width: 15, + height: 15, + ), + ), + ], + ), + CupertinoListSection.insetGrouped( + header: Text("Prefabricated"), + children: _musicList.map((value) { + return CupertinoListTile( + leading: SvgPicture.asset( + "assets/music.note.svg", + colorFilter: .mode(theme.primaryColor, .srcIn), + width: 15, + height: 15, + ), + title: Text(value.name), + onTap: () { + _completeSelection(value.url); + }, + ); + }).toList(), + ), + ], + ), + ); + } +} diff --git a/app/lib/view/home/avatar.dart b/app/lib/view/home/avatar.dart new file mode 100644 index 0000000..8b2ff12 --- /dev/null +++ b/app/lib/view/home/avatar.dart @@ -0,0 +1,387 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +import 'dart:io'; +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:get/get.dart'; +import 'package:stack_chan/app_state.dart'; +import 'package:stack_chan/model/dance_list.dart'; +import 'package:stack_chan/network/web_socket_util.dart'; +import 'package:stack_chan/util/custom_colors.dart'; +import 'package:stack_chan/util/extension.dart'; +import 'package:stack_chan/view/util/stack_chan_face_view.dart'; + +import '../../model/expression_data.dart'; +import '../util/stack_chan_ar_view.dart'; + +class Avatar extends StatefulWidget { + const Avatar({super.key, required this.deviceMac}); + + final String deviceMac; + + @override + State createState() => _AvatarViewState(); +} + +class AvatarModel extends GetxController { + RxInt decorate = RxInt(1); + RxBool showPhoneScreen = RxBool(false); + Rx cameraImage = Rx(Uint8List(0)); + RxBool microphone = RxBool(false); +} + +class _AvatarViewState extends State { + final String tag = "Avatar"; + + late final AvatarModel model; + DateTime sendScreenLastTime = DateTime.now(); + + @override + void dispose() { + WebSocketUtil.shared.removeObserver(tag); + AppState.shared.sendWebSocketMessage( + .offPhoneScreen, + data: widget.deviceMac.toUint8List(), + ); + AppState.shared.sendWebSocketMessage( + .offCamera, + data: widget.deviceMac.toUint8List(), + ); + if (widget.deviceMac != AppState.shared.deviceMac) { + AppState.shared.sendWebSocketMessage(.hangupCall); + } + model.onClose(); + super.dispose(); + } + + @override + void initState() { + super.initState(); + model = AvatarModel(); + initCameraAndSocket(); + WebSocketUtil.shared.connectionSuccessful = () { + initCameraAndSocket(); + }; + } + + void initCameraAndSocket() { + WebSocketUtil.shared.removeObserver(tag); + WebSocketUtil.shared.addObserver(tag, (message) { + if (message is Uint8List) { + final result = AppState.shared.parseMessage(message); + final msgType = result.$1; + final parsedData = result.$2; + if (msgType != null) { + switch (msgType) { + case .jpeg: + if (parsedData != null) { + model.cameraImage.value = parsedData; + } + break; + case .hangupCall: + break; + case .opus: + break; + default: + break; + } + } + } + }); + AppState.shared.sendWebSocketMessage( + .onCamera, + data: widget.deviceMac.toUint8List(), + ); + } + + @override + Widget build(BuildContext context) { + return CupertinoPageScaffold( + backgroundColor: CupertinoColors.black, + navigationBar: CupertinoNavigationBar( + middle: Text("Avatar", style: TextStyle(color: CupertinoColors.white)), + backgroundColor: CustomColors.transparent, + automaticBackgroundVisibility: false, + enableBackgroundFilterBlur: false, + border: null, + brightness: .dark, + ), + child: LayoutBuilder( + builder: (context, constraints) { + return Stack( + alignment: .bottomCenter, + children: [ + Column( + children: [ + Container( + width: .infinity, + height: constraints.maxWidth / 4 * 3, + color: CupertinoColors.systemGrey, + child: Obx(() { + if (model.cameraImage.value.isEmpty) { + return const CupertinoActivityIndicator(); + } + return Image.memory( + model.cameraImage.value, + gaplessPlayback: true, + fit: BoxFit.cover, + ); + }), + ), + Expanded( + child: Obx( + () => Platform.isIOS + ? StackChanArView( + decorate: model.decorate.value, + captureScreen: model.showPhoneScreen.value, + onCallback: sendDanceData, + onFrameCallback: compressMobilePhoneScreen, + ) + : StackChanFaceView( + captureScreen: model.showPhoneScreen.value, + onCallback: sendDanceData, + onFrameCallback: compressMobilePhoneScreen, + ), + ), + ), + ], + ), + Padding( + padding: .only( + left: 15, + right: 15, + top: 15, + bottom: MediaQuery.of(context).padding.bottom + 15, + ), + child: Row( + spacing: 15, + children: [ + if (Platform.isIOS) + CupertinoButton( + padding: .all(22), + color: CupertinoColors.black.withValues(alpha: 0.5), + borderRadius: .circular(100), + child: SizedBox( + width: 66, + height: 66, + child: Center( + child: Column( + mainAxisSize: .min, + spacing: 5, + children: [ + SizedBox( + width: 44, + height: 44, + child: Align( + alignment: .center, + child: Obx(() { + switch (model.decorate.value) { + case 0: + return SvgPicture.asset( + "assets/slash.circle.svg", + width: .infinity, + height: .infinity, + colorFilter: .mode( + CupertinoColors.white, + .srcIn, + ), + ); + case 1: + return Image.asset( + "assets/image1.png", + width: .infinity, + height: .infinity, + ); + case 2: + return Text( + "🐽", + style: TextStyle( + fontSize: 44, + height: 1.1, + ), + ); + default: + return Text( + "🎲", + style: TextStyle( + fontSize: 44, + height: 1.1, + ), + ); + } + }), + ), + ), + Text( + "Mask", + textScaler: .noScaling, + textAlign: .center, + style: TextStyle( + color: CupertinoColors.white, + fontSize: 12, + fontWeight: .bold, + ), + ), + ], + ), + ), + ), + onPressed: () { + if (model.decorate.value == 0) { + model.decorate.value = 1; + } else if (model.decorate.value == 1) { + model.decorate.value = 2; + } else if (model.decorate.value == 2) { + model.decorate.value = 0; + } + }, + ), + Spacer(), + // CupertinoButton( + // padding: .all(22), + // color: CupertinoColors.black.withValues(alpha: 0.5), + // borderRadius: .circular(50), + // child: SizedBox( + // width: 44, + // height: 44, + // child: Center( + // child: Obx( + // () => SvgPicture.asset( + // model.microphone.value + // ? "assets/microphone.svg" + // : "assets/microphone.slash.svg", + // width: .infinity, + // height: .infinity, + // colorFilter: .mode(CupertinoColors.white, .srcIn), + // ), + // ), + // ), + // ), + // onPressed: () { + // model.microphone.toggle(); + // }, + // ), + CupertinoButton( + padding: .all(22), + color: CupertinoColors.black.withValues(alpha: 0.5), + borderRadius: .circular(100), + child: SizedBox( + width: 66, + height: 66, + child: Center( + child: Column( + mainAxisSize: .min, + spacing: 6, + children: [ + SizedBox( + width: 44, + height: 44, + child: Center( + child: Obx( + () => SvgPicture.asset( + "assets/iphone.gen1.badge.play.svg", + width: .infinity, + height: .infinity, + colorFilter: .mode( + model.showPhoneScreen.value + ? CupertinoTheme.of( + context, + ).primaryColor + : CupertinoColors.white, + .srcIn, + ), + ), + ), + ), + ), + Text( + "Mirror", + textScaler: .noScaling, + textAlign: .center, + style: TextStyle( + color: CupertinoColors.white, + fontSize: 12, + fontWeight: .bold, + ), + ), + ], + ), + ), + ), + onPressed: () { + model.showPhoneScreen.toggle(); + if (model.showPhoneScreen.value) { + AppState.shared.sendWebSocketMessage( + .onPhoneScreen, + data: widget.deviceMac.toUint8List(), + ); + } else { + AppState.shared.sendWebSocketMessage( + .offPhoneScreen, + data: widget.deviceMac.toUint8List(), + ); + } + }, + ), + ], + ), + ), + ], + ); + }, + ), + ); + } + + void sendDanceData(DanceData data) { + final expressionData = ExpressionData( + leftEye: data.leftEye, + rightEye: data.rightEye, + mouth: data.mouth, + ); + final expressionJsonString = widget.deviceMac + expressionData.toString(); + final expressionMessageData = expressionJsonString.toUint8List(); + AppState.shared.sendWebSocketMessage( + .controlAvatar, + data: expressionMessageData, + ); + + final motionData = MotionData( + pitchServo: data.pitchServo, + yawServo: data.yawServo, + ); + final motionJsonString = widget.deviceMac + motionData.toString(); + final motionMessageData = motionJsonString.toUint8List(); + AppState.shared.sendWebSocketMessage( + .controlMotion, + data: motionMessageData, + ); + } + + void compressMobilePhoneScreen(Uint8List imageData) async { + if (imageData.isEmpty) return; + final newDate = DateTime.now(); + final timeDiff = newDate.difference(sendScreenLastTime).inMilliseconds; + if (timeDiff >= 500) { + sendScreenLastTime = newDate; + Uint8List? newImageData = await imageData.compress( + resolutionSize: ui.Size(320, 240), + memorySize: 0.02, + cropCenter: true, + ); + if (newImageData != null) { + final macBytes = widget.deviceMac.toUint8List(); + final payload = Uint8List(macBytes.length + newImageData.length); + payload.setAll(0, macBytes); + payload.setAll(macBytes.length, newImageData); + AppState.shared.sendWebSocketMessage(.jpeg, data: payload); + } + } + } +} diff --git a/app/lib/view/home/conversation_page.dart b/app/lib/view/home/conversation_page.dart new file mode 100644 index 0000000..fafc986 --- /dev/null +++ b/app/lib/view/home/conversation_page.dart @@ -0,0 +1,254 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +import 'package:flutter/cupertino.dart'; +import 'package:flutter_slidable/flutter_slidable.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:get/get.dart'; +import 'package:intl/intl.dart'; +import 'package:stack_chan/app_state.dart'; +import 'package:stack_chan/util/XiaoZhi_util.dart'; +import 'package:stack_chan/view/popup/conversation_message_page.dart'; + +import '../../model/XiaoZhi/conversation.dart'; + +class ConversationPage extends StatefulWidget { + const ConversationPage({super.key}); + + @override + State createState() => _ConversationPageState(); +} + +class _ConversationPageState extends State { + RxList conversationList = RxList([]); + + final DateFormat dateFormat = DateFormat("yyyy-MM-dd HH:mm"); + + int page = 1; + int pageSize = 100; + + @override + void initState() { + super.initState(); + page = 1; + getList(); + } + + void getList() async { + final devices = await XiaoZhiUtil.shared.getDevice( + AppState.shared.deviceMac, + ); + if (devices.isNotEmpty && devices.first.device_id != null) { + final deviceId = devices.first.device_id; + final agentId = devices.first.agent_id; + await AppState.shared.getDeviceInfo(); + String startDate = ""; + + if (AppState.shared.deviceInfo.value?.bindTime != null) { + if (AppState.shared.deviceInfo.value!.bindTime!.isNotEmpty) { + String bindTime = AppState.shared.deviceInfo.value!.bindTime!; + startDate = bindTime.split(" ").first; + } + } + if (startDate == "") { + DateTime tenDaysAgo = DateTime.now().subtract(const Duration(days: 10)); + startDate = + "${tenDaysAgo.year.toString()}-${tenDaysAgo.month.toString().padLeft(2, '0')}-${tenDaysAgo.day.toString().padLeft(2, '0')}"; + } + if (deviceId != null) { + conversationList.value = await XiaoZhiUtil.shared.getConversationList( + startDate, + deviceId, + page, + pageSize, + agentId, + ); + } + } + } + + String formatTime(String? timeStr) { + if (timeStr == null || timeStr.isEmpty) return 'Unknown time'; + try { + DateTime dateTime = DateTime.parse(timeStr); + return dateFormat.format(dateTime); + } catch (e) { + return timeStr; + } + } + + Future deleteConversation(int? id, int? agentId) async { + if (id != null && agentId != null) { + final result = await XiaoZhiUtil.shared.deleteConversation(agentId, id); + if (result) { + getList(); + } + } + } + + Widget buildConversationItem(Conversation conversation, int index) { + String title = conversation.chat_summary?.title ?? "Unlabeled conversation"; + String summary = conversation.chat_summary?.summary ?? "No abstract."; + String createTime = formatTime(conversation.created_at); + String model = conversation.model ?? "Unknown model"; + int msgCount = conversation.msg_count ?? 0; + int tokenCount = conversation.token_count ?? 0; + int? chatId = conversation.id; + + return Slidable( + key: ValueKey(index), + endActionPane: ActionPane( + extentRatio: 0.25, + motion: ScrollMotion(), + children: [ + //deletebutton + SlidableAction( + onPressed: (_) => deleteConversation(chatId, conversation.agent_id), + backgroundColor: CupertinoColors.systemRed.withValues(alpha: 0.9), + foregroundColor: CupertinoColors.white, + icon: CupertinoIcons.trash, + label: 'Delete', + ), + ], + ), + child: CupertinoListTile( + title: Text( + title, + style: TextStyle( + fontSize: 16, + fontWeight: .w500, + color: CupertinoColors.label.resolveFrom(context), + ), + maxLines: 1, + overflow: .ellipsis, + ), + subtitle: Column( + crossAxisAlignment: .start, + children: [ + Text( + summary, + style: TextStyle( + fontSize: 14, + color: CupertinoColors.secondaryLabel.resolveFrom(context), + ), + maxLines: 2, + overflow: .ellipsis, + ), + const SizedBox(height: 4), + Text( + createTime, + style: TextStyle( + fontSize: 12, + color: CupertinoColors.tertiaryLabel.resolveFrom(context), + ), + ), + ], + ), + trailing: Column( + mainAxisAlignment: .center, + crossAxisAlignment: .end, + children: [ + Text( + "$msgCount Message", + style: TextStyle( + fontSize: 12, + color: CupertinoColors.secondaryLabel.resolveFrom(context), + ), + ), + const SizedBox(height: 2), + Text( + model, + style: TextStyle( + fontSize: 11, + color: CupertinoColors.tertiaryLabel.resolveFrom(context), + ), + ), + const SizedBox(height: 2), + Text( + "Token: $tokenCount", + style: TextStyle( + fontSize: 10, + color: CupertinoColors.quaternaryLabel.resolveFrom(context), + ), + ), + ], + ), + onTap: () { + if (chatId != null) { + showCupertinoSheet( + context: context, + builder: (context) { + return ConversationMessagePage(chatId: chatId); + }, + ); + } + }, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + ), + ); + } + + @override + Widget build(BuildContext context) { + return CupertinoPageScaffold( + child: Obx( + () => CustomScrollView( + slivers: [ + CupertinoSliverNavigationBar(largeTitle: Text("Chat History")), + CupertinoSliverRefreshControl( + onRefresh: () async { + page = 1; + getList(); + }, + ), + if (conversationList.isEmpty) + SliverToBoxAdapter( + child: SizedBox( + height: MediaQuery.of(context).size.height - 200, + child: Center( + child: Column( + mainAxisAlignment: .center, + children: [ + SvgPicture.asset( + "assets/questionmark.bubble.svg", + colorFilter: ColorFilter.mode( + CupertinoColors.secondaryLabel.resolveFrom(context), + BlendMode.srcIn, + ), + width: 64, + height: 64, + ), + SizedBox(height: 16), + Text( + "No conversation records available.", + style: TextStyle( + fontSize: 18, + color: CupertinoColors.secondaryLabel.resolveFrom( + context, + ), + ), + ), + ], + ), + ), + ), + ) + else + SliverList.separated( + itemCount: conversationList.length, + itemBuilder: (context, index) { + return buildConversationItem(conversationList[index], index); + }, + separatorBuilder: (context, index) => Container( + height: 0.5, + color: CupertinoColors.separator.resolveFrom(context), + ), + ), + ], + ), + ), + ); + } +} diff --git a/app/lib/view/home/dance.dart b/app/lib/view/home/dance.dart new file mode 100644 index 0000000..caab02b --- /dev/null +++ b/app/lib/view/home/dance.dart @@ -0,0 +1,811 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +import 'dart:async'; +import 'dart:convert'; + +import 'package:flex_color_picker/flex_color_picker.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:get/get.dart'; +import 'package:stack_chan/app_state.dart'; +import 'package:stack_chan/model/dance_list.dart'; +import 'package:stack_chan/model/expression_data.dart'; +import 'package:stack_chan/model/model.dart'; +import 'package:stack_chan/network/http.dart'; +import 'package:stack_chan/network/urls.dart'; +import 'package:stack_chan/util/blue_util.dart'; +import 'package:stack_chan/util/music_util.dart'; +import 'package:stack_chan/util/value_constant.dart'; +import 'package:stack_chan/view/util/grid_coordinate_joystick.dart'; + +import '../../util/extension.dart'; + +class Dance extends StatefulWidget { + const Dance({super.key, required this.danceInfo}); + + final DanceList danceInfo; + + @override + State createState() => _DanceState(); +} + +class DanceModel extends GetxController { + RxInt selectedDance = RxInt(0); //currentSelectedDanceindex + Rx danceInfo = Rx(DanceList()); //danceData + RxInt dancePlayIndex = RxInt(-1); //inplayindex(forhighlight) + RxBool isRun = RxBool(false); //whetherinwholeplay + RxBool isLoop = RxBool(false); //new:loopplaymode + + //playControl related + Timer? playTimer; //wholeplaytimer(mode) + List?> bluetoothPlayTasks = []; //Bluetoothplaytasklist + RxBool isPlayingSingle = RxBool(false); //whetherinplay +} + +class _DanceState extends State { + late DanceModel model; + + @override + void initState() { + super.initState(); + model = DanceModel(); + model.danceInfo.value = widget.danceInfo; + } + + @override + void dispose() { + model.isRun.value = false; + model.isLoop.value = false; //disposewhenresetloopstate + model.onClose(); + stopDance(); //disposewhenstopplay + super.dispose(); + } + + ///savedancedatatoserviceSide / End + void saveDance() async { + Map map = { + ValueConstant.id: model.danceInfo.value.id, + ValueConstant.danceData: model.danceInfo.value.danceDataToJson(), + ValueConstant.musicUrl: model.danceInfo.value.musicUrl ?? "", + ValueConstant.danceName: model.danceInfo.value.danceName ?? "Dance", + }; + final response = await Http.instance.put(Urls.v2dance, data: map); + if (response.data != null) { + Model modelRes = Model.fromJsonT(response.data); + if (!modelRes.isSuccess()) { + AppState.shared.showToast(modelRes.message); + } + } + } + + ///fromserviceSide / Endrefreshdancedata + Future getDanceList() async { + final Map map = { + ValueConstant.id: model.danceInfo.value.id, + }; + + final response = await Http.instance.get(Urls.dance, data: map); + if (response.data != null) { + Model responseData = Model.fromJsonT( + response.data, + factory: (data) => DanceList.fromJson(data), + ); + if (responseData.isSuccess() && responseData.data != null) { + final data = responseData.data!; + model.danceInfo.value = data; + } + } + } + + ///playselectedSingle framedance + void startDanceOne() { + //stopCurrentlyinperformwholeplay + if (model.isRun.value) { + stopDance(); + model.isRun.value = false; + } + //Single frameplayinThendirectreturn + if (model.isPlayingSingle.value) return; + + final danceDataList = model.danceInfo.value.danceData; + if (danceDataList.isEmpty) { + Get.snackbar("提示", "暂无舞蹈数据可播放", snackPosition: SnackPosition.BOTTOM); + return; + } + + //verifyselectedindexhasEffectiveness + final int selectedIndex = model.selectedDance.value.clamp( + 0, + danceDataList.length - 1, + ); + final DanceData currentData = danceDataList[selectedIndex]; + + //updateplaystate + model.isPlayingSingle.value = true; + model.dancePlayIndex.value = selectedIndex; //highlightcurrentplay + + ///Single frameplaycorelogic + Future playSingleFrame() async { + try { + if (AppState.shared.deviceControlMode == 0) { + //NetworkControlmode:sendSingle frameJSONdata + final jsonString = jsonEncode([currentData.toJson()]); + AppState.shared.sendWebSocketMessage( + .dance, + data: jsonString.toUint8List(), + ); + //Waitcurrent frameplayduration + await Future.delayed(Duration(milliseconds: currentData.durationMs)); + } else if (AppState.shared.deviceControlMode == 1) { + //BluetoothControlmode:directsendFramedatatoBluetoothdevice + await BlueUtil.shared.sendDanceData(currentData); + //Delay(+70ms forAligneddeviceresponselogic) + await Future.delayed( + Duration(milliseconds: currentData.durationMs + 70), + ); + } + } catch (e) { + Get.snackbar("错误", "播放失败: $e", snackPosition: SnackPosition.BOTTOM); + } finally { + //playcompleteafterresetstate + model.isPlayingSingle.value = false; + model.dancePlayIndex.value = -1; + } + } + + playSingleFrame(); + } + + ///playwholedance(supportloop) + void startDance() { + //stopallCurrentlyinperformplay + stopDance(); + + final danceDataList = model.danceInfo.value.danceData; + if (danceDataList.isEmpty) { + Get.snackbar("提示", "暂无舞蹈数据可播放", snackPosition: SnackPosition.BOTTOM); + model.isRun.value = false; + return; + } + + //playAssociatedmusic(ifhas) + if (model.danceInfo.value.musicUrl != null && + model.danceInfo.value.musicUrl!.isNotEmpty) { + MusicUtil.shared.stopMusic(); //stoporiginalhasmusic + MusicUtil.shared.playMusic( + model.danceInfo.value.musicInfo, + isLoop: model.isLoop.value, + ); //musicsyncloop + } + + //wrapwholeplaylogic(forloopCall) + void playFullDance() { + if (!model.isRun.value) return; //stopexit + + if (AppState.shared.deviceControlMode == 0) { + //NetworkControlmode:1One-timesendalldancedata + final jsonString = jsonEncode(DanceData.listToJson(danceDataList)); + AppState.shared.sendWebSocketMessage( + .dance, + data: jsonString.toUint8List(), + ); + + //Recordplaystarttime,forPrecise / AccuratelycalculateAlreadyplayduration + final startTime = DateTime.now(); + + //timerupdateplayFrameindex(forUIhighlight) + model.playTimer = Timer.periodic(const Duration(milliseconds: 50), ( + timer, + ) { + if (!model.isRun.value) { + timer.cancel(); + return; + } + + //Precise / AccuratelycalculateAlreadyplayduration(avoidtimerDeviation) + final elapsedMs = DateTime.now().difference(startTime).inMilliseconds; + + //calculatecurrentplaytoFrame + int currentIndex = -1; + int accumulatedDuration = 0; + for (int i = 0; i < danceDataList.length; i++) { + final frameDuration = danceDataList[i].durationMs; + if (elapsedMs < accumulatedDuration + frameDuration) { + currentIndex = i; + break; + } + accumulatedDuration += frameDuration; + } + + if (currentIndex != -1) { + model.dancePlayIndex.value = currentIndex; + } else { + //playcomplete + model.dancePlayIndex.value = -1; + timer.cancel(); + + //ifenableloop,Thenreplay + if (model.isLoop.value && model.isRun.value) { + //replaymusic,Maintain / Keepsync + if (model.danceInfo.value.musicUrl != null && + model.danceInfo.value.musicUrl!.isNotEmpty) { + MusicUtil.shared.stopMusic(); + MusicUtil.shared.playMusic( + model.danceInfo.value.musicInfo, + isLoop: true, + ); + } + Future.delayed(const Duration(milliseconds: 100), () { + if (model.isRun.value) playFullDance(); + }); + } else { + stopDance(); + model.isRun.value = false; + } + } + }); + } else if (AppState.shared.deviceControlMode == 1) { + //BluetoothControlmode:frame by framesenddata(supportloop) + Future playAllFrames() async { + for (int i = 0; i < danceDataList.length; i++) { + //checkwhetherNeedstopplay + if (!model.isRun.value) break; + + final DanceData currentData = danceDataList[i]; + model.dancePlayIndex.value = i; //highlightcurrent frame + + try { + //sendBluetoothdata + await BlueUtil.shared.sendDanceData(currentData); + //WaitFrameduration(+70ms forAligneddeviceresponse) + await Future.delayed( + Duration(milliseconds: currentData.durationMs + 70), + ); + } catch (e) { + break; + } + } + + //playcompleteafterhandle + model.dancePlayIndex.value = -1; + if (model.isRun.value) { + //loopmodeThenreplay + if (model.isLoop.value) { + //replaymusic,Maintain / Keepsync + if (model.danceInfo.value.musicUrl != null && + model.danceInfo.value.musicUrl!.isNotEmpty) { + MusicUtil.shared.stopMusic(); + MusicUtil.shared.playMusic( + model.danceInfo.value.musicInfo, + isLoop: true, + ); + } + Future.delayed(const Duration(milliseconds: 100), () { + if (model.isRun.value) playAllFrames(); + }); + } else { + stopDance(); + model.isRun.value = false; + } + } + } + + //willtaskaddlist(For easycancel) + final task = playAllFrames(); + model.bluetoothPlayTasks.add(task); + task.whenComplete(() => model.bluetoothPlayTasks.remove(task)); + } + } + + //startfirstplay + playFullDance(); + } + + ///stopallplay + void stopDance() { + //stopmusic + MusicUtil.shared.stopMusic(); + + //resetplaystate + model.isPlayingSingle.value = false; + model.dancePlayIndex.value = -1; + + //canceltimer + model.playTimer?.cancel(); + model.playTimer = null; + + //cancelallBluetoothplaytask + for (var task in model.bluetoothPlayTasks) { + task?.ignore(); + } + model.bluetoothPlayTasks.clear(); + } + + @override + Widget build(BuildContext context) { + return CupertinoPageScaffold( + backgroundColor: CupertinoColors.systemGroupedBackground.resolveFrom( + context, + ), + child: CustomScrollView( + slivers: [ + CupertinoSliverNavigationBar( + largeTitle: Obx( + () => Text(model.danceInfo.value.danceName ?? "Dance"), + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Obx( + () => CupertinoButton( + padding: const EdgeInsets.all(12), + child: SvgPicture.asset( + model.isLoop.value + ? "assets/repeat.fill.svg" //loopenableicon + : "assets/repeat.svg", //loopcloseicon + colorFilter: ColorFilter.mode( + model.isLoop.value + ? CupertinoTheme.of(context).primaryColor + : CupertinoColors.systemGrey, + BlendMode.srcIn, + ), + ), + onPressed: () { + //switchloopstate + model.isLoop.value = !model.isLoop.value; + //ifCurrentlyinplay,updatemusicloopstate + if (model.isRun.value && + model.danceInfo.value.musicUrl?.isNotEmpty == true) { + MusicUtil.shared.setMusicLoop(model.isLoop.value); + } + }, + ), + ), + //wholeplay/stopbutton + CupertinoButton( + padding: const EdgeInsets.all(12), + child: Obx( + () => SvgPicture.asset( + model.isRun.value + ? "assets/stop.fill.svg" + : "assets/play.fill.svg", + colorFilter: ColorFilter.mode( + CupertinoTheme.of(context).primaryColor, + BlendMode.srcIn, + ), + ), + ), + onPressed: () { + model.isRun.value = !model.isRun.value; + if (model.isRun.value) { + startDance(); + } else { + stopDance(); + } + }, + ), + ], + ), + ), + //DownPullrefresh + CupertinoSliverRefreshControl(onRefresh: getDanceList), + //dancelist + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(15), + child: ClipRRect( + borderRadius: BorderRadius.circular(25), + child: Container( + color: CupertinoColors.tertiarySystemBackground.resolveFrom( + context, + ), + child: Obx( + () => model.danceInfo.value.danceData.isNotEmpty + ? ListView.separated( + padding: EdgeInsets.zero, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemBuilder: (context, index) => + danceItemView(context, index), + itemCount: model.danceInfo.value.danceData.length, + separatorBuilder: (context, index) => Padding( + padding: const EdgeInsets.only(left: 15), + child: Container( + color: CupertinoColors.separator.resolveFrom( + context, + ), + width: double.infinity, + height: 0.5, + ), + ), + ) + : CupertinoListTile( + onTap: () { + model.danceInfo.value.danceData.add( + DanceData( + leftEye: ExpressionItem(weight: 100), + rightEye: ExpressionItem(weight: 100), + mouth: ExpressionItem(weight: 0), + yawServo: MotionDataItem(), + pitchServo: MotionDataItem(), + durationMs: 200, + ), + ); + model.danceInfo.refresh(); + saveDance(); + }, + title: Center( + child: Icon(CupertinoIcons.add_circled), + ), + ), + ), + ), + ), + ), + ), + ], + ), + ); + } + + ///danceFramelistItem(NoModify,keeporiginallogic) + Widget danceItemView(BuildContext context, int index) { + TextStyle titleStyle = TextStyle( + color: CupertinoColors.label.resolveFrom(context), + fontSize: 15, + ); + TextStyle valueStyle = TextStyle( + color: CupertinoColors.secondaryLabel.resolveFrom(context), + fontSize: 15, + fontWeight: FontWeight.w500, + ); + + return Obx( + () => Container( + color: index == model.dancePlayIndex.value + ? CupertinoColors.systemPink + .resolveFrom(context) + .withValues(alpha: 0.2) + : CupertinoColors.transparent, + child: CupertinoExpansionTile( + transitionMode: ExpansionTileTransitionMode.scroll, + title: Row( + children: [ + SizedBox( + width: 80, + child: Row( + mainAxisSize: .max, + children: [ + Spacer(), + CupertinoButton( + padding: EdgeInsets.zero, + minimumSize: .zero, + child: const Icon( + CupertinoIcons.minus_circle, + color: CupertinoColors.separator, + ), + onPressed: () { + model.danceInfo.value.danceData.removeAt(index); + model.danceInfo.refresh(); + saveDance(); + }, + ), + Spacer(), + CupertinoButton( + padding: EdgeInsets.zero, + minimumSize: .zero, + onPressed: () { + //Duplicate / Copycurrent frame + final currentData = model + .danceInfo + .value + .danceData[index] + .copy(); + if (index + 1 < + model.danceInfo.value.danceData.length) { + model.danceInfo.value.danceData.insert( + index, + currentData, + ); + } else { + model.danceInfo.value.danceData.add(currentData); + } + model.danceInfo.refresh(); + saveDance(); + }, + child: const Icon(CupertinoIcons.plus_circle), + ), + Spacer(), + ], + ), + ), + Text( + "Dance ${index + 1}", + style: TextStyle( + color: CupertinoColors.label.resolveFrom(context), + fontWeight: FontWeight.w700, + ), + ), + const Spacer(), + Text( + "${model.danceInfo.value.danceData[index].durationMs} ms", + style: TextStyle( + color: CupertinoColors.secondaryLabel.resolveFrom(context), + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(width: 10), + ], + ), + child: Padding( + padding: const EdgeInsets.all(10), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(width: 80), + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + //orientationAngleshow + Row( + children: [ + Text("Orientation", style: titleStyle), + const Spacer(), + Obx( + () => Text( + "x: ${model.danceInfo.value.danceData[index].yawServo.angle} y: ${model.danceInfo.value.danceData[index].pitchServo.angle}", + style: valueStyle, + ), + ), + ], + ), + //orientationControl joystick + LayoutBuilder( + builder: (context, constraints) { + return Container( + height: constraints.maxWidth / 2, + decoration: BoxDecoration( + color: CupertinoColors.systemGroupedBackground + .resolveFrom(context), + borderRadius: BorderRadius.circular(25), + ), + child: Obx( + () => GridCoordinateJoystick( + minX: -1280, + maxX: 1280, + minY: 0, + maxY: 900, + padding: const EdgeInsets.all(25), + showMarking: false, + targetGridSize: 50, + buttonSize: 50, + point: Offset( + model + .danceInfo + .value + .danceData[index] + .yawServo + .angle + .toDouble(), + model + .danceInfo + .value + .danceData[index] + .pitchServo + .angle + .toDouble(), + ), + onImmediatelyRelease: (point) { + setState(() { + model + .danceInfo + .value + .danceData[index] + .yawServo + .rotate = + 0; + model + .danceInfo + .value + .danceData[index] + .yawServo + .angle = point.dx + .toInt(); + model + .danceInfo + .value + .danceData[index] + .pitchServo + .angle = point.dy + .toInt(); + saveDance(); + }); + }, + ), + ), + ); + }, + ), + SizedBox(height: 10), + + //LeftSidelight stripcolorselect + Row( + children: [ + Text("Light strip left color", style: titleStyle), + const Spacer(), + CupertinoButton( + borderRadius: BorderRadius.circular(50), + color: CupertinoColors.systemGroupedBackground + .resolveFrom(context), + minimumSize: Size.zero, + padding: const EdgeInsets.all(5), + child: Obx( + () => Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: hexToColor( + model + .danceInfo + .value + .danceData[index] + .leftRgbColor, + ), + borderRadius: BorderRadius.circular(50), + ), + ), + ), + onPressed: () => colorPickerDialog( + true, + model.danceInfo.value.danceData[index], + ), + ), + ], + ), + + //rightlight stripcolorselect + SizedBox(height: 10), + + Row( + children: [ + Text("Light strip right color", style: titleStyle), + const Spacer(), + CupertinoButton( + borderRadius: BorderRadius.circular(50), + color: CupertinoColors.systemGroupedBackground + .resolveFrom(context), + minimumSize: Size.zero, + padding: const EdgeInsets.all(5), + child: Obx( + () => Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: hexToColor( + model + .danceInfo + .value + .danceData[index] + .rightRgbColor, + ), + borderRadius: BorderRadius.circular(50), + ), + ), + ), + onPressed: () => colorPickerDialog( + false, + model.danceInfo.value.danceData[index], + ), + ), + ], + ), + + SizedBox(height: 10), + Row( + children: [ + Text("Exercise duration", style: titleStyle), + const Spacer(), + Obx( + () => Text( + "ms: ${model.danceInfo.value.danceData[index].durationMs}", + style: valueStyle, + ), + ), + ], + ), + SizedBox(height: 10), + //durationAdjustment sliderBlock + SizedBox( + width: double.infinity, + child: CupertinoSlider( + max: 3000, + min: 0, + value: model + .danceInfo + .value + .danceData[index] + .durationMs + .toDouble(), + onChanged: (value) { + setState(() { + model + .danceInfo + .value + .danceData[index] + .durationMs = value + .toInt(); + }); + }, + onChangeEnd: (value) => saveDance(), + ), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ); + } + + String colorToHex(Color color) { + return '#${color.toARGB32().toRadixString(16).padLeft(8, '0').substring(2).toUpperCase()}'; + } + + Color hexToColor(String hexString) { + final hex = hexString.replaceAll('#', ''); + return Color(int.parse('FF$hex', radix: 16)); + } + + //colorselectControllerpopup(NoModify,keeporiginallogic) + Future colorPickerDialog(bool isLeft, DanceData danceData) async { + Color initialColor = isLeft + ? hexToColor(danceData.leftRgbColor) + : hexToColor(danceData.rightRgbColor); + + CupertinoSlidingSegmentedControl; + + return ColorPicker( + color: initialColor, + onColorChanged: (Color color) { + if (isLeft) { + danceData.leftRgbColor = colorToHex(color); + } else { + danceData.rightRgbColor = colorToHex(color); + } + model.danceInfo.refresh(); + }, + enableOpacity: false, + showMaterialName: true, + showColorName: true, + showColorCode: true, + copyPasteBehavior: const ColorPickerCopyPasteBehavior( + longPressMenu: true, + ), + pickersEnabled: const { + ColorPickerType.both: false, + ColorPickerType.primary: true, + ColorPickerType.accent: true, + ColorPickerType.bw: false, + ColorPickerType.custom: true, + ColorPickerType.wheel: true, + }, + ) + .showPickerDialog( + context, + backgroundColor: CupertinoColors.systemGroupedBackground, + ) + .then((value) { + if (value == true) { + saveDance(); //colorselectcompleteaftersavedata + } + return value; + }); + } +} diff --git a/app/lib/view/home/dance_list_page.dart b/app/lib/view/home/dance_list_page.dart new file mode 100644 index 0000000..258f8c7 --- /dev/null +++ b/app/lib/view/home/dance_list_page.dart @@ -0,0 +1,776 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter_slidable/flutter_slidable.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:get/get.dart'; +import 'package:stack_chan/app_state.dart'; +import 'package:stack_chan/model/dance_list.dart'; +import 'package:stack_chan/model/model.dart'; +import 'package:stack_chan/network/http.dart'; +import 'package:stack_chan/network/urls.dart'; +import 'package:stack_chan/util/blue_util.dart'; +import 'package:stack_chan/util/extension.dart'; +import 'package:stack_chan/util/music_util.dart'; +import 'package:stack_chan/util/value_constant.dart'; +import 'package:stack_chan/view/home/dance.dart'; +import 'package:stack_chan/view/home/record_dance.dart'; + +class DanceListPage extends StatefulWidget { + const DanceListPage({super.key}); + + @override + State createState() => _DanceListPageState(); +} + +class DanceListPageModel extends GetxController { + RxList list = RxList([]); + RxInt runId = RxInt(-1); + RxBool isLoopMode = RxBool(false); + RxBool isConnectBlue = RxBool(false); +} + +class _DanceListPageState extends State { + DanceListPageModel model = DanceListPageModel(); + bool isPlaying = false; + Timer? _playTimer; + final List?> _bluetoothPlayTasks = []; + + @override + void initState() { + super.initState(); + BlueUtil.shared.connectionStateChanged = (device, status) { + model.isConnectBlue.value = status; + }; + if (BlueUtil.shared.currentPeripheral == null) { + model.isConnectBlue.value = false; + } else { + model.isConnectBlue.value = true; + } + if (AppState.shared.deviceControlMode == 1) { + BlueUtil.shared.blueMode = 2; + } + getDanceList(); + } + + @override + void dispose() { + model.onClose(); + stopPlay(); + if (AppState.shared.deviceControlMode == 1) { + BlueUtil.shared.blueMode = 1; + } + BlueUtil.shared.connectionStateChanged = null; + if (mounted) { + FocusScope.of(context).unfocus(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final primaryColor = CupertinoTheme.of(context).primaryColor; + return CupertinoPageScaffold( + backgroundColor: CupertinoColors.systemBackground.resolveFrom(context), + child: Obx( + () => CustomScrollView( + slivers: [ + CupertinoSliverNavigationBar( + largeTitle: Text("Dance List"), + trailing: Row( + mainAxisSize: .min, + children: [ + CupertinoButton( + padding: const EdgeInsets.all(8), + borderRadius: BorderRadius.circular(20), + child: Obx( + () => SvgPicture.asset( + model.isLoopMode.value + ? "assets/repeat.svg" + : "assets/repeat.1.svg", + colorFilter: ColorFilter.mode( + primaryColor, + BlendMode.srcIn, + ), + width: 24, + height: 24, + ), + ), + onPressed: () { + model.isLoopMode.value = !model.isLoopMode.value; + }, + ), + const SizedBox(width: 4), + //Controlmodeswitch - optimizestyle + CupertinoButton( + padding: const EdgeInsets.all(10), + minimumSize: .zero, + borderRadius: BorderRadius.circular(16), + color: primaryColor.withValues(alpha: 0.1), + child: Obx(() { + if (AppState.shared.deviceControlMode == 0) { + return Text( + "Network", + style: TextStyle( + color: primaryColor, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ); + } else { + return Row( + mainAxisSize: .min, + spacing: 5, + children: [ + Text( + "Bluetooth", + style: TextStyle( + color: primaryColor, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + Icon( + CupertinoIcons.circle_filled, + size: 12, + color: model.isConnectBlue.value + ? CupertinoColors.activeGreen + : CupertinoColors.destructiveRed, + ), + ], + ); + } + }), + onPressed: () { + if (AppState.shared.deviceControlMode == 0) { + AppState.shared.deviceControlMode = 1; + BlueUtil.shared.blueMode = 2; + } else { + AppState.shared.deviceControlMode = 0; + BlueUtil.shared.blueMode = 1; + } + }, + ), + ], + ), + ), + CupertinoSliverRefreshControl(onRefresh: getDanceList), + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20), + sliver: SliverList.separated( + itemCount: model.list.length + 1, + itemBuilder: listItem, + separatorBuilder: (context, index) => + const SizedBox(height: 16), + ), + ), + ], + ), + ), + ); + } + + Future showEditDanceName( + List danceList, + String musicUrl, + String? musicName, + ) async { + String text = musicName ?? ""; + String errorMessage = ""; + TextEditingController controller = TextEditingController(text: text); + + await showCupertinoDialog( + context: context, + builder: (context) { + return StatefulBuilder( + builder: (context, setDialogState) { + return CupertinoAlertDialog( + title: const Text("Please give the dance a name"), + content: Padding( + padding: const EdgeInsets.only(bottom: 10), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CupertinoTextField( + controller: controller, + maxLength: 25, + decoration: BoxDecoration( + border: Border.all( + color: errorMessage.isNotEmpty + ? CupertinoColors.destructiveRed + : CupertinoColors.separator.resolveFrom(context), + width: 0.5, + ), + borderRadius: BorderRadius.circular(8), + ), + placeholder: "Enter dance name", + onChanged: (value) { + text = value; + if (errorMessage.isNotEmpty) { + setDialogState(() { + errorMessage = ""; + }); + } + }, + autofocus: true, + ), + if (errorMessage.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + errorMessage, + style: const TextStyle( + color: CupertinoColors.destructiveRed, + fontSize: 12, + ), + ), + ), + ], + ), + ), + actions: [ + CupertinoDialogAction( + child: const Text("Cancel"), + onPressed: () => Navigator.of(context).pop(), + ), + CupertinoDialogAction( + child: const Text("Confirm"), + onPressed: () { + if (text.isEmpty) { + setDialogState(() { + errorMessage = "Please enter the name of the dance"; + }); + return; + } + + //checknamewhetherAlreadyexist + bool nameExists = model.list.any( + (item) => item.danceName == text, + ); + + if (nameExists) { + setDialogState(() { + errorMessage = "This dance name already exists"; + }); + return; + } + + Navigator.of(context).pop(); + addDance(danceList, musicUrl, text); + }, + ), + ], + ); + }, + ); + }, + ); + } + + Widget listItem(BuildContext context, int index) { + final double itemHeight = 110; + final double itemRadius = 20; + final primaryColor = CupertinoTheme.of(context).primaryColor; + final textColor = CupertinoColors.label.resolveFrom(context); + final subTextColor = CupertinoColors.secondaryLabel.resolveFrom(context); + + //after1addbutton - optimizestyle + if (index == model.list.length) { + return Container( + height: itemHeight, + decoration: BoxDecoration( + color: CupertinoColors.secondarySystemBackground.resolveFrom(context), + borderRadius: BorderRadius.circular(itemRadius), + boxShadow: [ + BoxShadow( + color: CupertinoColors.black.withValues(alpha: 0.04), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: CupertinoButton( + padding: EdgeInsets.zero, + borderRadius: BorderRadius.circular(itemRadius), + onPressed: () { + stopPlay(); + showCupertinoSheet( + context: context, + useNestedNavigation: true, + builder: (context) { + return RecordDance( + onResult: (danceList, musicUrl, musicName) async { + //Wait 2 Second(s),afterexecute + showEditDanceName(danceList, musicUrl, musicName); + + // if (musicName != null && + // !model.list.any( + // (item) => item.danceName == musicName, + // )) { + // addDance(danceList, musicUrl, musicName); + // } else { + // showCupertinoDialog( + // context: context, + // builder: (context) { + // String text = ""; + // return CupertinoAlertDialog( + // title: const Text("Please give the dance a name"), + // content: Padding( + // padding: const EdgeInsets.only(bottom: 10), + // child: CupertinoTextField( + // maxLength: 25, + // decoration: BoxDecoration( + // border: Border.all( + // color: CupertinoColors.separator + // .resolveFrom(context), + // width: 0.5, + // ), + // borderRadius: BorderRadius.circular(8), + // ), + // onChanged: (value) { + // text = value; + // }, + // ), + // ), + // actions: [ + // CupertinoDialogAction( + // child: const Text("Cancel"), + // onPressed: () => Navigator.of(context).pop(), + // ), + // CupertinoDialogAction( + // child: const Text("Confirm"), + // onPressed: () { + // if (text.isEmpty) { + // AppState.shared.showToast( + // "Please enter the name of the dance", + // ); + // return; + // } + // Navigator.of(context).pop(); + // addDance(danceList, musicUrl, text); + // }, + // ), + // ], + // ); + // }, + // ); + // } + }, + ); + }, + ); + }, + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SvgPicture.asset( + "assets/plus.circle.svg", + colorFilter: ColorFilter.mode(primaryColor, BlendMode.srcIn), + width: 36, + height: 36, + ), + const SizedBox(height: 8), + Text( + "Add New Dance", + style: TextStyle( + color: primaryColor, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ), + ); + } else { + DanceList dance = model.list[index]; + //useSlidableImplementLeftmenu + + return Slidable( + key: Key('dance_${dance.id}'), + //Leftconfig + endActionPane: ActionPane( + extentRatio: 0.6, + motion: const ScrollMotion(), //translated comment + dismissible: DismissiblePane( + onDismissed: () => deleteDance(dance.id), + ), + children: [ + //Editbutton + SlidableAction( + onPressed: (_) => Navigator.of(context).push( + CupertinoPageRoute( + builder: (context) => Dance(danceInfo: dance), + ), + ), + backgroundColor: CupertinoColors.systemBlue.withValues( + alpha: 0.9, + ), + foregroundColor: CupertinoColors.white, + icon: CupertinoIcons.pencil, + label: 'Edit', + borderRadius: BorderRadius.circular(itemRadius), + ), + //deletebutton + SlidableAction( + onPressed: (_) => deleteDance(dance.id), + backgroundColor: CupertinoColors.systemRed.withValues(alpha: 0.9), + foregroundColor: CupertinoColors.white, + icon: CupertinoIcons.trash, + label: 'Delete', + borderRadius: BorderRadius.circular(itemRadius), + ), + ], + ), + //listItemMain - optimizestyle + child: Obx( + () => Container( + height: itemHeight, + decoration: BoxDecoration( + //background + Shadow + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + dance.id == model.runId.value + ? CupertinoColors.systemPink.withValues(alpha: 0.1) + : CupertinoColors.systemOrange.withValues(alpha: 0.1), + dance.id == model.runId.value + ? CupertinoColors.systemPink.withValues(alpha: 0.3) + : CupertinoColors.systemOrange.withValues(alpha: 0.3), + ], + ), + borderRadius: BorderRadius.circular(itemRadius), + boxShadow: [ + BoxShadow( + color: CupertinoColors.black.withValues(alpha: 0.06), + blurRadius: 10, + offset: const Offset(0, 3), + ), + ], + border: Border.all( + color: dance.id == model.runId.value + ? CupertinoColors.systemPink.withValues(alpha: 0.2) + : CupertinoColors.systemOrange.withValues(alpha: 0.2), + width: 0.8, + ), + ), + child: dance.isLoading + ? Center(child: CupertinoActivityIndicator()) + : CupertinoButton( + padding: const EdgeInsets.symmetric(horizontal: 16), + borderRadius: BorderRadius.circular(itemRadius), + onPressed: () { + if (dance.id != null) { + if (dance.id == model.runId.value) { + stopPlay(); + } else { + stopPlay(); + model.runId.value = dance.id!; + startPlay(); + } + } + }, + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + //dancename + Text( + dance.danceName ?? "Untitled Dance", + style: TextStyle( + color: textColor, + fontSize: 18, + fontWeight: FontWeight.w700, + letterSpacing: 0.2, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 8), + //musicinfo + if (dance.musicInfo?.title != null) + Row( + children: [ + SvgPicture.asset( + "assets/music.note.svg", + colorFilter: ColorFilter.mode( + subTextColor, + BlendMode.srcIn, + ), + width: 16, + height: 16, + ), + const SizedBox(width: 6), + Expanded( + child: Text( + dance.musicInfo!.title ?? "", + style: TextStyle( + color: subTextColor, + fontSize: 14, + height: 1.2, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + //playstatehint + if (dance.id == model.runId.value) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + "Now Playing", + style: TextStyle( + color: dance.id == model.runId.value + ? CupertinoColors.systemPink + : primaryColor, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ), + //musiccover + if (dance.musicInfo?.artwork != null) + ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Image.file( + File(dance.musicInfo!.artwork!), + width: 70, + height: 70, + fit: BoxFit.cover, + ), + ), + ], + ), + ), + ), + ), + ); + } + } + + Future addDance( + List danceList, + String url, + String title, + ) async { + final Map map = { + ValueConstant.danceData: DanceData.listToJson(danceList), + ValueConstant.danceName: title, + ValueConstant.musicUrl: url, + ValueConstant.mac: AppState.shared.deviceMac, + }; + final response = await Http.instance.post(Urls.v2dance, data: map); + if (response.data != null) { + Model responseData = Model.fromJsonT(response.data); + if (responseData.isSuccess()) { + getDanceList(); + } + } + } + + void sendDanceData(List danceList) { + final jsonString = jsonEncode(DanceData.listToJson(danceList)); + AppState.shared.sendWebSocketMessage( + .dance, + data: jsonString.toUint8List(), + ); + } + + Future startPlay() async { + if (isPlaying) { + stopPlay(); + return; + } + + if (model.runId.value == -1) { + MusicUtil.shared.stopMusic(); + isPlaying = false; + return; + } + + final currentDance = model.list.firstWhere( + (dance) => dance.id == model.runId.value, + orElse: () => DanceList(), + ); + + if (currentDance.id == null || currentDance.danceData.isEmpty) { + AppState.shared.showToast( + "The current dance data is empty and cannot be played.", + ); + stopPlay(); + return; + } + + final danceList = currentDance.danceData; + isPlaying = true; + MusicUtil.shared.stopMusic(); + + //checkmusicinfoandfilewhetherhas + var musicInfo = currentDance.musicInfo; + if (musicInfo == null || !(await File(musicInfo.filePath).exists())) { + if (currentDance.musicUrl != null && currentDance.musicUrl!.isNotEmpty) { + //musicinfoinvalidorfilenot exist,reGet + currentDance.isLoading = true; + model.list.refresh(); + musicInfo = await MusicUtil.shared.getMusicInfoAsync( + currentDance.musicUrl!, + ); + currentDance.musicInfo = musicInfo; + currentDance.isLoading = false; + model.list.refresh(); + } + + if (musicInfo == null) { + AppState.shared.showToast( + "Music file is missing, please try again later.", + ); + stopPlay(); + return; + } + } + MusicUtil.shared.playMusic(musicInfo); + final currentPlayId = model.runId.value; + final currentLoopMode = model.isLoopMode.value; + if (AppState.shared.deviceControlMode == 0) { + sendDanceData(danceList); + + int totalDurationMs = danceList.fold( + 0, + (sum, data) => sum + (data.durationMs), + ); + double totalDurationSeconds = totalDurationMs / 1000.0; + + _playTimer = Timer(Duration(seconds: totalDurationSeconds.round()), () { + if (isPlaying && model.runId.value == currentPlayId) { + if (currentLoopMode) { + isPlaying = false; + startPlay(); //async, + } else { + stopPlay(); + } + } + }); + } else if (AppState.shared.deviceControlMode == 1) { + _playBluetoothDance(danceList, currentPlayId, currentLoopMode); + } + } + + Future _playBluetoothDance( + List danceList, + int currentPlayId, + bool currentLoopMode, + ) async { + final task = _playBluetoothFrames( + danceList, + currentPlayId, + currentLoopMode, + ); + _bluetoothPlayTasks.add(task); + await task; + _bluetoothPlayTasks.remove(task); + } + + Future _playBluetoothFrames( + List danceList, + int currentPlayId, + bool currentLoopMode, + ) async { + for (var danceData in danceList) { + if (!isPlaying || model.runId.value != currentPlayId) { + break; + } + + await BlueUtil.shared.sendDanceData(danceData); + + int waitMs = (danceData.durationMs) + 90; + await Future.delayed(Duration(milliseconds: waitMs)); + } + + if (isPlaying && model.runId.value == currentPlayId) { + if (currentLoopMode) { + isPlaying = false; + startPlay(); //async, + } else { + stopPlay(); + } + } + } + + void stopPlay() { + isPlaying = false; + model.runId.value = -1; + MusicUtil.shared.stopMusic(); + + _playTimer?.cancel(); + _playTimer = null; + + for (var task in _bluetoothPlayTasks) { + task?.ignore(); + } + _bluetoothPlayTasks.clear(); + } + + Future deleteDance(int? id) async { + if (id != null) { + Map map = {ValueConstant.id: id}; + final response = await Http.instance.delete(Urls.v2dance, data: map); + if (response.data != null) { + Model responseData = Model.fromJsonT(response.data); + if (responseData.isSuccess()) { + getDanceList(); + } + } + } + } + + Future getDanceList() async { + final map = {ValueConstant.mac: AppState.shared.deviceMac}; + final response = await Http.instance.get(Urls.v2dance, data: map); + Model> responseData = Model.fromJsonT( + response.data, + factory: (data) => DanceList.fromListJson(data), + ); + if (responseData.isSuccess() && responseData.data != null) { + final list = responseData.data!; + model.list.value = list; + getMusicInfo(); + } + } + + Future getMusicInfo() async { + for (int i = 0; i < model.list.length; i++) { + if (model.list[i].musicUrl != null && + model.list[i].musicUrl!.isNotEmpty) { + model.list[i].isLoading = true; + model.list.refresh(); + final musicInfo = await MusicUtil.shared.getMusicInfoAsync( + model.list[i].musicUrl!, + ); + if (musicInfo != null) { + model.list[i].musicInfo = musicInfo; + } + model.list[i].isLoading = false; + model.list.refresh(); + } + } + model.list.refresh(); + } +} diff --git a/app/lib/view/home/home.dart b/app/lib/view/home/home.dart new file mode 100644 index 0000000..1e9fc4e --- /dev/null +++ b/app/lib/view/home/home.dart @@ -0,0 +1,148 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +import 'dart:async'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:stack_chan/util/blue_util.dart'; +import 'package:stack_chan/view/home/settings.dart'; +import 'package:stack_chan/view/home/stack_chan.dart'; + +import '../../app_state.dart'; +import '../../model/blue_device_info.dart'; + +class Home extends StatefulWidget { + const Home({super.key}); + + @override + State createState() => _HomeState(); +} + +class _HomeState extends State { + @override + void initState() { + super.initState(); + init(); + } + + Future init() async { + //MaininitwhensetasWiFimode + BlueUtil.shared.blueMode = 1; + + AppState.shared.webSocketMessageMonitoring(); + if (AppState.shared.deviceMac != "") { + AppState.shared.connectWebSocket(); + } + + //setBluetoothdevicescancallback,updateMaindevicelist + BlueUtil.shared.blufDevicesMonitoring = (List devices) { + AppState.shared.blueDeviceList.value = devices; + }; + //Delaytriggerscan,Ensure BlueUtil SingletonAlreadyfullyinit + Timer.run(() { + if (BlueUtil.shared.blueSwitch && BlueUtil.shared.automaticScanning) { + BlueUtil.shared.startScan(); + } + }); + } + + int pageIndex = 0; + + @override + void dispose() { + //removeBluetoothscancallback + BlueUtil.shared.blufDevicesMonitoring = null; + super.dispose(); + } + + @override + Widget build(BuildContext context) { + Color activeColor = CupertinoTheme.of(context).primaryColor; + Color inactiveColor = CupertinoColors.inactiveGray.resolveFrom(context); + double size = 20; + return CupertinoTabScaffold( + tabBar: CupertinoTabBar( + backgroundColor: CupertinoTheme.of( + context, + ).barBackgroundColor.withValues(alpha: 0.6), + currentIndex: 0, + onTap: (index) { + setState(() { + pageIndex = index; + }); + }, + items: [ + BottomNavigationBarItem( + icon: SvgPicture.asset( + "assets/ipod.svg", + colorFilter: .mode( + pageIndex == 0 ? activeColor : inactiveColor, + .srcIn, + ), + width: size, + height: size, + ), + label: "StackChan", + ), + // BottomNavigationBarItem( + // icon: SvgPicture.asset( + // "assets/sensor.svg", + // colorFilter: .mode( + // pageIndex == 1 ? activeColor : inactiveColor, + // .srcIn, + // ), + // width: size, + // height: size, + // ), + // label: "Nearby", + // ), + // BottomNavigationBarItem( + // icon: SvgPicture.asset( + // "assets/person.3.svg", + // colorFilter: .mode( + // pageIndex == 2 ? activeColor : inactiveColor, + // .srcIn, + // ), + // width: size, + // height: size, + // ), + // label: "Moments", + // ), + BottomNavigationBarItem( + icon: SvgPicture.asset( + "assets/gear.svg", + colorFilter: .mode( + pageIndex == 1 ? activeColor : inactiveColor, + .srcIn, + ), + width: size, + height: size, + ), + label: "Settings", + ), + ], + ), + tabBuilder: (BuildContext context, int index) { + return CupertinoTabView( + builder: (context) { + switch (index) { + case 0: + return StackChan(); + // case 1: + // return Nearby(); + // case 2: + // return Moments(); + case 1: + return Settings(); + default: + return SizedBox(); + } + }, + ); + }, + ); + } +} diff --git a/app/lib/view/home/mcp_page.dart b/app/lib/view/home/mcp_page.dart new file mode 100644 index 0000000..0bfb911 --- /dev/null +++ b/app/lib/view/home/mcp_page.dart @@ -0,0 +1,383 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/services.dart'; +import 'package:get/get.dart'; +import 'package:stack_chan/app_state.dart'; +import 'package:stack_chan/model/XiaoZhi/device.dart'; +import 'package:stack_chan/util/XiaoZhi_util.dart'; + +import '../../model/XiaoZhi/endpoints_response.dart'; +import '../../model/XiaoZhi/mcp_endpoints.dart'; + +class McpPage extends StatefulWidget { + const McpPage({super.key}); + + @override + State createState() => _McpPageState(); +} + +class _McpPageState extends State { + final RxList mcpEndpoints = RxList([]); + final RxBool isLoading = false.obs; + late final TextEditingController _nameController; + late final TextEditingController _descController; + + final RxnString endpointToken = RxnString(); + + final RxList toolList = RxList([]); + + @override + void initState() { + super.initState(); + _nameController = TextEditingController(); + _descController = TextEditingController(); + // fetchMcpList(); + getAgent(); + } + + final Rxn device = Rxn(); + + Future getAgent() async { + if (AppState.shared.deviceMac.isNotEmpty) { + final devices = await XiaoZhiUtil.shared.getDevice( + AppState.shared.deviceMac, + ); + if (devices.isNotEmpty) { + device.value = devices.first; + if (device.value!.agent_id != null) { + getToken(device.value!.agent_id!); + getEndpointsList(device.value!.agent_id!); + } + } + } + } + + Future getEndpointsList(int agentId) async { + final data = await XiaoZhiUtil.shared.endpointsList(agentId); + if (data != null) { + if (data.endpoints.isNotEmpty) { + if (data.endpoints.first.tools.isNotEmpty) { + toolList.value = data.endpoints.first.tools; + } + } + } + } + + Future getToken(int id) async { + final token = await XiaoZhiUtil.shared.generateMcpEndpointToken(id); + if (token != null) { + endpointToken.value = token; + } + } + + @override + void dispose() { + _nameController.dispose(); + _descController.dispose(); + if (mounted) { + FocusScope.of(context).unfocus(); + } + super.dispose(); + } + + Future fetchMcpList() async { + isLoading.value = true; + mcpEndpoints.value = await XiaoZhiUtil.shared.mcpEndpoints(); + isLoading.value = false; + } + + final RxBool enabled = RxBool(false); + + void showMcpDialog({McpEndpoints? editItem}) { + if (editItem != null) { + _nameController.text = editItem.name ?? ""; + _descController.text = editItem.description ?? ""; + } else { + _nameController.clear(); + _descController.clear(); + } + enabled.value = editItem?.enabled == 1; + showCupertinoDialog( + context: context, + builder: (context) { + return CupertinoAlertDialog( + title: Text( + editItem == null + ? "New MCP access point added" + : "Editorial access point", + ), + content: Column( + spacing: 10, + children: [ + CupertinoTextField( + controller: _nameController, + placeholder: "Please enter the name.", + ), + CupertinoTextField( + controller: _descController, + placeholder: "Please provide the description.", + ), + Row( + mainAxisAlignment: .spaceBetween, + children: [ + Text("Enabled status"), + Obx( + () => CupertinoSwitch( + value: enabled.value, + onChanged: (value) => enabled.value = value, + ), + ), + ], + ), + ], + ), + actions: [ + CupertinoDialogAction( + child: const Text('Cancel'), + onPressed: () => Navigator.pop(context), + ), + CupertinoDialogAction( + isDestructiveAction: false, + isDefaultAction: true, + child: Text("Confirm"), + onPressed: () async { + final name = _nameController.text.trim(); + final desc = _descController.text.trim(); + if (name.isEmpty) { + AppState.shared.showToast("The name cannot be left blank."); + return; + } + Navigator.pop(context); + if (editItem == null) { + await XiaoZhiUtil.shared.createMcpEndpoints( + name, + desc, + enabled.value, + ); + } else { + await XiaoZhiUtil.shared.editEndpoints( + editItem.id!, + name: name, + description: desc, + enabled: enabled.value, + ); + } + fetchMcpList(); + }, + ), + ], + ); + }, + ); + } + + void showDeleteDialog(int id) { + showCupertinoDialog( + context: context, + builder: (context) { + return CupertinoAlertDialog( + title: Text("Are you sure you want to delete?"), + content: Text( + "Are you sure you want to delete this access point? This operation is irreversible.", + ), + actions: [ + CupertinoDialogAction( + child: const Text('Cancel'), + onPressed: () => Get.back(), + ), + CupertinoDialogAction( + isDestructiveAction: true, + child: const Text('Delete'), + onPressed: () async { + Navigator.pop(context); + final success = await XiaoZhiUtil.shared.deleteEndpoints(id); + if (success) { + fetchMcpList(); + AppState.shared.showToast("successfully delete"); + } + }, + ), + ], + ); + }, + ); + } + + Future generateAndCopyToken(int id) async { + isLoading.value = true; + final token = await XiaoZhiUtil.shared.getEndpointToken(id); + isLoading.value = false; + + if (token != null && token.isNotEmpty) { + await Clipboard.setData(ClipboardData(text: token)); + AppState.shared.showToast("Token has been copied"); + } else { + AppState.shared.showToast("Failed to generate token"); + } + } + + Widget buildItem(McpEndpoints item) { + return CupertinoListTile( + title: Text(item.name ?? "Unnamed"), + subtitle: Column( + crossAxisAlignment: .start, + children: [ + Text( + item.description ?? "No description", + style: TextStyle(fontSize: 12), + ), + const SizedBox(height: 4), + Row( + children: [ + Text( + item.enabled == 1 ? "Enabled" : "Disabled", + style: TextStyle( + fontSize: 11, + color: item.enabled == 1 + ? CupertinoColors.activeGreen + : CupertinoColors.systemRed, + ), + ), + const SizedBox(width: 10), + Text( + item.createdAt ?? "", + style: TextStyle( + fontSize: 11, + color: CupertinoColors.systemGrey, + ), + ), + ], + ), + ], + ), + trailing: Row( + mainAxisSize: .min, + children: [ + CupertinoButton( + padding: .zero, + child: const Icon(CupertinoIcons.doc_on_clipboard, size: 18), + onPressed: () => generateAndCopyToken(item.id!), + ), + CupertinoButton( + padding: .zero, + child: const Icon(CupertinoIcons.pencil, size: 18), + onPressed: () => showMcpDialog(editItem: item), + ), + CupertinoButton( + padding: .zero, + child: Icon( + CupertinoIcons.trash, + size: 18, + color: CupertinoColors.destructiveRed, + ), + onPressed: () => showDeleteDialog(item.id!), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + return CupertinoPageScaffold( + backgroundColor: CupertinoColors.systemGroupedBackground.resolveFrom( + context, + ), + navigationBar: CupertinoNavigationBar.large( + backgroundColor: CupertinoColors.systemGroupedBackground.resolveFrom( + context, + ), + largeTitle: Text("MCP"), + ), + child: Padding( + padding: EdgeInsets.only( + bottom: 15 + MediaQuery.of(context).padding.bottom, + left: 15, + right: 15, + ), + child: Column( + children: [ + Expanded( + child: Obx( + () => ListView( + padding: .zero, + children: [ + CupertinoListSection.insetGrouped( + header: Text("Access Point Status"), + children: toolList.isEmpty + ? [ + CupertinoListTile( + title: Center(child: Text("Offline")), + ), + ] + : toolList + .map( + (tool) => + CupertinoListTile(title: Text(tool.name)), + ) + .toList(), + ), + CupertinoListSection.insetGrouped( + header: Text("Access point address"), + children: [ + if (endpointToken.value != null) + CupertinoListTile( + title: Padding( + padding: .all(5), + child: Text( + softWrap: true, + maxLines: 100, + "wss://api.XiaoZhi.me/mcp/?token=${endpointToken.value}", + ), + ), + trailing: CupertinoButton( + child: Icon(CupertinoIcons.doc_on_doc), + onPressed: () async { + await Clipboard.setData( + ClipboardData( + text: + "wss://api.XiaoZhi.me/mcp/?token=${endpointToken.value}", + ), + ); + AppState.shared.showToast("Already copied"); + }, + ), + onTap: () async { + await Clipboard.setData( + ClipboardData( + text: + "wss://api.XiaoZhi.me/mcp/?token=${endpointToken.value}", + ), + ); + AppState.shared.showToast("Already copied"); + }, + ) + else + CupertinoListTile( + title: Center(child: Text("Loading...")), + ), + ], + ), + ], + ), + ), + ), + CupertinoButton.filled( + child: SizedBox( + width: .infinity, + child: Center(child: Text("OK")), + ), + onPressed: () { + Navigator.pop(context); + }, + ), + ], + ), + ), + ); + } +} diff --git a/app/lib/view/home/moments.dart b/app/lib/view/home/moments.dart new file mode 100644 index 0000000..c36e433 --- /dev/null +++ b/app/lib/view/home/moments.dart @@ -0,0 +1,24 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +import 'package:flutter/cupertino.dart'; + +import '../util/gradation_page_background.dart'; + +class Moments extends StatefulWidget { + const Moments({super.key}); + + @override + State createState() => _MomentsState(); +} + +class _MomentsState extends State { + @override + Widget build(BuildContext context) { + return CupertinoPageScaffold( + child: GradationPageBackground(child: SizedBox.expand()), + ); + } +} diff --git a/app/lib/view/home/monitoring_camera.dart b/app/lib/view/home/monitoring_camera.dart new file mode 100644 index 0000000..334d699 --- /dev/null +++ b/app/lib/view/home/monitoring_camera.dart @@ -0,0 +1,204 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/services.dart'; +import 'package:get/get.dart'; +import 'package:stack_chan/app_state.dart'; +import 'package:stack_chan/model/expression_data.dart'; +import 'package:stack_chan/network/web_socket_util.dart'; +import 'package:stack_chan/util/audio_engine_manager.dart'; +import 'package:stack_chan/util/extension.dart'; + +import '../util/grid_coordinate_joystick.dart'; + +class MonitoringCamera extends StatefulWidget { + const MonitoringCamera({super.key}); + + @override + State createState() => _MonitoringCameraState(); +} + +class _MonitoringCameraState extends State { + final String tag = "MonitoringCamera"; + + MotionData motionData = MotionData( + pitchServo: MotionDataItem(), + yawServo: MotionDataItem(), + ); + + DateTime sendScreenLastTime = DateTime.now(); + + Rx cameraImage = Rx(Uint8List(0)); + RxBool onMic = RxBool(false); + + @override + void initState() { + super.initState(); + initCameraAndSocket(); + WebSocketUtil.shared.connectionSuccessful = () { + initCameraAndSocket(); + }; + } + + @override + void dispose() { + _cleanResources(); + super.dispose(); + } + + //====================== coreinitmethod ====================== + Future initCameraAndSocket() async { + WebSocketUtil.shared.removeObserver(tag); + WebSocketUtil.shared.addObserver(tag, (message) { + if (message is Uint8List) { + final result = AppState.shared.parseMessage(message); + final msgType = result.$1; + final parsedData = result.$2; + if (msgType != null) { + switch (msgType) { + case .jpeg: + if (parsedData != null) { + cameraImage.value = parsedData; + } + break; + case .opus: + if (parsedData != null) { + AudioEngineManager.shared.playOpus(parsedData); + } + break; + default: + break; + } + } + } + }); + + //reCamera + audioStream(keyfix:exitAgainmust) + if (AppState.shared.deviceMac.isNotEmpty) { + AppState.shared.sendWebSocketMessage( + .onCamera, + data: AppState.shared.deviceMac.toUint8List(), + ); + // AppState.shared.sendWebSocketMessage( + // .onAudio, + // data: AppState.shared.deviceMac.toUint8List(), + // ); + } + + //audiodatasend + // AudioEngineManager.shared.onAudioData = (opusData) { + // final bytesBuilder = BytesBuilder(); + // bytesBuilder.add(AppState.shared.deviceMac.toUint8List()); + // bytesBuilder.add(opusData); + // final sendData = bytesBuilder.toBytes(); + // AppState.shared.sendWebSocketMessage(.opus, data: sendData); + // }; + } + + //cleanAsset / Resource + void _cleanResources() { + WebSocketUtil.shared.removeObserver(tag); + if (AppState.shared.deviceMac.isNotEmpty) { + AppState.shared.sendWebSocketMessage( + .offCamera, + data: AppState.shared.deviceMac.toUint8List(), + ); + // AppState.shared.sendWebSocketMessage( + // .offAudio, + // data: AppState.shared.deviceMac.toUint8List(), + // ); + } + // AudioEngineManager.shared.onAudioData = null; + // AudioEngineManager.shared.stopPlayOpus(); + // AudioEngineManager.shared.stopRecording(); + } + + Widget imageView(Uint8List imageData) { + if (imageData.isNotEmpty) { + return Image.memory( + imageData, + gaplessPlayback: true, + fit: BoxFit.cover, + width: double.infinity, + height: double.infinity, + ); + } else { + return Container( + width: double.infinity, + height: double.infinity, + color: CupertinoColors.systemGroupedBackground.resolveFrom(context), + ); + } + } + + @override + Widget build(BuildContext context) { + return CupertinoPageScaffold( + backgroundColor: CupertinoColors.systemBackground.resolveFrom(context), + navigationBar: CupertinoNavigationBar.large( + backgroundColor: CupertinoColors.systemBackground.resolveFrom(context), + largeTitle: const Text("CAMERA"), + ), + child: LayoutBuilder( + builder: (context, constraints) { + return Column( + children: [ + SizedBox( + width: constraints.maxWidth, + height: constraints.maxWidth / 4 * 3, + child: Obx(() => imageView(cameraImage.value)), + ), + const Spacer(), + Padding( + padding: const EdgeInsets.all(15), + child: Container( + width: double.infinity, + height: 300, + decoration: BoxDecoration( + color: CupertinoColors.systemGroupedBackground.resolveFrom( + context, + ), + borderRadius: BorderRadius.circular(25), + ), + child: GridCoordinateJoystick( + minX: -1280, + maxX: 1280, + minY: 0, + maxY: 900, + padding: const EdgeInsets.all(25), + showMarking: false, + targetGridSize: 50, + buttonSize: 50, + point: Offset(0, 450), + onImmediatelyRelease: saveMotionData, + ), + ), + ), + const Spacer(), + SizedBox(height: MediaQuery.of(context).padding.bottom), + ], + ); + }, + ), + ); + } + + void saveMotionData(Offset point) { + if (AppState.shared.deviceMac.isNotEmpty) { + final newDate = DateTime.now(); + final timeDiff = newDate.difference(sendScreenLastTime).inMilliseconds; + if (timeDiff >= 200) { + sendScreenLastTime = newDate; + motionData.pitchServo.angle = point.dy.toInt(); + motionData.yawServo.angle = point.dx.toInt(); + final String jsonString = + "${AppState.shared.deviceMac}${motionData.toString()}"; + final data = jsonString.toUint8List(); + AppState.shared.sendWebSocketMessage(.controlMotion, data: data); + } + } + } +} diff --git a/app/lib/view/home/nearby.dart b/app/lib/view/home/nearby.dart new file mode 100644 index 0000000..cc69b5c --- /dev/null +++ b/app/lib/view/home/nearby.dart @@ -0,0 +1,33 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +import 'package:flutter/cupertino.dart'; +import 'package:stack_chan/app_state.dart'; + +import '../util/gradation_page_background.dart'; + +class Nearby extends StatefulWidget { + const Nearby({super.key}); + + @override + State createState() => _NearbyState(); +} + +class _NearbyState extends State { + + @override + void initState() { + super.initState(); + ///startGetLocation info + AppState.shared.obtainLocation(); + } + + @override + Widget build(BuildContext context) { + return CupertinoPageScaffold( + child: GradationPageBackground(child: SizedBox.expand()), + ); + } +} diff --git a/app/lib/view/home/pano_page.dart b/app/lib/view/home/pano_page.dart new file mode 100644 index 0000000..456c477 --- /dev/null +++ b/app/lib/view/home/pano_page.dart @@ -0,0 +1,423 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +import 'dart:typed_data'; + +import 'package:flutter/cupertino.dart'; +import 'package:get/get.dart'; +import 'package:opencv_dart/opencv.dart' as cv; +import 'package:stack_chan/app_state.dart'; + +import '../../model/expression_data.dart'; +import '../../network/web_socket_util.dart'; +import '../../util/extension.dart'; + +class PanoPage extends StatefulWidget { + const PanoPage({super.key}); + + @override + State createState() => _PanoPageState(); +} + +class _PanoPageState extends State { + final String tag = "PanoPage"; + + bool recordSwitch = false; + + RxList imageDataList = RxList([]); + + Rxn panoImage = Rxn(); + + RxBool isTakingPhotos = false.obs; + RxBool isLoading = false.obs; + final Duration motionDelay = Duration(milliseconds: 500); + final Duration captureDelay = Duration(milliseconds: 500); + + final SliverGridDelegateWithFixedCrossAxisCount gridDelegate = + SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + crossAxisSpacing: 8, + mainAxisSpacing: 8, + childAspectRatio: 1, + ); + + //data + List motionList = [ + //1 + MotionData( + pitchServo: MotionDataItem(angle: 0, speed: 0), + yawServo: MotionDataItem(angle: 900, speed: 0), + ), + + //2 * 7 + MotionData( + pitchServo: MotionDataItem(angle: 1280, speed: 0), + yawServo: MotionDataItem(angle: 675, speed: 0), + ), + MotionData( + pitchServo: MotionDataItem(angle: 853, speed: 0), + yawServo: MotionDataItem(angle: 675, speed: 0), + ), + MotionData( + pitchServo: MotionDataItem(angle: 426, speed: 0), + yawServo: MotionDataItem(angle: 675, speed: 0), + ), + MotionData( + pitchServo: MotionDataItem(angle: 0, speed: 0), + yawServo: MotionDataItem(angle: 675, speed: 0), + ), + MotionData( + pitchServo: MotionDataItem(angle: -426, speed: 0), + yawServo: MotionDataItem(angle: 675, speed: 0), + ), + MotionData( + pitchServo: MotionDataItem(angle: -853, speed: 0), + yawServo: MotionDataItem(angle: 675, speed: 0), + ), + MotionData( + pitchServo: MotionDataItem(angle: -1280, speed: 0), + yawServo: MotionDataItem(angle: 675, speed: 0), + ), + + //3 * 7 + MotionData( + pitchServo: MotionDataItem(angle: -1280, speed: 0), + yawServo: MotionDataItem(angle: 450, speed: 0), + ), + MotionData( + pitchServo: MotionDataItem(angle: -853, speed: 0), + yawServo: MotionDataItem(angle: 450, speed: 0), + ), + MotionData( + pitchServo: MotionDataItem(angle: -426, speed: 0), + yawServo: MotionDataItem(angle: 450, speed: 0), + ), + MotionData( + pitchServo: MotionDataItem(angle: 0, speed: 0), + yawServo: MotionDataItem(angle: 450, speed: 0), + ), + MotionData( + pitchServo: MotionDataItem(angle: 426, speed: 0), + yawServo: MotionDataItem(angle: 450, speed: 0), + ), + MotionData( + pitchServo: MotionDataItem(angle: 853, speed: 0), + yawServo: MotionDataItem(angle: 450, speed: 0), + ), + MotionData( + pitchServo: MotionDataItem(angle: 1280, speed: 0), + yawServo: MotionDataItem(angle: 450, speed: 0), + ), + + //4 * 7 + MotionData( + pitchServo: MotionDataItem(angle: 1280, speed: 0), + yawServo: MotionDataItem(angle: 225, speed: 0), + ), + MotionData( + pitchServo: MotionDataItem(angle: 853, speed: 0), + yawServo: MotionDataItem(angle: 225, speed: 0), + ), + MotionData( + pitchServo: MotionDataItem(angle: 426, speed: 0), + yawServo: MotionDataItem(angle: 225, speed: 0), + ), + MotionData( + pitchServo: MotionDataItem(angle: 0, speed: 0), + yawServo: MotionDataItem(angle: 225, speed: 0), + ), + MotionData( + pitchServo: MotionDataItem(angle: -426, speed: 0), + yawServo: MotionDataItem(angle: 225, speed: 0), + ), + MotionData( + pitchServo: MotionDataItem(angle: -853, speed: 0), + yawServo: MotionDataItem(angle: 225, speed: 0), + ), + MotionData( + pitchServo: MotionDataItem(angle: -1280, speed: 0), + yawServo: MotionDataItem(angle: 225, speed: 0), + ), + + //5 * 7 + MotionData( + pitchServo: MotionDataItem(angle: -1280, speed: 0), + yawServo: MotionDataItem(angle: 0, speed: 0), + ), + MotionData( + pitchServo: MotionDataItem(angle: -853, speed: 0), + yawServo: MotionDataItem(angle: 0, speed: 0), + ), + MotionData( + pitchServo: MotionDataItem(angle: -426, speed: 0), + yawServo: MotionDataItem(angle: 0, speed: 0), + ), + MotionData( + pitchServo: MotionDataItem(angle: 0, speed: 0), + yawServo: MotionDataItem(angle: 0, speed: 0), + ), + MotionData( + pitchServo: MotionDataItem(angle: 426, speed: 0), + yawServo: MotionDataItem(angle: 0, speed: 0), + ), + MotionData( + pitchServo: MotionDataItem(angle: 853, speed: 0), + yawServo: MotionDataItem(angle: 0, speed: 0), + ), + MotionData( + pitchServo: MotionDataItem(angle: 1280, speed: 0), + yawServo: MotionDataItem(angle: 0, speed: 0), + ), + ]; + + @override + void initState() { + super.initState(); + WebSocketUtil.shared.addObserver(tag, (message) { + if (message is Uint8List) { + final result = AppState.shared.parseMessage(message); + final msgType = result.$1; + final parsedData = result.$2; + if (msgType != null) { + switch (msgType) { + case .jpeg: + if (parsedData != null) { + if (recordSwitch) { + imageDataList.add(parsedData); + recordSwitch = false; + } + } + break; + default: + break; + } + } + } + }); + } + + @override + void dispose() { + WebSocketUtil.shared.removeObserver(tag); + super.dispose(); + } + + Future startTakingPhotos() async { + if (AppState.shared.deviceMac.isEmpty) { + AppState.shared.showToast("Please re-attempt after binding the device."); + return; + } + + if (isTakingPhotos.value) return; + + try { + isTakingPhotos.value = true; + imageDataList.clear(); + panoImage.value = null; + AppState.shared.showToast("Start panoramic shooting..."); + + AppState.shared.sendWebSocketMessage( + .onCamera, + data: AppState.shared.deviceMac.toUint8List(), + ); + await Future.delayed(Duration(milliseconds: 300)); + + for (final motion in motionList) { + final jsonString = AppState.shared.deviceMac + motion.toString(); + AppState.shared.sendWebSocketMessage( + .controlMotion, + data: jsonString.toUint8List(), + ); + + await Future.delayed(motionDelay); + + recordSwitch = true; + + await Future.delayed(captureDelay); + } + + AppState.shared.sendWebSocketMessage( + .offCamera, + data: AppState.shared.deviceMac.toUint8List(), + ); + + AppState.shared.showToast("The shooting is complete."); + + startAssemble(); + } catch (e) { + AppState.shared.sendWebSocketMessage( + .offCamera, + data: AppState.shared.deviceMac.toUint8List(), + ); + AppState.shared.showToast("The shooting was unsuccessful.:${e.toString()}"); + } finally { + isTakingPhotos.value = false; + recordSwitch = false; + } + + ///deviceSide / EndCamera + AppState.shared.sendWebSocketMessage( + .onCamera, + data: AppState.shared.deviceMac.toUint8List(), + ); + + for (final motion in motionList) { + String jsonString = AppState.shared.deviceMac + motion.toString(); + AppState.shared.sendWebSocketMessage( + .controlMotion, + data: jsonString.toUint8List(), + ); + + ///Wait500ms,after recordSwitch = true, Again 500ms, executeNext + } + + ///closedeviceSide / EndCamera + AppState.shared.sendWebSocketMessage( + .offCamera, + data: AppState.shared.deviceMac.toUint8List(), + ); + } + + Future startAssemble() async { + if (imageDataList.length < 5) { + AppState.shared.showToast("At least 5 photos are needed to stitch together a panoramic image!"); + return; + } + if (isLoading.value) return; + isLoading.value = true; + panoImage.value = null; + + List mats = []; + cv.VecMat? vecMat; + cv.Stitcher? stitcher; + + try { + for (final data in imageDataList) { + final mat = await cv.imdecodeAsync(data, cv.IMREAD_COLOR); + if (mat.isEmpty) { + throw Exception("Invalid image data"); + } + mats.add(mat); + } + vecMat = cv.VecMat.fromList(mats); + stitcher = cv.Stitcher.create(mode: .PANORAMA); + final (status, result) = await stitcher.stitchAsync(vecMat); + if (status != cv.StitcherStatus.OK) { + throw Exception("Stitch error code: $status"); + } + final (resultStatus, jpeg) = await cv.imencodeAsync(".jpg", result); + if (!resultStatus) { + throw Exception("Encode failed"); + } + panoImage.value = jpeg; + AppState.shared.showToast("Stitch success!"); + result.dispose(); + } catch (e) { + AppState.shared.showToast("Error: ${e.toString()}"); + } finally { + for (var mat in mats) { + mat.dispose(); + } + vecMat?.dispose(); + stitcher?.dispose(); + isLoading.value = false; + } + } + + Widget buildImageItem(BuildContext context, int index) { + final data = imageDataList[index]; + return Stack( + fit: .expand, + children: [ + ClipRRect( + borderRadius: .circular(8), + child: Image.memory(data, fit: .cover), + ), + Positioned( + top: 4, + right: 4, + child: CupertinoButton( + padding: .zero, + minimumSize: Size(26, 26), + child: Icon( + CupertinoIcons.clear_circled_solid, + color: CupertinoColors.systemRed, + size: 26, + ), + onPressed: () { + imageDataList.removeAt(index); + }, + ), + ), + ], + ); + } + + @override + Widget build(BuildContext context) { + return CupertinoPageScaffold( + backgroundColor: CupertinoColors.systemBackground.resolveFrom(context), + navigationBar: CupertinoNavigationBar.large( + largeTitle: Text("Panorama"), + backgroundColor: CupertinoColors.systemBackground.resolveFrom(context), + ), + child: Padding( + padding: .only( + top: 15, + bottom: 15 + MediaQuery.paddingOf(context).bottom, + left: 15, + right: 15, + ), + child: Column( + children: [ + Obx(() { + if (panoImage.value != null) { + return Column( + mainAxisSize: .min, + spacing: 8, + children: [ + Text("Panorama Result"), + Container( + height: 220, + decoration: BoxDecoration( + borderRadius: .circular(10), + image: DecorationImage( + image: MemoryImage(panoImage.value!), + fit: .contain, + ), + ), + ), + ], + ); + } + return SizedBox.shrink(); + }), + Expanded( + child: Obx(() { + if (imageDataList.isEmpty) { + return const Center(child: Text("No photos, take first")); + } + return GridView.builder( + itemCount: imageDataList.length, + gridDelegate: gridDelegate, + itemBuilder: buildImageItem, + ); + }), + ), + CupertinoButton.filled( + child: const SizedBox( + width: double.infinity, + child: Center(child: Text("Generate")), + ), + onPressed: () { + startTakingPhotos(); + }, + ), + ], + ), + ), + ); + } +} diff --git a/app/lib/view/home/record_dance.dart b/app/lib/view/home/record_dance.dart new file mode 100644 index 0000000..3e6423d --- /dev/null +++ b/app/lib/view/home/record_dance.dart @@ -0,0 +1,576 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +import 'dart:async'; + +import 'package:flex_color_picker/flex_color_picker.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:get/get.dart' hide FormData, MultipartFile; +import 'package:intl/intl.dart'; +import 'package:stack_chan/app_state.dart'; +import 'package:stack_chan/model/dance_list.dart'; +import 'package:stack_chan/model/expression_data.dart'; +import 'package:stack_chan/util/blue_util.dart'; +import 'package:stack_chan/util/extension.dart'; +import 'package:stack_chan/util/music_util.dart'; +import 'package:stack_chan/view/util/grid_coordinate_joystick.dart'; +import 'package:uuid/uuid.dart'; + +import '../util/stackchan_robot_box.dart'; +import 'add_music.dart'; + +class RecordDance extends StatefulWidget { + const RecordDance({super.key, this.onResult}); + + final Function(List, String, String?)? onResult; + + @override + State createState() => _RecordDanceState(); +} + +class RecordDanceModel extends GetxController { + Rxn musicInfo = Rxn(null); + RxnString musicUrl = RxnString(null); + RxString danceName = RxString(""); + + RxBool isPlaying = RxBool(false); + RxBool isRecording = RxBool(false); + RxDouble playbackProgress = RxDouble(0.0); + + Rx avatarData = Rx( + ExpressionData( + leftEye: ExpressionItem(weight: 100), + rightEye: ExpressionItem(weight: 100), + mouth: ExpressionItem(weight: 0), + ), + ); + Rx motionData = Rx( + MotionData(pitchServo: MotionDataItem(), yawServo: MotionDataItem()), + ); + + Rx leftRgbColor = RxString("#FFFFFF"); + Rx rightRgbColor = RxString("#FFFFFF"); + + RxList bandFrequency = RxList([]); +} + +class _RecordDanceState extends State { + RecordDanceModel model = RecordDanceModel(); + + Timer? recordTimer; + Timer? playbackTimer; + DateTime? recordStartTime; + final Uuid uuid = const Uuid(); + + List recordedDanceFrames = []; + + @override + void dispose() { + stopAllTimers(); + MusicUtil.shared.stopMusic(); + model.onClose(); + super.dispose(); + } + + void stopAllTimers() { + recordTimer?.cancel(); + playbackTimer?.cancel(); + recordTimer = null; + playbackTimer = null; + } + + String formatTime(int seconds) { + final minutes = seconds ~/ 60; + final remainingSeconds = seconds % 60; + return DateFormat( + 'mm:ss', + ).format(DateTime(0, 0, 0, 0, minutes, remainingSeconds)); + } + + void recordDanceFrame() { + if (!model.isRecording.value) return; + final danceFrame = DanceData( + leftEye: model.avatarData.value.leftEye.copy(), + rightEye: model.avatarData.value.rightEye.copy(), + mouth: model.avatarData.value.mouth.copy(), + yawServo: model.motionData.value.yawServo.copy(), + pitchServo: model.motionData.value.pitchServo.copy(), + leftRgbColor: model.leftRgbColor.value, + rightRgbColor: model.rightRgbColor.value, + durationMs: 100, + ); + recordedDanceFrames.add(danceFrame); + } + + Widget buildBandFrequencyChart(List frequencies, double progress) { + if (frequencies.isEmpty) { + return const SizedBox(height: 0); + } + return SizedBox( + height: 60, + width: .infinity, + child: Stack( + clipBehavior: .none, + alignment: .bottomCenter, + children: [ + Row( + crossAxisAlignment: .end, + mainAxisAlignment: .spaceEvenly, + children: frequencies.map((freq) { + final normalizedFreq = freq.clamp(0.0, 1.0); + return Expanded( + child: Padding( + padding: const .symmetric(horizontal: 1), + child: Container( + height: normalizedFreq * 250, + decoration: BoxDecoration( + color: CupertinoColors.systemBlue.withValues(alpha: 0.7), + borderRadius: .vertical(top: .circular(2)), + ), + ), + ), + ); + }).toList(), + ), + Positioned( + left: progress * MediaQuery.of(context).size.width - 1, + top: 0, + bottom: 0, + child: Container( + width: 2, + color: CupertinoColors.systemRed, + height: 60, + ), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + final theme = CupertinoTheme.of(context); + List listWidget = []; + + if (model.musicInfo.value != null) { + listWidget.add( + Column( + mainAxisSize: .min, + spacing: 8, + children: [ + Row( + children: [ + Obx( + () => Text( + model.musicInfo.value!.title ?? "Music", + style: theme.textTheme.textStyle, + ), + ), + Spacer(), + CupertinoButton( + padding: .zero, + minimumSize: .zero, + child: Row( + spacing: 4, + mainAxisSize: .min, + children: [ + Obx( + () => SvgPicture.asset( + model.isRecording.value + ? "assets/stop.circle.fill.svg" + : "assets/record.circle.fill.svg", + colorFilter: .mode( + model.isRecording.value + ? CupertinoColors.systemRed + : CupertinoColors.systemOrange, + .srcIn, + ), + width: 15, + height: 15, + ), + ), + Obx( + () => Text( + model.isRecording.value + ? "Stop Record" + : "Start Record", + style: theme.textTheme.textStyle, + ), + ), + ], + ), + onPressed: () { + if (model.isRecording.value) { + stopRecordingAndPlayback(); + } else { + startRecordingAndPlayback(); + } + }, + ), + ], + ), + Obx(() { + final musicInfo = model.musicInfo.value; + final duration = musicInfo?.duration ?? 0; + final currentSec = (model.playbackProgress.value * duration) + .toInt(); + + return Column( + mainAxisSize: .min, + spacing: 4, + children: [ + Obx( + () => buildBandFrequencyChart( + model.bandFrequency, + model.playbackProgress.value, + ), + ), + LinearProgressIndicator( + value: model.playbackProgress.value, + color: theme.primaryColor, + ), + Row( + mainAxisAlignment: .spaceBetween, + children: [ + Text( + formatTime(currentSec), + style: theme.textTheme.dateTimePickerTextStyle, + ), + Text( + formatTime(duration), + style: theme.textTheme.dateTimePickerTextStyle, + ), + ], + ), + ], + ); + }), + ], + ), + ); + } else { + listWidget.add( + CupertinoButton( + color: CupertinoColors.systemGroupedBackground.resolveFrom(context), + borderRadius: .circular(12), + padding: .zero, + minimumSize: .zero, + child: SizedBox( + height: 60, + child: Row( + crossAxisAlignment: .center, + children: [ + Spacer(), + Text("Select Music", style: TextStyle(fontSize: 30)), + Spacer(), + ], + ), + ), + onPressed: () { + showCupertinoSheet( + context: context, + builder: (context) { + return AddMusic( + onResult: (url) async { + final musicInfo = await MusicUtil.shared.getMusicInfoAsync( + url, + ); + if (musicInfo != null) { + setState(() { + model.musicUrl.value = url; + model.musicInfo.value = musicInfo; + model.danceName.value = musicInfo.title ?? ""; + }); + final progressList = await model.musicInfo.value! + .getProgressData(targetSampleCount: 100); + model.bandFrequency.value = progressList; + } + }, + ); + }, + ); + }, + ), + ); + } + + listWidget.add( + Obx( + () => StackChanRobotBox( + topLook: true, + width: double.infinity, + height: 250, + data: DanceData( + leftEye: model.avatarData.value.leftEye, + rightEye: model.avatarData.value.rightEye, + mouth: model.avatarData.value.mouth, + yawServo: model.motionData.value.yawServo, + pitchServo: model.motionData.value.pitchServo, + durationMs: 1000, + ), + ), + ), + ); + + listWidget.add( + Container( + height: 200, + width: .infinity, + decoration: BoxDecoration( + color: CupertinoColors.systemGroupedBackground.resolveFrom(context), + borderRadius: BorderRadius.circular(25), + ), + child: Obx( + () => GridCoordinateJoystick( + minX: -1280, + maxX: 1280, + minY: 0, + maxY: 900, + padding: const EdgeInsets.all(25), + showMarking: false, + targetGridSize: 50, + buttonSize: 50, + point: Offset( + model.motionData.value.yawServo.angle.toDouble(), + model.motionData.value.pitchServo.angle.toDouble(), + ), + onImmediatelyRelease: (point) { + model.motionData.value.yawServo.rotate = 0; + model.motionData.value.yawServo.angle = point.dx.toInt(); + model.motionData.value.pitchServo.angle = point.dy.toInt(); + model.motionData.refresh(); + saveMotionData(); + }, + ), + ), + ), + ); + + listWidget.add(SizedBox(height: 15)); + + listWidget.add( + Row( + children: [ + Text("Light strip left color", style: theme.textTheme.textStyle), + Spacer(), + Obx( + () => CupertinoButton( + borderRadius: .circular(50), + color: CupertinoColors.systemGroupedBackground.resolveFrom( + context, + ), + minimumSize: .zero, + padding: .all(5), + child: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: hexToColor(model.leftRgbColor.value), + borderRadius: .circular(50), + ), + ), + onPressed: () { + colorPickerDialog(true); + }, + ), + ), + ], + ), + ); + + listWidget.add(SizedBox(height: 15)); + + listWidget.add( + Row( + children: [ + Text("Light strip right color", style: theme.textTheme.textStyle), + Spacer(), + Obx( + () => CupertinoButton( + borderRadius: .circular(50), + color: CupertinoColors.systemGroupedBackground.resolveFrom( + context, + ), + minimumSize: .zero, + padding: .all(5), + child: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: hexToColor(model.rightRgbColor.value), + borderRadius: .circular(50), + ), + ), + onPressed: () { + colorPickerDialog(false); + }, + ), + ), + ], + ), + ); + + return CupertinoPageScaffold( + backgroundColor: CupertinoColors.systemBackground.resolveFrom(context), + navigationBar: CupertinoNavigationBar.large( + backgroundColor: CupertinoColors.systemBackground.resolveFrom(context), + largeTitle: Text("Record Dance"), + trailing: CupertinoButton( + sizeStyle: .medium, + onPressed: () async { + if (model.isRecording.value) { + stopRecordingAndPlayback(); + } + CupertinoSheetRoute.popSheet(context); + //directreturn + widget.onResult?.call( + recordedDanceFrames, + model.musicUrl.value ?? "", + model.danceName.value, + ); + }, + child: Icon(CupertinoIcons.check_mark), + ), + leading: CupertinoButton( + sizeStyle: .medium, + child: Icon(CupertinoIcons.xmark), + onPressed: () => CupertinoSheetRoute.popSheet(context), + ), + ), + child: ListView(padding: .all(15), children: listWidget), + ); + } + + Future colorPickerDialog(bool isLeft) async { + return ColorPicker( + // Use the dialogPickerColor as start and active color. + color: isLeft + ? hexToColor(model.leftRgbColor.value) + : hexToColor(model.rightRgbColor.value), + // Update the dialogPickerColor using the callback. + onColorChanged: (Color color) { + if (isLeft) { + model.leftRgbColor.value = colorToHex(color); + } else { + model.rightRgbColor.value = colorToHex(color); + } + }, + width: 40, + height: 40, + borderRadius: 4, + spacing: 5, + runSpacing: 5, + wheelDiameter: 155, + heading: Text( + 'Select color', + style: CupertinoTheme.of(context).textTheme.navTitleTextStyle, + ), + subheading: Text( + 'Select color shade', + style: CupertinoTheme.of(context).textTheme.navTitleTextStyle, + ), + wheelSubheading: Text( + 'Selected color and its shades', + style: CupertinoTheme.of(context).textTheme.navTitleTextStyle, + ), + showMaterialName: true, + showColorName: true, + showColorCode: true, + copyPasteBehavior: const ColorPickerCopyPasteBehavior( + longPressMenu: true, + ), + + pickersEnabled: const { + ColorPickerType.both: false, + ColorPickerType.primary: true, + ColorPickerType.accent: true, + ColorPickerType.bw: false, + ColorPickerType.custom: true, + ColorPickerType.wheel: true, + }, + ).showPickerDialog(context); + } + + String colorToHex(Color color) { + return '#${color.toARGB32().toRadixString(16).padLeft(8, '0').substring(2).toUpperCase()}'; + } + + Color hexToColor(String hexString) { + final hex = hexString.replaceAll('#', ''); + return Color(int.parse('FF$hex', radix: 16)); + } + + void stopRecordingAndPlayback() { + MusicUtil.shared.stopMusic(); + + stopAllTimers(); + + model.isRecording.value = false; + model.isPlaying.value = false; + + if (model.playbackProgress.value > 0.9) { + model.playbackProgress.value = 1.0; + } + } + + void startRecordingAndPlayback() { + final musicInfo = model.musicInfo.value; + if (musicInfo == null) return; + + recordedDanceFrames.clear(); + model.playbackProgress.value = 0.0; + model.isRecording.value = true; + recordStartTime = DateTime.now(); + recordTimer = Timer.periodic(const Duration(milliseconds: 100), (timer) { + recordDanceFrame(); + }); + MusicUtil.shared.playMusicOnce(musicInfo, () { + stopRecordingAndPlayback(); + }); + + playbackTimer = Timer.periodic(const Duration(milliseconds: 50), (timer) { + final duration = MusicUtil.shared.getMusicDuration(); + final currentPosition = MusicUtil.shared.getCurrentPosition(); + + if (duration > 0 && currentPosition >= 0) { + //calculatenormalizeprogress (0.0 to 1.0) + final progress = currentPosition / duration; + model.playbackProgress.value = progress.clamp(0.0, 1.0); + } + }); + } + + DateTime lastBluetoothSendTime = DateTime.now(); + + void saveMotionData() { + if (AppState.shared.deviceControlMode == 0) { + if (AppState.shared.deviceMac.isNotEmpty) { + final jsonString = + AppState.shared.deviceMac + model.motionData.value.toString(); + final data = jsonString.toUint8List(); + AppState.shared.sendWebSocketMessage(.controlMotion, data: data); + } + } else { + final currentTime = DateTime.now(); + final timeInterval = currentTime + .difference(lastBluetoothSendTime) + .inMilliseconds; + if (timeInterval >= 200) { + final danceData = DanceData( + leftEye: ExpressionItem(weight: 100), + rightEye: ExpressionItem(weight: 100), + mouth: ExpressionItem(weight: 0), + yawServo: model.motionData.value.yawServo, + pitchServo: model.motionData.value.pitchServo, + durationMs: 0, + ); + BlueUtil.shared.sendDanceData(danceData); + lastBluetoothSendTime = currentTime; + } + } + } +} diff --git a/app/lib/view/home/settings.dart b/app/lib/view/home/settings.dart new file mode 100644 index 0000000..e30dd5f --- /dev/null +++ b/app/lib/view/home/settings.dart @@ -0,0 +1,682 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_slidable/flutter_slidable.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:get/get.dart'; +import 'package:pull_down_button/pull_down_button.dart'; +import 'package:stack_chan/app_state.dart'; +import 'package:stack_chan/view/home/conversation_page.dart'; +import 'package:stack_chan/view/home/mcp_page.dart'; +import 'package:stack_chan/view/popup/user_info_page.dart'; + +import '../popup/xiaozhi_welcome_page.dart'; + +class Settings extends StatefulWidget { + const Settings({super.key}); + + @override + State createState() => _SettingsState(); +} + +class _SettingsState extends State { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (AppState.shared.isLogin.value) { + AppState.shared.getUserInfo(); + AppState.shared.getDeviceInfo(); + } + }); + } + + @override + void dispose() { + if (mounted) { + FocusScope.of(context).unfocus(); + } + super.dispose(); + } + + void _navigateToPage(Widget page) { + Navigator.of( + context, + rootNavigator: true, + ).push(CupertinoPageRoute(builder: (context) => page)); + } + + bool _checkDeviceBinding() { + if (AppState.shared.deviceMac.isEmpty) { + AppState.shared.showBindingDevice(context); + return false; + } + return true; + } + + //selectdevicemoreoption + Future showUnbindingPopup() async { + showCupertinoModalPopup( + context: context, + builder: (context) { + return CupertinoActionSheet( + title: Text(AppState.shared.deviceInfo.value?.name ?? "StackChan"), + cancelButton: CupertinoActionSheetAction( + onPressed: () { + Navigator.pop(context); + }, + child: Text("Cancel"), + ), + actions: [ + CupertinoActionSheetAction( + onPressed: () { + Navigator.pop(context); + showCupertinoDialog( + context: context, + builder: (context) { + return CupertinoAlertDialog( + title: const Text('Unbind Confirmation'), + content: const Text( + 'Are you sure you want to unbind this device?', + ), + actions: [ + CupertinoDialogAction( + child: const Text('Cancel'), + onPressed: () { + Navigator.pop(context); + }, + ), + CupertinoDialogAction( + isDestructiveAction: true, + onPressed: () { + Navigator.pop(context); + AppState.shared.unbindDevice( + AppState.shared.deviceMac, + ); + }, + child: const Text('Unbind'), + ), + ], + ); + }, + ); + }, + isDestructiveAction: true, + child: Text("Unbind"), + ), + ], + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + return CupertinoPageScaffold( + backgroundColor: CupertinoColors.systemGroupedBackground.resolveFrom( + context, + ), + child: CustomScrollView( + slivers: [ + CupertinoSliverNavigationBar( + largeTitle: Text("Settings"), + trailing: CupertinoButton( + padding: .zero, + child: Icon( + CupertinoIcons.person_alt_circle, + color: CupertinoTheme.of(context).primaryColor, + size: 44, + ), + onPressed: () { + if (AppState.shared.isLogin.value) { + showCupertinoSheet( + context: context, + showDragHandle: true, + builder: (context) { + return UserInfoPage(); + }, + ); + } else { + AppState.shared.showLoginPopup(context); + } + }, + ), + ), + SliverList.list( + children: [ + Padding( + padding: .all(20), + child: Row( + children: [ + Image.asset("assets/image1.png", width: 100, height: 100), + Spacer(), + Obx(() { + if (AppState.shared.devices.isEmpty) { + return CupertinoButton( + padding: .all(15), + minimumSize: .zero, + pressedOpacity: 1, + borderRadius: .circular(50), + color: CupertinoColors.secondarySystemFill + .resolveFrom(context), + onPressed: () { + AppState.shared.showBindingDevice(context); + }, + child: Row( + mainAxisSize: .min, + spacing: 5, + children: [ + Icon(CupertinoIcons.add_circled, size: 18), + ConstrainedBox( + constraints: BoxConstraints(maxWidth: 180), + child: Text( + "Add a new StackChan", + maxLines: 2, + overflow: .visible, + style: TextStyle( + fontSize: 17, + fontWeight: .bold, + ), + ), + ), + ], + ), + ); + } else { + return PullDownButton( + itemBuilder: (context) { + final list = AppState.shared.devices + .map( + (value) => PullDownMenuItem.selectable( + iconWidget: Image.asset( + "assets/image1.png", + ), + selected: + value.mac == AppState.shared.deviceMac, + onTap: () { + if (value.mac != + AppState + .shared + .deviceInfo + .value + ?.mac) { + AppState.shared.switchDevice(value); + } + }, + title: value.getDisplayName(), + ), + ) + .toList(); + + list.add( + PullDownMenuItem( + icon: CupertinoIcons.add_circled, + onTap: () => + AppState.shared.showBindingDevice(context), + title: "Add a new StackChan", + ), + ); + return list; + }, + buttonBuilder: (context, showMenu) => CupertinoButton( + padding: .all(15), + minimumSize: .zero, + onLongPress: () { + showUnbindingPopup(); + }, + pressedOpacity: 1, + borderRadius: .circular(50), + color: CupertinoColors.secondarySystemFill + .resolveFrom(context), + onPressed: showMenu, + child: Row( + mainAxisSize: .min, + spacing: 5, + children: [ + if (AppState.shared.devices.length > 1) + Icon(CupertinoIcons.chevron_down, size: 25), + ConstrainedBox( + constraints: BoxConstraints(maxWidth: 150), + child: Text( + AppState.shared.deviceInfo.value + ?.getDisplayName() ?? + "", + style: TextStyle( + fontSize: 18, + fontWeight: .bold, + ), + ), + ), + ], + ), + ), + ); + } + }), + ], + ), + ), + + //====================== userinfo ====================== + // CupertinoListSection.insetGrouped( + // children: [_buildUserProfileTile()], + // ), + // Obx( + // () => CupertinoListSection.insetGrouped( + // header: const Text("Devices"), + // children: _devices(), + // ), + // ), + + //====================== set ====================== + CupertinoListSection.insetGrouped( + children: [_buildChangeNameTile()], + ), + + //====================== AIset ====================== + CupertinoListSection.insetGrouped( + children: [ + _buildAgentConfigTile(), + _buildMcpListTile(), + _buildConversationListTile(), + ], + ), + + //====================== Systemset ====================== + CupertinoListSection.insetGrouped( + children: [_unbindResetTitle(), _buildBindDeviceTile()], + ), + ], + ), + ], + ), + ); + } + + Widget _buildUserProfileTile() { + return CupertinoListTile( + onTap: () { + if (AppState.shared.isLogin.value) { + showCupertinoSheet( + context: context, + showDragHandle: true, + builder: (context) { + return UserInfoPage(); + }, + ); + } else { + AppState.shared.showLoginPopup(context); + } + }, + padding: .symmetric(vertical: 10, horizontal: 20), + title: Obx(() { + final user = AppState.shared.userInfo.value; + final title = AppState.shared.isLogin.value + ? (user?.displayName?.isNotEmpty == true + ? user!.displayName! + : "Name") + : "Please Login"; + return Text(title, style: TextStyle(fontSize: 18, fontWeight: .bold)); + }), + leading: ClipOval( + child: Container( + color: CupertinoTheme.of(context).primaryColor, + alignment: Alignment.center, + child: const Icon( + CupertinoIcons.person_fill, + color: CupertinoColors.white, + size: 30, + ), + ), + ), + leadingSize: 50, + trailing: SvgPicture.asset( + "assets/chevron.right.svg", + width: 15, + height: 15, + colorFilter: ColorFilter.mode( + CupertinoColors.secondaryLabel.resolveFrom(context), + BlendMode.srcIn, + ), + ), + ); + } + + List _devices() { + if (AppState.shared.devices.isNotEmpty) { + return AppState.shared.devices + .map( + (value) => Slidable( + key: Key(value.mac), + endActionPane: ActionPane( + extentRatio: 0.25, + motion: const ScrollMotion(), + children: [ + SlidableAction( + onPressed: (_) { + showCupertinoDialog( + context: context, + builder: (context) { + return CupertinoAlertDialog( + title: const Text('Unbind Confirmation'), + content: const Text( + 'Are you sure you want to unbind this device?', + ), + actions: [ + CupertinoDialogAction( + child: const Text('Cancel'), + onPressed: () { + Navigator.pop(context); + }, + ), + CupertinoDialogAction( + isDestructiveAction: true, + onPressed: () { + Navigator.pop(context); + AppState.shared.unbindDevice(value.mac); + }, + child: const Text('Unbind'), + ), + ], + ); + }, + ); + }, + backgroundColor: CupertinoColors.systemOrange.resolveFrom( + context, + ), + foregroundColor: CupertinoColors.white, + icon: CupertinoIcons.link, + label: 'Unbind', + ), + ], + ), + child: CupertinoListTile( + leading: Image.asset( + "assets/image1.png", + width: 28, + height: 28, + ), + title: Text(value.getDisplayName()), + trailing: value.mac == AppState.shared.deviceMac + ? SvgPicture.asset( + "assets/checkmark.svg", + width: 15, + height: 15, + colorFilter: ColorFilter.mode( + CupertinoColors.secondaryLabel.resolveFrom(context), + BlendMode.srcIn, + ), + ) + : SizedBox.shrink(), + onTap: () { + if (AppState.shared.deviceMac != value.mac) { + AppState.shared.switchDevice(value); + } + }, + ), + ), + ) + .toList(); + } else { + return [ + CupertinoListTile( + title: Center(child: Text("No device bound")), + onTap: () { + AppState.shared.showBindingDevice(context); + }, + ), + ]; + } + } + + Widget _buildChangeNameTile() { + return CupertinoListTile( + title: const Text("Change Device Name"), + onTap: () => _showChangeNameDialog(), + leading: _buildSectionIcon( + iconPath: "assets/character.svg", + color: CupertinoColors.activeGreen, + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + spacing: 15, + children: [ + // Obx( + // () => Text( + // AppState.shared.deviceInfo.value?.name ?? "", + // style: TextStyle( + // color: CupertinoColors.secondaryLabel.resolveFrom(context), + // ), + // ), + // ), + _buildChevronIcon(), + ], + ), + ); + } + + Widget _configureWiFi() { + return CupertinoListTile( + title: const Text("Configure Wi-Fi"), + onTap: () {}, + leading: _buildSectionIcon( + iconPath: "assets/wifi.svg", + color: CupertinoColors.activeBlue, + ), + trailing: _buildChevronIcon(), + ); + } + + Widget _buildAgentConfigTile() { + return CupertinoListTile( + title: const Text("AI Agent Config"), + onTap: () { + if (_checkDeviceBinding()) { + showCupertinoSheet( + context: context, + builder: (context) { + return XiaoZhiWelcomePage(isWelCome: false); + }, + ); + // showCupertinoSheet( + // useNestedNavigation: true, + // context: context, + // builder: (context) => const AgentConfiguration(), + // ); + } + }, + leading: _buildSectionIcon( + iconPath: "assets/rectangle.badge.sparkles.fill.svg", + color: CupertinoColors.activeGreen.resolveFrom(context), + ), + trailing: _buildChevronIcon(), + ); + } + + Widget _buildMcpListTile() { + return CupertinoListTile( + title: const Text("MCP"), + onTap: () { + if (_checkDeviceBinding()) { + showCupertinoSheet( + context: context, + builder: (context) { + return const McpPage(); + }, + ); + } + }, + leading: _buildSectionIcon( + iconPath: "assets/network.badge.shield.half.filled.svg", + color: CupertinoColors.activeBlue, + ), + trailing: _buildChevronIcon(), + ); + } + + Widget _buildConversationListTile() { + return CupertinoListTile( + title: const Text("Chat History"), + onTap: () { + if (_checkDeviceBinding()) _navigateToPage(const ConversationPage()); + }, + leading: _buildSectionIcon( + iconPath: "assets/bubble.left.and.bubble.right.fill.svg", + color: CupertinoColors.activeOrange, + ), + trailing: _buildChevronIcon(), + ); + } + + Widget _unbindResetTitle() { + return PullDownButton( + itemBuilder: (context) => AppState.shared.devices + .map( + (device) => PullDownMenuItem.selectable( + selected: AppState.shared.deviceMac == device.mac, + iconWidget: SvgPicture.asset( + "assets/personalhotspot.slash.svg", + colorFilter: const ColorFilter.mode( + CupertinoColors.destructiveRed, + BlendMode.srcIn, + ), + ), + onTap: () { + showCupertinoDialog( + context: context, + builder: (context) { + return CupertinoAlertDialog( + title: const Text('Unbind Confirmation'), + content: const Text( + 'Are you sure you want to unbind this device?', + ), + actions: [ + CupertinoDialogAction( + child: const Text('Cancel'), + onPressed: () { + Navigator.pop(context); + }, + ), + CupertinoDialogAction( + isDestructiveAction: true, + onPressed: () { + Navigator.pop(context); + AppState.shared.unbindDevice(device.mac); + }, + child: const Text('Unbind'), + ), + ], + ); + }, + ); + }, + title: device.name ?? device.mac, + ), + ) + .toList(), + buttonBuilder: (context, showMenu) => CupertinoListTile( + title: const Text("Unbind & Reset"), + onTap: () { + if (_checkDeviceBinding()) showMenu(); + }, + leading: _buildSectionIcon( + iconPath: "assets/personalhotspot.slash.svg", + color: CupertinoColors.destructiveRed, + ), + trailing: _buildChevronIcon(), + ), + ); + } + + Widget _buildBindDeviceTile() { + return CupertinoListTile( + title: const Text("Add a new StackChan"), + onTap: () => AppState.shared.showBindingDevice(context), + leading: _buildSectionIcon( + iconPath: "assets/plus.app.svg", + color: CupertinoColors.systemBlue, + ), + trailing: _buildChevronIcon(), + ); + } + + Widget _buildSectionIcon({required String iconPath, required Color color}) { + return Container( + alignment: Alignment.center, + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(8), + ), + width: 28, + height: 28, + padding: const EdgeInsets.all(6), + child: SvgPicture.asset( + iconPath, + colorFilter: const ColorFilter.mode( + CupertinoColors.white, + BlendMode.srcIn, + ), + ), + ); + } + + Widget _buildChevronIcon() { + return SvgPicture.asset( + "assets/chevron.right.svg", + width: 15, + height: 15, + colorFilter: ColorFilter.mode( + CupertinoColors.secondaryLabel.resolveFrom(context), + BlendMode.srcIn, + ), + ); + } + + void _showChangeNameDialog() { + if (!_checkDeviceBinding()) return; + + String newName = AppState.shared.deviceInfo.value?.name ?? "My StackChan"; + showCupertinoDialog( + context: context, + builder: (context) => CupertinoAlertDialog( + title: const Text("Please enter device name"), + content: Padding( + padding: const EdgeInsets.only(top: 10), + child: CupertinoTextField( + controller: TextEditingController(text: newName), + maxLength: 15, + maxLines: 1, + autofocus: true, + inputFormatters: [ + //addinputlimit, + LengthLimitingTextInputFormatter(15), + ], + onChanged: (value) => newName = value, + ), + ), + actions: [ + CupertinoDialogAction( + onPressed: () => Navigator.pop(context), + child: const Text("Cancel"), + ), + CupertinoDialogAction( + isDefaultAction: true, + onPressed: () { + if (newName.trim().isNotEmpty) { + AppState.shared.deviceInfo.value?.name = newName.trim(); + AppState.shared.updateDeviceInfo(); + } + Navigator.pop(context); + }, + child: const Text("Confirm"), + ), + ], + ), + ); + } +} diff --git a/app/lib/view/home/stack_chan.dart b/app/lib/view/home/stack_chan.dart new file mode 100644 index 0000000..193a19a --- /dev/null +++ b/app/lib/view/home/stack_chan.dart @@ -0,0 +1,543 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +import 'package:flutter/cupertino.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:get/get.dart'; +import 'package:pull_down_button/pull_down_button.dart'; +import 'package:stack_chan/app_state.dart'; +import 'package:stack_chan/view/app.dart'; +import 'package:stack_chan/view/home/avatar.dart'; +import 'package:stack_chan/view/home/monitoring_camera.dart'; +import 'package:stack_chan/view/popup/motion.dart'; + +import '../../util/custom_colors.dart'; +import '../util/gradation_page_background.dart'; +import 'dance_list_page.dart'; + +class StackChan extends StatefulWidget { + const StackChan({super.key}); + + @override + State createState() => _StackChanState(); +} + +class _StackChanState extends State { + RxString deviceStatus = "".obs; + + @override + void initState() { + super.initState(); + if (AppState.shared.isLogin.value) { + AppState.shared.getDevices(); + } + } + + //selectdevicemoreoption + Future showUnbindingPopup() async { + showCupertinoModalPopup( + context: context, + builder: (context) { + return CupertinoActionSheet( + title: Text(AppState.shared.deviceInfo.value?.name ?? "StackChan"), + cancelButton: CupertinoActionSheetAction( + onPressed: () { + Navigator.pop(context); + }, + child: Text("Cancel"), + ), + actions: [ + CupertinoActionSheetAction( + onPressed: () { + Navigator.pop(context); + showCupertinoDialog( + context: context, + builder: (context) { + return CupertinoAlertDialog( + title: const Text('Unbind Confirmation'), + content: const Text( + 'Are you sure you want to unbind this device?', + ), + actions: [ + CupertinoDialogAction( + child: const Text('Cancel'), + onPressed: () { + Navigator.pop(context); + }, + ), + CupertinoDialogAction( + isDestructiveAction: true, + onPressed: () { + Navigator.pop(context); + AppState.shared.unbindDevice( + AppState.shared.deviceMac, + ); + }, + child: const Text('Unbind'), + ), + ], + ); + }, + ); + }, + isDestructiveAction: true, + child: Text("Unbind"), + ), + ], + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + double? textSize = CupertinoTheme.of( + context, + ).textTheme.navLargeTitleTextStyle.fontSize; + + return CupertinoPageScaffold( + child: GradationPageBackground( + child: CustomScrollView( + slivers: [ + CupertinoSliverNavigationBar( + brightness: MediaQuery.of(context).platformBrightness, + automaticBackgroundVisibility: false, + border: null, + largeTitle: Text("StackChan World"), + backgroundColor: CustomColors.transparent, + ), + SliverPadding( + padding: .all(20), + sliver: SliverList.list( + children: [ + Row( + children: [ + Image.asset("assets/image1.png", width: 100, height: 100), + Spacer(), + Obx(() { + if (AppState.shared.devices.isEmpty) { + return CupertinoButton( + padding: .all(15), + minimumSize: .zero, + pressedOpacity: 1, + borderRadius: .circular(50), + color: CupertinoColors.secondarySystemFill + .resolveFrom(context), + onPressed: () { + AppState.shared.showBindingDevice(context); + }, + child: Row( + mainAxisSize: .min, + spacing: 5, + children: [ + Icon(CupertinoIcons.add_circled, size: 18), + ConstrainedBox( + constraints: BoxConstraints(maxWidth: 180), + child: Text( + "Add a new StackChan", + maxLines: 2, + overflow: .visible, + style: TextStyle( + fontSize: 17, + fontWeight: .bold, + ), + ), + ), + ], + ), + ); + } else { + return PullDownButton( + itemBuilder: (context) { + final list = AppState.shared.devices + .map( + (value) => PullDownMenuItem.selectable( + iconWidget: Image.asset( + "assets/image1.png", + ), + selected: + value.mac == + AppState.shared.deviceMac, + onTap: () { + if (value.mac != + AppState + .shared + .deviceInfo + .value + ?.mac) { + AppState.shared.switchDevice(value); + } + }, + title: value.getDisplayName(), + ), + ) + .toList(); + + list.add( + PullDownMenuItem( + icon: CupertinoIcons.add_circled, + onTap: () => AppState.shared + .showBindingDevice(context), + title: "Add a new StackChan", + ), + ); + return list; + }, + buttonBuilder: (context, showMenu) => + CupertinoButton( + padding: .all(15), + minimumSize: .zero, + onLongPress: () { + showUnbindingPopup(); + }, + pressedOpacity: 1, + borderRadius: .circular(50), + color: CupertinoColors.secondarySystemFill + .resolveFrom(context), + onPressed: showMenu, + child: Row( + mainAxisSize: .min, + spacing: 5, + children: [ + if (AppState.shared.devices.length > 1) + Icon( + CupertinoIcons.chevron_down, + size: 25, + ), + ConstrainedBox( + constraints: BoxConstraints( + maxWidth: 150, + ), + child: Text( + AppState.shared.deviceInfo.value + ?.getDisplayName() ?? + "", + style: TextStyle( + fontSize: 18, + fontWeight: .bold, + ), + ), + ), + ], + ), + ), + ); + } + }), + ], + ), + SizedBox(height: 20), + CupertinoButton( + padding: .zero, + child: ClipRSuperellipse( + clipBehavior: .antiAliasWithSaveLayer, + borderRadius: .circular(35), + child: Container( + color: CustomColors.ff659c, + padding: .only( + left: 20, + right: 20, + top: 20, + bottom: 20, + ), + child: Row( + children: [ + SizedBox( + width: 44, + height: 44, + child: Image.asset( + "assets/avatar_icon.png", + width: 44, + height: 44, + ), + ), + Spacer(), + Text( + "AVATAR", + textScaler: .noScaling, + style: TextStyle( + fontSize: textSize, + fontWeight: .bold, + color: CupertinoColors.systemBackground + .resolveFrom(context), + ), + ), + ], + ), + ), + ), + onPressed: () { + if (AppState.shared.deviceMac.isEmpty) { + //showbindpopup + AppState.shared.showBindingDevice(context); + } else { + Navigator.of(context, rootNavigator: true).push( + CupertinoPageRoute( + builder: (context) { + return Avatar( + deviceMac: AppState.shared.deviceMac, + ); + }, + ), + ); + } + }, + ), + SizedBox(height: 20), + CupertinoButton( + padding: .zero, + child: ClipRSuperellipse( + clipBehavior: .antiAliasWithSaveLayer, + borderRadius: .circular(35), + child: Container( + color: CupertinoColors.label + .resolveFrom(context) + .withValues(alpha: 0.8), + padding: .only( + left: 20, + right: 20, + top: 20, + bottom: 20, + ), + child: Row( + children: [ + SizedBox( + width: 44, + height: 44, + child: SvgPicture.asset( + "assets/video.svg", + colorFilter: .mode( + CupertinoColors.systemBackground.resolveFrom( + context, + ), + .srcIn, + ), + width: 44, + height: 44, + ), + ), + Spacer(), + Text( + "MONITORING\nCAMERA", + textAlign: .end, + textScaler: .noScaling, + style: TextStyle( + fontSize: textSize, + fontWeight: .bold, + color: CupertinoColors.systemBackground + .resolveFrom(context), + ), + ), + ], + ), + ), + ), + onPressed: () { + if (AppState.shared.deviceMac.isEmpty) { + //showbindpopup + AppState.shared.showBindingDevice(context); + } else { + Navigator.of(context, rootNavigator: true).push( + CupertinoPageRoute( + builder: (context) { + return MonitoringCamera(); + }, + ), + ); + } + }, + ), + SizedBox(height: 20), + CupertinoButton( + padding: .zero, + child: ClipRSuperellipse( + borderRadius: .circular(35), + clipBehavior: .antiAliasWithSaveLayer, + child: Container( + color: CupertinoColors.inactiveGray + .resolveFrom(context) + .withValues(alpha: 0.5), + padding: .only( + left: 20, + right: 20, + top: 20, + bottom: 20, + ), + child: Row( + children: [ + SizedBox( + width: 44, + height: 44, + child: SvgPicture.asset( + "assets/arrow.up.and.down.and.arrow.left.and.right.svg", + colorFilter: .mode( + CupertinoColors.label.resolveFrom(context), + .srcIn, + ), + width: 44, + height: 44, + ), + ), + Spacer(), + Text( + "MOTION", + textScaler: .noScaling, + style: TextStyle( + fontSize: textSize, + fontWeight: .bold, + color: CupertinoColors.label.resolveFrom( + context, + ), + ), + ), + ], + ), + ), + ), + onPressed: () { + if (AppState.shared.deviceMac.isEmpty) { + //showbindpopup + AppState.shared.showBindingDevice(context); + } else { + App.showAppSheet(showDragHandle: true, (context) { + return Motion(); + }); + } + }, + ), + SizedBox(height: 20), + CupertinoButton( + padding: .zero, + child: ClipRSuperellipse( + borderRadius: .circular(35), + clipBehavior: .antiAliasWithSaveLayer, + child: Container( + color: CupertinoColors.activeOrange.resolveFrom( + context, + ), + padding: .only( + left: 20, + right: 20, + top: 20, + bottom: 20, + ), + child: Row( + children: [ + SizedBox( + width: 44, + height: 44, + child: SvgPicture.asset( + "assets/figure.dance.svg", + colorFilter: .mode( + CupertinoColors.systemBackground.resolveFrom( + context, + ), + .srcIn, + ), + width: 44, + height: 44, + ), + ), + Spacer(), + Text( + "DANCE", + textScaler: .noScaling, + style: TextStyle( + fontSize: textSize, + fontWeight: .bold, + color: CupertinoColors.systemBackground + .resolveFrom(context), + ), + ), + ], + ), + ), + ), + onPressed: () { + if (AppState.shared.deviceMac.isEmpty) { + //showbindpopup + AppState.shared.showBindingDevice(context); + } else { + Navigator.of(context, rootNavigator: true).push( + CupertinoPageRoute( + builder: (context) { + return DanceListPage(); + }, + ), + ); + } + }, + ), + SizedBox(height: 20), + // CupertinoButton( + // padding: .zero, + // child: ClipRSuperellipse( + // borderRadius: .circular(35), + // clipBehavior: .antiAliasWithSaveLayer, + // child: Container( + // color: CupertinoColors.systemBlue.resolveFrom(context), + // padding: .only( + // left: 20, + // right: 20, + // top: 20, + // bottom: 20, + // ), + // child: Row( + // children: [ + // SizedBox( + // width: 44, + // height: 44, + // child: SvgPicture.asset( + // "assets/pano.svg", + // colorFilter: .mode( + // CupertinoColors.systemBackground.resolveFrom( + // context, + // ), + // .srcIn, + // ), + // width: 44, + // height: 44, + // ), + // ), + // Spacer(), + // Text( + // "PANO", + // textScaler: .noScaling, + // style: TextStyle( + // fontSize: textSize, + // fontWeight: .bold, + // color: CupertinoColors.systemBackground + // .resolveFrom(context), + // ), + // ), + // ], + // ), + // ), + // ), + // onPressed: () { + // // if (AppState.shared.deviceMac.isEmpty) { + //// // showbindpopup + // // AppState.shared.showBindingDevice(context); + // // } else { + // // Navigator.of(context, rootNavigator: true).push( + // // CupertinoPageRoute( + // // builder: (context) { + // // return PanoPage(); + // // }, + // // ), + // // ); + // // } + // }, + // ), + SizedBox(height: 20 + MediaQuery.of(context).padding.bottom), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/app/lib/view/home/web_page.dart b/app/lib/view/home/web_page.dart new file mode 100644 index 0000000..c0d89d1 --- /dev/null +++ b/app/lib/view/home/web_page.dart @@ -0,0 +1,60 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +import 'package:flutter/cupertino.dart'; +import 'package:get/get.dart'; +import 'package:webview_flutter/webview_flutter.dart'; + +class WebPage extends StatefulWidget { + const WebPage({super.key, required this.url, this.previousPageTitle}); + + final String url; + final String? previousPageTitle; + + @override + State createState() => _WebPageState(); +} + +class _WebPageState extends State { + late WebViewController controller; + RxString title = "".obs; + + @override + void initState() { + super.initState(); + controller = WebViewController() + ..setJavaScriptMode(JavaScriptMode.unrestricted) + ..setNavigationDelegate( + NavigationDelegate( + onHttpAuthRequest: (HttpAuthRequest request) {}, + onPageFinished: (String url) async { + title.value = (await controller.getTitle()) ?? ""; + }, + ), + ) + ..loadRequest(Uri.parse(widget.url)); + } + + @override + Widget build(BuildContext context) { + return CupertinoPageScaffold( + navigationBar: CupertinoNavigationBar( + padding: .only(start: 8, end: 8), + middle: Obx(() => Text(title.value, maxLines: 1, overflow: .ellipsis)), + leading: CupertinoNavigationBarBackButton( + previousPageTitle: widget.previousPageTitle, + ), + trailing: CupertinoButton( + padding: .zero, + child: Icon(CupertinoIcons.refresh), + onPressed: () { + controller.reload(); + }, + ), + ), + child: WebViewWidget(controller: controller), + ); + } +} diff --git a/app/lib/view/popup/agent_configuration.dart b/app/lib/view/popup/agent_configuration.dart new file mode 100644 index 0000000..0629681 --- /dev/null +++ b/app/lib/view/popup/agent_configuration.dart @@ -0,0 +1,507 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +import 'dart:async'; + +import 'package:flutter/cupertino.dart'; +import 'package:get/get.dart'; +import 'package:stack_chan/app_state.dart'; +import 'package:stack_chan/view/popup/edit_agent.dart'; + +import '../../model/XiaoZhi/agent.dart'; +import '../../model/XiaoZhi/agent_template.dart'; +import '../../model/XiaoZhi/device.dart'; +import '../../util/XiaoZhi_util.dart'; + +//System1UIConstant - Can +const double kCardRadius = 16.0; +const double kDefaultPadding = 16.0; +const double kDefaultSpacing = 16.0; +const Color kPrimaryTintColor = CupertinoColors.systemBlue; +const double kElevation = 2.0; + +class AgentConfiguration extends StatefulWidget { + const AgentConfiguration({super.key}); + + @override + State createState() => _AgentConfigurationState(); +} + +class AgentConfigurationModel extends GetxController { + Rxn device = Rxn(null); + RxList agentTemplatesList = RxList([]); + Rxn currentBindAgent = Rxn(null); + + //search + int agentListPage = 1; + RxBool isLoading = false.obs; + RxBool isDialogLoading = false.obs; + RxBool isListLoading = false.obs; + bool hasMoreList = true; + + Future loadAllData() async { + isLoading.value = true; + try { + await Future.wait([loadDevice(), loadAgentTemplates()]); + } catch (e) { + } finally { + isLoading.value = false; + } + } + + //loaddeviceinfo + Future loadDevice() async { + final devices = await XiaoZhiUtil.shared.getDevice( + AppState.shared.deviceMac, + ); + if (devices.isNotEmpty) { + device.value = devices.first; + if (device.value?.agent_id != null) { + await getBindAgent(device.value!.agent_id!); + } else { + currentBindAgent.value = null; + } + } else { + device.value = null; + currentBindAgent.value = null; + } + } + + //Getdeviceboundagentdetails + Future getBindAgent(int agentId) async { + final agent = await XiaoZhiUtil.shared.getAgentDetail(agentId); + currentBindAgent.value = agent; + } + + //switchbindagent + Future switchBindAgent( + Agent targetAgent, + String verificationCode, + ) async { + if (device.value == null) { + AppState.shared.showToast("No device detected, cannot bind AI Agent"); + return false; + } + if (targetAgent.id == null) { + AppState.shared.showToast("Invalid AI Agent ID, cannot bind"); + return false; + } + if (verificationCode.isEmpty) { + AppState.shared.showToast("Please enter device verification code"); + return false; + } + if (currentBindAgent.value?.id == targetAgent.id) { + AppState.shared.showToast("Device is already bound to this AI Agent"); + return true; + } + + isLoading.value = true; + try { + //First / Previouslyunbindcurrentdevice + if (device.value?.device_id != null) { + await XiaoZhiUtil.shared.unbindDevice(device.value!.device_id!); + } + //bindtonewagent + final bindSuccess = await XiaoZhiUtil.shared.bindDeviceToAgent( + targetAgent.id!, + verificationCode, + ); + if (!bindSuccess) { + throw Exception("Failed to bind device to AI Agent"); + } + //refreshdata + await loadDevice(); + AppState.shared.showToast("Successfully switched AI Agent"); + return true; + } catch (e) { + AppState.shared.showToast(e.toString().replaceAll("Exception: ", "")); + return false; + } finally { + isLoading.value = false; + } + } + + //loadagent + Future loadAgentTemplates() async { + final templates = await XiaoZhiUtil.shared.agentTemplatesList( + agentListPage, + 20, + ); + agentTemplatesList.assignAll(templates); + } + + //ReplaceDescriptionin + String replacePlaceholdersInCharacter(Agent agent) { + if (agent.character == null || agent.character!.isEmpty) { + return ""; + } + String characterText = agent.character!; + if (agent.assistant_name != null && agent.assistant_name!.isNotEmpty) { + characterText = characterText.replaceAll( + "{{assistant_name}}", + agent.assistant_name!, + ); + } + if (agent.user_name != null && agent.user_name!.isNotEmpty) { + characterText = characterText.replaceAll( + "{{user_name}}", + agent.user_name!, + ); + } + return characterText; + } +} + +class _AgentConfigurationState extends State { + late AgentConfigurationModel model = Get.put(AgentConfigurationModel()); + + final ScrollController scrollController = ScrollController(); + + @override + void dispose() { + scrollController.dispose(); + Get.delete(); + super.dispose(); + } + + @override + void initState() { + super.initState(); + model.loadAllData(); + scrollController.addListener(() { + if (scrollController.position.pixels >= + scrollController.position.maxScrollExtent - 200) { + if (!model.isListLoading.value && model.hasMoreList) { + model.loadAgentTemplates(); + } + } + }); + } + + ///showCreate agentpopup + void goToEditAgentPage({bool isCreate = false, Agent? editAgent}) { + Navigator.of(context) + .push( + CupertinoPageRoute( + builder: (context) { + return EditAgent(agent: editAgent); + }, + ), + ) + .then((_) { + model.loadAllData(); + }); + } + + @override + Widget build(BuildContext context) { + //GetCupertinodynamicTheme + final CupertinoThemeData cupertinoTheme = CupertinoTheme.of(context); + final Color surfaceColor = CupertinoColors.systemGroupedBackground + .resolveFrom(context); + final Color secondaryTextColor = CupertinoColors.secondaryLabel.resolveFrom( + context, + ); + final Color primaryColor = cupertinoTheme.primaryColor; + + return CupertinoPageScaffold( + backgroundColor: surfaceColor, + navigationBar: CupertinoNavigationBar.large( + largeTitle: Obx( + () => Text( + model.currentBindAgent.value?.agent_name ?? "AI Agent", + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + leading: CupertinoButton( + padding: EdgeInsets.zero, + child: const Icon(CupertinoIcons.xmark, size: 24), + onPressed: () => CupertinoSheetRoute.popSheet(context), + ), + backgroundColor: surfaceColor, + trailing: CupertinoButton( + padding: EdgeInsets.zero, + onPressed: () => goToEditAgentPage( + isCreate: false, + editAgent: model.currentBindAgent.value, + ), + child: Icon( + CupertinoIcons.pencil_circle, + size: 26, + color: primaryColor, + ), + ), + border: const Border(bottom: BorderSide.none), //remove + ), + child: Obx(() { + //Initialloadstate + if (model.isLoading.value) { + return const Center( + child: CupertinoActivityIndicator(radius: 16), //increaseloadindicator + ); + } + //Nodevicestate + if (model.device.value == null) { + return _buildNoDeviceWidget(secondaryTextColor, primaryColor); + } + //MainLayout + return ListView( + controller: scrollController, + padding: const EdgeInsets.symmetric( + horizontal: kDefaultPadding, + vertical: 24, + ), + children: [ + //currentbindagent + _buildCurrentBindCard( + cupertinoTheme, + primaryColor, + secondaryTextColor, + ), + const SizedBox(height: 32), + //increase + //listtitle + // const Text( + // "AI Agent Templates", + // style: TextStyle( + // fontSize: 20, + // fontWeight: FontWeight.w600, + //letterSpacing: -0.5, // Zoom out + // ), + // ), + // const SizedBox(height: 16), + //// Agent templatelist + // _buildAgentTemplatesList(cupertinoTheme, primaryColor), + //// loadmoreindicator + // if (model.isListLoading.value) + // const Padding( + // padding: EdgeInsets.symmetric(vertical: 24), + // child: Center(child: CupertinoActivityIndicator(radius: 14)), + // ), + const SizedBox(height: 40), + ], + ); + }), + ); + } + + //currentbindagent - optimize + Widget _buildCurrentBindCard( + CupertinoThemeData theme, + Color primaryColor, + Color secondaryTextColor, + ) { + final cardRadius = 40.0; + + final currentAgent = model.currentBindAgent.value; + if (currentAgent == null) { + return ClipRSuperellipse( + borderRadius: .circular(cardRadius), + child: Container( + padding: .all(cardRadius / 2), + color: CupertinoColors.systemBackground.resolveFrom(context), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + CupertinoIcons.person_badge_plus, //useicon + size: 56, + color: secondaryTextColor.withValues(alpha: 0.7), + ), + const SizedBox(height: 16), + const Text( + "No Agent Bound", + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.w600, + letterSpacing: -0.3, + ), + ), + const SizedBox(height: 12), + const Text( + "Select an Agent template below to bind, or create a custom AI Agent by tapping the edit button in the upper right corner.", + style: TextStyle( + fontSize: 15, + height: 1.5, //translated comment + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + } + + String processedCharacter = model.replacePlaceholdersInCharacter( + currentAgent, + ); + + return ClipRSuperellipse( + borderRadius: .circular(cardRadius), + child: Container( + padding: .all(cardRadius / 2), + color: CupertinoColors.systemBackground.resolveFrom(context), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: .min, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + "Current AI Agent", + style: TextStyle( + fontSize: 17, + fontWeight: FontWeight.w600, + letterSpacing: -0.3, + ), + ), + Text( + "ID: ${currentAgent.id}", + style: TextStyle( + fontSize: 12, + color: secondaryTextColor.withValues(alpha: 0.8), + ), + ), + ], + ), + const SizedBox(height: 16), + _buildInfoItem( + "AI Model", + currentAgent.llm_model ?? "Not configured", + secondaryTextColor, + ), + _buildInfoItem( + "Voice Tone", + currentAgent.tts_voice ?? "Not configured", + secondaryTextColor, + ), + _buildInfoItem( + "Speech Speed", + currentAgent.tts_speech_speed ?? "Normal", + secondaryTextColor, + ), + _buildInfoItem( + "Pitch", + currentAgent.tts_pitch?.toString() ?? "0", + secondaryTextColor, + ), + if (processedCharacter.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 16, bottom: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + "Character Profile", + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + letterSpacing: -0.2, + ), + ), + const SizedBox(height: 8), + ClipRSuperellipse( + borderRadius: .circular(cardRadius / 2), + child: Container( + padding: .all(cardRadius / 4), + color: CupertinoColors.systemGroupedBackground + .resolveFrom(context), + child: Text( + processedCharacter, + style: TextStyle( + fontSize: 14, + color: secondaryTextColor, + height: 1.6, + ), + ), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } + + //Nodevicehintcomponent - optimize + Widget _buildNoDeviceWidget(Color secondaryTextColor, Color primaryColor) { + return Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + CupertinoIcons.nosign, + size: 72, + color: secondaryTextColor.withValues(alpha: 0.6), + ), + const SizedBox(height: 24), + const Text( + "No Device Detected", + style: TextStyle( + fontSize: 22, + fontWeight: FontWeight.w600, + letterSpacing: -0.5, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + Text( + "We couldn't find any connected devices. Please check your device connection and try again.", + style: TextStyle( + fontSize: 15, + color: secondaryTextColor, + height: 1.5, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 32), + CupertinoButton.filled( + borderRadius: BorderRadius.circular(12), + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14), + onPressed: model.loadDevice, + child: const Text( + "Retry Detection", + style: TextStyle(fontSize: 15, fontWeight: FontWeight.w500), + ), + ), + ], + ), + ), + ); + } + + //infoItemcomponent - optimize + Widget _buildInfoItem(String title, String value, Color secondaryTextColor) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + title, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w500, + letterSpacing: -0.2, + ), + ), + Text( + value, + style: TextStyle( + fontSize: 15, + color: secondaryTextColor, + letterSpacing: -0.1, + ), + ), + ], + ), + ); + } +} diff --git a/app/lib/view/popup/binding_device.dart b/app/lib/view/popup/binding_device.dart new file mode 100644 index 0000000..f9f84c5 --- /dev/null +++ b/app/lib/view/popup/binding_device.dart @@ -0,0 +1,268 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +import 'dart:convert'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter_svg/svg.dart'; +import 'package:stack_chan/app_state.dart'; +import 'package:stack_chan/util/blue_util.dart'; +import 'package:stack_chan/util/value_constant.dart'; +import 'package:stack_chan/view/popup/select_blue_device.dart'; +import 'package:stack_chan/view/util/loop_playback_video.dart'; +import 'package:stack_chan/view/util/scan_view.dart'; + +class BindingDevice extends StatefulWidget { + const BindingDevice({super.key}); + + @override + State createState() => _BindingDeviceState(); +} + +class _BindingDeviceState extends State { + @override + void initState() { + super.initState(); + openBlue(); + } + + @override + void dispose() { + BlueUtil.shared.blufDevicesMonitoring = null; + super.dispose(); + } + + void openBlue() { + BlueUtil.shared.blufDevicesMonitoring = (devices) { + final pairingModeDevices = AppState.shared.screeningDevices(devices); + AppState.shared.blueDeviceList.value = pairingModeDevices; + if (AppState.shared.blueDeviceList.isNotEmpty) { + if (AppState.shared.manualShutdownTime != null) { + final timeInterval = DateTime.now() + .difference(AppState.shared.manualShutdownTime!) + .inSeconds; + if (timeInterval < 5) { + return; + } + } + if (AppState.shared.showBlueDevicesSetStep) { + return; + } + AppState.shared.showBlueDevicesSetStep = true; + Navigator.pushAndRemoveUntil( + context, + CupertinoPageRoute( + builder: (context) { + return SelectBlueDevice(); + }, + ), + (route) => false, + ); + } + }; + } + + @override + Widget build(BuildContext context) { + return CupertinoPageScaffold( + backgroundColor: CupertinoColors.systemGroupedBackground.resolveFrom( + context, + ), + child: CustomScrollView( + slivers: [ + CupertinoSliverNavigationBar( + largeTitle: Text("Add a new StackChan"), + trailing: CupertinoButton( + padding: EdgeInsets.zero, + child: Icon( + CupertinoIcons.xmark_circle_fill, + size: 25, + color: CupertinoColors.separator.resolveFrom(context), + ), + onPressed: () { + CupertinoSheetRoute.popSheet(context); + }, + ), + ), + + SliverPadding( + padding: .all(15), + sliver: SliverList.list( + children: [ + Image.asset( + "assets/lateral_image.png", + width: 150, + height: 150, + ), + SizedBox(height: 20), + Row( + spacing: 15, + children: [ + SvgPicture.asset( + "assets/1.circle.fill.svg", + width: 15, + height: 15, + colorFilter: ColorFilter.mode( + CupertinoTheme.of(context).primaryColor, + BlendMode.srcIn, + ), + ), + Expanded( + child: Text("Put the new StackChan into binding mode"), + ), + ], + ), + SizedBox(height: 20), + Row( + spacing: 15, + children: [ + SvgPicture.asset( + "assets/2.circle.fill.svg", + width: 15, + height: 15, + colorFilter: ColorFilter.mode( + CupertinoTheme.of(context).primaryColor, + BlendMode.srcIn, + ), + ), + Expanded( + child: Column( + mainAxisSize: .min, + spacing: 5, + crossAxisAlignment: .start, + children: [ + Text( + "If the welcome screen is displayed, tap \"Next\"", + ), + SizedBox( + width: 200, + child: Center( + child: ClipRSuperellipse( + borderRadius: .circular(20), + child: LoopPlaybackVideo( + url: "assets/setup2.mov", + ), + ), + ), + ), + ], + ), + ), + ], + ), + SizedBox(height: 20), + Row( + spacing: 15, + children: [ + SvgPicture.asset( + "assets/3.circle.fill.svg", + width: 15, + height: 15, + colorFilter: ColorFilter.mode( + CupertinoTheme.of(context).primaryColor, + BlendMode.srcIn, + ), + ), + Expanded( + child: Column( + mainAxisSize: .min, + spacing: 5, + crossAxisAlignment: .start, + children: [ + Text( + "Otherwise, go to \"SETUP\" and tap \"Change Wi-Fi\"", + ), + SizedBox( + width: 200, + child: Center( + child: ClipRSuperellipse( + borderRadius: .circular(20), + child: LoopPlaybackVideo( + url: "assets/setup1.mov", + ), + ), + ), + ), + ], + ), + ), + ], + ), + SizedBox(height: 20), + ], + ), + ), + ], + ), + ); + } +} + +class ScanningEquipment extends StatefulWidget { + const ScanningEquipment({super.key}); + + @override + State createState() => _ScanningEquipmentState(); +} + +class _ScanningEquipmentState extends State { + @override + Widget build(BuildContext context) { + return CupertinoPageScaffold( + backgroundColor: CupertinoColors.systemBackground.resolveFrom(context), + navigationBar: CupertinoNavigationBar.large( + backgroundColor: CupertinoColors.systemBackground.resolveFrom(context), + padding: .zero, + leading: CupertinoNavigationBarBackButton( + color: CupertinoTheme.of(context).primaryColor, + previousPageTitle: "Back", + ), + largeTitle: Text("Scan QR Code"), + ), + child: Padding( + padding: .only( + top: 15 + MediaQuery.viewPaddingOf(context).top, + bottom: 15 + MediaQuery.viewPaddingOf(context).bottom, + left: 15, + right: 15, + ), + child: ClipRSuperellipse( + clipBehavior: .antiAliasWithSaveLayer, + borderRadius: .circular(50), + child: Container( + color: CupertinoColors.black, + width: .infinity, + height: .infinity, + child: ScanView( + onDetect: (result) { + if (result.barcodes.first.rawValue != null) { + readCodeString(result.barcodes.first.rawValue!); + } + }, + ), + ), + ), + ), + ); + } + + void readCodeString(String value) { + try { + final dynamic jsonData = jsonDecode(value); + if (jsonData is Map && + jsonData.containsKey(ValueConstant.mac) && + jsonData[ValueConstant.mac] is String) { + String mac = jsonData[ValueConstant.mac] as String; + final RegExp macRegex = RegExp(r'[^A-F0-9]', caseSensitive: false); + String cleanedMac = mac.toUpperCase().replaceAll(macRegex, ''); + AppState.shared.deviceMac = cleanedMac; + AppState.shared.connectWebSocket(); + CupertinoSheetRoute.popSheet(context); + } + } on FormatException catch (e) { + } on Exception catch (e) { + } + } +} diff --git a/app/lib/view/popup/conversation_message_page.dart b/app/lib/view/popup/conversation_message_page.dart new file mode 100644 index 0000000..8b1e64f --- /dev/null +++ b/app/lib/view/popup/conversation_message_page.dart @@ -0,0 +1,268 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +import 'package:flutter/cupertino.dart'; +import 'package:get/get.dart'; +import 'package:intl/intl.dart'; +import 'package:stack_chan/model/XiaoZhi/conversation_message_data.dart'; +import 'package:stack_chan/util/XiaoZhi_util.dart'; + +class ConversationMessagePage extends StatefulWidget { + const ConversationMessagePage({super.key, required this.chatId}); + + final int chatId; + + @override + State createState() => _ConversationMessagePageState(); +} + +class _ConversationMessagePageState extends State { + RxInt page = RxInt(1); + int pageSize = 30; + + RxBool isLoading = RxBool(false); //loadstate + RxBool hasMore = RxBool(true); //whetherhasmoredata + + final DateFormat timeFormat = DateFormat("yyyy-MM-dd HH:mm"); //time + + RxList messageList = RxList([]); + + final ScrollController scrollController = ScrollController(); + + @override + void initState() { + super.initState(); + scrollController.addListener(onScroll); + init(); + } + + void init() async { + await getMessages(isLoadMore: true); + WidgetsBinding.instance.addPostFrameCallback((_) { + scrollToBottom(); + }); + } + + @override + void dispose() { + scrollController.dispose(); + super.dispose(); + } + + void scrollToBottom() { + if (scrollController.hasClients) { + scrollController.jumpTo(scrollController.position.maxScrollExtent); + } + } + + void onScroll() { + if (scrollController.offset <= + scrollController.position.minScrollExtent + 50 && + !isLoading.value && + hasMore.value) { + getMessages(isLoadMore: true); + } + } + + Future getMessages({bool isLoadMore = false}) async { + if (isLoadMore && !hasMore.value) return; + isLoading.value = true; + final Map map = { + "page": page.value, + "pageSize": pageSize, + "chatId": widget.chatId, + }; + final list = await XiaoZhiUtil.shared.getChatsMessages(map); + final newData = list.reversed.toList(); + if (isLoadMore) { + messageList.insertAll(0, newData); + if (newData.length < pageSize) { + hasMore.value = false; + } + } else { + messageList.value = newData; + hasMore.value = true; + } + } + + Future onRefresh() async { + page.value++; + await getMessages(isLoadMore: true); + } + + String formatMessageTime(String? timeStr) { + if (timeStr == null || timeStr.isEmpty) return 'Unknown time'; + try { + DateTime dateTime = DateTime.parse(timeStr); + return timeFormat.format(dateTime); + } catch (e) { + return timeStr; + } + } + + Widget buildMessageItem(ConversationMessageData message) { + bool isUserMessage = message.role == "user"; + + Widget messageContent = Container( + padding: .symmetric(horizontal: 12, vertical: 8), + margin: .symmetric(horizontal: 16, vertical: 4), + decoration: BoxDecoration( + color: isUserMessage + ? CupertinoTheme.of(context).primaryColor + : CupertinoColors.secondarySystemBackground.resolveFrom(context), + borderRadius: .circular(15), + ), + child: Column( + crossAxisAlignment: isUserMessage ? .start : .end, + mainAxisSize: .min, + children: [ + Text( + message.content ?? "Empty message", + style: TextStyle( + fontSize: 15, + color: isUserMessage + ? CupertinoColors.white + : CupertinoColors.label.resolveFrom(context), + ), + ), + const SizedBox(height: 4), + Text( + formatMessageTime(message.created_at), + style: TextStyle( + fontSize: 10, + color: isUserMessage + ? CupertinoColors.white.withValues(alpha: 0.5) + : CupertinoColors.secondaryLabel + .resolveFrom(context) + .withValues(alpha: 0.5), + ), + ), + ], + ), + ); + + return Align( + alignment: isUserMessage ? .centerRight : .centerLeft, + child: Padding( + padding: .only( + left: isUserMessage ? 40 : 0, + right: isUserMessage ? 0 : 40, + ), + child: messageContent, + ), + ); + } + + Widget buildLoadMoreWidget() { + return Obx( + () => isLoading.value + ? const Padding( + padding: EdgeInsets.symmetric(vertical: 12), + child: Center(child: CupertinoActivityIndicator()), + ) + : !hasMore.value + ? Padding( + padding: EdgeInsets.symmetric(vertical: 12), //fix:EdgeInsets + child: Center( + child: Text( + "No more messages", + style: TextStyle( + color: CupertinoColors.secondaryLabel, + fontSize: 14, + ), + ), + ), + ) + : const SizedBox.shrink(), //hasmoredatawhenshowcomponent(autoload) + ); + } + + @override + Widget build(BuildContext context) { + return ClipRSuperellipse( + borderRadius: .circular(12), + clipBehavior: .antiAliasWithSaveLayer, + child: CupertinoPageScaffold( + navigationBar: CupertinoNavigationBar( + middle: Text("Chat Messages"), + trailing: CupertinoButton( + padding: .zero, + child: Icon( + CupertinoIcons.xmark_circle_fill, + size: 25, + color: CupertinoColors.separator.resolveFrom(context), + ), + onPressed: () { + CupertinoSheetRoute.popSheet(context); + }, + ), + ), + child: Obx( + () => CustomScrollView( + controller: scrollController, + reverse: false, + slivers: [ + CupertinoSliverRefreshControl(onRefresh: onRefresh), + SliverToBoxAdapter(child: buildLoadMoreWidget()), + if (messageList.isEmpty && !isLoading.value) + //Nulldata + SliverToBoxAdapter( + child: SizedBox( + height: MediaQuery.of(context).size.height - 200, + child: Center( + child: Column( + mainAxisAlignment: .center, + children: [ + Icon( + CupertinoIcons.chat_bubble, + size: 64, + color: CupertinoColors.secondaryLabel, + ), + const SizedBox(height: 16), + Text( + "No messages in this conversation", + style: TextStyle( + fontSize: 18, + color: CupertinoColors.secondaryLabel.resolveFrom( + context, + ), + ), + ), + ], + ), + ), + ), + ) + else if (messageList.isEmpty && isLoading.value) + SliverToBoxAdapter( + child: SizedBox( + height: MediaQuery.of(context).size.height - 200, + child: const Center( + child: CupertinoActivityIndicator(radius: 20), + ), + ), + ) + else + SliverPadding( + padding: .only( + top: MediaQuery.viewPaddingOf(context).top, + bottom: MediaQuery.viewPaddingOf(context).bottom + 200, + ), + sliver: SliverList.separated( + itemCount: messageList.length, + itemBuilder: (context, index) { + return buildMessageItem(messageList[index]); + }, + separatorBuilder: (context, index) => + const SizedBox(height: 8), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/app/lib/view/popup/cupertino_popup_windows.dart b/app/lib/view/popup/cupertino_popup_windows.dart new file mode 100644 index 0000000..05101f1 --- /dev/null +++ b/app/lib/view/popup/cupertino_popup_windows.dart @@ -0,0 +1,217 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +import 'dart:math'; + +import 'package:flutter/cupertino.dart'; +import 'package:liquid_glass_renderer/liquid_glass_renderer.dart'; + +enum SheetSize { medium, large } + +Widget _defaultTransitionsBuilder( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + Widget child, +) { + return child; +} + +Animation _getSlideAnimation( + Alignment alignment, + Animation animation, +) { + final curvedAnimation = CurvedAnimation( + parent: animation, + curve: Curves.fastOutSlowIn, + ); + return Tween( + begin: const Offset(0, 1), + end: Offset.zero, + ).animate(curvedAnimation); +} + +Widget _buildBody( + BuildContext context, + WidgetBuilder builder, + Animation animation, + SheetSize sheetSize, +) { + final body = Padding( + padding: MediaQuery.of(context).padding, + child: LayoutBuilder( + builder: (context, constraints) { + double height; + switch (sheetSize) { + case SheetSize.medium: + height = constraints.maxHeight / 2; + case SheetSize.large: + height = constraints.maxHeight; + } + return SizedBox( + height: height, + child: Padding( + padding: .all(10), + child: LiquidGlassLayer( + settings: LiquidGlassSettings( + lightAngle: 0.25 * pi, + refractiveIndex: 1.5, + ), + child: LiquidStretch( + child: LiquidGlass( + shape: LiquidRoundedSuperellipse(borderRadius: 25), + child: GlassGlow(child: builder(context)), + ), + ), + ), + ), + ); + }, + ), + ); + + final slideAnimation = _getSlideAnimation(.bottomCenter, animation); + return SlideTransition(position: slideAnimation, child: body); +} + +Future showCupertinoPopupWindows({ + required BuildContext context, + required WidgetBuilder builder, + bool useRootNavigator = true, + Duration transitionDuration = const Duration(milliseconds: 400), + Duration reverseTransitionDuration = const Duration(milliseconds: 400), + RouteTransitionsBuilder transitionsBuilder = _defaultTransitionsBuilder, + bool opaque = false, + bool barrierDismissible = true, + Color? barrierColor, + String barrierLabel = 'Dismiss', + bool maintainState = true, + bool allowSnapshotting = true, + SheetSize sheetSize = .medium, +}) { + final GlobalKey nestedNavigatorKey = + GlobalKey(); + + Widget widgetBuilder(BuildContext context) { + return NavigatorPopHandler( + onPopWithResult: (T? result) { + nestedNavigatorKey.currentState!.maybePop(); + }, + child: Navigator( + key: nestedNavigatorKey, + initialRoute: '/', + onGenerateInitialRoutes: + (NavigatorState navigator, String initialRouteName) { + return >[ + CupertinoPageRoute( + builder: (BuildContext context) { + return PopScope( + canPop: false, + onPopInvokedWithResult: (bool didPop, Object? result) { + if (didPop) { + return; + } + Navigator.of(context, rootNavigator: true).pop(result); + }, + child: builder(context), + ); + }, + ), + ]; + }, + ), + ); + } + + return Navigator.of(context, rootNavigator: useRootNavigator).push( + CupertinoPopupRoute( + builder: widgetBuilder, + sheetSize: sheetSize, + transitionDuration: transitionDuration, + reverseTransitionDuration: reverseTransitionDuration, + transitionsBuilder: transitionsBuilder, + barrierDismissible: barrierDismissible, + barrierColor: barrierColor, + barrierLabel: barrierLabel, + maintainState: maintainState, + allowSnapshotting: allowSnapshotting, + opaque: opaque, + ), + ); +} + +class CupertinoPopupRoute extends PopupRoute { + final WidgetBuilder builder; + final RouteTransitionsBuilder transitionsBuilder; + final SheetSize sheetSize; + + CupertinoPopupRoute({ + required this.builder, + required this.sheetSize, + this.transitionsBuilder = _defaultTransitionsBuilder, + this.transitionDuration = const Duration(milliseconds: 400), + this.reverseTransitionDuration = const Duration(milliseconds: 400), + this.barrierLabel, + this.barrierDismissible = true, + this.barrierColor, + this.maintainState = true, + this.allowSnapshotting = true, + this.opaque = false, + }); + + @override + bool opaque; + + @override + bool allowSnapshotting; + + @override + bool maintainState; + + @override + final Duration transitionDuration; + + @override + Duration reverseTransitionDuration; + + @override + Color? barrierColor; + + @override + bool barrierDismissible; + + @override + String? barrierLabel; + + @override + Widget buildPage( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + ) { + return AnimatedBuilder( + animation: animation, + builder: (context, child) { + return Container( + color: CupertinoColors.systemFill + .resolveFrom(context) + .withValues(alpha: 0.2), + alignment: .bottomCenter, + child: _buildBody(context, builder, animation, sheetSize), + ); + }, + ); + } + + @override + Widget buildTransitions( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + Widget child, + ) { + return transitionsBuilder(context, animation, secondaryAnimation, child); + } +} diff --git a/app/lib/view/popup/device_name_page.dart b/app/lib/view/popup/device_name_page.dart new file mode 100644 index 0000000..86247f3 --- /dev/null +++ b/app/lib/view/popup/device_name_page.dart @@ -0,0 +1,138 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +//====================== devicename(completed) ====================== +import 'package:flutter/cupertino.dart'; +import 'package:stack_chan/view/popup/XiaoZhi_welcome_page.dart'; + +import '../../app_state.dart'; +import 'device_wifi_config.dart'; + +class DeviceNamePage extends StatefulWidget { + const DeviceNamePage({super.key}); + + @override + State createState() => _DeviceNamePageState(); +} + +class _DeviceNamePageState extends State { + late TextEditingController _nameController; + + @override + void initState() { + super.initState(); + _nameController = TextEditingController(text: "My StackChan"); + } + + @override + void dispose() { + _nameController.dispose(); + if (mounted) { + FocusScope.of(context).unfocus(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return CupertinoPageScaffold( + backgroundColor: CupertinoColors.systemGroupedBackground.resolveFrom( + context, + ), + child: Column( + children: [ + Expanded( + child: CustomScrollView( + slivers: [ + CupertinoSliverNavigationBar(largeTitle: Text("Device Name")), + SliverList.list( + children: [ + Image.asset( + "assets/lateral_image.png", + width: double.infinity, + height: 120, + fit: BoxFit.contain, + ), + SizedBox(height: 15), + CupertinoListSection.insetGrouped( + header: Text("Give this StackChan a name"), + children: [ + CupertinoListTile( + padding: .all(15), + title: CupertinoTextField( + decoration: BoxDecoration(), + controller: _nameController, + placeholder: "Enter device name", + clearButtonMode: OverlayVisibilityMode.editing, + autofocus: true, + ), + ), + ], + ), + ], + ), + ], + ), + ), + Padding( + padding: .only( + left: 15, + right: 15, + top: 15, + bottom: MediaQuery.paddingOf(context).bottom + 15, + ), + child: Row( + spacing: 15, + children: [ + Expanded( + child: CupertinoButton.tinted( + child: Text("Skip"), + onPressed: () { + if (mounted) { + FocusScope.of(context).unfocus(); + } + Navigator.of(context).push( + CupertinoPageRoute( + builder: (context) { + return DeviceWifiConfig(); + }, + ), + ); + }, + ), + ), + Expanded( + child: CupertinoButton.filled( + child: Text("Continue"), + onPressed: () async { + if (mounted) { + FocusScope.of(context).unfocus(); + } + if (_nameController.text.trim().isEmpty) { + AppState.shared.showToast("Please enter device name"); + return; + } + AppState.shared.deviceInfo.value?.name = _nameController + .text + .trim(); + AppState.shared.updateDeviceInfo(); + Navigator.of(context).push( + CupertinoPageRoute( + builder: (context) { + return XiaoZhiWelcomePage(isWelCome: true); + }, + ), + ); + }, + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/app/lib/view/popup/device_wifi_config.dart b/app/lib/view/popup/device_wifi_config.dart new file mode 100644 index 0000000..06001fb --- /dev/null +++ b/app/lib/view/popup/device_wifi_config.dart @@ -0,0 +1,327 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/services.dart'; +import 'package:network_info_plus/network_info_plus.dart'; +import 'package:permission_handler/permission_handler.dart'; +import 'package:stack_chan/util/native_bridge.dart'; +import 'package:stack_chan/view/app.dart'; + +import '../../app_state.dart'; +import '../../model/blue_model.dart'; +import '../../util/blue_util.dart'; + +class DeviceWifiConfig extends StatefulWidget { + const DeviceWifiConfig({super.key, this.isWelCome}); + + final bool? isWelCome; + + @override + State createState() => _DeviceWifiConfigState(); +} + +class WifiCacheKeys { + static const String wifiName = 'cached_wifi_name'; + static const String wifiPassword = 'cached_wifi_password'; +} + +class _DeviceWifiConfigState extends State { + String _wifiName = ""; + String _wifiPassword = ""; + + late TextEditingController _nameTextEditingController; + late TextEditingController _passwordTextEditingController; + + @override + void initState() { + super.initState(); + _nameTextEditingController = TextEditingController(text: ""); + _passwordTextEditingController = TextEditingController(text: ""); + _onAppear(); + _initializeWifiInfo(); + _registerNativeHandler(); + } + + /// Register native handler for iOS WiFi name callback + void _registerNativeHandler() { + NativeBridge.shared.registerHandler(Method.wifiName, (message) async { + if (message.arguments is String) { + final wifiName = message.arguments as String; + if (wifiName.isNotEmpty) { + setState(() { + _wifiName = wifiName; + _nameTextEditingController.text = wifiName; + }); + } + } + }); + } + + /// Save WiFi information to local cache + Future _saveWifiInfoToCache(String name, String password) async { + try { + await AppState.asyncPrefs.setString(WifiCacheKeys.wifiName, name); + await AppState.asyncPrefs.setString(WifiCacheKeys.wifiPassword, password); + } catch (e) { + AppState.shared.showToast("Failed to save WiFi information"); + } + } + + /// Load WiFi information from local cache + /// Returns true if cache exists and is valid + Future _loadCachedWifiInfo() async { + try { + final cachedName = + await AppState.asyncPrefs.getString(WifiCacheKeys.wifiName) ?? ""; + final cachedPassword = + await AppState.asyncPrefs.getString(WifiCacheKeys.wifiPassword) ?? ""; + + if (cachedName.isNotEmpty) { + setState(() { + _wifiName = cachedName; + _wifiPassword = cachedPassword; + _nameTextEditingController.text = cachedName; + _passwordTextEditingController.text = cachedPassword; + }); + return true; + } + return false; + } catch (e) { + return false; + } + } + + /// Initialize WiFi info - cache first, then system if no cache + Future _initializeWifiInfo() async { + final hasCache = await _loadCachedWifiInfo(); + if (!hasCache) { + await _fetchSystemWifiName(); + } + } + + /// Fetch WiFi name from system with platform-specific handling + Future _fetchSystemWifiName() async { + if (Platform.isAndroid) { + // Android: Request permission first, then fetch if granted + final status = await Permission.location.request(); + if (status.isGranted) { + await _fetchAndroidWifiName(); + } else { + } + } else if (Platform.isIOS) { + // iOS: Request permission and immediately fetch via native bridge + // (no need to wait for permission result on iOS) + await Permission.locationWhenInUse.request(); + NativeBridge.shared.sendMessage(Method.wifiName); + } + } + + /// Fetch WiFi name on Android using network_info_plus library + Future _fetchAndroidWifiName() async { + try { + final networkInfo = NetworkInfo(); + String? wifiName = await networkInfo.getWifiName(); + + if (wifiName != null && wifiName.isNotEmpty) { + final cleanWifiName = wifiName.replaceAll('"', ''); + if (cleanWifiName.isNotEmpty && cleanWifiName != "unknown ssid") { + setState(() { + _wifiName = cleanWifiName; + _nameTextEditingController.text = cleanWifiName; + }); + } + } + } on PlatformException catch (e) { + } + } + + @override + void dispose() { + NativeBridge.shared.unregisterHandler(Method.wifiName); + _nameTextEditingController.dispose(); + _passwordTextEditingController.dispose(); + BlueUtil.shared.characteristicCallback = null; + BlueUtil.shared.wifiSetCharacteristicCall = null; + BlueUtil.shared.onReconnectSuccess = null; + if (mounted) { + FocusScope.of(context).unfocus(); + } + super.dispose(); + } + + bool isSuccess = false; + + void dismiss() { + AppState.shared.manualShutdownTime = DateTime.now(); + BlueUtil.shared.characteristicCallback = null; + BlueUtil.shared.wifiSetCharacteristicCall = null; + BlueUtil.shared.onReconnectSuccess = null; + BlueUtil.shared.disconnectCurrentPeripheral(); + + if (widget.isWelCome == true) { + CupertinoSheetRoute.popSheet(context); + } else { + Navigator.of(context).pop(); + } + } + + void _onAppear() { + BlueUtil.shared.wifiSetCharacteristicCall = (data) { + String json = utf8.decode(data); + final model = BlueNotifyStateModel.fromJson(json); + if (model?.data?.state != null) { + String state = model!.data!.state!; + if (state == "wifiConnecting") { + setState(() {}); + } else if (state == "wifiConnected") { + if (isSuccess) { + return; + } + isSuccess = true; + setState(() {}); + dismiss(); + } else if (state == "wifiConnectFailed") { + setState(() {}); + Future.delayed(const Duration(seconds: 1), () { + if (mounted) { + showCupertinoDialog( + context: context, + builder: (context) { + return CupertinoAlertDialog( + title: const Text("Configuration failed"), + content: const Text( + "Configuration failed, please re-enter wifi name and password", + ), + actions: [ + CupertinoDialogAction( + child: const Text("OK"), + onPressed: () { + Navigator.of(context).pop(); + _passwordTextEditingController.clear(); + FocusScope.of(context).requestFocus(FocusNode()); + }, + ), + ], + ); + }, + ); + } + }); + } + } + }; + } + + @override + Widget build(BuildContext context) { + return CupertinoPageScaffold( + backgroundColor: CupertinoColors.systemGroupedBackground.resolveFrom( + context, + ), + child: CustomScrollView( + slivers: [ + CupertinoSliverNavigationBar( + largeTitle: Text("Wi-Fi Info"), + leading: CupertinoNavigationBarBackButton( + color: CupertinoColors.systemOrange, + onPressed: () { + Navigator.of(context).pop(); + }, + ), + trailing: CupertinoButton( + sizeStyle: .large, + onPressed: () { + TextInput.finishAutofillContext(); + confirmWifi(); + }, + child: Icon(CupertinoIcons.check_mark), + ), + ), + SliverList.list( + children: [ + Padding( + padding: .only(left: 20, right: 20), + child: Text( + "Input the Wi-Fi in your environment for StackChan to connect to. Compatible with 2.4GHz, not 5GHz.", + style: TextStyle( + fontSize: 15, + color: CupertinoColors.placeholderText.resolveFrom(context), + ), + ), + ), + CupertinoListSection.insetGrouped( + header: Text("Wi-Fi Name"), + children: [ + CupertinoListTile( + padding: .only(left: 10, right: 10), + title: CupertinoTextField( + controller: _nameTextEditingController, + decoration: BoxDecoration(), + autofocus: true, + textAlign: .start, + textInputAction: .next, + onChanged: (value) { + _wifiName = value; + }, + ), + ), + ], + ), + CupertinoListSection.insetGrouped( + header: Text("Wi-Fi Password"), + children: [ + CupertinoListTile( + padding: .only(left: 10, right: 10), + title: CupertinoTextField( + controller: _passwordTextEditingController, + decoration: BoxDecoration(), + textAlign: .start, + textInputAction: .done, + onSubmitted: (value) { + confirmWifi(); + }, + onChanged: (value) { + _wifiPassword = value; + }, + ), + ), + ], + ), + ], + ), + ], + ), + ); + } + + void confirmWifi() async { + if (_wifiName.isEmpty || _wifiPassword.isEmpty) { + AppState.shared.showToast("Please enter the full name and password"); + return; + } + + _saveWifiInfoToCache(_wifiName, _wifiPassword); + + //savewifiinfo + final BlueWifiModel model = BlueWifiModel( + cmd: "setWifi", + data: BlueWifi(ssid: _wifiName, password: _wifiPassword), + ); + final jsonString = model.toJson(); + if (jsonString != null) { + final result = await BlueUtil.shared.sendWifiSetData(jsonString); + if (!result) { + dismiss(); + App.showDialog( + "Bluetooth disconnected. Please re-pair WiFi on your StackChan.", + ); + } + } + } +} diff --git a/app/lib/view/popup/edit_agent.dart b/app/lib/view/popup/edit_agent.dart new file mode 100644 index 0000000..40176ce --- /dev/null +++ b/app/lib/view/popup/edit_agent.dart @@ -0,0 +1,654 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +import 'package:flutter/cupertino.dart'; +import 'package:get/get.dart'; +import 'package:stack_chan/app_state.dart'; +import 'package:stack_chan/model/XiaoZhi/agent.dart'; +import 'package:stack_chan/model/XiaoZhi/tts_list.dart'; +import 'package:stack_chan/model/XiaoZhi/XiaoZhi_model.dart'; +import 'package:stack_chan/util/value_constant.dart'; + +import '../../model/XiaoZhi/agent_create.dart'; +import '../../model/XiaoZhi/common_mcp_tool.dart'; +import '../../util/XiaoZhi_util.dart'; +import 'agent_configuration.dart'; + +//EditorCreate agent +class EditAgent extends StatefulWidget { + const EditAgent({super.key, this.agent}); + + final Agent? agent; + + @override + State createState() => _EditAgentState(); +} + +class EditAgentModel extends GetxController { + final Agent? agent; + + EditAgentModel(this.agent); + + final RxBool isEdit = false.obs; + final RxBool isLoading = false.obs; + + late TextEditingController agentNameController; + late TextEditingController assistantNameController; + late TextEditingController characterController; + late TextEditingController memoryController; + + final Rxn selectedModel = Rxn(); + final Rxn selectedTtsVoice = Rxn(); + final RxString selectedLanguage = "".obs; //,dynamic + final RxString ttsSpeed = "normal".obs; + final RxInt ttsPitch = 0.obs; + final RxString asrSpeed = "normal".obs; + final RxString memoryType = "SHORT_TERM".obs; + final List selectedMcpEndpoints = []; + + TTsList? ttsData; + RxList ttsList = RxList([]); + + RxList languageList = RxList([]); + + RxList modelList = RxList([]); + List commonMcpTools = []; + + final List speedList = ["slow", "normal", "fast"]; + final List pitchList = [-2, -1, 0, 1, 2]; + final List memoryTypeList = ["OFF", "SHORT_TERM"]; + + void initPageData() async { + agentNameController = TextEditingController(); + assistantNameController = TextEditingController(); + characterController = TextEditingController(); + memoryController = TextEditingController(); + + //listenlanguageswitch → autorefreshvoice tone + ever(selectedLanguage, (lang) => _updateTtsVoiceList(lang)); + + await loadCommonMcpTools(); + await loadTtsList(); //loadTTS → autogeneratelanguagelist + await loadModelList(); + + if (agent != null) { + isEdit.value = true; + fillEditData(agent!); + } else { + isEdit.value = false; + setDefaultCreateData(); + } + } + + //loadTTSdata + generatedynamiclanguagelist + Future loadTtsList() async { + ttsData = await XiaoZhiUtil.shared.getTtsList(); + + //✅ key:from ttsVoices key generatelanguagelist + if (ttsData?.ttsVoices != null) { + languageList.value = ttsData!.ttsVoices!.keys.toList(); + } + + //initdefaultlanguage + if (languageList.isNotEmpty && selectedLanguage.isEmpty) { + selectedLanguage.value = languageList.first; + } + + //updatevoice tone + _updateTtsVoiceList(selectedLanguage.value); + } + + //switchlanguage → switchvoice tone + void _updateTtsVoiceList(String lang) { + if (ttsData?.ttsVoices == null || lang.isEmpty) { + ttsList.clear(); + selectedTtsVoice.value = null; + return; + } + ttsList.value = ttsData!.ttsVoices![lang] ?? []; + selectedTtsVoice.value = ttsList.isNotEmpty ? ttsList.first : null; + update(); + } + + Future loadModelList() async { + final models = await XiaoZhiUtil.shared.getModelList(); + modelList.assignAll(models); + } + + Future loadCommonMcpTools() async { + commonMcpTools = await XiaoZhiUtil.shared.getCommonMcpTool(); + update(); + } + + void fillEditData(Agent agent) { + agentNameController.text = agent.agent_name ?? ""; + assistantNameController.text = agent.assistant_name ?? ""; + characterController.text = agent.character ?? ""; + memoryController.text = agent.memory ?? ""; + + //Editmode:languagemustexistdynamiclistin + if (languageList.contains(agent.language)) { + selectedLanguage.value = agent.language!; + } else if (languageList.isNotEmpty) { + selectedLanguage.value = languageList.first; + } + + ttsSpeed.value = agent.tts_speech_speed ?? "normal"; + ttsPitch.value = agent.tts_pitch ?? 0; + asrSpeed.value = agent.asr_speed ?? "normal"; + memoryType.value = agent.memory_type ?? "SHORT_TERM"; + + if (agent.llm_model != null) { + selectedModel.value = modelList.firstWhereOrNull( + (m) => m.name == agent.llm_model, + ); + } + + if (agent.tts_voice != null) { + Future.delayed(const Duration(milliseconds: 100), () { + selectedTtsVoice.value = ttsList.firstWhereOrNull( + (t) => t.voiceId == agent.tts_voice, + ); + update(); + }); + } + + if (agent.mcp_endpoints != null) { + selectedMcpEndpoints.addAll(agent.mcp_endpoints!); + } + update(); + } + + void setDefaultCreateData() { + agentNameController.text = "My AI Agent"; + assistantNameController.text = "StackChan"; + ttsSpeed.value = "normal"; + ttsPitch.value = 0; + asrSpeed.value = "normal"; + memoryType.value = "SHORT_TERM"; + if (modelList.isNotEmpty) selectedModel.value = modelList.first; + update(); + } + + void toggleMcpTool(String? endpointId) { + if (endpointId == null) return; + selectedMcpEndpoints.contains(endpointId) + ? selectedMcpEndpoints.remove(endpointId) + : selectedMcpEndpoints.add(endpointId); + update(); + } + + Future submitAgent() async { + if (agentNameController.text.isEmpty) { + AppState.shared.showToast("Please enter the AI Agent name."); + return false; + } + if (selectedModel.value == null) { + AppState.shared.showToast("Please select an LLM Model."); + return false; + } + if (selectedTtsVoice.value == null) { + AppState.shared.showToast("Please select a voice tone."); + return false; + } + + isLoading.value = true; + + final agentParams = AgentCreate( + agent_name: agentNameController.text.trim(), + assistant_name: assistantNameController.text.trim(), + llm_model: selectedModel.value!.name!, + tts_voice: selectedTtsVoice.value!.voiceId!, + tts_speech_speed: ttsSpeed.value, + tts_pitch: ttsPitch.value, + asr_speed: asrSpeed.value, + language: selectedLanguage.value, + character: characterController.text.trim(), + memory: memoryController.text.trim(), + memory_type: memoryType.value, + mcp_endpoints: selectedMcpEndpoints, + product_mcp_endpoints: [], + ); + + bool result = false; + if (isEdit.value) { + result = await XiaoZhiUtil.shared.updateAgent(agent!.id!, agentParams); + } else { + final agentId = await XiaoZhiUtil.shared.createAgent(agentParams); + result = agentId != null; + } + + isLoading.value = false; + if (result) { + AppState.shared.showToast(isEdit.value + ? "Agent edited successfully" + : "Agent created successfully"); + } + return result; + } + + @override + void onClose() { + agentNameController.dispose(); + assistantNameController.dispose(); + characterController.dispose(); + memoryController.dispose(); + super.onClose(); + } +} + +class _EditAgentState extends State { + AgentConfigurationModel agentConfigurationModel = + Get.find(); + late EditAgentModel model; + + int selectedItem = 0; + + String getLanguagesTitle(String lg) { + if (ValueConstant.languages[lg] != null) { + return ValueConstant.languages[lg]!; + } else { + return lg; + } + } + + @override + void initState() { + super.initState(); + model = EditAgentModel(widget.agent); + model.initPageData(); + } + + @override + void dispose() { + model.onClose(); + if (mounted) { + FocusScope.of(context).unfocus(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return CupertinoPageScaffold( + navigationBar: CupertinoNavigationBar.large( + largeTitle: Obx( + () => Text( + model.isEdit.value + ? (agentConfigurationModel.currentBindAgent.value?.agent_name ?? + "Edit Agent") //error + : "Create AI Agent", //translated comment + ), + ), + leading: CupertinoNavigationBarBackButton(), + backgroundColor: CupertinoColors.systemBackground.resolveFrom(context), + trailing: Obx( + () => model.isLoading.value + ? const CupertinoActivityIndicator(radius: 10) + : CupertinoButton( + padding: EdgeInsets.zero, + onPressed: () async { + final result = await model.submitAgent(); + if (result && mounted) { + Navigator.pop(this.context); + } + }, + child: const Icon(CupertinoIcons.check_mark), + ), + ), + ), + backgroundColor: CupertinoColors.systemBackground.resolveFrom(context), + child: Obx( + () => model.isLoading.value + ? const Center(child: CupertinoActivityIndicator()) + : SingleChildScrollView( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 20, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + //1. info + _buildSectionTitle("Basic Information"), + // _buildInputItem( + // title: "Agent Name", + // controller: model.agentNameController, + // placeholder: + //"Please enter the name of the AI Agent.", // System1 + // ), + _buildInputItem( + title: "Assistant Name", //translated comment + controller: model.assistantNameController, + placeholder: + "Please enter the assistant's name (e.g. StackChan).", + ), + //2. modelconfig + _buildSectionTitle("Model Configuration"), //standard + _buildSelectItem( + title: "LLM Model", //(Large Language Model) + value: model.selectedModel.value?.name ?? "Please select", + onTap: showModelPicker, + ), + _buildSelectItem( + title: "Language", + value: getLanguagesTitle(model.selectedLanguage.value), + onTap: showLanguagePicker, + ), + //3. config + _buildSectionTitle("Voice Configuration"), //standard + _buildSelectItem( + title: "Voice Tone", //Timbre, + value: + model.selectedTtsVoice.value?.voiceName ?? + "Please select", + onTap: showTtsPicker, + ), + _buildSelectItem( + title: "TTS Speech Speed", //isTTS + value: model.ttsSpeed.value, + onTap: showSpeedPicker, + ), + _buildSelectItem( + title: "TTS Pitch", //isTTS + value: model.ttsPitch.value.toString(), + onTap: showPitchPicker, + ), + _buildSelectItem( + title: "ASR Speed", //(Automatic Speech Recognition) + value: model.asrSpeed.value, + onTap: showAsrSpeedPicker, + ), + //4. characterconfig + _buildSectionTitle("Character Configuration"), //standard + _buildInputItem( + title: "Character Description", + controller: model.characterController, + placeholder: + "Please provide the character description (max 2000 characters).", + //(words→characters) + maxLines: 4, + ), + _buildInputItem( + title: "Short-term Memory Content", //translated comment + controller: model.memoryController, + placeholder: + "Please enter the short-term memory content.", + maxLines: 3, + ), + _buildSelectItem( + title: "Memory Type", //standard + value: model.memoryType.value == "SHORT_TERM" + ? "Short-term Memory" //standard + : "Disabled", //Shut down,UI + onTap: showMemoryTypePicker, + ), + //5. MCPtoolconfig(Partoptimize) + // _buildSectionTitle("MCP Tools (Optional)"), + // model.commonMcpTools.isEmpty + // ? const Center(child: Text("No available MCP tools")) + // : Wrap( + // spacing: 8, + // runSpacing: 8, + // children: model.commonMcpTools.map((tool) { + // final isSelected = model.selectedMcpEndpoints + // .contains(tool.endpoint_id); + // return CupertinoButton( + // padding: const EdgeInsets.symmetric( + // horizontal: 12, + // vertical: 6, + // ), + // color: isSelected + // ? CupertinoColors.activeBlue + // : CupertinoColors.systemGrey5, + // onPressed: () => + // model.toggleMcpTool(tool.endpoint_id), + // child: Text( + // tool.name ?? "", + // style: TextStyle( + // color: isSelected + // ? CupertinoColors.white + // : CupertinoColors.black, + // fontSize: 14, + // ), + // ), + // ); + // }).toList(), + // ), + const SizedBox(height: 40), + ], + ), + ), + ), + ); + } + + void showModelPicker() { + final initialIndex = model.modelList.indexOf(model.selectedModel.value); + showPicker( + title: "Select LLM Model", + items: model.modelList.map((e) => e.name ?? "").toList(), + initialIndex: initialIndex.clamp(0, model.modelList.length - 1), + onSelected: (index) => model.selectedModel.value = model.modelList[index], + ); + } + + void showLanguagePicker() { + final initialIndex = model.languageList.indexOf( + model.selectedLanguage.value, + ); + showPicker( + title: "Select Language", + items: model.languageList + .map((value) => getLanguagesTitle(value)) + .toList(), + initialIndex: initialIndex.clamp(0, model.languageList.length - 1), + onSelected: (index) { + //fix:AssignValue languageKEY(zh/en),And / WhileNotisshowname! + model.selectedLanguage.value = model.languageList[index]; + }, + ); + } + + void showTtsPicker() { + final initialIndex = model.ttsList.indexOf(model.selectedTtsVoice.value); + showPicker( + title: "Select Voice Tone", + items: model.ttsList.map((e) => e.voiceName ?? "").toList(), + initialIndex: initialIndex.clamp(0, model.ttsList.length - 1), + onSelected: (index) => + model.selectedTtsVoice.value = model.ttsList[index], + ); + } + + void showSpeedPicker() { + final initialIndex = model.speedList.indexOf(model.ttsSpeed.value); + showPicker( + title: "Select TTS Speech Speed", + items: model.speedList, + initialIndex: initialIndex.clamp(0, model.speedList.length - 1), + onSelected: (index) => model.ttsSpeed.value = model.speedList[index], + ); + } + + void showPitchPicker() { + final initialIndex = model.pitchList.indexOf(model.ttsPitch.value); + showPicker( + title: "Select TTS Pitch", + items: model.pitchList.map((e) => e.toString()).toList(), + initialIndex: initialIndex.clamp(0, model.pitchList.length - 1), + onSelected: (index) => model.ttsPitch.value = model.pitchList[index], + ); + } + + void showAsrSpeedPicker() { + final initialIndex = model.speedList.indexOf(model.asrSpeed.value); + showPicker( + title: "Select ASR Speed", + items: model.speedList, + initialIndex: initialIndex.clamp(0, model.speedList.length - 1), + onSelected: (index) => model.asrSpeed.value = model.speedList[index], + ); + } + + void showMemoryTypePicker() { + final initialIndex = model.memoryTypeList.indexOf(model.memoryType.value); + showPicker( + title: "Select Memory Type", + items: model.memoryTypeList + .map((e) => e == "SHORT_TERM" ? "Short-term Memory" : "Disabled") + .toList(), + initialIndex: initialIndex.clamp(0, model.memoryTypeList.length - 1), + onSelected: (index) => + model.memoryType.value = model.memoryTypeList[index], + ); + } + + void showPicker({ + required String title, + required List items, + required Function(int) onSelected, + int initialIndex = 0, //new:selectedindex + }) { + //initselectedItemascurrentValue(fixdefaultselectederror) + selectedItem = initialIndex; + showCupertinoModalPopup( + context: context, + builder: (context) { + return Container( + height: 260, + color: CupertinoColors.systemBackground.resolveFrom(context), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + CupertinoButton( + child: const Text("Cancel"), + onPressed: () => Navigator.pop(context), + ), + CupertinoButton( + child: const Text("Confirm"), + onPressed: () { + Navigator.pop(context); + onSelected(selectedItem); + }, + ), + ], + ), + Expanded( + child: CupertinoPicker( + itemExtent: 32, + magnification: 1.22, + squeeze: 1.2, + useMagnifier: true, + scrollController: FixedExtentScrollController( + initialItem: initialIndex, + ), + onSelectedItemChanged: (index) => selectedItem = index, + children: items.map((e) => Text(e)).toList(), + ), + ), + ], + ), + ); + }, + ); + } + + //title + Widget _buildSectionTitle(String title) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Text( + title, + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600), + ), + ); + } + + //inputItem + Widget _buildInputItem({ + required String title, + required TextEditingController controller, + required String placeholder, + int maxLines = 1, + }) { + return Container( + margin: const EdgeInsets.only(bottom: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w500), + ), + const SizedBox(height: 6), + CupertinoTextField( + controller: controller, + placeholder: placeholder, + maxLines: maxLines, + minLines: maxLines, + textAlign: TextAlign.left, + decoration: BoxDecoration( + color: CupertinoColors.systemGrey6, + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.all(12), + ), + ], + ), + ); + } + + //selectItem + Widget _buildSelectItem({ + required String title, + required String value, + required VoidCallback onTap, + }) { + return CupertinoButton( + padding: EdgeInsets.zero, + onPressed: onTap, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 12), + decoration: const BoxDecoration( + border: Border( + bottom: BorderSide(color: CupertinoColors.systemGrey5, width: 0.5), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + title, + style: TextStyle( + fontSize: 15, + color: CupertinoColors.label.resolveFrom(context), + ), + ), + Row( + children: [ + Text( + value, + style: TextStyle( + fontSize: 15, + color: CupertinoColors.secondaryLabel.resolveFrom(context), + ), + ), + const SizedBox(width: 6), + Icon( + CupertinoIcons.chevron_right, + size: 14, + color: CupertinoColors.secondaryLabel.resolveFrom(context), + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/app/lib/view/popup/login_page.dart b/app/lib/view/popup/login_page.dart new file mode 100644 index 0000000..59894f4 --- /dev/null +++ b/app/lib/view/popup/login_page.dart @@ -0,0 +1,648 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/services.dart'; +import 'package:get/get.dart'; +import 'package:stack_chan/app_state.dart'; +import 'package:stack_chan/model/model.dart'; +import 'package:stack_chan/model/registration_response.dart'; +import 'package:stack_chan/network/http.dart'; +import 'package:stack_chan/network/urls.dart'; +import 'package:stack_chan/util/blue_util.dart'; +import 'package:stack_chan/util/value_constant.dart'; +import 'package:stack_chan/view/app.dart'; + +class LoginPage extends StatefulWidget { + const LoginPage({super.key, this.isWelCome}); + + final bool? isWelCome; + + @override + State createState() => _LoginPageState(); +} + +///loginAPIresponsemodel +class LoginResponseModel { + String? token; + + ///Constructorfunction + LoginResponseModel({this.token}); + + ///from JSON maptoasmodelobject + factory LoginResponseModel.fromJson(Map json) { + return LoginResponseModel(token: json['token'] as String?); + } + + ///willmodelobjecttoas JSON map + Map toJson() { + final Map data = {}; + data['token'] = token; + return data; + } +} + +class _LoginPageState extends State { + String username = ""; + String password = ""; + + late TextEditingController nameTextEditingController; + late TextEditingController passwordTextEditingController; + + bool _obscurePassword = true; + + RxBool loading = false.obs; + + @override + void initState() { + super.initState(); + nameTextEditingController = TextEditingController(); + passwordTextEditingController = TextEditingController(); + } + + @override + void dispose() { + nameTextEditingController.dispose(); + passwordTextEditingController.dispose(); + if (mounted) { + FocusScope.of(context).unfocus(); + } + super.dispose(); + } + + Future login() async { + if (loading.value) { + return; + } + loading.value = true; + if (username.isEmpty) { + AppState.shared.showToast( + "Please enter your account number or email address.", + ); + loading.value = false; + return; + } + if (password.isEmpty) { + AppState.shared.showToast("Please enter the password."); + loading.value = false; + return; + } + final Map map = { + ValueConstant.username: username, + ValueConstant.password: password, + }; + final response = await Http.instance.post(Urls.login, data: map); + if (response.data != null) { + Model responseData = Model.fromJsonT( + response.data, + factory: (value) => LoginResponseModel.fromJson(value), + ); + if (responseData.isSuccess()) { + final token = responseData.data?.token; + if (token != null) { + AppState.shared.setIsLogin(true); + await AppState.asyncPrefs.setString(ValueConstant.token, token); + AppState.shared.showToast("Login successful"); + if (mounted) { + AppState.shared.getUserInfo(); + AppState.shared.getDevices(); + if (widget.isWelCome != true) { + CupertinoSheetRoute.popSheet(context); + } else { + Navigator.of(context).pop(); + } + BlueUtil.shared.cachedDeviceMacs = []; + } + } + } else { + showLoginErrMessage(responseData.message); + } + } else { + AppState.shared.showToast(response.statusMessage); + } + loading.value = false; + } + + void showLoginErrMessage(String? text) { + if (text == "[[error:invalid-login-credentials]]") { + ///passworderror + App.showDialog( + "Incorrect password or account number (the account will be locked for one hour after five incorrect attempts)", + ); + return; + } else if (text == "[[error:account-locked]]") { + App.showDialog( + "Your account has been locked. Please wait for a moment before trying again", + ); + return; + } else { + //defaulthint + String errorMessage = "Login failed"; + + if (text != null && text.isNotEmpty) { + //match [[error:xxx]] ,Extractinerrorcontent + final regExp = RegExp(r'\[\[error:(.*?)\]\]'); + final match = regExp.firstMatch(text); + + if (match != null) { + //Extracttocustomerrorcontent + errorMessage = match.group(1)!.trim(); + } else { + //texterror,directuse + errorMessage = text.trim(); + } + } + // Toast + AppState.shared.showToast(errorMessage); + } + } + + @override + Widget build(BuildContext context) { + return CupertinoPageScaffold( + backgroundColor: CupertinoColors.systemGroupedBackground.resolveFrom( + context, + ), + child: GestureDetector( + onTap: () { + if (mounted) { + FocusScope.of(context).unfocus(); + } + }, + behavior: .opaque, + child: Obx(() { + if (loading.value) { + return Center(child: CupertinoActivityIndicator()); + } else { + return Column( + children: [ + Expanded( + child: CustomScrollView( + slivers: [ + CupertinoSliverNavigationBar( + largeTitle: Text("Login"), + trailing: widget.isWelCome != true + ? CupertinoButton( + padding: .zero, + child: Icon( + CupertinoIcons.xmark_circle_fill, + size: 25, + color: CupertinoColors.separator.resolveFrom( + context, + ), + ), + onPressed: () { + Navigator.of( + context, + rootNavigator: true, + ).pop(); + }, + ) + : SizedBox.shrink(), + ), + SliverList.list( + children: [ + Row( + children: [ + SizedBox(width: 20), + Image.asset( + "assets/lateral_image.png", + height: 120, + width: 120, + ), + SizedBox(width: 20), + Expanded( + child: Text( + "M5Stack account is used across UiFlow, M5Burner and forum 😊", + ), + ), + SizedBox(width: 20), + ], + ), + SizedBox(height: 15), + AutofillGroup( + child: Column( + mainAxisSize: .min, + spacing: 0, + children: [ + CupertinoListSection.insetGrouped( + header: Text("username or email"), + children: [ + CupertinoListTile( + padding: .zero, + title: CupertinoTextField( + placeholder: "username or email", + controller: nameTextEditingController, + placeholderStyle: TextStyle( + fontSize: 20, + color: + CupertinoColors.placeholderText, + ), + style: TextStyle( + fontSize: 20, + color: CupertinoColors.label, + ), + keyboardType: .emailAddress, + padding: .only( + left: 20, + right: 20, + top: 15, + bottom: 15, + ), + decoration: BoxDecoration(), + autofocus: true, + textInputAction: .next, + onChanged: (value) { + username = value; + }, + autofillHints: const [ + AutofillHints.username, + ], + ), + ), + ], + ), + CupertinoListSection.insetGrouped( + header: Text("password"), + children: [ + CupertinoListTile( + padding: .zero, + title: CupertinoTextField( + controller: + passwordTextEditingController, + placeholder: "password", + obscureText: _obscurePassword, + style: TextStyle( + fontSize: 20, + color: CupertinoColors.label, + ), + placeholderStyle: TextStyle( + fontSize: 20, + color: + CupertinoColors.placeholderText, + ), + padding: .only( + left: 20, + right: 20, + top: 15, + bottom: 15, + ), + decoration: BoxDecoration(), + keyboardType: .visiblePassword, + textInputAction: .go, + onSubmitted: (value) { + TextInput.finishAutofillContext(); + login(); + }, + onChanged: (value) { + password = value; + }, + autofillHints: const [ + AutofillHints.password, + ], + suffix: CupertinoButton( + child: Icon( + _obscurePassword + ? CupertinoIcons.eye_slash + : CupertinoIcons.eye, + size: 22, + color: CupertinoColors.systemGrey + .resolveFrom(context), + ), + onPressed: () { + setState(() { + _obscurePassword = + !_obscurePassword; + }); + }, + ), + ), + ), + ], + ), + ], + ), + ), + SizedBox(height: 15), + Row( + spacing: 0, + children: [ + Spacer(), + Text("Don't have an account? "), + CupertinoButton( + onPressed: () { + TextInput.finishAutofillContext(); + if (mounted) { + FocusScope.of(context).unfocus(); + } + Navigator.of(context).push( + CupertinoPageRoute( + builder: (context) { + return RegistrationPage(); + // return WebPage( + // url: + // "https://community.m5stack.com/register", + // previousPageTitle: "Login", + // ); + }, + ), + ); + }, + minimumSize: .zero, + padding: .zero, + child: Text("Register now"), + ), + Spacer(), + ], + ), + SizedBox(height: 15), + ], + ), + ], + ), + ), + Padding( + padding: .only( + top: 15, + left: 15, + right: 15, + bottom: 15 + MediaQuery.paddingOf(context).bottom, + ), + child: CupertinoButton.filled( + sizeStyle: .large, + child: SizedBox( + width: .infinity, + child: Center(child: Text("Login")), + ), + onPressed: () { + login(); + }, + ), + ), + ], + ); + } + }), + ), + ); + } +} + +class RegistrationPage extends StatefulWidget { + const RegistrationPage({super.key}); + + @override + State createState() => _RegistrationPageState(); +} + +class _RegistrationPageState extends State { + late final TextEditingController usernameController; + late final TextEditingController passwordController; + late final TextEditingController emailController; + + @override + void initState() { + super.initState(); + usernameController = TextEditingController(); + passwordController = TextEditingController(); + emailController = TextEditingController(); + } + + @override + void dispose() { + usernameController.dispose(); + passwordController.dispose(); + emailController.dispose(); + super.dispose(); + } + + Future register() async { + final username = usernameController.text.trim(); + final password = passwordController.text.trim(); + final email = emailController.text.trim(); + + if (username.isEmpty) { + AppState.shared.showToast("Please enter your username."); + return; + } + if (password.isEmpty) { + AppState.shared.showToast("Please enter the password."); + return; + } + if (email.isEmpty || !email.contains('@')) { + AppState.shared.showToast("Please enter a valid email address."); + return; + } + + final Map requestData = { + ValueConstant.username: username, + ValueConstant.password: password, + ValueConstant.email: email, + }; + + final response = await Http.instance.post( + Urls.registration, + data: requestData, + ); + + if (response.data != null) { + Model responseData = Model.fromJsonT( + response.data, + factory: (value) => RegistrationResponse.fromJson(value), + ); + if (responseData.isSuccess()) { + AppState.shared.showToast("Registration successful!"); + if (mounted) { + Navigator.pop(context); //returnlogin + } + } else { + showLoginErrMessage(responseData.message); + } + } else { + AppState.shared.showToast(response.statusMessage ?? "Register failed"); + } + } + + void showLoginErrMessage(String? text) { + //defaulthint + String errorMessage = "Register failed"; + + if (text != null && text.isNotEmpty) { + //match [[error:xxx]] ,Extractinerrorcontent + final regExp = RegExp(r'\[\[error:(.*?)\]\]'); + final match = regExp.firstMatch(text); + + if (match != null) { + //Extracttocustomerrorcontent + errorMessage = match.group(1)!.trim(); + } else { + //texterror,directuse + errorMessage = text.trim(); + } + } + // Toast + AppState.shared.showToast(errorMessage); + } + + @override + Widget build(BuildContext context) { + return CupertinoPageScaffold( + backgroundColor: CupertinoColors.systemGroupedBackground.resolveFrom( + context, + ), + child: GestureDetector( + onTap: () => FocusScope.of(context).unfocus(), + behavior: .opaque, + child: Column( + children: [ + Expanded( + child: CustomScrollView( + slivers: [ + CupertinoSliverNavigationBar( + largeTitle: Text("Register"), + padding: .only(start: 8, end: 8), + leading: CupertinoNavigationBarBackButton( + previousPageTitle: "Login", + ), + ), + SliverList.list( + children: [ + CupertinoListSection.insetGrouped( + header: Text("Username"), + children: [ + CupertinoListTile( + padding: .zero, + title: CupertinoTextField( + controller: usernameController, + placeholder: "username", + placeholderStyle: TextStyle( + fontSize: 20, + color: CupertinoColors.placeholderText + .resolveFrom(context), + ), + style: TextStyle( + fontSize: 20, + color: CupertinoColors.label.resolveFrom( + context, + ), + ), + keyboardType: .name, + padding: .only( + left: 20, + right: 20, + top: 15, + bottom: 15, + ), + decoration: BoxDecoration(), + autofocus: true, + textInputAction: .next, + ), + ), + ], + ), + CupertinoListSection.insetGrouped( + header: Text("Password"), + children: [ + CupertinoListTile( + padding: .zero, + title: CupertinoTextField( + controller: passwordController, + placeholder: "password", + placeholderStyle: TextStyle( + fontSize: 20, + color: CupertinoColors.placeholderText + .resolveFrom(context), + ), + style: TextStyle( + fontSize: 20, + color: CupertinoColors.label.resolveFrom( + context, + ), + ), + keyboardType: .visiblePassword, + padding: .only( + left: 20, + right: 20, + top: 15, + bottom: 15, + ), + decoration: BoxDecoration(), + autofocus: true, + textInputAction: .next, + ), + ), + ], + ), + CupertinoListSection.insetGrouped( + header: Text("Email"), + children: [ + CupertinoListTile( + padding: .zero, + title: CupertinoTextField( + controller: emailController, + placeholder: "email", + placeholderStyle: TextStyle( + fontSize: 20, + color: CupertinoColors.placeholderText + .resolveFrom(context), + ), + style: TextStyle( + fontSize: 20, + color: CupertinoColors.label.resolveFrom( + context, + ), + ), + keyboardType: .emailAddress, + padding: .only( + left: 20, + right: 20, + top: 15, + bottom: 15, + ), + decoration: BoxDecoration(), + autofocus: true, + textInputAction: .go, + onSubmitted: (value) { + if (mounted) { + FocusScope.of(context).unfocus(); + } + register(); + }, + ), + ), + ], + ), + ], + ), + ], + ), + ), + Padding( + padding: .only( + top: 15, + left: 15, + right: 15, + bottom: 15 + MediaQuery.paddingOf(context).bottom, + ), + child: CupertinoButton.filled( + sizeStyle: .large, + child: SizedBox( + width: .infinity, + child: Center(child: Text("Submit")), + ), + onPressed: () { + if (mounted) { + FocusScope.of(context).unfocus(); + } + register(); + }, + ), + ), + ], + ), + ), + ); + } +} diff --git a/app/lib/view/popup/motion.dart b/app/lib/view/popup/motion.dart new file mode 100644 index 0000000..de49a1d --- /dev/null +++ b/app/lib/view/popup/motion.dart @@ -0,0 +1,537 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +import 'dart:typed_data'; + +import 'package:flutter/cupertino.dart'; +import 'package:stack_chan/app_state.dart'; +import 'package:stack_chan/model/dance_list.dart'; +import 'package:stack_chan/model/expression_data.dart'; +import 'package:stack_chan/network/web_socket_util.dart'; +import 'package:stack_chan/util/extension.dart'; +import 'package:stack_chan/view/util/grid_coordinate_joystick.dart'; +import 'package:stack_chan/view/util/stackchan_robot_box.dart'; + +class Motion extends StatefulWidget { + const Motion({super.key}); + + @override + State createState() => _MotionState(); +} + +class _MotionState extends State { + int _selectedIndex = 0; + + late ExpressionData avatarData; + late MotionData motionData; + + final String tag = "Motion"; + + @override + void dispose() { + WebSocketUtil.shared.removeObserver(tag); + super.dispose(); + } + + @override + void initState() { + super.initState(); + avatarData = ExpressionData( + leftEye: ExpressionItem(weight: 100), + rightEye: ExpressionItem(weight: 100), + mouth: ExpressionItem(weight: 0), + ); + motionData = MotionData( + pitchServo: MotionDataItem(), + yawServo: MotionDataItem(), + ); + + AppState.shared.sendWebSocketMessage(.getAvatarPosture); + + WebSocketUtil.shared.addObserver(tag, (message) { + if (message is Uint8List) { + final result = AppState.shared.parseMessage(message); + final msgType = result.$1; + final parsedData = result.$2; + if (msgType != null) { + switch (msgType) { + case .getAvatarPosture: + break; + default: + break; + } + } + } + }); + } + + void _saveAvatarData() { + if (AppState.shared.deviceMac.isNotEmpty) { + String jsonString = AppState.shared.deviceMac + avatarData.toString(); + AppState.shared.sendWebSocketMessage( + .controlAvatar, //as WebSocketMessageType + data: jsonString.toUint8List(), + ); + } + } + + void _saveMotionData() { + if (AppState.shared.deviceMac.isNotEmpty) { + String jsonString = AppState.shared.deviceMac + motionData.toString(); + AppState.shared.sendWebSocketMessage( + .controlMotion, + data: jsonString.toUint8List(), + ); + } + } + + @override + Widget build(BuildContext context) { + return ClipRSuperellipse( + borderRadius: .only(topLeft: .circular(12), topRight: .circular(12)), + child: CupertinoPageScaffold( + backgroundColor: CupertinoColors.systemGroupedBackground.resolveFrom( + context, + ), + navigationBar: CupertinoNavigationBar( + trailing: CupertinoButton( + padding: .zero, + child: Icon( + CupertinoIcons.xmark_circle_fill, + size: 25, + color: CupertinoColors.separator.resolveFrom(context), + ), + onPressed: () { + CupertinoSheetRoute.popSheet(context); + }, + ), + ), + child: ListView( + padding: .only( + top: MediaQuery.paddingOf(context).top + 15, + left: 15, + right: 15, + bottom: MediaQuery.paddingOf(context).bottom + 15, + ), + children: [ + StackChanRobotBox( + mirrorFace: true, + width: double.infinity, + height: 250, + data: DanceData( + leftEye: avatarData.leftEye, + rightEye: avatarData.rightEye, + mouth: avatarData.mouth, + yawServo: motionData.yawServo, + pitchServo: motionData.pitchServo, + durationMs: 1000, + ), + ), + const SizedBox(height: 10), + Row( + children: [ + Expanded( + child: CupertinoSlidingSegmentedControl( + children: const {0: Text("Motion"), 1: Text("Avatar")}, + groupValue: _selectedIndex, + onValueChanged: (value) { + setState(() { + _selectedIndex = value!; + }); + }, + ), + ), + const SizedBox(width: 15), + CupertinoButton( + sizeStyle: .medium, + onPressed: () { + setState(() { + if (_selectedIndex == 1) { + avatarData = ExpressionData( + leftEye: ExpressionItem(weight: 100), + rightEye: ExpressionItem(weight: 100), + mouth: ExpressionItem(weight: 0), + ); + _saveAvatarData(); + } else { + motionData = MotionData( + pitchServo: MotionDataItem(), + yawServo: MotionDataItem(), + ); + _saveMotionData(); + } + }); + }, + child: const Icon(CupertinoIcons.refresh), + ), + ], + ), + if (_selectedIndex == 1) _buildAvatarControls(context), + if (_selectedIndex == 0) _buildMotionControls(context), + ], + ), + ), + ); + } + + Widget _buildAvatarControls(BuildContext context) { + TextStyle titleStyle = TextStyle( + color: CupertinoColors.label.resolveFrom(context), + fontSize: 15, + ); + TextStyle valueStyle = TextStyle( + color: CupertinoColors.secondaryLabel.resolveFrom(context), + fontSize: 15, + ); + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildSectionTitle("Left Eye"), + _buildSlider( + "x", + avatarData.leftEye.x.toDouble(), + -100, + 100, + titleStyle, + valueStyle, + (val) { + setState(() => avatarData.leftEye.x = val.toInt()); + }, + onDragEnd: _saveAvatarData, + ), + _buildSlider( + "y", + avatarData.leftEye.y.toDouble(), + -100, + 100, + titleStyle, + valueStyle, + (val) { + setState(() => avatarData.leftEye.y = val.toInt()); + }, + onDragEnd: _saveAvatarData, + ), + _buildSlider( + "rotation", + avatarData.leftEye.rotation.toDouble(), + -1800, + 1800, + titleStyle, + valueStyle, + (val) { + setState(() => avatarData.leftEye.rotation = val.toInt()); + }, + onDragEnd: _saveAvatarData, + ), + _buildSlider( + "weight", + avatarData.leftEye.weight.toDouble(), + 0, + 100, + titleStyle, + valueStyle, + (val) { + setState(() => avatarData.leftEye.weight = val.toInt()); + }, + onDragEnd: _saveAvatarData, + ), + _buildSlider( + "size", + avatarData.leftEye.size.toDouble(), + -100, + 100, + titleStyle, + valueStyle, + (val) { + setState(() => avatarData.leftEye.size = val.toInt()); + }, + onDragEnd: _saveAvatarData, + ), + + _buildSectionTitle("Right Eye"), + _buildSlider( + "x", + avatarData.rightEye.x.toDouble(), + -100, + 100, + titleStyle, + valueStyle, + (val) { + setState(() => avatarData.rightEye.x = val.toInt()); + }, + onDragEnd: _saveAvatarData, + ), + _buildSlider( + "y", + avatarData.rightEye.y.toDouble(), + -100, + 100, + titleStyle, + valueStyle, + (val) { + setState(() => avatarData.rightEye.y = val.toInt()); + }, + onDragEnd: _saveAvatarData, + ), + _buildSlider( + "rotation", + avatarData.rightEye.rotation.toDouble(), + -1800, + 1800, + titleStyle, + valueStyle, + (val) { + setState(() => avatarData.rightEye.rotation = val.toInt()); + }, + onDragEnd: _saveAvatarData, + ), + _buildSlider( + "weight", + avatarData.rightEye.weight.toDouble(), + 0, + 100, + titleStyle, + valueStyle, + (val) { + setState(() => avatarData.rightEye.weight = val.toInt()); + }, + onDragEnd: _saveAvatarData, + ), + _buildSlider( + "size", + avatarData.rightEye.size.toDouble(), + -100, + 100, + titleStyle, + valueStyle, + (val) { + setState(() => avatarData.rightEye.size = val.toInt()); + }, + onDragEnd: _saveAvatarData, + ), + + _buildSectionTitle("Mouth"), + _buildSlider( + "x", + avatarData.mouth.x.toDouble(), + -100, + 100, + titleStyle, + valueStyle, + (val) { + setState(() => avatarData.mouth.x = val.toInt()); + }, + onDragEnd: _saveAvatarData, + ), + _buildSlider( + "y", + avatarData.mouth.y.toDouble(), + -100, + 100, + titleStyle, + valueStyle, + (val) { + setState(() => avatarData.mouth.y = val.toInt()); + }, + onDragEnd: _saveAvatarData, + ), + _buildSlider( + "rotation", + avatarData.mouth.rotation.toDouble(), + -1800, + 1800, + titleStyle, + valueStyle, + (val) { + setState(() => avatarData.mouth.rotation = val.toInt()); + }, + onDragEnd: _saveAvatarData, + ), + _buildSlider( + "weight", + avatarData.mouth.weight.toDouble(), + 0, + 100, + titleStyle, + valueStyle, + (val) { + setState(() => avatarData.mouth.weight = val.toInt()); + }, + onDragEnd: _saveAvatarData, + ), + + SizedBox(height: MediaQuery.of(context).padding.bottom + 20), + ], + ); + } + + Widget _buildMotionControls(BuildContext context) { + TextStyle titleStyle = TextStyle( + color: CupertinoColors.label.resolveFrom(context), + fontSize: 15, + ); + TextStyle valueStyle = TextStyle( + color: CupertinoColors.secondaryLabel.resolveFrom(context), + fontSize: 15, + ); + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildSectionTitle("Joystick"), + Container( + height: 200, + decoration: BoxDecoration( + color: CupertinoColors.tertiarySystemBackground.resolveFrom( + context, + ), + borderRadius: BorderRadius.circular(25), + ), + clipBehavior: Clip.antiAlias, + child: GridCoordinateJoystick( + minX: -1280, + maxX: 1280, + minY: 0, + maxY: 900, + padding: const EdgeInsets.all(25), + showMarking: false, + targetGridSize: 50, + buttonSize: 50, + point: Offset( + motionData.yawServo.angle.toDouble(), + motionData.pitchServo.angle.toDouble(), + ), + onImmediatelyRelease: (point) { + setState(() { + motionData.yawServo.rotate = 0; + motionData.yawServo.angle = point.dx.toInt(); + motionData.pitchServo.angle = point.dy.toInt(); + _saveMotionData(); + }); + }, + ), + ), + + _buildSectionTitle("Yaw Servo"), + _buildSlider( + "angle", + motionData.yawServo.angle.toDouble(), + -1280, + 1280, + titleStyle, + valueStyle, + (val) { + setState(() { + motionData.yawServo.rotate = 0; + motionData.yawServo.angle = val.toInt(); + }); + }, + onDragEnd: _saveMotionData, + ), + _buildSlider( + "speed", + motionData.yawServo.speed.toDouble(), + 0, + 1000, + titleStyle, + valueStyle, + (val) { + setState(() => motionData.yawServo.speed = val.toInt()); + }, + onDragEnd: _saveMotionData, + ), + _buildSlider( + "rotate", + motionData.yawServo.rotate.toDouble(), + -1000, + 1000, + titleStyle, + valueStyle, + (val) { + setState(() { + motionData.yawServo.angle = 0; + motionData.yawServo.rotate = val.toInt(); + }); + }, + onDragEnd: _saveMotionData, + ), + + _buildSectionTitle("Pitch Servo"), + _buildSlider( + "angle", + motionData.pitchServo.angle.toDouble(), + 0, + 900, + titleStyle, + valueStyle, + (val) { + setState(() => motionData.pitchServo.angle = val.toInt()); + }, + onDragEnd: _saveMotionData, + ), + _buildSlider( + "speed", + motionData.pitchServo.speed.toDouble(), + 0, + 1000, + titleStyle, + valueStyle, + (val) { + setState(() => motionData.pitchServo.speed = val.toInt()); + }, + onDragEnd: _saveMotionData, + ), + SizedBox(height: MediaQuery.of(context).padding.bottom + 20), + ], + ); + } + + Widget _buildSlider( + String label, + double value, + double min, + double max, + TextStyle titleStyle, + TextStyle valueStyle, + ValueChanged onChanged, { + VoidCallback? onDragEnd, + }) { + return Row( + children: [ + SizedBox(width: 60, child: Text(label, style: titleStyle)), + Expanded( + child: CupertinoSlider( + value: value, + min: min, + max: max, + onChanged: onChanged, + onChangeEnd: (_) => onDragEnd?.call(), + ), + ), + SizedBox( + width: 50, + child: Align( + alignment: Alignment.centerRight, + child: Text(value.toInt().toString(), style: valueStyle), + ), + ), + ], + ); + } + + Widget _buildSectionTitle(String title) { + return Padding( + padding: const EdgeInsets.only(top: 16, bottom: 8), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + title, + style: CupertinoTheme.of(context).textTheme.navTitleTextStyle, + ), + ), + ); + } +} diff --git a/app/lib/view/popup/select_blue_device.dart b/app/lib/view/popup/select_blue_device.dart new file mode 100644 index 0000000..cfc5b5f --- /dev/null +++ b/app/lib/view/popup/select_blue_device.dart @@ -0,0 +1,501 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter_blue_plus/flutter_blue_plus.dart'; +import 'package:flutter_svg/svg.dart'; +import 'package:get/get.dart'; +import 'package:stack_chan/util/XiaoZhi_util.dart'; +import 'package:stack_chan/util/rsa_util.dart'; +import 'package:stack_chan/view/popup/login_page.dart'; + +import '../../app_state.dart'; +import '../../model/blue_device_info.dart'; +import '../../model/blue_model.dart'; +import '../../util/blue_util.dart'; +import '../../util/mac_address_validator.dart'; +import '../../util/value_constant.dart'; +import 'device_name_page.dart'; + +enum DeviceWifiConfigStep { selectDevice, wifiConfig } + +///scanBluetoothbinddevicepopup +class SelectBlueDevice extends StatefulWidget { + const SelectBlueDevice({super.key}); + + @override + State createState() => _SelectBlueDeviceState(); +} + +class _SelectBlueDeviceState extends State { + //connecttimeouttimer(preventNoWait) + Timer? _connectTimer; + + //verifytimeouttimer(preventdeviceNoresponsewhen1to) + Timer? _verifyTimer; + + //timeouttime:30Second(s) + static const int _connectTimeout = 30; + + //verifytimeouttime:20Second(s) + static const int _verifyTimeout = 20; + + String? _getDeviceId(BlueDeviceInfo blueDeviceInfo) { + final Map> manufacturerDataMap = + blueDeviceInfo.advertisementData[ValueConstant.manufacturerData]; + if (manufacturerDataMap.isNotEmpty) { + final MapEntry> firstEntry = + manufacturerDataMap.entries.first; + final List customData = firstEntry.value; + final address = customData.map((byte) { + return byte.toRadixString(16).padLeft(2, '0').toUpperCase(); + }).join(); + return address; + } + return null; + } + + RxString connectDeviceId = RxString(""); + + @override + void dispose() { + _connectTimer?.cancel(); + _verifyTimer?.cancel(); + BlueUtil.shared.characteristicCallback = null; + BlueUtil.shared.wifiSetCharacteristicCall = null; + BlueUtil.shared.onReconnectSuccess = null; + connectDeviceId.value = ""; + isSuccess = false; + super.dispose(); + } + + bool isSuccess = false; + + @override + void initState() { + super.initState(); + //enterbindStreamProcess / Thread,setbindflag + _registerBlueCallbacks(); + BlueUtil.shared.onReconnectSuccess = (device) { + if (mounted) { + AppState.shared.showToast("Reconnected to device successfully."); + } + _registerBlueCallbacks(); + //reconnectafterretriggerdeviceverify(key:originallogiconlyhasfirstconnectwhentrigger) + if (BlueUtil.shared.writeWifiSetCharacteristic != null) { + startVerificationEquipment(BlueUtil.shared.writeWifiSetCharacteristic!); + } + }; + } + + //resetconnectstate(System1wrap) + void _resetConnectState() { + connectDeviceId.value = ""; + _connectTimer?.cancel(); + _verifyTimer?.cancel(); + if (mounted) { + setState(() {}); + } + } + + void _registerBlueCallbacks() { + BlueUtil.shared.characteristicCallback = (device, characteristic) async { + final properties = characteristic.properties; + final canWrite = properties.write || properties.writeWithoutResponse; + if (canWrite) { + final uuid = characteristic.uuid.toString(); + if (uuid == BlueUtil.wifiSetCharacteristicUUID) { + BlueUtil.shared.writeWifiSetCharacteristic = characteristic; + if (Platform.isAndroid) { + await device + .requestMtu(512) + .then((mtu) { + }) + .catchError((e) { + if (mounted) { + AppState.shared.showToast( + "Failed to set MTU. Device may not work properly.", + ); + } + }); + } + //enablefeatureValuenotify + try { + await characteristic.setNotifyValue(true); + startVerificationEquipment(characteristic); + } catch (e) { + _resetConnectState(); + if (mounted) { + AppState.shared.showToast( + "Failed to enable device notification.", + ); + } + } + return; + } + } + if (mounted) { + AppState.shared.showToast("Device configuration feature not found."); + } + }; + BlueUtil.shared.wifiSetCharacteristicCall = (data) async { + try { + String json = utf8.decode(data); + final model = BlueNotifyStateModel.fromJson(json); + + if (model == null) { + if (mounted) { + AppState.shared.showToast("Failed to parse device data."); + } + return; + } + + if (model.cmd != null && + model.cmd == "notifyState" && + model.data?.type == 4) { + //toEncryptinfo + String? data = model.data?.state; + if (data == null) { + if (mounted) { + AppState.shared.showToast( + "Device did not return encryption data.", + ); + } + return; + } + + //RSADecrypt + final result = RsaUtil.decryptStackChanBlue(data); + //newIncrease / Add:Decryptfail/lengthNot + if (result.isEmpty || result.length < 12) { + if (mounted) { + AppState.shared.showToast( + "Device verification decryption failed.", + ); + } + return; + } + + if (isSuccess) return; + isSuccess = true; + _connectTimer?.cancel(); //cancelconnecttimeouttimer + _verifyTimer?.cancel(); //cancelverifytimeouttimer + + final macAddress = result.substring(0, 12); + + AppState.shared.showToast( + "The initially activated MAC address: $macAddress", + ); + + AppState.shared.deviceMac = macAddress; + AppState.shared.connectWebSocket(); + activateDevice(macAddress); + } + } catch (e) { + if (mounted) { + AppState.shared.showToast("Failed to process device data."); + } + } + }; + } + + String formatMacAddress(String mac) { + if (mac.length != 12) return mac; + return mac + .toUpperCase() + .replaceAllMapped(RegExp(r'(.{2})'), (match) => '${match.group(1)}:') + .substring(0, 17); //removeafter + } + + bool isValidMac(String mac) { + final RegExp macRegex = RegExp(r'^[0-9A-Fa-f]{12}$'); + return macRegex.hasMatch(mac); + } + + //activatedevice + Future activateDevice(String macAddress) async { + try { + ///startqueryagentconfiginfo + bool isConfiguration = await queryConfiguration(macAddress); + if (!isConfiguration) { + _resetConnectState(); + //activatefail(Alreadyhashint,NoRepeat) + return; + } + + if (mounted) { + AppState.shared.showToast("The AI Agent has been configured."); + } + + //binddevice + bool result = await AppState.shared.bindDevice(macAddress); + if (result) { + _resetConnectState(); + if (mounted) { + AppState.shared.showToast("Device bound successfully!"); + //configjump + Navigator.of(context).push( + CupertinoPageRoute(builder: (context) => const DeviceNamePage()), + ); + } + } else { + _resetConnectState(); + //newIncrease / Add:binddevicefail + if (mounted) { + AppState.shared.showToast("Device binding failed. Please try again."); + } + } + } catch (e) { + _resetConnectState(); + if (mounted) { + AppState.shared.showToast("Device activation exception."); + } + } + } + + //querydeviceconfig(StreamProcess / Threaderrorhint) + Future queryConfiguration(String macAddress) async { + try { + //1. querydevicewhetheractivated (laterNotAgainquery directactivate) + // final devices = await XiaoZhiUtil.shared.getDevice(macAddress); + // if (devices.isNotEmpty) { + // if (devices.first.agent_id != null) { + //debugPrint("✅ deviceactivated"); + // return true; + // } + // } + + //2. generatelicense + final generateLicense = await XiaoZhiUtil.shared.generateLicense( + macAddress, + ); + if (generateLicense == null || generateLicense.serialNumber == null) { + if (mounted) { + AppState.shared.showToast("Failed to generate device license."); + } + return false; + } + + //3. activatedevice + final serialNumber = generateLicense.serialNumber!; + final mac = MacAddressValidator.formatMac(AppState.shared.deviceMac); + if (mac == null) { + AppState.shared.showToast("Failed to format device MAC address."); + return false; + } + bool activateResult = await XiaoZhiUtil.shared.agentsDevicesActivate( + serialNumber, + mac, + ); + if (!activateResult) { + if (mounted) { + AppState.shared.showToast("Device cloud activation failed."); + } + return false; + } + + ///deviceMayexist Notactivate,Needverify + final checkDevice = await XiaoZhiUtil.shared.serialNumberGetDevice( + serialNumber, + ); + if (checkDevice == null || checkDevice.agent_id == null) { + //activatefail + return false; + } else { + AppState.shared.showToast("Device activation successful"); + return true; + } + } catch (e) { + if (mounted) { + AppState.shared.showToast("Failed to query device configuration."); + } + return false; + } + } + + @override + Widget build(BuildContext context) { + return CupertinoPageScaffold( + backgroundColor: CupertinoColors.systemGroupedBackground.resolveFrom( + context, + ), + child: CustomScrollView( + slivers: [ + CupertinoSliverNavigationBar( + largeTitle: Text("Select Device"), + leading: SizedBox.shrink(), + trailing: CupertinoButton( + padding: .zero, + child: Icon( + CupertinoIcons.xmark_circle_fill, + size: 25, + color: CupertinoColors.separator.resolveFrom(context), + ), + onPressed: () { + AppState.shared.manualShutdownTime = DateTime.now(); + BlueUtil.shared.characteristicCallback = null; + BlueUtil.shared.wifiSetCharacteristicCall = null; + BlueUtil.shared.onReconnectSuccess = null; + CupertinoSheetRoute.popSheet(context); + }, + ), + ), + SliverList.list( + children: [ + Obx( + () => CupertinoListSection.insetGrouped( + children: AppState.shared.blueDeviceList.isNotEmpty + ? AppState.shared.blueDeviceList.map((deviceInfo) { + String? deviceId = _getDeviceId(deviceInfo); + return CupertinoListTile( + onTap: () async { + if (!AppState.shared.isLogin.value) { + if (mounted) { + Navigator.of(context).push( + CupertinoPageRoute( + builder: (context) => + const LoginPage(isWelCome: true), + ), + ); + } + return; + } + + //Repeatclick:Alreadyinconnectin + final deviceRemoteId = deviceInfo.device.remoteId + .toString(); + if (deviceRemoteId == connectDeviceId.value) { + if (mounted) { + AppState.shared.showToast( + "Already connecting... Please wait patiently.", + ); + } + return; + } + + //startconnect + isSuccess = false; + connectDeviceId.value = deviceRemoteId; + AppState.shared.blueDeviceList.refresh(); + + //newIncrease / Add:connecttimeouttimer + _connectTimer?.cancel(); + _connectTimer = Timer( + Duration(seconds: _connectTimeout), + () { + _resetConnectState(); + if (mounted) { + AppState.shared.showToast( + "Device connection timed out. Please try again.", + ); + } + }, + ); + + //newIncrease / Add:BluetoothconnectThrowsCatch + try { + if (mounted) { + AppState.shared.showToast( + "Connecting to device...", + ); + } + await BlueUtil.shared.connect( + deviceInfo.device, + ); + } catch (e) { + _resetConnectState(); + if (mounted) { + AppState.shared.showToast( + "Bluetooth connection failed: ${e.toString()}", + ); + } + } + }, + leading: Image.asset( + "assets/image1.png", + width: 28, + height: 28, + ), + title: Text(deviceInfo.device.advName), + subtitle: deviceId != null + ? Text("ID: $deviceId") + : null, + trailing: + deviceInfo.device.remoteId.toString() == + connectDeviceId.value + ? const CupertinoActivityIndicator() + : SvgPicture.asset( + "assets/chevron.right.svg", + width: 15, + height: 15, + colorFilter: ColorFilter.mode( + CupertinoColors.secondaryLabel + .resolveFrom(context), + BlendMode.srcIn, + ), + ), + ); + }).toList() + : [CupertinoListTile(title: Text("No devices found"))], + ), + ), + ], + ), + ], + ), + ); + } + + //startverifydevice(senddata) + void startVerificationEquipment( + BluetoothCharacteristic characteristic, + ) async { + try { + if (mounted) { + AppState.shared.showToast("Verifying device..."); + } + //startverifytimeouttimer + _verifyTimer?.cancel(); + _verifyTimer = Timer(Duration(seconds: _verifyTimeout), () { + _resetConnectState(); + if (mounted) { + AppState.shared.showToast( + "Device verification timed out. Please try again.", + ); + } + }); + //data + final dateTimeString = DateTime.now().millisecondsSinceEpoch.toString(); + final BlueEncryptionDecryption data = BlueEncryptionDecryption( + cmd: "handshake", + data: dateTimeString, + ); + final jsonString = jsonEncode(data.toJson()); + + //senddata + bool result = await BlueUtil.shared.sendWifiSetData(jsonString); + if (result) { + } else { + _verifyTimer?.cancel(); + AppState.shared.showToast( + "The equipment may have been disconnected. Please reconfigure it on the StackChan end.", + ); + _resetConnectState(); + } + } catch (e) { + _verifyTimer?.cancel(); + _resetConnectState(); + if (mounted) { + AppState.shared.showToast( + "Failed to send device verification command.", + ); + } + } + } +} diff --git a/app/lib/view/popup/user_info_page.dart b/app/lib/view/popup/user_info_page.dart new file mode 100644 index 0000000..7dd7fae --- /dev/null +++ b/app/lib/view/popup/user_info_page.dart @@ -0,0 +1,203 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +import 'package:flutter/cupertino.dart'; +import 'package:get/get.dart'; +import 'package:stack_chan/app_state.dart'; + +class UserInfoPage extends StatefulWidget { + const UserInfoPage({super.key}); + + @override + State createState() => _UserInfoPageState(); +} + +class _UserInfoPageState extends State { + @override + void initState() { + super.initState(); + if (AppState.shared.userInfo.value == null) { + AppState.shared.getUserInfo(); + } + } + + //timeto + String _formatTimestamp(int? timestamp) { + if (timestamp == null) return "Unknown"; + final date = DateTime.fromMillisecondsSinceEpoch(timestamp); + return "${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}"; + } + + //emailvalidatestate + String _getEmailStatus(int? status) { + if (status == 1) return "Verified"; + return "Unverified"; + } + + @override + Widget build(BuildContext context) { + return ClipRSuperellipse( + borderRadius: .circular(12), + child: CupertinoPageScaffold( + backgroundColor: CupertinoColors.systemGroupedBackground.resolveFrom( + context, + ), + navigationBar: CupertinoNavigationBar( + middle: Obx( + () => + Text(AppState.shared.userInfo.value?.displayName ?? "Profile"), + ), + trailing: CupertinoButton( + padding: .zero, + child: Icon( + CupertinoIcons.xmark_circle_fill, + size: 25, + color: CupertinoColors.separator.resolveFrom(context), + ), + onPressed: () { + Navigator.pop(context); + }, + ), + ), + child: Obx(() { + if (AppState.shared.userInfo.value == null) { + return const Center(child: CupertinoActivityIndicator(radius: 14)); + } + + final userInfo = AppState.shared.userInfo.value!; + return ListView( + children: [ + //====================== centerAvatar ====================== + Center( + child: Container( + width: 100, + height: 100, + decoration: BoxDecoration( + color: + _hexToColor(userInfo.iconBgColor) ?? + CupertinoColors.systemBlue, + shape: BoxShape.circle, + ), + child: Center( + child: Text( + userInfo.iconText ?? "?", + style: const TextStyle( + color: CupertinoColors.white, + fontSize: 36, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ), + const SizedBox(height: 30), + + CupertinoListSection.insetGrouped( + children: [ + _buildInfoItem( + "User ID", + userInfo.uid?.toString() ?? "Unknown", + ), + _buildInfoItem("Username", userInfo.username ?? "Unknown"), + _buildInfoItem( + "Display Name", + userInfo.displayName ?? "Unknown", + ), + _buildInfoItem("User Slug", userInfo.userslug ?? "Unknown"), + _buildInfoItem( + "Account Status", + userInfo.userStatus ?? "Normal", + ), + _buildInfoItem( + "Email Verification", + _getEmailStatus(userInfo.emailConfirmed), + ), + _buildInfoItem( + "Registered", + _formatTimestamp(userInfo.joinDate), + ), + _buildInfoItem( + "Last Online", + _formatTimestamp(userInfo.lastOnline), + ), + ], + ), + + //====================== exitloginbutton ====================== + CupertinoListSection.insetGrouped( + children: [ + CupertinoListTile( + title: Center( + child: Text( + "Log Out", + style: TextStyle( + color: CupertinoColors.destructiveRed.resolveFrom( + context, + ), + fontWeight: FontWeight.w500, + ), + ), + ), + onTap: () { + _showLogoutDialog(); + }, + ), + ], + ), + ], + ); + }), + ), + ); + } + + //infocomponent + Widget _buildInfoItem(String title, String value) { + return CupertinoListTile( + title: Text(title, style: TextStyle(fontWeight: FontWeight.w500)), + trailing: Text(value), + ); + } + + //exitconfirmpopup + void _showLogoutDialog() { + showCupertinoDialog( + context: context, + builder: (context) => CupertinoAlertDialog( + title: const Text("Confirm Log Out"), + content: const Text("You will need to log in again after logging out."), + actions: [ + CupertinoDialogAction( + child: Text("Cancel"), + onPressed: () => Navigator.of(context).pop(), + ), + CupertinoDialogAction( + isDestructiveAction: true, + onPressed: () async { + Navigator.of(context).pop(); + await AppState.shared.logout(); + if (mounted) { + Navigator.of(this.context).pop(); + } + }, + child: const Text("Log Out"), + ), + ], + ), + ); + } + + //HexcolortoFluttercolor + Color? _hexToColor(String? hexString) { + if (hexString == null || hexString.isEmpty) return null; + hexString = hexString.replaceAll("#", ""); + if (hexString.length == 6) hexString = "FF$hexString"; + try { + return Color(int.parse("0x$hexString")); + } catch (e) { + return null; + } + } +} diff --git a/app/lib/view/popup/xiaozhi_welcome_page.dart b/app/lib/view/popup/xiaozhi_welcome_page.dart new file mode 100644 index 0000000..54fa151 --- /dev/null +++ b/app/lib/view/popup/xiaozhi_welcome_page.dart @@ -0,0 +1,908 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +import 'package:flutter/cupertino.dart'; +import 'package:get/get.dart'; +import 'package:pull_down_button/pull_down_button.dart'; +import 'package:stack_chan/model/XiaoZhi/agent.dart'; +import 'package:stack_chan/util/extension.dart'; +import 'package:stack_chan/util/music_util.dart'; +import 'package:stack_chan/view/popup/device_wifi_config.dart'; + +import '../../app_state.dart'; +import '../../model/XiaoZhi/XiaoZhi_model.dart'; +import '../../model/XiaoZhi/agent_create.dart'; +import '../../model/XiaoZhi/common_mcp_tool.dart'; +import '../../model/XiaoZhi/tts_list.dart'; +import '../../util/XiaoZhi_util.dart'; +import '../../util/mac_address_validator.dart'; +import '../../util/value_constant.dart'; + +class XiaoZhiWelcomePage extends StatefulWidget { + const XiaoZhiWelcomePage({super.key, this.isWelCome}); + + final bool? isWelCome; + + @override + State createState() => _XiaoZhiWelcomePageState(); +} + +class XiaoZhiEditAgentModel extends GetxController { + Agent? agent; + + final RxBool isLoading = false.obs; + + late TextEditingController agentNameController; + late TextEditingController assistantNameController; + late TextEditingController characterController; + late TextEditingController memoryController; + + final Rxn selectedModel = Rxn(); + final Rxn selectedTtsVoice = Rxn(); + final RxString selectedLanguage = "".obs; + final RxString ttsSpeed = "normal".obs; + final RxInt ttsPitch = 0.obs; + final RxString asrSpeed = "normal".obs; + final RxString memoryType = "SHORT_TERM".obs; + final List selectedMcpEndpoints = []; + + TTsList? ttsData; + RxList ttsList = RxList([]); + + RxList languageList = RxList([]); + + RxList modelList = RxList([]); + RxList commonMcpTools = RxList([]); + + final List speedList = ["slow", "normal", "fast"]; + final List pitchList = [-2, -1, 0, 1, 2]; + final List memoryTypeList = ["OFF", "SHORT_TERM"]; + + Future initPageData(bool isWelCome) async { + agentNameController = TextEditingController(); + assistantNameController = TextEditingController(); + characterController = TextEditingController(); + memoryController = TextEditingController(); + ever(selectedLanguage, (lang) => _updateTtsVoiceList(lang)); + await loadCommonMcpTools(); + await loadTtsList(); + await loadModelList(); + final devices = await XiaoZhiUtil.shared.getDevice( + AppState.shared.deviceMac, + ); + + if (devices.isNotEmpty) { + if (devices.first.agent_id != null) { + if (isWelCome) { + final originallyAgent = await XiaoZhiUtil.shared.getAgentDetail( + devices.first.agent_id!, + ); + + //fromtemplateGet + final agentTemplatesList = await XiaoZhiUtil.shared + .agentTemplatesList(1, 10); + + if (agentTemplatesList.isNotEmpty) { + final template = agentTemplatesList.first; + agent = Agent( + id: originallyAgent?.id, + user_name: template.user_name, + agent_name: template.agent_name, + assistant_name: template.assistant_name, + llm_model: template.llm_model, + tts_voice: getTtsVoice("en", template.tts_voices ?? []), + tts_speech_speed: template.tts_speech_speed, + tts_pitch: template.tts_pitch, + asr_speed: template.asr_speed, + language: "en", + character: template.character, + memory: "", + memory_type: "SHORT_TERM", + knowledge_base_ids: template.knowledge_base_ids, + ); + fillEditData(agent!); + } else { + agent = originallyAgent; + fillEditData(agent!); + } + } else { + //update + agent = await XiaoZhiUtil.shared.getAgentDetail( + devices.first.agent_id!, + ); + fillEditData(agent!); + } + } else { + ///hasactivateagent,Needactivateagent + //2. generatelicense + final generateLicense = await XiaoZhiUtil.shared.generateLicense( + AppState.shared.deviceMac, + ); + if (generateLicense == null || generateLicense.serialNumber == null) { + AppState.shared.showToast("Failed to generate device license."); + return; + } + + //3. activatedevice + final serialNumber = generateLicense.serialNumber!; + final mac = MacAddressValidator.formatMac(AppState.shared.deviceMac); + if (mac == null) { + AppState.shared.showToast("Failed to format device MAC address."); + return; + } + bool activateResult = await XiaoZhiUtil.shared.agentsDevicesActivate( + serialNumber, + mac, + ); + if (!activateResult) { + AppState.shared.showToast("Device cloud activation failed."); + return; + } + + //activatedevicesuccess + initPageData(isWelCome); + } + } else { + setDefaultCreateData(); + } + update(); + } + + String getTtsVoice(String language, List ttsVoices) { + final String prefix = '$language:'; + for (final String voice in ttsVoices) { + if (voice.startsWith(prefix)) { + return voice.substring(prefix.length); + } + } + if (ttsVoices.isNotEmpty) { + for (final String voice in ttsVoices) { + final int idx = voice.indexOf(':'); + if (idx != -1) { + return voice.substring(idx + 1); + } + } + return ttsVoices[0]; + } + return ''; + } + + Future loadTtsList() async { + ttsData = await XiaoZhiUtil.shared.getTtsList(); + if (ttsData?.ttsVoices != null) { + languageList.value = ttsData!.ttsVoices!.keys.toList(); + } + if (languageList.isNotEmpty && selectedLanguage.isEmpty) { + selectedLanguage.value = languageList.first; + } + _updateTtsVoiceList(selectedLanguage.value); + } + + void _updateTtsVoiceList(String lang) { + if (ttsData?.ttsVoices == null || lang.isEmpty) { + ttsList.clear(); + selectedTtsVoice.value = null; + return; + } + ttsList.value = ttsData!.ttsVoices![lang] ?? []; + selectedTtsVoice.value = ttsList.isNotEmpty ? ttsList.first : null; + update(); + } + + Future loadModelList() async { + final models = await XiaoZhiUtil.shared.getModelList(); + modelList.assignAll(models); + update(); + } + + Future loadCommonMcpTools() async { + commonMcpTools.value = await XiaoZhiUtil.shared.getCommonMcpTool(); + update(); + } + + String getContext(String? context, String defaultString) { + if (context == null) { + return defaultString; + } else if (context.isEmpty) { + return defaultString; + } else { + return context; + } + } + + void fillEditData(Agent agent) { + agentNameController.text = getContext( + agent.agent_name, + "StackChan AI Agent", + ); + assistantNameController.text = getContext( + agent.assistant_name, + "StackChan", + ); + characterController.text = agent.character ?? ""; + memoryController.text = agent.memory ?? ""; + if (languageList.contains(agent.language)) { + selectedLanguage.value = agent.language!; + } else if (languageList.isNotEmpty) { + selectedLanguage.value = languageList.first; + } + + ttsSpeed.value = agent.tts_speech_speed ?? "normal"; + ttsPitch.value = agent.tts_pitch ?? 0; + asrSpeed.value = agent.asr_speed ?? "normal"; + memoryType.value = agent.memory_type ?? "SHORT_TERM"; + + if (agent.llm_model != null && agent.llm_model!.isNotEmpty) { + selectedModel.value = modelList.firstWhereOrNull( + (m) => m.name == agent.llm_model, + ); + } else { + for (var i in modelList) { + if (i.name != null) { + if (i.name!.toLowerCase() == "qwen") { + selectedModel.value = i; + update(); + break; + } else if (i.name!.toLowerCase().contains("qwen")) { + selectedModel.value = i; + update(); + break; + } + } + } + } + + if (agent.tts_voice != null) { + Future.delayed(const Duration(milliseconds: 100), () { + selectedTtsVoice.value = ttsList.firstWhereOrNull( + (t) => t.voiceId == agent.tts_voice, + ); + update(); + }); + } + + if (agent.mcp_endpoints != null) { + selectedMcpEndpoints.addAll(agent.mcp_endpoints!); + } + update(); + } + + void setDefaultCreateData() { + agentNameController.text = "My AI Agent"; + assistantNameController.text = "StackChan"; + ttsSpeed.value = "normal"; + ttsPitch.value = 0; + asrSpeed.value = "normal"; + memoryType.value = "SHORT_TERM"; + if (modelList.isNotEmpty) selectedModel.value = modelList.first; + update(); + } + + void toggleMcpTool(String? endpointId) { + if (endpointId == null) return; + selectedMcpEndpoints.contains(endpointId) + ? selectedMcpEndpoints.remove(endpointId) + : selectedMcpEndpoints.add(endpointId); + update(); + } + + Future submitAgent() async { + if (assistantNameController.text.trim().isEmpty) { + AppState.shared.showToast("Please input assistant assistant name."); + return false; + } + if (selectedModel.value == null) { + AppState.shared.showToast("Please select an LLM Model."); + return false; + } + if (selectedTtsVoice.value == null) { + AppState.shared.showToast("Please select a voice tone."); + return false; + } + isLoading.value = true; + final agentParams = AgentCreate( + agent_name: agent?.agent_name ?? "StackChan AI Agent", + assistant_name: assistantNameController.text.trim(), + llm_model: selectedModel.value!.name!, + tts_voice: selectedTtsVoice.value!.voiceId!, + tts_speech_speed: ttsSpeed.value, + tts_pitch: ttsPitch.value, + asr_speed: asrSpeed.value, + language: selectedLanguage.value, + character: characterController.text.trim(), + memory: memoryController.text.trim(), + memory_type: memoryType.value, + mcp_endpoints: null, + product_mcp_endpoints: null, + ); + bool result = false; + if (agent != null) { + result = await XiaoZhiUtil.shared.updateAgent(agent!.id!, agentParams); + } else { + final agentId = await XiaoZhiUtil.shared.createAgent(agentParams); + result = agentId != null; + } + + isLoading.value = false; + if (result) { + AppState.shared.showToast( + agent != null + ? "Agent edited successfully" + : "Agent created successfully", + ); + } + return result; + } + + @override + void onClose() { + agentNameController.dispose(); + assistantNameController.dispose(); + characterController.dispose(); + memoryController.dispose(); + super.onClose(); + } +} + +class _XiaoZhiWelcomePageState extends State { + String getLanguagesTitle(String lg) { + if (ValueConstant.languages[lg] != null) { + return ValueConstant.languages[lg]!; + } else { + return lg; + } + } + + String getSpeedText(String speed) { + switch (speed) { + case "slow": + return "Slow"; + case "fast": + return "Fast"; + default: + return "Normal"; + } + } + + String getMemoryText(String type) { + switch (type) { + case "OFF": + return "Off"; + case "SHORT_TERM": + return "Short Term"; + default: + return type; + } + } + + XiaoZhiEditAgentModel model = XiaoZhiEditAgentModel(); + + @override + void initState() { + super.initState(); + init(); + } + + Future init() async { + model.initPageData(widget.isWelCome ?? false); + setState(() {}); + model.ttsList.listen((list) { + setState(() {}); + }); + model.selectedTtsVoice.listen((data) { + setState(() {}); + }); + } + + @override + void dispose() { + MusicUtil.shared.stopMusic(); + model.onClose(); + if (mounted) { + FocusScope.of(context).unfocus(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return CupertinoPageScaffold( + backgroundColor: CupertinoColors.systemGroupedBackground.resolveFrom( + context, + ), + child: GestureDetector( + onTap: () { + if (mounted) { + FocusScope.of(context).unfocus(); + } + }, + behavior: .opaque, + child: Column( + children: [ + Expanded( + child: CustomScrollView( + slivers: [ + CupertinoSliverNavigationBar( + largeTitle: Text("Agent Setting"), + trailing: widget.isWelCome != true + ? CupertinoButton( + padding: .zero, + child: Icon( + CupertinoIcons.xmark_circle_fill, + size: 25, + color: CupertinoColors.separator.resolveFrom( + context, + ), + ), + onPressed: () { + CupertinoSheetRoute.popSheet(context); + }, + ) + : SizedBox.shrink(), + ), + SliverList.list( + children: [ + Image.asset( + "assets/lateral_image.png", + width: double.infinity, + height: 120, + fit: BoxFit.contain, + ), + SizedBox(height: 15), + + //info + CupertinoListSection.insetGrouped( + header: Row( + mainAxisSize: .min, + spacing: 10, + children: [ + Text("Assistant Name"), + Text( + "This is what Agent calls itself", + style: TextStyle( + fontSize: 10, + color: CupertinoColors.separator.resolveFrom( + context, + ), + ), + ), + ], + ), + children: [ + CupertinoListTile( + padding: .only(left: 10, right: 10), + title: CupertinoTextField( + maxLength: 30, + decoration: BoxDecoration(), + controller: model.assistantNameController, + placeholder: "Enter assistant name", + clearButtonMode: OverlayVisibilityMode.editing, + ), + ), + ], + ), + + //languageset + CupertinoListSection.insetGrouped( + header: Text("Language"), + children: [ + Obx( + () => buildSelectItem( + title: getLanguagesTitle( + model.selectedLanguage.value, + ), + value: Icon( + CupertinoIcons.right_chevron, + size: 16, + ), + items: model.languageList + .map((value) => getLanguagesTitle(value)) + .toList(), + onTapIndex: (index) { + model.selectedLanguage.value = + model.languageList[index]; + }, + selectedValue: getLanguagesTitle( + model.selectedLanguage.value, + ), + ), + ), + ], + ), + + //LLMmodel + CupertinoListSection.insetGrouped( + header: Text("LLM Model"), + children: [ + Obx( + () => buildSelectItem( + title: + model.selectedModel.value?.description + .regularExpressionSubstitution() ?? + "Select model", + value: Icon( + CupertinoIcons.right_chevron, + size: 16, + ), + items: model.modelList + .map( + (e) => + e.description + .regularExpressionSubstitution() ?? + "", + ) + .toList(), + onTapIndex: (index) { + model.selectedModel.value = + model.modelList[index]; + }, + selectedValue: model + .selectedModel + .value + ?.description + .regularExpressionSubstitution(), + ), + ), + ], + ), + //TTSset + CupertinoListSection.insetGrouped( + header: Text("Voice Settings"), + children: [ + //voice tone + ttsVoiceWidget(), + //translated comment + Obx( + () => buildSelectItem( + title: "Speed :", + value: Row( + mainAxisSize: .min, + spacing: 15, + children: [ + Text(getSpeedText(model.ttsSpeed.value)), + Icon(CupertinoIcons.right_chevron, size: 16), + ], + ), + items: model.speedList + .map((e) => getSpeedText(e)) + .toList(), + onTapIndex: (index) { + model.ttsSpeed.value = model.speedList[index]; + }, + selectedValue: getSpeedText(model.ttsSpeed.value), + ), + ), + //translated comment + Obx( + () => buildSelectItem( + title: "Pitch :", + value: Row( + mainAxisSize: .min, + spacing: 15, + children: [ + Text(model.ttsPitch.value.toString()), + Icon(CupertinoIcons.right_chevron, size: 16), + ], + ), + items: model.pitchList + .map((e) => e.toString()) + .toList(), + onTapIndex: (index) { + model.ttsPitch.value = model.pitchList[index]; + }, + selectedValue: model.ttsPitch.value.toString(), + ), + ), + //ASR + Obx( + () => buildSelectItem( + title: "ASR Speed :", + value: Row( + mainAxisSize: .min, + spacing: 15, + children: [ + Text(getSpeedText(model.asrSpeed.value)), + Icon(CupertinoIcons.right_chevron, size: 16), + ], + ), + items: model.speedList + .map((e) => getSpeedText(e)) + .toList(), + onTapIndex: (index) { + model.asrSpeed.value = model.speedList[index]; + }, + selectedValue: getSpeedText(model.asrSpeed.value), + ), + ), + ], + ), + + //with + CupertinoListSection.insetGrouped( + header: Text("Personality"), + children: [ + CupertinoListTile( + padding: .all(15), + title: CupertinoTextField( + padding: .all(15), + controller: model.characterController, + placeholder: "Set agent character", + maxLines: 10, + clearButtonMode: OverlayVisibilityMode.editing, + decoration: BoxDecoration( + color: CupertinoColors.systemGroupedBackground + .resolveFrom(context), + borderRadius: .circular(15), + ), + ), + ), + ], + ), + CupertinoListSection.insetGrouped( + header: Text("Memory"), + children: [ + Obx( + () => buildSelectItem( + title: "", + value: Row( + mainAxisSize: .min, + spacing: 15, + children: [ + Text(getMemoryText(model.memoryType.value)), + Icon(CupertinoIcons.right_chevron, size: 16), + ], + ), + items: model.memoryTypeList + .map((e) => getMemoryText(e)) + .toList(), + onTapIndex: (index) { + model.memoryType.value = + model.memoryTypeList[index]; + }, + selectedValue: getMemoryText( + model.memoryType.value, + ), + ), + ), + CupertinoListTile( + padding: .all(15), + title: CupertinoTextField( + controller: model.memoryController, + placeholder: "Set memory content", + maxLines: 10, + padding: .all(15), + decoration: BoxDecoration( + color: CupertinoColors.systemGroupedBackground + .resolveFrom(context), + borderRadius: .circular(15), + ), + clearButtonMode: OverlayVisibilityMode.editing, + ), + ), + ], + ), + + // MCP Tools + // CupertinoListSection.insetGrouped( + // header: Text("MCP Tools"), + // children: model.commonMcpTools.isNotEmpty + // ? model.commonMcpTools.map((tool) { + // final isSelected = model.selectedMcpEndpoints + // .contains(tool.endpoint_id); + // return CupertinoListTile( + // onTap: () { + // model.toggleMcpTool(tool.endpoint_id); + // setState(() {}); + // }, + // title: Text(tool.name ?? ""), + // trailing: isSelected + // ? Icon( + // CupertinoIcons.check_mark, + // size: 20, + // ) + // : null, + // ); + // }).toList() + // : [ + // CupertinoListTile( + // title: Center( + // child: Text( + // "There is currently no MCP tool", + // ), + // ), + // ), + // ], + // ), + SizedBox(height: 20), + ], + ), + ], + ), + ), + + //Bottombutton + Padding( + padding: .only( + left: 15, + right: 15, + top: 15, + bottom: MediaQuery.paddingOf(context).bottom + 15, + ), + child: Obx( + () => model.isLoading.value + ? CupertinoActivityIndicator(radius: 16) + : widget.isWelCome == true + ? Row( + spacing: 15, + children: [ + Expanded( + child: CupertinoButton.tinted( + child: Text("Skip"), + onPressed: () { + if (mounted) { + FocusScope.of(context).unfocus(); + } + Navigator.of(context).push( + CupertinoPageRoute( + builder: (context) { + return DeviceWifiConfig(isWelCome: true); + }, + ), + ); + }, + ), + ), + Expanded( + child: CupertinoButton.filled( + child: Text("Continue"), + onPressed: () async { + if (mounted) { + FocusScope.of(context).unfocus(); + } + final res = await model.submitAgent(); + if (res && mounted) { + Navigator.push( + this.context, + CupertinoPageRoute( + builder: (_) => + DeviceWifiConfig(isWelCome: true), + ), + ); + } + }, + ), + ), + ], + ) + : CupertinoButton.filled( + child: SizedBox( + width: .infinity, + child: Center(child: Text("Save")), + ), + onPressed: () async { + if (mounted) { + FocusScope.of(context).unfocus(); + } + final result = await model.submitAgent(); + if (result && mounted) { + showCupertinoDialog( + barrierDismissible: false, + context: this.context, + builder: (context) { + return CupertinoAlertDialog( + title: Text( + "AI agent config has been saved successfully, and will be active after manually restarting the device.", + ), + actions: [ + CupertinoDialogAction( + onPressed: () { + Navigator.of(context).pop(); + Navigator.pop(this.context); + }, + isDefaultAction: true, + child: Text("Confirm"), + ), + ], + ); + }, + ); + } + }, + ), + ), + ), + ], + ), + ), + ); + } + + Widget ttsVoiceWidget() { + return PullDownButton( + useRootNavigator: true, + itemBuilder: (context) => model.ttsList + .map( + (e) => PullDownMenuItem.selectable( + onTap: () { + model.selectedTtsVoice.value = e; + }, + title: e.voiceName ?? "", + selected: model.selectedTtsVoice.value?.voiceId == e.voiceId, + iconWidget: e.voiceDemo != null + ? CupertinoButton( + padding: .zero, + child: Icon(CupertinoIcons.speaker_2), + onPressed: () { + MusicUtil.shared.playUrlMusicOnce( + e.voiceDemo, + completion: () { + setState(() {}); + }, + ); + }, + ) + : null, + ), + ) + .toList(), + buttonBuilder: (context, showMenu) => CupertinoButton( + padding: EdgeInsets.zero, + foregroundColor: CupertinoColors.systemGrey.resolveFrom(context), + minimumSize: .zero, + onPressed: showMenu, + pressedOpacity: 1, + child: CupertinoListTile( + title: Text("Voice :"), + trailing: Row( + mainAxisSize: .min, + spacing: 15, + children: [ + Text(model.selectedTtsVoice.value?.voiceName ?? "Select voice"), + Icon(CupertinoIcons.right_chevron, size: 16), + ], + ), + leading: null, + ), + ), + ); + } + + Widget buildSelectItem({ + required String title, + required Widget value, + required List items, + required Function(int) onTapIndex, + required String? selectedValue, + }) { + return PullDownButton( + useRootNavigator: true, + itemBuilder: (context) => items.indexed + .map( + (item) => PullDownMenuItem.selectable( + onTap: () { + onTapIndex(item.$1); + }, + title: item.$2, + selected: item.$2 == selectedValue, + ), + ) + .toList(), + buttonBuilder: (context, showMenu) { + return CupertinoButton( + padding: EdgeInsets.zero, + foregroundColor: CupertinoColors.systemGrey.resolveFrom(context), + minimumSize: .zero, + onPressed: showMenu, + pressedOpacity: 1, + child: CupertinoListTile( + title: Text(title), + trailing: value, + leading: null, + ), + ); + }, + ); + } +} diff --git a/app/lib/view/util/cupertino_sheet.dart b/app/lib/view/util/cupertino_sheet.dart new file mode 100644 index 0000000..1cdf1c0 --- /dev/null +++ b/app/lib/view/util/cupertino_sheet.dart @@ -0,0 +1,57 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +import 'dart:math'; + +import 'package:flutter/cupertino.dart'; +import 'package:liquid_glass_renderer/liquid_glass_renderer.dart'; + +import '../popup/cupertino_popup_windows.dart'; + +class CupertinoSheet extends StatelessWidget { + const CupertinoSheet({ + super.key, + required this.builder, + this.backgroundColor = CupertinoColors.transparent, + this.sheetSize = .medium, + }); + + final WidgetBuilder builder; + final Color backgroundColor; + final SheetSize sheetSize; + + @override + Widget build(BuildContext context) { + return Padding( + padding: MediaQuery.of(context).padding, + child: LayoutBuilder( + builder: (context, constraints) { + double height; + switch (sheetSize) { + case SheetSize.medium: + height = constraints.maxHeight / 2 * 1; + case SheetSize.large: + height = constraints.maxHeight; + } + return SizedBox( + height: height, + child: Padding( + padding: .all(10), + child: LiquidGlassLayer( + settings: LiquidGlassSettings(lightAngle: 0.25 * pi), + child: LiquidStretch( + child: LiquidGlass( + shape: LiquidRoundedSuperellipse(borderRadius: 25), + child: GlassGlow(child: builder(context)), + ), + ), + ), + ), + ); + }, + ), + ); + } +} diff --git a/app/lib/view/util/glass_effect_circle.dart b/app/lib/view/util/glass_effect_circle.dart new file mode 100644 index 0000000..2c5146f --- /dev/null +++ b/app/lib/view/util/glass_effect_circle.dart @@ -0,0 +1,38 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +import 'package:flutter/cupertino.dart'; + +//translated comment +class GlassEffectCircle extends StatelessWidget { + const GlassEffectCircle({super.key, this.padding, this.child}); + + final EdgeInsetsGeometry? padding; + final Widget? child; + + @override + Widget build(BuildContext context) { + return ClipOval( + clipBehavior: .antiAliasWithSaveLayer, + child: BackdropFilter( + filter: .blur(sigmaX: 10, sigmaY: 10), + child: Padding(padding: padding ?? .zero, child: child), + ), + ); + } +} + +// class GlassEffectRegular extends StatelessWidget { +// const GlassEffectRegular({super.key, this.padding, this.borderRadius}); +// +// final EdgeInsetsGeometry? padding; +// final BorderRadius? borderRadius; +// +// @override +// Widget build(BuildContext context) { +// // TODO: implement build +// throw UnimplementedError(); +// } +// } diff --git a/app/lib/view/util/gradation_page_background.dart b/app/lib/view/util/gradation_page_background.dart new file mode 100644 index 0000000..6195efb --- /dev/null +++ b/app/lib/view/util/gradation_page_background.dart @@ -0,0 +1,32 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +import 'package:flutter/cupertino.dart'; + +class GradationPageBackground extends StatelessWidget { + final Widget? child; + + const GradationPageBackground({super.key, this.child}); + + @override + Widget build(BuildContext context) { + return Container( + width: .infinity, + height: .infinity, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + CupertinoColors.activeBlue.withValues(alpha: 0.5), + CupertinoColors.systemPink.withValues(alpha: 0.1), + CupertinoColors.systemBlue.withValues(alpha: 0.2), + ], + ), + ), + child: child, + ); + } +} diff --git a/app/lib/view/util/grid_coordinate_joystick.dart b/app/lib/view/util/grid_coordinate_joystick.dart new file mode 100644 index 0000000..f6e09f9 --- /dev/null +++ b/app/lib/view/util/grid_coordinate_joystick.dart @@ -0,0 +1,311 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +import 'package:flutter/cupertino.dart'; + +class GridCoordinateJoystick extends StatefulWidget { + const GridCoordinateJoystick({ + super.key, + required this.minX, + required this.maxX, + required this.minY, + required this.maxY, + required this.showMarking, + required this.targetGridSize, + required this.buttonSize, + required this.point, + this.onRelease, + this.padding, + this.onImmediatelyRelease, + }); + + final double minX; + final double maxX; + final double minY; + final double maxY; + final bool showMarking; + final double targetGridSize; + final double buttonSize; + + final EdgeInsetsGeometry? padding; + + final Offset point; + final Function(Offset)? onRelease; + final Function(Offset)? onImmediatelyRelease; + + @override + State createState() => _GridCoordinateJoystickState(); +} + +class _GridCoordinateJoystickState extends State { + bool _isDragging = false; + late Offset _currentPoint; + + @override + void initState() { + super.initState(); + _currentPoint = widget.point; + } + + @override + void didUpdateWidget(covariant GridCoordinateJoystick oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.point != widget.point && !_isDragging) { + _currentPoint = widget.point; + } + } + + //in _GridCoordinateJoystickState ClassinModifyDownmethod + + void _updatePoint(Offset localPosition, Size size) { + //Get padding Value + final padding = + widget.padding?.resolve(Directionality.of(context)) ?? EdgeInsets.zero; + + //calculateCan(Insideafter) + double clickableWidth = size.width - padding.left - padding.right; + double clickableHeight = size.height - padding.top - padding.bottom; + + //willinputlimitin padding rangeInside + double clampedX = localPosition.dx.clamp( + padding.left, + size.width - padding.right, + ); + double clampedY = localPosition.dy.clamp( + padding.top, + size.height - padding.bottom, + ); + + //convertlogic:in (clampedX - padding.left) rangeis 0 to clickableWidth + double normalizedX = + ((clampedX - padding.left) / clickableWidth) * + (widget.maxX - widget.minX) + + widget.minX; + + //Y axis + double normalizedY = + widget.maxY - + ((clampedY - padding.top) / clickableHeight) * + (widget.maxY - widget.minY); + + final newPoint = Offset(normalizedX, normalizedY); + + setState(() { + _currentPoint = newPoint; + }); + + widget.onImmediatelyRelease?.call(newPoint); + } + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final size = constraints.biggest; + final padding = + widget.padding?.resolve(Directionality.of(context)) ?? + EdgeInsets.zero; + + //corecalculatelogic:willcurrentmapBackscreenposition + double clickableWidth = size.width - padding.left - padding.right; + double clickableHeight = size.height - padding.top - padding.bottom; + + double xPercent = + (_currentPoint.dx - widget.minX) / (widget.maxX - widget.minX); + double yPercent = + (_currentPoint.dy - widget.minY) / (widget.maxY - widget.minY); + + //Up padding.left/top asoffset + double xPos = padding.left + (xPercent * clickableWidth); + double yPos = + padding.top + (clickableHeight - (yPercent * clickableHeight)); + + return GestureDetector( + onVerticalDragStart: (_) {}, + onHorizontalDragStart: (_) {}, + child: Listener( + onPointerDown: (event) { + setState(() => _isDragging = true); + _updatePoint(event.localPosition, size); + }, + onPointerMove: (event) { + _updatePoint(event.localPosition, size); + }, + onPointerUp: (event) { + setState(() => _isDragging = false); + widget.onRelease?.call(_currentPoint); + }, + onPointerCancel: (_) => setState(() => _isDragging = false), + behavior: HitTestBehavior.opaque, + child: Stack( + clipBehavior: Clip.none, //buttonor + children: [ + if (widget.showMarking) + Positioned.fill( + child: CustomPaint( + painter: JoystickPainter( + point: _currentPoint, + minX: widget.minX, + maxX: widget.maxX, + minY: widget.minY, + maxY: widget.maxY, + //canaccording toNeedPassed padding For / To Painter draw + padding: padding, + gridCountX: (clickableWidth / widget.targetGridSize) + .floor(), + gridCountY: (clickableHeight / widget.targetGridSize) + .floor(), + accentColor: CupertinoTheme.of(context).primaryColor, + ), + ), + ), + Positioned( + //buttoncanin padding Outsideshow,depends on xPos/yPos + left: xPos - widget.buttonSize / 2, + top: yPos - widget.buttonSize / 2, + child: _buildJoystickButton(context), + ), + ], + ), + ), + ); + }, + ); + } + + Widget _buildJoystickButton(BuildContext context) { + return Container( + width: widget.buttonSize, + height: widget.buttonSize, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: _isDragging + ? CupertinoTheme.of(context).primaryColor.withValues(alpha: 0.8) + : CupertinoTheme.of(context).primaryColor, + boxShadow: [ + BoxShadow( + color: CupertinoColors.systemGrey.withValues(alpha: 0.2), + blurRadius: 3, + ), + ], + ), + alignment: Alignment.center, + child: widget.showMarking + ? Text( + '${_currentPoint.dx.toInt()},${_currentPoint.dy.toInt()}', + style: TextStyle( + color: CupertinoColors.white, + fontSize: (widget.buttonSize * 0.25).clamp(6.0, 12.0), + ), + ) + : null, + ); + } +} + +class JoystickPainter extends CustomPainter { + final Offset point; + final double minX, maxX, minY, maxY; + final int gridCountX, gridCountY; + final Color accentColor; + + final EdgeInsets padding; + + JoystickPainter({ + required this.point, + required this.minX, + required this.maxX, + required this.minY, + required this.maxY, + required this.gridCountX, + required this.gridCountY, + required this.accentColor, + required this.padding, + }); + + @override + void paint(Canvas canvas, Size size) { + final drawArea = Rect.fromLTWH( + padding.left, + padding.top, + size.width - padding.left - padding.right, + size.height - padding.top - padding.bottom, + ); + + final gridSpacingX = drawArea.width / gridCountX; + final gridSpacingY = drawArea.height / gridCountY; + + final gridPaint = Paint() + ..color = CupertinoColors.systemGrey + ..style = PaintingStyle.stroke; + + //1. drawbackgroundand + for (int i = 0; i <= gridCountX; i++) { + double x = i * gridSpacingX; + double opacity = (i == 0 || i == gridCountX) + ? 1.0 + : (i == gridCountX ~/ 2 ? 0.7 : 0.3); + gridPaint.color = CupertinoColors.systemGrey.withValues(alpha: opacity); + gridPaint.strokeWidth = opacity == 1.0 ? 2 : 1; + + canvas.drawLine(Offset(x, 0), Offset(x, size.height), gridPaint); + + //draw X axis + double xValue = i * (maxX - minX) / gridCountX + minX; + _drawText( + canvas, + xValue.toInt().toString(), + Offset(x, size.height - 15), + size, + ); + } + + for (int i = 0; i <= gridCountY; i++) { + double y = i * gridSpacingY; + double opacity = (i == 0 || i == gridCountY) + ? 1.0 + : (i == gridCountY ~/ 2 ? 0.7 : 0.3); + gridPaint.color = CupertinoColors.systemGrey.withValues(alpha: opacity); + gridPaint.strokeWidth = opacity == 1.0 ? 2 : 1; + + canvas.drawLine(Offset(0, y), Offset(size.width, y), gridPaint); + + //draw Y axis + double yValue = maxY - i * (maxY - minY) / gridCountY; + _drawText(canvas, yValue.toInt().toString(), Offset(10, y - 5), size); + } + + //2. drawactivateMainaxis + final activePaint = Paint() + ..color = accentColor.withValues(alpha: 0.8) + ..strokeWidth = 2 + ..style = PaintingStyle.stroke; + + double xPercent = (point.dx - minX) / (maxX - minX); + double yPercent = (point.dy - minY) / (maxY - minY); + double xPos = xPercent * size.width; + double yPos = size.height - (yPercent * size.height); + + canvas.drawLine(Offset(xPos, 0), Offset(xPos, size.height), activePaint); + canvas.drawLine(Offset(0, yPos), Offset(size.width, yPos), activePaint); + } + + void _drawText(Canvas canvas, String text, Offset offset, Size size) { + final tp = TextPainter( + text: TextSpan( + text: text, + style: const TextStyle(color: CupertinoColors.systemGrey, fontSize: 8), + ), + textDirection: TextDirection.ltr, + ); + tp.layout(); + tp.paint(canvas, offset); + } + + @override + bool shouldRepaint(covariant JoystickPainter oldDelegate) { + return oldDelegate.point != point; + } +} diff --git a/app/lib/view/util/loop_playback_video.dart b/app/lib/view/util/loop_playback_video.dart new file mode 100644 index 0000000..0539cbf --- /dev/null +++ b/app/lib/view/util/loop_playback_video.dart @@ -0,0 +1,54 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +import 'package:flutter/cupertino.dart'; +import 'package:video_player/video_player.dart'; + +class LoopPlaybackVideo extends StatefulWidget { + const LoopPlaybackVideo({super.key, required this.url}); + + final String url; + + @override + State createState() => _LoopPlaybackVideoState(); +} + +class _LoopPlaybackVideoState extends State { + late VideoPlayerController _controller; + + @override + void initState() { + super.initState(); + + _controller = VideoPlayerController.asset( + widget.url, + videoPlayerOptions: VideoPlayerOptions(mixWithOthers: true), + ); + + _controller.initialize().then((_) { + setState(() {}); + _controller.setVolume(0); + _controller.setLooping(true); + _controller.play(); + }); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (!_controller.value.isInitialized) { + return const Center(child: CupertinoActivityIndicator()); + } + return AspectRatio( + aspectRatio: _controller.value.aspectRatio, + child: VideoPlayer(_controller), + ); + } +} diff --git a/app/lib/view/util/scan_view.dart b/app/lib/view/util/scan_view.dart new file mode 100644 index 0000000..d1b4641 --- /dev/null +++ b/app/lib/view/util/scan_view.dart @@ -0,0 +1,115 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +import 'package:flutter/cupertino.dart'; +import 'package:flutter_svg/svg.dart'; +import 'package:mobile_scanner/mobile_scanner.dart'; + +class ScanView extends StatefulWidget { + const ScanView({super.key, this.onDetect}); + + final void Function(BarcodeCapture barcodes)? onDetect; + + @override + State createState() => _ScanViewState(); +} + +class _ScanViewState extends State + with SingleTickerProviderStateMixin { + final MobileScannerController controller = MobileScannerController( + formats: [.qrCode, .ean13, .code128], + detectionSpeed: .normal, + ); + + late AnimationController _animationController; + late Animation _scaleAnimation; + + bool isProcessing = false; + + @override + void initState() { + super.initState(); + _animationController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 700), + )..repeat(reverse: true); + _scaleAnimation = Tween(begin: 0.9, end: 1.1).animate( + CurvedAnimation(parent: _animationController, curve: Curves.easeInOut), + ); + } + + @override + void dispose() { + _animationController.dispose(); + controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final double width = constraints.maxWidth; + final double height = constraints.maxHeight; + final double viewfinderSize = (width < height ? width : height) / 2; + return Stack( + alignment: .center, + children: [ + MobileScanner(onDetect: widget.onDetect, controller: controller), + ScaleTransition( + scale: _scaleAnimation, + child: SvgPicture.asset( + "assets/viewfinder.svg", + width: viewfinderSize, + height: viewfinderSize, + colorFilter: .mode(CupertinoColors.white, .srcIn), + ), + ), + buildFlashlightButton(width, height, viewfinderSize), + ], + ); + }, + ); + } + + Widget buildFlashlightButton( + double screenWidth, + double screenHeight, + double viewfinderSize, + ) { + bool isLandscape = screenWidth > screenHeight; + double top, left; + const double buttonSize = 44.0; + + if (isLandscape) { + left = + (screenWidth + viewfinderSize) / 2 + + (screenWidth - (screenWidth + viewfinderSize) / 2 - buttonSize) / 2; + top = (screenHeight - buttonSize) / 2; + } else { + left = (screenWidth - buttonSize) / 2; + top = + (screenHeight + viewfinderSize) / 2 + + (screenHeight - (screenHeight + viewfinderSize) / 2 - buttonSize) / 2; + } + + return Positioned( + top: top, + left: left, + child: CupertinoButton( + padding: .zero, + child: SvgPicture.asset( + "assets/flashlight.off.fill.svg", + width: buttonSize, + height: buttonSize, + colorFilter: .mode(CupertinoColors.white, .srcIn), + ), + onPressed: () async { + await controller.toggleTorch(); + }, + ), + ); + } +} diff --git a/app/lib/view/util/stack_chan_ar_view.dart b/app/lib/view/util/stack_chan_ar_view.dart new file mode 100644 index 0000000..197d27a --- /dev/null +++ b/app/lib/view/util/stack_chan_ar_view.dart @@ -0,0 +1,129 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/services.dart'; +import 'package:stack_chan/model/dance_list.dart'; + +class StackChanArView extends StatefulWidget { + const StackChanArView({ + super.key, + required this.decorate, + required this.captureScreen, + this.onFrameCallback, + this.onCallback, + }); + + final int decorate; //ismodel + final bool captureScreen; //output + final Function(Uint8List)? onFrameCallback; //outputcallback + final Function(DanceData)? onCallback; //datacallback + + @override + State createState() => _StackChanArViewState(); +} + +class _StackChanArViewState extends State { + final String viewType = "stackchan_ar_view"; + final String methodChannelName = "com.stackchan.ar.view"; + int? viewId; + late MethodChannel methodChannel; + late EventChannel expressionChannel; + late EventChannel frameChannel; + + void initializeChannels(int id) { + String methodName = "${methodChannelName}_${id.toString()}"; + methodChannel = MethodChannel(methodName); + expressionChannel = EventChannel("${methodName}_expression"); + frameChannel = EventChannel("${methodName}_frame"); + } + + void registerExpressionCallback() { + if (widget.onCallback != null) { + expressionChannel.receiveBroadcastStream().listen((event) { + final danceData = DanceData.fromJson(jsonDecode(event)); + widget.onCallback!(danceData); + }); + } + } + + void registerFrameCallback() { + if (widget.onFrameCallback != null) { + frameChannel.receiveBroadcastStream().listen((event) { + if (event is Uint8List) { + widget.onFrameCallback!(event); + } + }); + } + } + + @override + void didUpdateWidget(covariant StackChanArView oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.onCallback != widget.onCallback || + oldWidget.onFrameCallback != widget.onFrameCallback) { + registerCallbacks(); + } + if (oldWidget.decorate != widget.decorate) { + setDecorate(); + } + if (oldWidget.captureScreen != widget.captureScreen) { + setCaptureScreen(); + } + } + + void registerCallbacks() { + registerExpressionCallback(); + registerFrameCallback(); + } + + @override + void dispose() { + disposeNative(); + super.dispose(); + } + + Future disposeNative() async { + if (viewId == null) return; + try { + await methodChannel.invokeMethod("dispose"); + methodChannel.setMethodCallHandler(null); + } catch (_) {} + } + + Future setDecorate() async { + if (viewId == null) return; + await methodChannel.invokeMethod("setDecorate", widget.decorate); + } + + Future setCaptureScreen() async { + if (viewId == null) return; + await methodChannel.invokeMethod("setCaptureScreen", widget.captureScreen); + } + + @override + Widget build(BuildContext context) { + if (Platform.isIOS) { + return UiKitView( + viewType: viewType, + creationParamsCodec: const StandardMessageCodec(), + onPlatformViewCreated: onPlatformViewCreated, + ); + } else { + return Center(child: Text("暂不支持")); + } + } + + void onPlatformViewCreated(int id) { + viewId = id; + initializeChannels(id); + registerCallbacks(); + setCaptureScreen(); + setDecorate(); + } +} diff --git a/app/lib/view/util/stack_chan_face_view.dart b/app/lib/view/util/stack_chan_face_view.dart new file mode 100644 index 0000000..e70596d --- /dev/null +++ b/app/lib/view/util/stack_chan_face_view.dart @@ -0,0 +1,223 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +import 'dart:typed_data'; + +import 'package:camera/camera.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:google_mlkit_face_detection/google_mlkit_face_detection.dart'; +import 'package:opencv_dart/opencv_dart.dart' as cv; +import 'package:stack_chan/model/expression_data.dart'; +import 'package:stack_chan/util/ml_kit_util.dart'; + +import '../../model/dance_list.dart'; + +class StackChanFaceView extends StatefulWidget { + const StackChanFaceView({ + super.key, + required this.captureScreen, + this.onFrameCallback, + this.onCallback, + }); + + final bool captureScreen; //output + final Function(Uint8List)? onFrameCallback; //outputcallback + final Function(DanceData)? onCallback; //datacallback + + @override + State createState() => _StackChanFaceViewState(); +} + +class _StackChanFaceViewState extends State { + CameraController? cameraController; + + DateTime lastProcessTime = DateTime.now(); + bool isProcessing = false; + + @override + void initState() { + super.initState(); + initCamera(); + } + + Future initCamera() async { + List cameras = await availableCameras(); + if (cameras.isEmpty) return; + + CameraDescription? frontCamera; + for (var i in cameras) { + if (i.lensDirection == .front) { + frontCamera = i; + break; + } + } + if (frontCamera == null) { + return; + } + + cameraController = CameraController( + frontCamera, + .medium, + imageFormatGroup: .nv21, + ); + + cameraController! + .initialize() + .then((_) async { + if (!mounted) { + return; + } + await cameraController!.startImageStream((image) { + processCameraImage(image, frontCamera!.sensorOrientation); + }); + setState(() {}); + }) + .catchError((Object e) { + if (e is CameraException) { + switch (e.code) { + case "CameraAccessDenied": + // Handle access errors here. + break; + default: + // Handle other errors here. + break; + } + } + }); + } + + ///faceandSurface / Side + void processCameraImage(CameraImage image, int sensorOrientation) { + ///face + MlKitUtil.shared.testing(image, sensorOrientation, (faces) { + if (faces.isNotEmpty) { + dataConversionTesting(faces.first); + } + }); + if (widget.captureScreen) { + //willimagecompressConcurrencyBack + final now = DateTime.now(); + if (now.difference(lastProcessTime).inMilliseconds >= 100) { + if (isProcessing) return; + lastProcessTime = now; + isProcessing = true; + handleAsyncCompression(image, sensorOrientation); + } + } + } + + ///willdataconvert + void dataConversionTesting(Face face) { + double headYaw = face.headEulerAngleY ?? 0; + double headPitch = face.headEulerAngleX ?? 0; + + int yawServoAngle = (headYaw * -20).toInt().clamp(-1280, 1280); + int pitchServoAngle = (headPitch * 10).toInt().clamp(0, 900); + + double leftEyeProb = face.leftEyeOpenProbability ?? 1.0; + double rightEyeProb = face.rightEyeOpenProbability ?? 1.0; + + int leftWeight = (leftEyeProb * 100).toInt().clamp(0, 100); + int rightWeight = (rightEyeProb * 100).toInt().clamp(0, 100); + + double smileProb = face.smilingProbability ?? 0.0; + int mouthWeight = (smileProb * 100).toInt().clamp(0, 100); + + ExpressionItem leftEye = ExpressionItem( + x: 0, + y: 0, + rotation: 0, + weight: leftWeight, + ); + ExpressionItem rightEye = ExpressionItem( + x: 0, + y: 0, + rotation: 0, + weight: rightWeight, + ); + ExpressionItem mouth = ExpressionItem( + x: 0, + y: 0, + rotation: 0, + weight: mouthWeight, + ); + + if (smileProb > 0.3) { + leftEye.weight = (leftEye.weight - 35).clamp(0, 100); + leftEye.rotation = -2150; + rightEye.weight = (rightEye.weight - 35).clamp(0, 100); + rightEye.rotation = 2150; + } else if (smileProb < 0.1 && (leftEyeProb < 0.5 || rightEyeProb < 0.5)) { + leftEye.rotation = 450; + rightEye.rotation = -450; + } + + DanceData data = DanceData( + leftEye: leftEye, + rightEye: rightEye, + mouth: mouth, + yawServo: MotionDataItem(angle: yawServoAngle), + pitchServo: MotionDataItem(angle: pitchServoAngle), + durationMs: 1000, + ); + + if (widget.onCallback != null) { + widget.onCallback!(data); + } + } + + ///compressAndsend + Future handleAsyncCompression( + CameraImage image, + int sensorOrientation, + ) async { + final nv21Bytes = image.planes.first.bytes; + final mat = cv.Mat.fromList( + (image.height * 1.5).toInt(), + image.width, + .CV_8UC1, + nv21Bytes, + ); + final bgrMat = cv.cvtColor(mat, cv.COLOR_YUV2BGR_NV21); + final rotatedMat = rotateMatIfNeeded(bgrMat, sensorOrientation); + final (success, jpegByte) = cv.imencode(".jpg", rotatedMat); + if (success) { + if (jpegByte.isNotEmpty && widget.onFrameCallback != null) { + widget.onFrameCallback!(jpegByte); + } + } + mat.dispose(); + bgrMat.dispose(); + rotatedMat.dispose(); + isProcessing = false; + } + + cv.Mat rotateMatIfNeeded(cv.Mat src, int orientation) { + if (orientation == 90) { + return cv.rotate(src, cv.ROTATE_90_CLOCKWISE); + } else if (orientation == 180) { + return cv.rotate(src, cv.ROTATE_180); + } else if (orientation == 270) { + return cv.rotate(src, cv.ROTATE_90_COUNTERCLOCKWISE); + } + return src; + } + + @override + void dispose() { + cameraController?.stopImageStream(); + cameraController?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (cameraController == null) { + return Center(child: CupertinoActivityIndicator()); + } else { + return SizedBox.expand(child: CameraPreview(cameraController!)); + } + } +} diff --git a/app/lib/view/util/stack_chan_rotary_robot_box.dart b/app/lib/view/util/stack_chan_rotary_robot_box.dart new file mode 100644 index 0000000..2def337 --- /dev/null +++ b/app/lib/view/util/stack_chan_rotary_robot_box.dart @@ -0,0 +1,303 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +import 'dart:math'; +import 'dart:ui' as ui; + +import 'package:flutter/cupertino.dart'; +import 'package:three_js/three_js.dart' as three; + +import '../../model/dance_list.dart'; +import '../../model/expression_data.dart'; + +class StackChanRotaryRobotBox extends StatelessWidget { + final double width; + final double height; + + const StackChanRotaryRobotBox({ + super.key, + required this.width, + required this.height, + }); + + @override + Widget build(BuildContext context) { + return SizedBox( + width: width, + height: height, + child: LayoutBuilder( + builder: (context, constraints) { + return StackChanRotaryRobotJs( + width: constraints.maxWidth, + height: constraints.maxHeight, + ); + }, + ), + ); + } +} + +class StackChanRotaryRobotJs extends StatefulWidget { + const StackChanRotaryRobotJs({ + super.key, + required this.width, + required this.height, + }); + + final double width; + final double height; + + @override + State createState() => _StackChanRotaryRobotJsState(); +} + +class _StackChanRotaryRobotJsState extends State { + late three.ThreeJS threeJs; + + @override + void initState() { + super.initState(); + threeJs = three.ThreeJS( + settings: three.Settings( + alpha: true, + clearAlpha: 0.0, + clearColor: 0x000000, + ), + setup: setup, + onSetupComplete: () { + setState(() {}); + }, + ); + } + + @override + void dispose() { + threeJs.dispose(); + super.dispose(); + } + + void startAnimation(three.Object3D model) { + threeJs.events.clear(); + + threeJs.addAnimationEvent((double dt) { + if (!mounted) return; + model.rotation.z += (2 * pi / 5) * dt; + }); + } + + Future setup() async { + threeJs.scene = three.Scene(); + + //1. set (Maintain / KeepNot) + final hemiLight = three.HemisphereLight(0xffffff, 0x444444, 1); + hemiLight.position.setValues(0, 100, 0); + threeJs.scene.add(hemiLight); + + final dirLight = three.DirectionalLight(0xffffff, 1); + dirLight.position.setValues(50, 50, 70); + threeJs.scene.add(dirLight); + + //2. set + threeJs.camera = three.PerspectiveCamera( + 60, + widget.width / widget.height, + 1, + 300, + ); + threeJs.camera.position.setValues(0, -100, 0); + threeJs.camera.lookAt(threeJs.scene.position); + + //3. loadmodel + three.GLTFLoader loader = three.GLTFLoader(flipY: true).setPath('assets/'); + final gltf = await loader.fromAsset('stack_chan_model.glb'); + if (gltf == null || !mounted) return; + + final model = gltf.scene; + threeJs.scene.add(model); + + model.position.y = 15; + model.position.z = 10; + model.rotation.x = 20 * (pi / 180); + + addExpressionPlane(); + + startAnimation(model); + } + + void addExpressionPlane() async { + final model = threeJs.scene.children.firstWhere( + (element) => element.type == "Group", + ); + final head = model.getObjectByName('_00_stackchan450_1'); + if (head == null) return; + + final geometry = three.PlaneGeometry(42, 32); + three.CanvasTexture expressionTexture = three.CanvasTexture(); + final material = three.MeshBasicMaterial({ + three.MaterialProperty.map: expressionTexture, + three.MaterialProperty.transparent: false, + three.MaterialProperty.side: three.DoubleSide, + }); + three.Mesh expressionPlaneMesh = three.Mesh(geometry, material); + expressionPlaneMesh.name = "expressionPlane"; + expressionPlaneMesh.position.setValues(0, 15.8, 0); + expressionPlaneMesh.rotation.x = -90 * pi / 180.0; + expressionPlaneMesh.rotation.z = pi; + + final double canvasWidth = 210; + final double canvasHeight = 160; + final data = DanceData( + leftEye: ExpressionItem(weight: 100), + rightEye: ExpressionItem(weight: 100), + mouth: ExpressionItem(weight: 0), + yawServo: MotionDataItem(angle: 0), + pitchServo: MotionDataItem(angle: 0), + durationMs: 1000, + ); + + //1. createdrawExpressioncanvas + final recorder = ui.PictureRecorder(); + final canvas = ui.Canvas(recorder); + final paint = ui.Paint(); + + //background:With / Carry 70% transparency (0xB3 = 179/255) + paint.color = const ui.Color(0xB3000000); + canvas.drawRect(ui.Rect.fromLTWH(0, 0, canvasWidth, canvasHeight), paint); + + final eyeSize = canvasWidth / 10; + + //draweyefunction + void drawEye(ExpressionItem item, ui.Offset centerOffset) { + canvas.save(); + + //calculatesizescale + final clampedSize = item.size.clamp(-100, 100); + final sizeScale = clampedSize >= 0 + ? 1.0 + clampedSize / 100.0 + : 1.0 + clampedSize / 200.0; + + final scaledEyeSize = eyeSize * sizeScale; + final visibleHeight = scaledEyeSize * (item.weight / 100); + + //positionoffset + final centerX = centerOffset.dx + item.x / 10 + eyeSize / 2; + final centerY = centerOffset.dy + item.y / 10 + eyeSize / 2; + + final eyeRect = ui.Rect.fromCenter( + center: ui.Offset(centerX, centerY), + width: scaledEyeSize, + height: scaledEyeSize, + ); + + //rotatehandle + final rotationDegrees = item.rotation / 10.0; + canvas.translate(centerX, centerY); + canvas.rotate(rotationDegrees * pi / 180); + canvas.translate(-centerX, -centerY); + + //createcropeye + final clipRect = ui.Rect.fromLTRB( + eyeRect.left, + eyeRect.bottom - visibleHeight, + eyeRect.right, + eyeRect.bottom, + ); + canvas.clipRect(clipRect); + + //draweye + paint.color = const ui.Color(0xFFFFFFFF); + canvas.drawOval(eyeRect, paint); + + canvas.restore(); + } + + //calculateeyeposition + final eyeY = (canvasHeight * 0.4) - (eyeSize / 2); + final leftEyePoint = ui.Offset((canvasWidth / 4) - (eyeSize / 2), eyeY); + final rightEyePoint = ui.Offset( + (canvasWidth / 4 * 3) - (eyeSize / 2), + eyeY, + ); + + drawEye(data.leftEye, leftEyePoint); + drawEye(data.rightEye, rightEyePoint); + + //2. drawmouth + canvas.save(); + + final mouthWidth = (canvasWidth * 0.3 - data.mouth.weight / 10).toDouble(); + final mouthHeight = (3 + data.mouth.weight * 0.2).toDouble(); + final mouthX = ((canvasWidth - mouthWidth) / 2) + data.mouth.x / 10; + final mouthY = (canvasHeight * 0.65) + data.mouth.y / 10; + + final mouthCenter = ui.Offset( + mouthX + mouthWidth / 2, + mouthY + mouthHeight / 2, + ); + final mRotation = data.mouth.rotation / 10.0; + + canvas.translate(mouthCenter.dx, mouthCenter.dy); + canvas.rotate(mRotation * pi / 180); + canvas.translate(-mouthCenter.dx, -mouthCenter.dy); + + final mouthRect = ui.Rect.fromLTWH(mouthX, mouthY, mouthWidth, mouthHeight); + paint.color = const ui.Color(0xFFFFFFFF); + + canvas.drawRRect( + ui.RRect.fromRectAndRadius( + mouthRect, + ui.Radius.circular(mouthHeight / 2), + ), + paint, + ); + canvas.restore(); + + //3. will Canvas convertastexturedata + final picture = recorder.endRecording(); + final image = await picture.toImage( + canvasWidth.toInt(), + canvasHeight.toInt(), + ); + + if (!mounted) { + image.dispose(); + return; + } + + //[Core Fix]:use rawRgba And / WhileNotis png + final byteData = await image.toByteData(format: ui.ImageByteFormat.rawRgba); + + if (byteData != null) { + //convertas three_js Uint8Array + final uint8List = byteData.buffer.asUint8List(); + final nativeArray = three.Uint8Array.fromList(uint8List); + + //updatetexture + expressionTexture.image = three.ImageElement( + data: nativeArray, + width: canvasWidth.toInt(), + height: canvasHeight.toInt(), + ); + + //marktextureNeedupdate + expressionTexture.needsUpdate = true; + + //ifuse MeshBasicMaterial,EnsureWillrereadtexture + if (expressionPlaneMesh.material is three.Material) { + (expressionPlaneMesh.material as three.Material).needsUpdate = true; + } + } + + head.add(expressionPlaneMesh); + material.needsUpdate = true; + + image.dispose(); + } + + @override + Widget build(BuildContext context) { + return threeJs.build(); + } +} diff --git a/app/lib/view/util/stackchan_robot.dart b/app/lib/view/util/stackchan_robot.dart new file mode 100644 index 0000000..dc86973 --- /dev/null +++ b/app/lib/view/util/stackchan_robot.dart @@ -0,0 +1,179 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/services.dart'; + +import '../../model/dance_list.dart'; + +class StackChanRobot extends StatefulWidget { + final DanceData data; + + final double width; + final double height; + + final bool? topLook; + final bool? allowsCameraControl; + + const StackChanRobot({ + super.key, + required this.data, + required this.width, + required this.height, + this.topLook, + this.allowsCameraControl, + }); + + @override + State createState() => _StackchanRobotState(); +} + +class _StackchanRobotState extends State { + late MethodChannel _methodChannel; + int? _viewId; + + final String _viewType = "stackchan_robot_view"; + final String _methodChannelName = "com.stackchan.robot.method"; + + @override + void didUpdateWidget(covariant StackChanRobot oldWidget) { + super.didUpdateWidget(oldWidget); + if (_viewId == null) return; + _updateDanceData(); + if (widget.topLook != oldWidget.topLook) { + _setTopLook(widget.topLook); + } + if (widget.allowsCameraControl != oldWidget.allowsCameraControl) { + _setAllowsCameraControl(widget.allowsCameraControl); + } + } + + void _initializeChannels(int id) { + String methodName = "${_methodChannelName}_${id.toString()}"; + _methodChannel = MethodChannel(methodName); + } + + void _updateDanceData() async { + if (_viewId == null) return; + await _methodChannel.invokeMethod( + "updateDanceData", + jsonEncode(widget.data.toJson()), + ); + } + + Future _setTopLook(bool? value) async { + if (_viewId == null) return; + await _methodChannel.invokeMethod("setTopLook", value ?? false); + } + + Future _setAllowsCameraControl(bool? value) async { + if (_viewId == null) return; + await _methodChannel.invokeMethod("setAllowsCameraControl", value ?? false); + } + + Future _disposeNative() async { + if (_viewId == null) return; + try { + await _methodChannel.invokeMethod("dispose"); + } catch (_) {} + } + + @override + Widget build(BuildContext context) { + return SizedBox( + width: widget.width, + height: widget.height, + child: _buildNativeView(), + ); + } + + Widget _buildNativeView() { + return LayoutBuilder( + builder: (context, constraints) { + if (Platform.isIOS) { + return UiKitView( + viewType: _viewType, + creationParamsCodec: const StandardMessageCodec(), + creationParams: widget.data.toJson(), + onPlatformViewCreated: _onPlatformViewCreated, + ); + } else if (Platform.isAndroid) { + return Image.asset("assets/image1.png"); + } else { + return Center(child: Text("暂时不支持")); + } + }, + ); + } + + void _onPlatformViewCreated(int id) { + _viewId = id; + _initializeChannels(id); + _updateDanceData(); + _setTopLook(widget.topLook); + _setAllowsCameraControl(widget.allowsCameraControl); + } + + @override + void dispose() { + _disposeNative(); + super.dispose(); + } +} + +class StackChanRotary extends StatelessWidget { + const StackChanRotary({super.key, required this.width, required this.height}); + + final double width; + final double height; + + final String _viewType = "stackchan_rotary_robot_view"; + + @override + Widget build(BuildContext context) { + return SizedBox(width: width, height: height, child: _buildNativeView()); + } + + Widget _buildNativeView() { + if (Platform.isIOS) { + return UiKitView( + viewType: _viewType, + creationParamsCodec: const StandardMessageCodec(), + ); + } else if (Platform.isAndroid) { + return PlatformViewLink( + surfaceFactory: (context, controller) { + return AndroidViewSurface( + controller: controller as AndroidViewController, + hitTestBehavior: .opaque, + gestureRecognizers: const >{}, + ); + }, + onCreatePlatformView: (params) { + return PlatformViewsService.initSurfaceAndroidView( + id: params.id, + viewType: _viewType, + layoutDirection: .ltr, + creationParamsCodec: const StandardMessageCodec(), + onFocus: () { + params.onFocusChanged(true); + }, + ) + ..addOnPlatformViewCreatedListener(params.onPlatformViewCreated) + ..create(); + }, + viewType: _viewType, + ); + } else { + return Center(child: Text("暂时不支持")); + } + } +} diff --git a/app/lib/view/util/stackchan_robot_box.dart b/app/lib/view/util/stackchan_robot_box.dart new file mode 100644 index 0000000..0c2030d --- /dev/null +++ b/app/lib/view/util/stackchan_robot_box.dart @@ -0,0 +1,502 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +import 'dart:math'; +import 'dart:ui' as ui; + +import 'package:flutter/cupertino.dart'; +import 'package:three_js/three_js.dart' as three; + +import '../../model/dance_list.dart'; +import '../../model/expression_data.dart'; + +class StackChanRobotBox extends StatelessWidget { + final DanceData data; + final double width; + final double height; + final bool topLook; + final bool allowsCameraControl; + final bool mirrorFace; + + const StackChanRobotBox({ + super.key, + required this.width, + required this.height, + required this.data, + this.topLook = false, + this.allowsCameraControl = false, + this.mirrorFace = false, + }); + + @override + Widget build(BuildContext context) { + return SizedBox( + width: width, + height: height, + child: LayoutBuilder( + builder: (context, constraints) { + return StackchanRobotJs( + width: constraints.maxWidth, + height: constraints.maxHeight, + data: data, + topLook: topLook, + allowsCameraControl: allowsCameraControl, + mirrorFace: mirrorFace, + ); + }, + ), + ); + } +} + +class StackchanRobotJs extends StatefulWidget { + const StackchanRobotJs({ + super.key, + required this.data, + required this.width, + required this.height, + required this.topLook, + required this.allowsCameraControl, + required this.mirrorFace, + }); + + final DanceData data; + final double width; + final double height; + final bool topLook; + final bool allowsCameraControl; + final bool mirrorFace; + + @override + State createState() => _StackchanRobotThreeState(); +} + +class _StackchanRobotThreeState extends State { + late three.ThreeJS threeJs; + + @override + void initState() { + super.initState(); + threeJs = three.ThreeJS( + settings: three.Settings( + alpha: true, + clearAlpha: 0.0, + clearColor: 0x000000, + antialias: true, + toneMapping: three.ReinhardToneMapping, + toneMappingExposure: 1, + ), + onSetupComplete: () { + setState(() {}); + }, + setup: setup, + ); + } + + @override + void dispose() { + threeJs.dispose(); + super.dispose(); + } + + @override + void didUpdateWidget(covariant StackchanRobotJs oldWidget) { + super.didUpdateWidget(oldWidget); + applyDanceData(); + if (oldWidget.topLook != widget.topLook) { + setupCamera(); + } + } + + Future setup() async { + threeJs.scene = three.Scene(); + + //translated comment + final hemiLight = three.HemisphereLight(0xffffff, 0x444444, 1); + hemiLight.position.setValues(0, 100, 0); + threeJs.scene.add(hemiLight); + + //translated comment + final dirLight = three.DirectionalLight(0xffffff, 1); + dirLight.position.setValues(50, 50, 70); + threeJs.scene.add(dirLight); + + //andset + threeJs.camera = three.PerspectiveCamera( + 60, + widget.width / widget.height, + 1, + 300, + ); + threeJs.camera.position.setValues(0, -100, 0); + + //loadmodel + three.GLTFLoader loader = three.GLTFLoader(flipY: true).setPath('assets/'); + final sky = await loader.fromAsset('stack_chan_model.glb'); + + if (sky == null || !mounted) return; + + final model = sky.scene; + + threeJs.scene.add(model); + + setupCamera(); + + setupRobotHierarchy(); + + applyDanceData(); + } + + //setAngle + void setupCamera() { + if (widget.topLook) { + threeJs.camera.position.setValues(0, -100, 70); + } else { + threeJs.camera.position.setValues(0, -100, 0); + } + threeJs.camera.lookAt(threeJs.scene.position); + } + + three.Object3D yawAxis = three.Object3D(); + three.Object3D pitchAxis = three.Object3D(); + + three.Mesh? expressionPlaneMesh; //faceshow + three.CanvasTexture? expressionTexture; //facecanvastexture + final double canvasWidth = 210; //canvas(correspondingiOS 42*5) + final double canvasHeight = 160; //canvas(correspondingiOS 32*5) + final String expressionPlaneName = "expressionPlane"; //name(foriOS) + Function(double)? currentRotationEvent; + + //translated comment + void setupRobotHierarchy() { + final model = threeJs.scene.children.firstWhere( + (element) => element.type == "Group", + ); + final foundation = model.getObjectByName('_00_stackchan450_3'); + final centralComponent = model.getObjectByName('_00_stackchan450_2'); + final head = model.getObjectByName('_00_stackchan450_1'); + + if (foundation == null || centralComponent == null || head == null) return; + + //========== LeftRightto(yaw axis)logic(originalhaslogicCankeep,) ========== + final centralWorldPos = centralComponent.worldPosition(); + centralWorldPos.y -= 20; + yawAxis.setWorldPosition(centralWorldPos); + foundation.add(yawAxis); + final centralWorldTransform = centralComponent.worldTransform(); + final centralWorldPosition = centralComponent.worldPosition(); + yawAxis.add(centralComponent); + centralComponent.setWorldTransform(centralWorldTransform); + centralComponent.setWorldPosition(centralWorldPosition); + + //========== UpDown(pitch axis)logic(corefixPart) ========== + final headWorldPosition = head.worldPosition(); + final headWorldTransform = head.worldTransform(); + final pitchAxisWorldPosition = pitchAxis.worldPosition(); + pitchAxisWorldPosition.y -= 25; + pitchAxis.setWorldPosition(pitchAxisWorldPosition); + centralComponent.add(pitchAxis); + pitchAxis.add(head); + head.setWorldTransform(headWorldTransform); + head.setWorldPosition(headWorldPosition); + + addExpressionPlane(); + } + + void addExpressionPlane() { + final model = threeJs.scene.children.firstWhere( + (element) => element.type == "Group", + ); + final head = model.getObjectByName('_00_stackchan450_1'); + if (head == null) return; + + final geometry = three.PlaneGeometry(42, 32); + expressionTexture = three.CanvasTexture(); + final material = three.MeshBasicMaterial({ + three.MaterialProperty.map: expressionTexture, + three.MaterialProperty.transparent: false, + three.MaterialProperty.side: three.DoubleSide, + }); + expressionPlaneMesh = three.Mesh(geometry, material); + expressionPlaneMesh!.name = "expressionPlane"; + expressionPlaneMesh!.position.setValues(0, 15.8, 0); + expressionPlaneMesh!.rotation.x = -90 * pi / 180.0; + expressionPlaneMesh!.rotation.z = pi; + head.add(expressionPlaneMesh); + material.needsUpdate = true; + } + + //writedata + void applyDanceData() { + updateServos(); + updateExpression(); + updateRGBColor(); + setupContinuousRotation(); + } + + void updateServos() async { + final data = widget.data; + if (data.yawServo.rotate == 0) { + double clampedYaw = data.yawServo.angle / 10.0; + if (clampedYaw < -128) clampedYaw = -128; + if (clampedYaw > 128) clampedYaw = 128; + yawAxis.rotation.z = clampedYaw * pi / 180.0; + } + double clampedPitch = data.pitchServo.angle / 10.0; + if (clampedPitch < 0) clampedPitch = 0; + if (clampedPitch > 90) clampedPitch = 90; + pitchAxis.rotation.x = -clampedPitch * pi / 180.0; + } + + void setupContinuousRotation() { + final data = widget.data; + + if (currentRotationEvent != null) { + threeJs.events.remove(currentRotationEvent); + currentRotationEvent = null; + } + + if (data.yawServo.rotate != 0) { + double rotateSpeed = data.yawServo.rotate / 10.0; + double radiansPerSecond = rotateSpeed * pi / 180.0; + currentRotationEvent = (double dt) { + yawAxis.rotation.z -= radiansPerSecond * dt; + }; + threeJs.addAnimationEvent(currentRotationEvent!); + } + } + + void updateRGBColor() { + final threeColor = toThreeColor(widget.data.leftRgbColor); + for (var node in threeJs.scene.children) { + if (node is three.Mesh) { + if (node.material != null) { + if (node.material!.name == "MTL12") { + if (node.material! is three.MeshStandardMaterial) { + node.material!.emissive = threeColor; + } else { + node.material!.color = threeColor; + } + } + } + } + } + } + + Future updateExpression() async { + final data = widget.data; + if (expressionPlaneMesh == null || expressionTexture == null) { + return; + } + + //1. createdrawExpressioncanvas + final recorder = ui.PictureRecorder(); + final canvas = ui.Canvas(recorder); + final paint = ui.Paint(); + + //fliphandle(Yaxisflip) + if (widget.mirrorFace) { + canvas.save(); + canvas.translate(canvasWidth, 0); + canvas.scale(-1, 1); + } + + //background:With / Carry 70% transparency (0xB3 = 179/255) + paint.color = const ui.Color(0xB3000000); + canvas.drawRect(ui.Rect.fromLTWH(0, 0, canvasWidth, canvasHeight), paint); + + final eyeSize = canvasWidth / 10; + + //draweyefunction + void drawEye(ExpressionItem item, ui.Offset centerOffset) { + canvas.save(); + + //calculatesizescale + final clampedSize = item.size.clamp(-100, 100); + final sizeScale = clampedSize >= 0 + ? 1.0 + clampedSize / 100.0 + : 1.0 + clampedSize / 200.0; + + final scaledEyeSize = eyeSize * sizeScale; + final visibleHeight = scaledEyeSize * (item.weight / 100); + + //positionoffset + final centerX = centerOffset.dx + item.x / 10 + eyeSize / 2; + final centerY = centerOffset.dy + item.y / 10 + eyeSize / 2; + + final eyeRect = ui.Rect.fromCenter( + center: ui.Offset(centerX, centerY), + width: scaledEyeSize, + height: scaledEyeSize, + ); + + //rotatehandle + final rotationDegrees = item.rotation / 10.0; + canvas.translate(centerX, centerY); + canvas.rotate(rotationDegrees * pi / 180); + canvas.translate(-centerX, -centerY); + + //createcropeye + final clipRect = ui.Rect.fromLTRB( + eyeRect.left, + eyeRect.bottom - visibleHeight, + eyeRect.right, + eyeRect.bottom, + ); + canvas.clipRect(clipRect); + + //draweye + paint.color = const ui.Color(0xFFFFFFFF); + canvas.drawOval(eyeRect, paint); + + canvas.restore(); + } + + //calculateeyeposition + final eyeY = (canvasHeight * 0.4) - (eyeSize / 2); + final leftEyePoint = ui.Offset((canvasWidth / 4) - (eyeSize / 2), eyeY); + final rightEyePoint = ui.Offset( + (canvasWidth / 4 * 3) - (eyeSize / 2), + eyeY, + ); + + drawEye(data.leftEye, leftEyePoint); + drawEye(data.rightEye, rightEyePoint); + + //2. drawmouth + canvas.save(); + + final mouthWidth = (canvasWidth * 0.3 - data.mouth.weight / 10).toDouble(); + final mouthHeight = (3 + data.mouth.weight * 0.2).toDouble(); + final mouthX = ((canvasWidth - mouthWidth) / 2) + data.mouth.x / 10; + final mouthY = (canvasHeight * 0.65) + data.mouth.y / 10; + + final mouthCenter = ui.Offset( + mouthX + mouthWidth / 2, + mouthY + mouthHeight / 2, + ); + final mRotation = data.mouth.rotation / 10.0; + + canvas.translate(mouthCenter.dx, mouthCenter.dy); + canvas.rotate(mRotation * pi / 180); + canvas.translate(-mouthCenter.dx, -mouthCenter.dy); + + final mouthRect = ui.Rect.fromLTWH(mouthX, mouthY, mouthWidth, mouthHeight); + paint.color = const ui.Color(0xFFFFFFFF); + + canvas.drawRRect( + ui.RRect.fromRectAndRadius( + mouthRect, + ui.Radius.circular(mouthHeight / 2), + ), + paint, + ); + canvas.restore(); + + //resumecanvasstate(ifperformflip) + if (widget.mirrorFace) { + canvas.restore(); + } + + //3. will Canvas convertastexturedata + final picture = recorder.endRecording(); + final image = await picture.toImage( + canvasWidth.toInt(), + canvasHeight.toInt(), + ); + + if (!mounted) { + image.dispose(); + return; + } + + //[Core Fix]:use rawRgba And / WhileNotis png + final byteData = await image.toByteData(format: ui.ImageByteFormat.rawRgba); + + if (byteData != null) { + //convertas three_js Uint8Array + final uint8List = byteData.buffer.asUint8List(); + final nativeArray = three.Uint8Array.fromList(uint8List); + + //updatetexture + expressionTexture!.image = three.ImageElement( + data: nativeArray, + width: canvasWidth.toInt(), + height: canvasHeight.toInt(), + ); + + //marktextureNeedupdate + expressionTexture!.needsUpdate = true; + + //ifuse MeshBasicMaterial,EnsureWillrereadtexture + if (expressionPlaneMesh!.material is three.Material) { + (expressionPlaneMesh!.material as three.Material).needsUpdate = true; + } + } + image.dispose(); + } + + @override + Widget build(BuildContext context) { + return threeJs.build(); + } + + three.Color toThreeColor(String rgbString) { + String hex = rgbString.replaceFirst('#', ''); + if (hex.length == 6) { + hex = 'FF$hex'; + } else if (hex.length != 8) { + return three.Color(1, 1, 1); + } + final intValue = int.parse(hex, radix: 16); + final int a = (intValue >> 24) & 0xFF; + final int r = (intValue >> 16) & 0xFF; + final int g = (intValue >> 8) & 0xFF; + final int b = intValue & 0xFF; + return three.Color(r / 255.0, g / 255.0, b / 255.0); + } +} + +extension Object3DUtil on three.Object3D { + three.Vector3 worldPosition() { + final position = three.Vector3.zero(); + getWorldPosition(position); + return position; + } + + void setWorldPosition(three.Vector3 worldPosition) { + if (parent != null) { + parent!.updateWorldMatrix(true, false); + final inverseParentMatrix = three.Matrix4() + .setFrom(parent!.matrixWorld) + .invert(); + final localPosition = worldPosition.clone().applyMatrix4( + inverseParentMatrix, + ); + position.setFrom(localPosition); + } else { + position.setFrom(worldPosition); + } + } + + three.Quaternion worldTransform() { + final worldQuaternion = three.Quaternion(); + getWorldQuaternion(worldQuaternion); + return worldQuaternion; + } + + void setWorldTransform(three.Quaternion worldTransform) { + if (parent != null) { + parent!.updateWorldMatrix(true, false); + final parentWorldQuaternion = three.Quaternion(); + parent!.getWorldQuaternion(parentWorldQuaternion); + + final inverseParentQuaternion = parentWorldQuaternion.clone().conjugate(); + quaternion.setFrom(inverseParentQuaternion.multiply(worldTransform)); + } else { + quaternion.setFrom(worldTransform); + } + } +} diff --git a/app/pubspec.yaml b/app/pubspec.yaml new file mode 100644 index 0000000..fc3f977 --- /dev/null +++ b/app/pubspec.yaml @@ -0,0 +1,80 @@ +name: stack_chan +description: "A new Flutter project." +publish_to: 'none' +version: 1.1.0+6 + +environment: + sdk: ^3.11.5 +dependencies: + flutter: + sdk: flutter + cupertino_icons: + web_socket: + encrypt: + pointycastle: + shared_preferences: + get: + liquid_glass_renderer: + flutter_blue_plus: + hydro_glass_nav_bar: + uuid: + three_js: + flex_color_picker: + flutter_svg: + camera: + google_mlkit_face_detection: + flutter_slidable: + opencv_dart: + pull_down_button: + dio: + mobile_scanner: + network_info_plus: + permission_handler: + image: + just_audio: + path_provider: + music_feature_analyzer: + ffmpeg_kit_flutter_new: + path: + intl: + file_picker: ^11.0.2 + opus_codec_dart: + opus_codec: + webview_flutter: + video_player: + geolocator: + package_info_plus: + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^6.0.0 +flutter: + uses-material-design: true + assets: + - assets/ + +hooks: + user_defines: + dartcv4: + include_modules: + - imgproc + - imgcodecs + exclude_modules: + - calib3d + - features2d + - flann + - dnn + - freetype + - highgui + - video + - videoio + - objdetect + - photo + - aruco + - img_hash + - quality + - wechat_qrcode + - ximgproc + - xobjdetect + - stitching \ No newline at end of file diff --git a/app/test/widget_test.dart b/app/test/widget_test.dart new file mode 100644 index 0000000..11c2a96 --- /dev/null +++ b/app/test/widget_test.dart @@ -0,0 +1,34 @@ +/* +SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +SPDX-License-Identifier: MIT +*/ + +// This is a basic Flutter widget test. +// +// To perform an interaction with a widget in your test, use the WidgetTester +// utility in the flutter_test package. For example, you can send tap and scroll +// gestures. You can also use WidgetTester to find child widgets in the widget +// tree, read text, and verify that the values of widget properties are correct. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stack_chan/view/app.dart'; + +void main() { + testWidgets('Counter increments smoke test', (WidgetTester tester) async { + // Build our app and trigger a frame. + await tester.pumpWidget(App()); + + // Verify that our counter starts at 0. + expect(find.text('0'), findsOneWidget); + expect(find.text('1'), findsNothing); + + // Tap the '+' icon and trigger a frame. + await tester.tap(find.byIcon(Icons.add)); + await tester.pump(); + + // Verify that our counter has incremented. + expect(find.text('0'), findsNothing); + expect(find.text('1'), findsOneWidget); + }); +} diff --git a/firmware/CMakeLists.txt b/firmware/CMakeLists.txt index ed7e18b..74a2ef4 100644 --- a/firmware/CMakeLists.txt +++ b/firmware/CMakeLists.txt @@ -2,7 +2,7 @@ # CMakeLists in this exact order for cmake to work correctly cmake_minimum_required(VERSION 3.16) -set(PROJECT_VER "1.2.4") +set(PROJECT_VER "1.3.0") add_definitions(-DFIRMWARE_VERSION=\"${PROJECT_VER}\") # Add this line to disable the specific warning diff --git a/firmware/main/apps/app_setup/app_setup.cpp b/firmware/main/apps/app_setup/app_setup.cpp index a50a917..2b4d98d 100644 --- a/firmware/main/apps/app_setup/app_setup.cpp +++ b/firmware/main/apps/app_setup/app_setup.cpp @@ -70,6 +70,15 @@ void AppSetup::onOpen() _worker = std::make_unique(); }}}, }, + { + "AI.Agent", + {{"Power Saving", + [&]() { + _destroy_menu = true; + _need_warm_reset = true; + _worker = std::make_unique(); + }}}, + }, { "Hardware Test", {{"Servo", diff --git a/firmware/main/apps/app_setup/workers/account.cpp b/firmware/main/apps/app_setup/workers/account.cpp index fc81885..87d3dc6 100644 --- a/firmware/main/apps/app_setup/workers/account.cpp +++ b/firmware/main/apps/app_setup/workers/account.cpp @@ -120,8 +120,7 @@ void AccountWorker::update() mclog::tagInfo(_tag, "unbind clicked"); _page_account.reset(); - // Unbind account - { + _worker_reset = std::make_unique([]() { auto loading_page = std::make_unique(0xF6F6F6, 0x26206A); GetHAL().lvglUnlock(); @@ -135,10 +134,7 @@ void AccountWorker::update() } GetHAL().lvglLock(); - } - - // Factory reset - _worker_reset = std::make_unique(); + }); } else if (_page_account->isQuitClicked()) { mclog::tagInfo(_tag, "quit clicked"); _is_done = true; diff --git a/firmware/main/apps/app_setup/workers/ai_agent.cpp b/firmware/main/apps/app_setup/workers/ai_agent.cpp new file mode 100644 index 0000000..abd4771 --- /dev/null +++ b/firmware/main/apps/app_setup/workers/ai_agent.cpp @@ -0,0 +1,141 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ +#include "workers.h" +#include +#include +#include + +using namespace smooth_ui_toolkit::lvgl_cpp; +using namespace setup_workers; + +static std::string _tag = "Setup-AIAgent"; + +XiaozhiPowerSavingWorker::XiaozhiPowerSavingWorker() +{ + mclog::info("XiaozhiPowerSavingWorker start"); + + for (uint32_t seconds = 0; seconds <= 3600; seconds += 300) { + _idle_shutdown_levels.push_back(seconds); + } + + _config = GetHAL().getXiaozhiConfig(); + + int current_index = static_cast(_idle_shutdown_levels.size()) - 1; + for (size_t i = 0; i < _idle_shutdown_levels.size(); ++i) { + if (_idle_shutdown_levels[i] >= _config.idleShutdownTimeSeconds) { + current_index = static_cast(i); + break; + } + } + + _panel = std::make_unique(lv_screen_active()); + _panel->setBgColor(lv_color_hex(0xEDF4FF)); + _panel->align(LV_ALIGN_CENTER, 0, 0); + _panel->setBorderWidth(0); + _panel->setSize(320, 240); + _panel->setRadius(0); + _panel->setPadding(0, 50, 24, 18); + _panel->setScrollDir(LV_DIR_VER); + _panel->setScrollbarMode(LV_SCROLLBAR_MODE_ACTIVE); + + _panel_idle_shutdown = std::make_unique(_panel->get()); + _panel_idle_shutdown->setSize(296, 148); + _panel_idle_shutdown->align(LV_ALIGN_TOP_MID, 0, 20); + _panel_idle_shutdown->setBgColor(lv_color_hex(0xD2E3FF)); + _panel_idle_shutdown->setBorderWidth(0); + _panel_idle_shutdown->setRadius(18); + _panel_idle_shutdown->setPadding(0, 0, 0, 0); + _panel_idle_shutdown->removeFlag(LV_OBJ_FLAG_SCROLLABLE); + + _label_idle_title = std::make_unique