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..76807f0 --- /dev/null +++ b/app/android/app/build.gradle.kts @@ -0,0 +1,82 @@ +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..b2e6e79 --- /dev/null +++ b/app/android/app/src/main/kotlin/com/m5stack/stackchan/MainActivity.kt @@ -0,0 +1,176 @@ +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 + + // 注册通道 + 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) + // 切到主线程发送 → 修复崩溃 + 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) // 必须回调 + } + + "startRecording" -> { + startRecording() + result.success(null) + } + + "stopRecording" -> { + stopRecording() + result.success(null) + } + + else -> result.notImplemented() // 缺失方法处理 + } + } + + 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..96fd775 --- /dev/null +++ b/app/android/app/src/main/kotlin/com/m5stack/stackchan/model/DanceData.kt @@ -0,0 +1,12 @@ +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..5678092 --- /dev/null +++ b/app/android/app/src/main/kotlin/com/m5stack/stackchan/model/DanceList.kt @@ -0,0 +1,7 @@ +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..4dcafac --- /dev/null +++ b/app/android/app/src/main/kotlin/com/m5stack/stackchan/model/ExpressionData.kt @@ -0,0 +1,8 @@ +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..898c93c --- /dev/null +++ b/app/android/app/src/main/kotlin/com/m5stack/stackchan/model/ExpressionItem.kt @@ -0,0 +1,9 @@ +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..936cefa --- /dev/null +++ b/app/android/app/src/main/kotlin/com/m5stack/stackchan/model/MotionData.kt @@ -0,0 +1,7 @@ +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..dedbfb0 --- /dev/null +++ b/app/android/app/src/main/kotlin/com/m5stack/stackchan/model/MotionDataItem.kt @@ -0,0 +1,7 @@ +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..3651de8 --- /dev/null +++ b/app/android/build.gradle.kts @@ -0,0 +1,27 @@ +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..68fe453 --- /dev/null +++ b/app/android/settings.gradle.kts @@ -0,0 +1,26 @@ +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..0aa7c6a --- /dev/null +++ b/app/ios/Runner/AppDelegate.swift @@ -0,0 +1,17 @@ +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..7a89092 --- /dev/null +++ b/app/ios/Runner/GeneratedPluginRegistrant.h @@ -0,0 +1,19 @@ +// +// 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..f48b1fd --- /dev/null +++ b/app/ios/Runner/GeneratedPluginRegistrant.m @@ -0,0 +1,154 @@ +// +// 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..d1ba00c --- /dev/null +++ b/app/ios/Runner/Model/DanceList.swift @@ -0,0 +1,108 @@ +// +// 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 { + print("JSON string is empty") + return nil + } + guard let jsonData = jsonString.data(using: .utf8) else { + print("Failed to convert string to UTF-8 data") + 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..308a2a5 --- /dev/null +++ b/app/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#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..0fc4338 --- /dev/null +++ b/app/ios/Runner/SceneDelegate.swift @@ -0,0 +1,79 @@ +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) { + print(manager.authorizationStatus) + 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 70% rename from app/StackChan/Utils/Extension.swift rename to app/ios/Runner/Utils/Extension.swift index 7274859..232542f 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) @@ -181,6 +213,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 { @@ -229,6 +269,51 @@ extension Encodable { } } + +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) + } +} + extension CGRect { var minDimension: CGFloat { min(width, height) @@ -276,7 +361,7 @@ extension View { self .background( RoundedRectangle(cornerRadius: cornerRadius) - .fill(.thinMaterial) + .fill(.ultraThinMaterial) ) } } @@ -295,7 +380,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..66b8253 --- /dev/null +++ b/app/ios/Runner/Utils/NativeBridge.swift @@ -0,0 +1,191 @@ +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 { + print("❌ 音频格式初始化失败") + return + } + + if !isAudioInitialized { + guard setupAudioSession() else { + print("❌ 会话初始化失败") + return + } + guard setupAudioEngine() else { + print("❌ 引擎初始化失败") + return + } + isAudioInitialized = true + print("✅ 音频初始化完成") + } + + guard let engine = audioEngine, let playerNode = audioPlayerNode else { + resetAudio() + return + } + + if !engine.isRunning { + do { + try engine.start() + } catch { + print("❌ 引擎启动失败: \(error)") + 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 + print("❌ 音频会话错误:\(nsError.code) - \(nsError.localizedDescription)") + return false + } + } + + // MARK: - 引擎初始化(修复-10868核心) + private func setupAudioEngine() -> Bool { + guard let audioFormat = audioFormat else { + print("❌ 音频格式为空") + return false + } + + let engine = AVAudioEngine() + let playerNode = AVAudioPlayerNode() + engine.attach(playerNode) + + // 格式统一,不会崩溃 + engine.connect(playerNode, to: engine.mainMixerNode, format: audioFormat) + + do { + try engine.start() + } catch { + print("❌ 引擎启动失败: \(error)") + 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 { + print("⚠️ 未知方法") + completion?(nil) + return + } + channel?.invokeMethod(method.rawValue, arguments: arguments) { result in + if let error = result as? FlutterError { + print("❌ 发送失败:\(error)") + } + 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 52% rename from app/StackChan/View/MimicryEmotion.swift rename to app/ios/Runner/View/StackChanArView.swift index c558401..b3a212a 100644 --- a/app/StackChan/View/MimicryEmotion.swift +++ b/app/ios/Runner/View/StackChanArView.swift @@ -1,447 +1,153 @@ -/* - * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD - * - * SPDX-License-Identifier: MIT - */ +// +// StackChanArView.swift +// Runner +// +// Created by 袁智鸿 on 2026/2/5. +// -import SwiftUI -import AVFoundation +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) + } + + // 表情数据事件通道 + let expressionChannelName = "\(methodChannelName)_expression" + expressionStreamHandler = ExpressionStreamHandler() + expressionChannel = FlutterEventChannel(name: expressionChannelName, binaryMessenger: messenger) + expressionChannel?.setStreamHandler(expressionStreamHandler) + + // 帧数据事件通道 + let frameChannelName = "\(methodChannelName)_frame" + frameStreamHandler = FrameStreamHandler() + frameChannel = FlutterEventChannel(name: frameChannelName, binaryMessenger: messenger) + frameChannel?.setStreamHandler(frameStreamHandler) + } + + private func setupARSession() { + guard ARFaceTrackingConfiguration.isSupported else { + print("设备不支持面部追踪") + 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)) + /// 面部数据 + 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 +164,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 +250,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 +265,134 @@ 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) // 上下旋转 + 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 { + 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 + + //隐藏一部分 + 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 +412,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 +440,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 + ) + }() + + /// 输出画面 + 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) + } + } + } + } + } + + /// 绘制机器人表情 + 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 } + + // 确保在主线程发送数据 + 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..e295d2a --- /dev/null +++ b/app/ios/Runner/View/StackChanRobot.swift @@ -0,0 +1,486 @@ +// +// 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 { + print("Failed to load StackChanModel.scn") + 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..2c2dc38 --- /dev/null +++ b/app/ios/Runner/View/StackChanRotaryRobot.swift @@ -0,0 +1,136 @@ +// +// 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 { + print("Failed to load StackChanModel.scn") + 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) + + // 旋转 + 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..692d41a --- /dev/null +++ b/app/ios/Runner/View/ViewFactory.swift @@ -0,0 +1,64 @@ +// +// 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..86a7c3b --- /dev/null +++ b/app/ios/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +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..e387e5b --- /dev/null +++ b/app/lib/app_state.dart @@ -0,0 +1,471 @@ +/* +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) { + debugPrint("Received a regular message: $message"); + } + }); + } + + 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; + debugPrint("GetPositionSuccess: 纬度${position.latitude}, 经度${position.longitude}"); + } catch (e) { + showToast("Failed to obtain location: ${e.toString()}"); + isLocationAvailable.value = false; + debugPrint("GetPositionError: $e"); + } + } + + ///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) { + debugPrint("PositionUpdateError: $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..70cdd9e --- /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 to需求抛Throws/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..3fe7e09 --- /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..c7221bb --- /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..7ce0e66 --- /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 { + //field采用Dart小驼峰namingstandard,parsewhenmapAPIreturnDown划线naming + 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( + //mapAPIreturnDown划线fieldtoDart小驼峰field + 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..eb6b902 --- /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..28fce8b --- /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; //注意:这里field名可能is拼写error,应该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..cc52658 --- /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 -> corresponding语音list,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..6ae6dde --- /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) { + //safe地from JSON in取Value,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..a408477 --- /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..50a5dcf --- /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..cadf655 --- /dev/null +++ b/app/lib/network/http.dart @@ -0,0 +1,143 @@ +/* +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) { + debugPrint(log); + return; + } + + //长content分段Print + for (int i = 0; i < log.length; i += chunkSize) { + int end = i + chunkSize; + if (end > log.length) end = log.length; + //usedebugPrint(比print更稳定,support更长content) + debugPrint(log.substring(i, end)); + } +} + +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) { + return handler.next(response); + }, + ), + ]; + + 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..0827986 --- /dev/null +++ b/app/lib/network/web_socket_util.dart @@ -0,0 +1,236 @@ +/* +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) { + debugPrint(' WebSocket ConnectFailed:DeviceMACAddressIs null/empty'); + return; + } + + //Printconnectstartlog + debugPrint('🔌 StartConnect WebSocket: $urlString'); + + 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; + debugPrint(' WebSocket ConnectSuccess [$connectTime]'); + debugPrint(' ConnectAddress: $urlString'); + debugPrint(' ConnectState: ${_socket?.readyState} (OPEN)'); + + _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; + debugPrint(' WebSocket ConnectFailed [$errorTime]'); + debugPrint(' ConnectAddress: $urlString'); + debugPrint(' Error原Because: $e'); + _scheduleReconnect(); + } + } + + /* ======================= + * Message Handling + * ======================= */ + void _handleMessage(dynamic message) { + final isPing = replyPong(message); + if (!isPing) { + _notifyObservers(message); + } + } + + void _handleError(Object error) { + //errorlog(DistinguishconnecterrorandRunning / Runtimewhenerror) + debugPrint(' WebSocket Running / RuntimeWhenError: $error'); + _isConnected = false; + _scheduleReconnect(); + } + + void _handleDone() { + //connectcloselog(ContainscloseoriginalBecause) + _isConnected = false; + final closeTime = DateTime.now().toString().split('.').first; + debugPrint(' WebSocket ConnectAlready关闭 [$closeTime]'); + debugPrint(' 关闭Address: $_urlString'); + debugPrint(' 关闭State: ${_socket?.closeCode} - ${_socket?.closeReason}'); + _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) { + debugPrint(' 发送StringMessageFailed:WebSocket Disconnected'); + return; + } + + debugPrint('📤 发送StringMessage: $message'); + try { + _socket!.add(message); + } catch (e) { + debugPrint(' 发送StringMessageFailed: $e'); + _isConnected = false; + _scheduleReconnect(); + } + } + + void send(Uint8List data) { + if (_socket == null) { + debugPrint(' 发送2BaseMessageFailed:WebSocket Disconnected'); + return; + } + + //debugPrint('📤 send2Basemessage: length=${data.length} Byte'); + try { + _socket!.add(data); + } catch (e) { + debugPrint(' 发送2BaseMessageFailed: $e'); + _isConnected = false; + _scheduleReconnect(); + } + } + + /* ======================= + * Reconnect + * ======================= */ + void _scheduleReconnect() async { + if (_urlString.isEmpty) return; + + //reconnectlog(avoidFrequentlyRepeatPrint) + if (!_isConnected) { + debugPrint('🔄 准备重连 WebSocket: $_urlString (1Second(s)BackRetry)'); + } + + await Future.delayed(const Duration(seconds: 1)); + await connect(_urlString); + } + + /* ======================= + * Disconnect + * ======================= */ + void disconnect() { + _subscription?.cancel(); + _socket?.close(WebSocketStatus.goingAway, '主动断开连接'); + _isConnected = false; + _socket = null; + debugPrint('🔌 WebSocket AlreadyProactiveDisconnectConnect'); + } + + /* ======================= + * 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..ff126bc --- /dev/null +++ b/app/lib/util/XiaoZhi_util.dart @@ -0,0 +1,846 @@ +/* +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) { + debugPrint('RetryRequestFailed: $e'); + } + } + } + } catch (e) { + debugPrint('ParseResponseFailed: $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) { + debugPrint('查询DeviceException:$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) { + debugPrint('查询DeviceException:$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; + } + + print("拿到generateToken"); + print(generateLicenseModel.data); + + 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) { + debugPrint('生成LicenseException:$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, + ); + debugPrint(xiaozhiResponse.message); + 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..60a208b --- /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 / Previouslycancel旧timer,avoidRepeat计when + _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..ec429de --- /dev/null +++ b/app/lib/util/audio_engine_manager.dart @@ -0,0 +1,157 @@ +/* +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; + debugPrint(" Audio引擎InitializeSuccess"); + + //listenoriginal生Recorddata + // NativeBridge.shared.recordChannel.receiveBroadcastStream().listen((data) { + // if (data is Uint8List) _processPcm(data); + // }); + } catch (e) { + debugPrint(" InitializeFailed: $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) { + debugPrint(" EncodeFailed: $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) { + debugPrint(" PlayFailed: $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) { + debugPrint("MicrophonePermissionNot授权"); + return false; + } + + debugPrint("🎙️ RecordStart (实WhenOpusEncode Int16)"); + NativeBridge.shared.sendMessage(.startRecording); + return true; + } + + //====================== stopRecord ====================== + Future stopRecording() async { + NativeBridge.shared.sendMessage(.stopRecording); + debugPrint("🛑 RecordStop"); + } + + Future dispose() async { + if (_isInitialized) { + simpleOpusEncoder.destroy(); + simpleOpusDecoder.destroy(); + _isInitialized = false; + } + debugPrint("♻️ Asset / Resource源ReleaseComplete / Done"); + } +} diff --git a/app/lib/util/blue_util.dart b/app/lib/util/blue_util.dart new file mode 100644 index 0000000..1509402 --- /dev/null +++ b/app/lib/util/blue_util.dart @@ -0,0 +1,780 @@ +/* +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) { + debugPrint(" BluetoothPermissionAllGetSuccess"); + _registerBluetoothStateListener(); + await _tryTurnOnBluetooth(); + } else { + debugPrint(" Part/AllBluetoothPermission被Reject,No法打开Bluetooth"); + _registerBluetoothStateListener(); + await _tryTurnOnBluetooth(); + } + } catch (e) { + debugPrint(" Permission申请Exception:$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; + debugPrint("🔵 CurrentBluetoothState: $currentState"); + + if (currentState == BluetoothAdapterState.off) { + debugPrint("🔵 BluetoothAlready关闭,In progressAutoRequest打开..."); + await FlutterBluePlus.turnOn(); + } else if (currentState == BluetoothAdapterState.on) { + //[fixkey]Androidfirstpermissionsuccess+BluetoothAlreadyenable → proactivetriggerscan + debugPrint(" BluetoothAlready开启,ProactiveTrigger首Time(s)Scan"); + blueSwitch = true; + if (automaticScanning) { + startScan(); + } + if (autoReconnect) { + reconnect(); + } + } + } catch (e) { + debugPrint(" Auto打开BluetoothFailed:$e"); + } + } + + //MARK: - Bluetooth status update (auto scan, auto reconnect) + void _centralManagerDidUpdateState(BluetoothAdapterState state) { + switch (state) { + case BluetoothAdapterState.unknown: + debugPrint("Bluetooth state unknown"); + break; + case BluetoothAdapterState.unavailable: + debugPrint("This device does not support Bluetooth"); + break; + case BluetoothAdapterState.unauthorized: + debugPrint("No permission to use Bluetooth, please check settings"); + break; + case BluetoothAdapterState.turningOn: + debugPrint("Bluetooth is resetting"); + break; + case BluetoothAdapterState.on: + debugPrint(" BluetoothAlready打开,AutoStartScan"); + blueSwitch = true; + //Bluetoothenable autostartscan + if (automaticScanning) { + startScan(); + } + //autoreconnect + if (autoReconnect) { + reconnect(); + } + break; + case BluetoothAdapterState.turningOff: + debugPrint("Bluetooth is turning off"); + break; + case BluetoothAdapterState.off: + debugPrint("🔌 BluetoothAlready关闭"); + blueSwitch = false; + //closeafterautoTryreOpen + _tryTurnOnBluetooth(); + break; + } + } + + //MARK: - Scan related (auto execute) + void startScan() { + if (FlutterBluePlus.adapterStateNow != BluetoothAdapterState.on) { + debugPrint("Bluetooth is not ready when scanning"); + //stateNotMeet / Satisfy,autoOpenBluetooth + _tryTurnOnBluetooth(); + return; + } + + discoveredDevices.clear(); + debugPrint("🔍 StartAutoScan附近BLEDevice"); + + 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) { + debugPrint("Scan error: $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) { + debugPrint( + "✅ 发现已绑定设备: ${deviceInfo.device.platformName}, MAC: $deviceMac, 自动连接", + ); + 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) { + debugPrint( + "✅ 匹配到目标设备: ${deviceInfo.device.platformName}, MAC: $deviceMac", + ); + 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() { + debugPrint("⏹️ StopScan"); + FlutterBluePlus.stopScan(); + _scanSubscription?.cancel(); + _cleanupTimer?.cancel(); + } + + //MARK: - Connection related + Future connect(BluetoothDevice peripheral) async { + debugPrint("🔗 StartConnectDevice: ${peripheral.platformName}"); + 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(); + debugPrint(" ConnectFailed: $errorMsg"); + connectionStateChanged?.call(peripheral, false); + } + } + + void _handleConnectionState( + BluetoothDevice peripheral, + BluetoothConnectionState state, + ) { + switch (state) { + case BluetoothConnectionState.connected: + debugPrint(" ConnectSuccess: ${peripheral.platformName}"); + currentPeripheral = peripheral; + _peripheralDidConnect(peripheral); + connectionStateChanged?.call(peripheral, true); + break; + case BluetoothConnectionState.disconnected: + final disconnectReason = peripheral.disconnectReason; + debugPrint( + "🔌 设备断开: ${peripheral.platformName}, ${disconnectReason?.description ?? "no error"}", + ); + 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()); + debugPrint("🔌 AlreadyWillDisconnectDevice从CacheInRemove: $mac"); + } + break; + } + } + + if (autoReconnect) { + reconnect(); + } + break; + default: + break; + } + } + + Future _peripheralDidConnect(BluetoothDevice peripheral) async { + await _discoverServices(peripheral); + } + + Future _discoverServices(BluetoothDevice peripheral) async { + try { + if (peripheral.isDisconnected) { + debugPrint(" DeviceDisconnected,No法DiscoverService"); + return; + } + debugPrint("====================================="); + debugPrint(" StartDiscoverService [Device: ${peripheral.platformName}]"); + debugPrint("====================================="); + + //CallSystemmethoddiscoverservice + final services = await peripheral.discoverServices(timeout: 35); + + debugPrint(" DiscoverServiceSuccess,TotalCount / Number量: ${services.length}"); + for (var s in services) { + debugPrint("🟢 ServiceUUID: ${s.uuid}"); + } + + //iteratediscoverfeature + for (var service in services) { + await _discoverCharacteristics(peripheral, service); + } + } catch (e, stack) { + //Add stack print trace + debugPrint("====================================="); + debugPrint(" DiscoverService[彻底Failed]"); + debugPrint(" ErrorType: ${e.runtimeType}"); + debugPrint(" ErrorInfo: $e"); + debugPrint(" Error堆Stack: $stack"); + debugPrint("====================================="); + } + } + + Future _discoverCharacteristics( + BluetoothDevice peripheral, + BluetoothService service, + ) async { + try { + final characteristics = service.characteristics; + debugPrint("🔍 DiscoverService[${service.uuid}]DownCharacteristicCount / Number量: ${characteristics.length}"); + + for (var characteristic in characteristics) { + debugPrint("🔍 DiscoverCharacteristic: ${characteristic.uuid}"); + characteristicCallback?.call(peripheral, characteristic); + await _setupCharacteristicListener(peripheral, characteristic); + _saveCharacteristicReference(characteristic); + } + } catch (e) { + debugPrint(" Discover特征Failed: $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)) { + debugPrint("ℹ️ Characteristic[$uuid] Not in白名单,SkipListener"); + return; + } + + //onlyhasinwhitelistInsidefeatureValue,Only thenexecuteDownSurface / Sidelistenlogic + if (characteristic.properties.notify || + characteristic.properties.indicate) { + try { + bool notifySuccess = await characteristic.setNotifyValue(true); + if (notifySuccess) { + debugPrint(" Characteristic[$uuid] Listener开启Success"); + } else { + debugPrint(" Characteristic[$uuid] Listener开启Failed"); + } + } catch (e) { + debugPrint(" Characteristic[$uuid] ListenerSettingsException: $e"); + } + } + //listendatareceive + characteristic.lastValueStream.listen((value) { + if (value.isEmpty) return; + debugPrint("📥 Characteristic[$uuid] 收到Data: ${utf8.decode(value)}"); + 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) { + debugPrint(" NoConnectedDevice"); + return; + } + + try { + await peripheral.disconnect(timeout: 35, queue: true, androidDelay: 2000); + _resetCharacteristics(); + } catch (e) { + debugPrint("DisconnectFailed: $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) { + debugPrint(" 发送Failed:BluetoothDisconnected,No法发送: 发送Within容Is: $data"); + return false; + } + + final dataToSend = utf8.encode(data); + if (dataToSend.isEmpty) { + debugPrint(" 发送Failed:DataIs null/empty"); + return false; + } + + try { + debugPrint("📤 In progress发送Data:$data"); + + await characteristic.write( + dataToSend, + withoutResponse: false, + allowLongWrite: true, + ); + + debugPrint(" 发送Success!Type:$type"); + return true; + } catch (e) { + debugPrint(" 发送Failed!Error:$e"); + + //Send failed = connection broken → can reconnect here + if (e.toString().contains("Timed out")) { + debugPrint("🔌 Bluetooth发送Timeout,DeviceDisconnected"); + //cantriggerreconnectlogic + } + return false; + } + } + + Future reconnect() async { + debugPrint("🔄 Try重连..."); + 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..9b20a27 --- /dev/null +++ b/app/lib/util/extension.dart @@ -0,0 +1,239 @@ +/* +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? { + /// Regex批量ReplaceString + /// 按照 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) { + debugPrint('图片压缩Failed:$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..9ad8734 --- /dev/null +++ b/app/lib/util/ml_kit_util.dart @@ -0,0 +1,67 @@ +/* +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) { + debugPrint("Face detection error: $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..76d7dc6 --- /dev/null +++ b/app/lib/util/music_util.dart @@ -0,0 +1,608 @@ +/* +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; //单位:秒 + 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 + debugPrint("DeleteWhenWhenPCMFileFailed: $e"); + } + } + } + + ///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(); + debugPrint("MusicFeatureAnalyzer InitializeSuccess"); + } catch (e) { + debugPrint("MusicFeatureAnalyzer InitializeFailed: $e"); + } + } + + ///configplayerlistener(System1Managerstate) + void _setupPlayerListener() { + _audioPlayer.setVolume(1.0); + + //playerstatelisten(Containsplaystateandhandlestate) + _audioPlayer.playerStateStream.listen((PlayerState state) { + debugPrint("PlayerState变化: Play=${state.playing}, HandleState=${state.processingState}"); + + //Playback completion check (handle completed status) + if (state.processingState == ProcessingState.completed) { + debugPrint("MusicPlayComplete / Done,LoopState: ${_audioPlayer.loopMode}"); + _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; + debugPrint("MusicDurationUpdate: $_musicDuration Second(s)"); + } + }); + + //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) { + debugPrint("PlayerError: 代码=${e.code}, Message=${e.message}"); + } + }); + } + + ///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(); + + debugPrint("MusicByteDataPlaySuccess,VolumeAlreadySettingsIs1.0"); + } on PlayerException catch (e) { + debugPrint("PlayMusicByteDataFailed: 代码=${e.code}, Message=${e.message}"); + throw Exception("播放失败: ${e.message}"); + } catch (e) { + debugPrint("PlayMusicByteDataFailed: $e"); + throw Exception("播放失败: $e"); + } + } + + ///playSinglemusic(playcompleteafterexecutecallback) + Future playMusicOnce(MusicInfo musicInfo, Function() completion) async { + _playbackCompletion = completion; + await playMusic(musicInfo, isLoop: false); //单次play强制closeloop + } + + ///PlayOnlineMusic1Time(s),RepeatCallThenStopFrontFrom beginningPlay + Future playUrlMusicOnce(String? url, {Function()? completion}) async { + if (url == null) { + debugPrint("PlayFailed:URLIs null/empty"); + 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(); + + debugPrint("OnlineMusicPlaySuccess:$url"); + } on PlayerException catch (e) { + debugPrint("PlayOnlineMusicFailed: 代码=${e.code}, Message=${e.message}"); + throw Exception("播放失败: ${e.message}"); + } catch (e) { + debugPrint("PlayOnlineMusicFailed: $e"); + throw Exception("播放失败: $e"); + } + } + + ///coreplaymethod(supportloop) + Future playMusic(MusicInfo? musicInfo, {bool isLoop = false}) async { + if (musicInfo == null) { + debugPrint("PlayFailed:MusicInfoIs null/empty"); + 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); + debugPrint("PlayMusicSuccess:${musicInfo.title ?? musicInfo.filePath}"); + } on PlayerException catch (e) { + debugPrint("PlayMusicFailed: 代码=${e.code}, Message=${e.message}"); + throw Exception("播放失败: ${e.message}"); + } catch (e) { + debugPrint("PlayMusicFailed: $e"); + throw Exception("播放失败: $e"); + } + } + + ///stopplay + Future stopMusic() async { + await _audioPlayer.stop(); + await _audioPlayer.seek(Duration.zero); //resetprogressto开头 + + _currentPosition = 0.0; + _playbackCompletion = null; + _currentMusicInfo = null; + debugPrint("MusicAlreadyStopPlay"); + } + + ///pauseplay + Future pauseMusic() async { + if (_audioPlayer.playing) { + await _audioPlayer.pause(); + debugPrint("MusicAlreadyPause"); + } + } + + ///resumeplay + Future resumeMusic() async { + if (!_audioPlayer.playing && _currentMusicInfo != null) { + await _audioPlayer.play(); + debugPrint("MusicAlreadyResumePlay"); + } + } + + ///setloopplaystate + void setMusicLoop(bool isLoop) { + final loopMode = isLoop ? LoopMode.one : LoopMode.off; + _audioPlayer.setLoopMode(loopMode); + debugPrint("LoopPlayStateAlreadySettingsIs: $isLoop (LoopMode: $loopMode)"); + } + + ///jumpplayprogress + Future seekTo(double seconds) async { + if (seconds < 0 || seconds > _musicDuration) { + debugPrint("ProgressJumpFailed:No效ProgressValue $seconds,TotalDuration $_musicDuration"); + return; + } + await _audioPlayer.seek(Duration(seconds: seconds.toInt())); + _currentPosition = seconds; + debugPrint("ProgressAlreadyJump到: $seconds Second(s)"); + } + + ///Set volume (0.0 ~ 1.0) + Future setVolume(double volume) async { + if (volume < 0.0 || volume > 1.0) { + debugPrint("VolumeSettingsFailed:No效Value $volume,Range需In 0.0 ~ 1.0 Between"); + return; + } + await _audioPlayer.setVolume(volume); + debugPrint("VolumeAlreadySettingsIs: $volume"); + } + + ///setplayspeed + Future setPlaybackSpeed(double speed) async { + if (speed <= 0) { + debugPrint("Play速度SettingsFailed:No效Value $speed,需Greater than 0"); + return; + } + await _audioPlayer.setSpeed(speed); + debugPrint("Play速度AlreadySettingsIs: $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; + debugPrint("MusicUtil Asset / Resource源AlreadyRelease"); + } + + ///improveaftermusicinfoparse(With / CarryVerboselog+cacheverify) + Future getMusicInfoAsync(String urlString) async { + const tag = "MusicUtil/getMusicInfoAsync"; + try { + debugPrint("$tag StartHandleMusicURL: $urlString"); + + //1. Parse URL + final uri = Uri.parse(urlString); + if (!uri.isAbsolute) { + debugPrint("$tag URLIs not绝ForPath,ParseFailed"); + return null; + } + + //2. Generate cache file info + final extension = path.extension(uri.path); + if (extension.isEmpty || + ![ + '.mp3', + '.wav', + '.m4a', + '.flac', + ].contains(extension.toLowerCase())) { + debugPrint("$tag Not支持File格式: $extension"); + 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) { + debugPrint("$tag CacheFile过小($fileSizeKB KB),DeleteAndAgainDownload"); + await file.delete(); + } else { + debugPrint("$tag UseCacheFile: $filePath"); + 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) { + debugPrint("$tag DownloadFile过小($fileSizeKB KB),No效File"); + return null; + } + + //5. Extract metadata + return await _extractMetadataFromFile(filePath, uri); + } catch (e, stackTrace) { + debugPrint("$tag ExecuteFailed:ExceptionType=${e.runtimeType},Message=$e,堆Stack=$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()); + debugPrint("$tag FileDownloadComplete / Done:${file.path}"); + } catch (e) { + debugPrint("$tag DownloadFailed:$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) { + debugPrint("$tag No法ExtractMetadata:MusicFeatureAnalyzerReturnnull"); + return null; + } + + final durationSec = song.duration ~/ 1000; //convertas秒 + debugPrint( + "$tag 提取元数据成功:时长=$durationSec秒,标题=${song.title},艺术家=${song.artist}", + ); + + return MusicInfo( + durationSec, + filePath, + title: song.title, + artist: song.artist, + album: song.album, + artwork: song.albumArt, + ); + } catch (e, stackTrace) { + debugPrint("$tag ExtractMetadataFailed:Exception=$e,堆Stack=$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++; + debugPrint("$tag DeleteExpiredCache:${file.path},ExistsDuration=${fileAge.inDays}天"); + } + } + } + } + debugPrint("$tag CacheCleanupComplete / Done,共Delete$deletedCount个File"); + } catch (e) { + debugPrint("$tag CacheCleanupFailed:$e"); + } + } +} diff --git a/app/lib/util/native_bridge.dart b/app/lib/util/native_bridge.dart new file mode 100644 index 0000000..45cbd2b --- /dev/null +++ b/app/lib/util/native_bridge.dart @@ -0,0 +1,119 @@ +/* +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 { + debugPrint('Unregistered method: ${call.method}'); + return null; + } + } catch (e) { + debugPrint('Error handling method call: $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) { + debugPrint('Error sending message to native: $e'); + return null; + } + } + + /// Send PCM audio stream to native side for playback + Future sendAudioStream(ByteData pcmData) async { + try { + await _audioPlayChannel.send(pcmData); + } catch (e) { + debugPrint("Audio stream send failed: $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..ef3e4a0 --- /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(); + } + + ///set沉浸式status 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], + ); + } + + ///set沉浸式status 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..c1f422a --- /dev/null +++ b/app/lib/view/home/dance.dart @@ -0,0 +1,813 @@ +/* +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); //currentSelectedDance帧index + Rx danceInfo = Rx(DanceList()); //danceData + RxInt dancePlayIndex = RxInt(-1); //正inplay帧index(forhighlight) + RxBool isRun = RxBool(false); //whether正inwholeplay + RxBool isLoop = RxBool(false); //new增:loopplaymode开关 + + //playControl related + Timer? playTimer; //wholeplaytimer(网络mode) + List?> bluetoothPlayTasks = []; //Bluetoothplaytasklist + RxBool isPlayingSingle = RxBool(false); //whether正inplay单帧 +} + +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(); //disposewhen强制stopplay + 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) { + debugPrint("Single framePlayFailed: $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; //已stop则exit + + 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) { + debugPrint("BluetoothPlayFrame $i Failed: $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..87ffe3d --- /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); + + //最after1个addbutton - 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]; + //useSlidableImplementLeft滑menu + + return Slidable( + key: Key('dance_${dance.id}'), + //Left滑config + endActionPane: ActionPane( + extentRatio: 0.6, + motion: const ScrollMotion(), //滑动动效 + 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..0ed249c --- /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 { + //Main页initwhensetasWiFimode + BlueUtil.shared.blueMode = 1; + + AppState.shared.webSocketMessageMonitoring(); + if (AppState.shared.deviceMac != "") { + AppState.shared.connectWebSocket(); + } + + //setBluetoothdevicescancallback,updateMain页devicelist + 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..7b2a98e --- /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; + } + } + } + }); + + //re打开Camera + audioStream(keyfix:exitAgain进must重发) + 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..0813b34 --- /dev/null +++ b/app/lib/view/home/pano_page.dart @@ -0,0 +1,425 @@ +/* +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()}"); + debugPrint("拍照Exception:$e"); + } 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()}"); + debugPrint("Stitch error: $e"); + } 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..98773fd --- /dev/null +++ b/app/lib/view/home/record_dance.dart @@ -0,0 +1,580 @@ +/* +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); + debugPrint( + "Recorded frame ${recordedDanceFrames.length}: " + "Yaw=${danceFrame.yawServo.angle}, Pitch=${danceFrame.pitchServo.angle}, Duration=100ms", + ); + } + + 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..4f48006 --- /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()], + ), + + //====================== AI代理set ====================== + 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..49edf25 --- /dev/null +++ b/app/lib/view/popup/agent_configuration.dart @@ -0,0 +1,509 @@ +/* +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) { + debugPrint("Failed to initialize data: $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) { + debugPrint("Failed to switch bound AI Agent: $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); + } + + //Replace字符Descriptionin占位符 + 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, //use填充icon更现代 + 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, //行高提升可读性 + ), + 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..c5f5885 --- /dev/null +++ b/app/lib/view/popup/binding_device.dart @@ -0,0 +1,270 @@ +/* +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) { + debugPrint('JSONParseFailed: ${e.message}'); + } on Exception catch (e) { + debugPrint('HandleMACAddressWhen出错: $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..7c4cfc4 --- /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(), //hasmoredatawhen不show任何component(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..8c34036 --- /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..3d39031 --- /dev/null +++ b/app/lib/view/popup/device_wifi_config.dart @@ -0,0 +1,335 @@ +/* +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); + debugPrint("WiFi info cached: $name"); + } catch (e) { + debugPrint("Failed to save WiFi cache: $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; + }); + debugPrint("Using cached WiFi info: $cachedName"); + return true; + } + return false; + } catch (e) { + debugPrint("Failed to load WiFi cache: $e"); + return false; + } + } + + /// Initialize WiFi info - cache first, then system if no cache + Future _initializeWifiInfo() async { + final hasCache = await _loadCachedWifiInfo(); + if (!hasCache) { + debugPrint("No WiFi cache found, fetching from system"); + 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 { + debugPrint("Location permission denied, cannot fetch WiFi name"); + } + } 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; + }); + debugPrint("Fetched Android WiFi name: $cleanWifiName"); + } + } + } on PlatformException catch (e) { + debugPrint("Failed to fetch Android WiFi name: $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..0872ddc --- /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'; + +//Editor者Create 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:languagemustexist于dynamiclistin + 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", //统一术语 + ), + ), + 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", //简化更自然 + 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", //更清晰 + 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(注释Part也optimize) + // _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), + ), + ); + } + + //input表单Item + 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), + ), + ], + ), + ); + } + + //select表单Item + 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..deab5b0 --- /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]] 格式,Extractin间errorcontent + 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]] 格式,Extractin间errorcontent + 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..404f31e --- /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..de49937 --- /dev/null +++ b/app/lib/view/popup/select_blue_device.dart @@ -0,0 +1,522 @@ +/* +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(preventNo限Wait) + Timer? _connectTimer; + + //verifytimeouttimer(preventdeviceNoresponsewhen1直to圈) + 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) { + debugPrint(" MTUSettingsSuccess: $mtu"); + }) + .catchError((e) { + debugPrint(" MTUSettingsFailed: $e"); + if (mounted) { + AppState.shared.showToast( + "Failed to set MTU. Device may not work properly.", + ); + } + }); + } + //enablefeatureValuenotify + try { + await characteristic.setNotifyValue(true); + debugPrint(" 开启DeviceNotificationSuccess"); + startVerificationEquipment(characteristic); + } catch (e) { + debugPrint(" 开启DeviceNotificationFailed: $e"); + _resetConnectState(); + if (mounted) { + AppState.shared.showToast( + "Failed to enable device notification.", + ); + } + } + return; + } + } + debugPrint(" Not找到WiFiConfigurationCharacteristic"); + if (mounted) { + AppState.shared.showToast("Device configuration feature not found."); + } + }; + BlueUtil.shared.wifiSetCharacteristicCall = (data) async { + try { + String json = utf8.decode(data); + debugPrint("📥 收到DeviceNotification: $json"); + final model = BlueNotifyStateModel.fromJson(json); + + if (model == null) { + debugPrint(" DeviceDataParseFailed"); + if (mounted) { + AppState.shared.showToast("Failed to parse device data."); + } + return; + } + + if (model.cmd != null && + model.cmd == "notifyState" && + model.data?.type == 4) { + //拿to身份Encryptinfo + String? data = model.data?.state; + if (data == null) { + debugPrint(" DeviceNotReturnEncryptInfo"); + 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) { + debugPrint(" DeviceInfoDecryptFailed"); + 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) { + debugPrint(" HandleDeviceNotificationFailed: $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); //remove最after一个冒号 + } + + 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,No需Repeat) + 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 + debugPrint(" DeviceBindFailed"); + if (mounted) { + AppState.shared.showToast("Device binding failed. Please try again."); + } + } + } catch (e) { + _resetConnectState(); + debugPrint(" DeviceActivateStreamProcess / ThreadException: $e"); + 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) { + debugPrint(" 生成DeviceLicenseFailed"); + 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) { + debugPrint(" Device云Side / EndActivateFailed"); + 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"); + debugPrint("DeviceActivateSuccess,DeviceId: ${checkDevice.device_id}"); + return true; + } + } catch (e) { + debugPrint(" 查询DeviceConfigurationException: $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(); + debugPrint(" DeviceConnectTimeout"); + 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(); + debugPrint(" BluetoothConnectFailed: $e"); + 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(send握手data) + void startVerificationEquipment( + BluetoothCharacteristic characteristic, + ) async { + try { + if (mounted) { + AppState.shared.showToast("Verifying device..."); + } + //startverifytimeouttimer + _verifyTimer?.cancel(); + _verifyTimer = Timer(Duration(seconds: _verifyTimeout), () { + _resetConnectState(); + debugPrint(" DeviceVerifyTimeout"); + 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) { + debugPrint(" 发送DeviceVerify指令Success"); + } 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(); + debugPrint(" 发送DeviceVerify指令Failed: $e"); + 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..bc6fb55 --- /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(); + } + } + + //time戳to日期 + 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: [ + //====================== center文字圆Avatar ====================== + 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(); + }, + ), + ], + ), + ], + ); + }), + ), + ); + } + + //info行component + 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..f82cb14 --- /dev/null +++ b/app/lib/view/popup/xiaozhi_welcome_page.dart @@ -0,0 +1,910 @@ +/* +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) { + debugPrint("❌ Failed to generate device license."); + 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) { + debugPrint(" Device云Side / EndActivateFailed"); + 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(), + //语速 + 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), + ), + ), + //音调 + 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..6a69f16 --- /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'; + +///圆形 +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..bc84689 --- /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 ClassinModify以Downmethod + + void _updatePoint(Offset localPosition, Size size) { + //Get padding Value便捷方式 + final padding = + widget.padding?.resolve(Directionality.of(context)) ?? EdgeInsets.zero; + + //calculate实际Can操控区域(Inside缩after矩形) + 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:willcurrent点mapBackscreen像素position + 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 作as起始offset + 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, //允许button阴影or元素超出父容器边界 + 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. drawbackground网格and刻度 + 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..34c8f5b --- /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..db1aedc --- /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; + } + } + }); + } + + ///检测faceand导出画Surface / 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..dac5265 --- /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); + + //createcrop区域模拟eye睁开度 + final clipRect = ui.Rect.fromLTRB( + eyeRect.left, + eyeRect.bottom - visibleHeight, + eyeRect.right, + eyeRect.bottom, + ); + canvas.clipRect(clipRect); + + //draw白色椭圆eye + paint.color = const ui.Color(0xFFFFFFFF); + canvas.drawOval(eyeRect, paint); + + canvas.restore(); + } + + //calculateeye基础position + 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(), + ); + + //marktexture及其源Needupdate + expressionTexture.needsUpdate = true; + + //ifuse MeshBasicMaterial,Ensure它也Willrereadtexture + 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..a3d6021 --- /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(); + + //环境光 + 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); + + //镜头and光线set + 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(); + } + + //set视Angle + 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(for齐iOS) + Function(double)? currentRotationEvent; + + //重组层级关系 + 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); + + //createcrop区域模拟eye睁开度 + final clipRect = ui.Rect.fromLTRB( + eyeRect.left, + eyeRect.bottom - visibleHeight, + eyeRect.right, + eyeRect.bottom, + ); + canvas.clipRect(clipRect); + + //draw白色椭圆eye + paint.color = const ui.Color(0xFFFFFFFF); + canvas.drawOval(eyeRect, paint); + + canvas.restore(); + } + + //calculateeye基础position + 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(), + ); + + //marktexture及其源Needupdate + expressionTexture!.needsUpdate = true; + + //ifuse MeshBasicMaterial,Ensure它也Willrereadtexture + 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); + }); +}