app code 4/27

This commit is contained in:
袁智鸿
2026-04-27 12:16:53 +08:00
parent f0fa33cd67
commit 7413e758ce
257 changed files with 24691 additions and 8371 deletions
+361 -51
View File
@@ -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
Doubleclick 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 <repository-url>
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://<server-ip>:<port>/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.
-404
View File
@@ -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 = "<group>"; };
0EBD7D382ECDA27C0001A9D1 /* StackChan.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = StackChan.app; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
0EBD7E222ECDC9510001A9D1 /* Exceptions for "StackChan" folder in "StackChan" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Info.plist,
);
target = 0EBD7D372ECDA27C0001A9D1 /* StackChan */;
};
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
0EBD7D3A2ECDA27C0001A9D1 /* StackChan */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
0EBD7E222ECDC9510001A9D1 /* Exceptions for "StackChan" folder in "StackChan" target */,
);
explicitFileTypes = {
Info.plist = text.xml;
};
path = StackChan;
sourceTree = "<group>";
};
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
0EBD7D352ECDA27C0001A9D1 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
0EBD7D2F2ECDA27C0001A9D1 = {
isa = PBXGroup;
children = (
0EBD7D3A2ECDA27C0001A9D1 /* StackChan */,
0EBD7D392ECDA27C0001A9D1 /* Products */,
0E4478B82F0A538600010197 /* README.MD */,
);
sourceTree = "<group>";
};
0EBD7D392ECDA27C0001A9D1 /* Products */ = {
isa = PBXGroup;
children = (
0EBD7D382ECDA27C0001A9D1 /* StackChan.app */,
);
name = Products;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
0EBD7D372ECDA27C0001A9D1 /* StackChan */ = {
isa = PBXNativeTarget;
buildConfigurationList = 0EBD7D432ECDA27D0001A9D1 /* Build configuration list for PBXNativeTarget "StackChan" */;
buildPhases = (
0EBD7D342ECDA27C0001A9D1 /* Sources */,
0EBD7D352ECDA27C0001A9D1 /* Frameworks */,
0EBD7D362ECDA27C0001A9D1 /* Resources */,
);
buildRules = (
);
dependencies = (
);
fileSystemSynchronizedGroups = (
0EBD7D3A2ECDA27C0001A9D1 /* StackChan */,
);
name = StackChan;
packageProductDependencies = (
);
productName = StackChan;
productReference = 0EBD7D382ECDA27C0001A9D1 /* StackChan.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
0EBD7D302ECDA27C0001A9D1 /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 2610;
LastUpgradeCheck = 2620;
TargetAttributes = {
0EBD7D372ECDA27C0001A9D1 = {
CreatedOnToolsVersion = 26.1.1;
};
};
};
buildConfigurationList = 0EBD7D332ECDA27C0001A9D1 /* Build configuration list for PBXProject "StackChan" */;
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = 0EBD7D2F2ECDA27C0001A9D1;
minimizedProjectReferenceProxies = 1;
packageReferences = (
);
preferredProjectObjectVersion = 77;
productRefGroup = 0EBD7D392ECDA27C0001A9D1 /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
0EBD7D372ECDA27C0001A9D1 /* StackChan */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
0EBD7D362ECDA27C0001A9D1 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
0E4478B92F0A538600010197 /* README.MD in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
0EBD7D342ECDA27C0001A9D1 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin XCBuildConfiguration section */
0EBD7D412ECDA27D0001A9D1 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = NG678HLKHZ;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.1;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
name = Debug;
};
0EBD7D422ECDA27D0001A9D1 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = NG678HLKHZ;
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.1;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SDKROOT = iphoneos;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_COMPILATION_MODE = wholemodule;
VALIDATE_PRODUCT = YES;
};
name = Release;
};
0EBD7D442ECDA27D0001A9D1 /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReferenceAnchor = 0EBD7D3A2ECDA27C0001A9D1 /* StackChan */;
baseConfigurationReferenceRelativePath = App.xcconfig;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = StackChan/StackChan.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 3;
DEVELOPMENT_TEAM = NG678HLKHZ;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
HEADER_SEARCH_PATHS = "";
INFOPLIST_FILE = StackChan/Info.plist;
INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Bluetooth permission is required to connect to nearby devices";
INFOPLIST_KEY_NSCameraUsageDescription = "Camera permission is required to scan the code";
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Local network access is required to discover devices";
INFOPLIST_KEY_NSLocationAlwaysAndWhenInUseUsageDescription = "Location permission is required to access Wi-Fi information";
INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "Location permission is required to access Wi-Fi information";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
IPHONEOS_DEPLOYMENT_TARGET = 16.6;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
LIBRARY_SEARCH_PATHS = "$(inherited)";
MARKETING_VERSION = 1.0.3;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
);
PRODUCT_BUNDLE_IDENTIFIER = com.m5stack.StackChan;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES;
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_OBJC_BRIDGING_HEADER = "";
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
0EBD7D452ECDA27D0001A9D1 /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReferenceAnchor = 0EBD7D3A2ECDA27C0001A9D1 /* StackChan */;
baseConfigurationReferenceRelativePath = App.xcconfig;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = StackChan/StackChan.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 3;
DEVELOPMENT_TEAM = NG678HLKHZ;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
HEADER_SEARCH_PATHS = "";
INFOPLIST_FILE = StackChan/Info.plist;
INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Bluetooth permission is required to connect to nearby devices";
INFOPLIST_KEY_NSCameraUsageDescription = "Camera permission is required to scan the code";
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Local network access is required to discover devices";
INFOPLIST_KEY_NSLocationAlwaysAndWhenInUseUsageDescription = "Location permission is required to access Wi-Fi information";
INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "Location permission is required to access Wi-Fi information";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
IPHONEOS_DEPLOYMENT_TARGET = 16.6;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
LIBRARY_SEARCH_PATHS = "$(inherited)";
MARKETING_VERSION = 1.0.3;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
);
PRODUCT_BUNDLE_IDENTIFIER = com.m5stack.StackChan;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES;
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_OBJC_BRIDGING_HEADER = "";
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
0EBD7D332ECDA27C0001A9D1 /* Build configuration list for PBXProject "StackChan" */ = {
isa = XCConfigurationList;
buildConfigurations = (
0EBD7D412ECDA27D0001A9D1 /* Debug */,
0EBD7D422ECDA27D0001A9D1 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
0EBD7D432ECDA27D0001A9D1 /* Build configuration list for PBXNativeTarget "StackChan" */ = {
isa = XCConfigurationList;
buildConfigurations = (
0EBD7D442ECDA27D0001A9D1 /* Debug */,
0EBD7D452ECDA27D0001A9D1 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = 0EBD7D302ECDA27C0001A9D1 /* Project object */;
}
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Bucket
uuid = "632D2E91-955D-4E23-9652-CB7F63F6388B"
type = "1"
version = "2.0">
</Bucket>
@@ -1,22 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>SchemeUserState</key>
<dict>
<key>StackChan.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>0</integer>
</dict>
</dict>
<key>SuppressBuildableAutocreation</key>
<dict>
<key>0EBD7D372ECDA27C0001A9D1</key>
<dict>
<key>primary</key>
<true/>
</dict>
</dict>
</dict>
</plist>
Binary file not shown.
-268
View File
@@ -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<String>.decode(from: success)
if response.isSuccess {
print("Update successful")
}
} catch {
print("Failed to parse data")
}
case .failure(let failure):
print("Request failed:", failure)
}
}
}
/// Distance detection, callback when close
func startDistanceDetection() {
detector.startDistanceDetection(
distanceUpdate: { distance in
let distanceInCm = distance * 100
if distanceInCm < 5 {
if self.showSwitchFace == false {
self.showSwitchFace = true
}
}
},
belowThreshold: {
// Execute your business logic
// For example: stop machine, send notification, etc.
}
)
}
func stopDistanceDetection() {
detector.stopDistanceDetection()
}
// Wrapper for ARSessionDelegate
class ARSessionDelegateWrapper: NSObject, ARSessionDelegate {
var onFrameUpdate: (ARFrame) -> Void
init(onFrameUpdate: @escaping (ARFrame) -> Void) {
self.onFrameUpdate = onFrameUpdate
}
func session(_ session: ARSession, didUpdate frame: ARFrame) {
onFrameUpdate(frame)
}
}
func getDeviceInfo() {
let map = [
ValueConstant.mac: deviceMac
]
Networking.shared.get(pathUrl: Urls.deviceInfo,parameters: map) { result in
switch result {
case .success(let success):
do {
let response = try Response<Device>.decode(from: success)
if response.isSuccess, let deviceInfo = response.data {
withAnimation {
self.deviceInfo = deviceInfo
self.newName = self.deviceInfo.name ?? ""
}
if deviceInfo.name == "" {
self.showCjamgeNameAlert = true
}
}
} catch {
print("Failed to parse data")
}
case .failure(let failure):
print("Request failed:", failure)
}
}
}
/// Enable Bluetooth functionality
func openBlufi() {
BlufiUtil.shared.blufDevicesMonitoring = { discovereDevices in
self.blufDeviceList = discovereDevices
// Check manualShutdownTime, if exists and not exceeding 5 seconds, temporarily do not show popup
if let shutdownTime = self.manualShutdownTime {
let timeInterval = Date().timeIntervalSince(shutdownTime)
if timeInterval < 5 {
return
}
}
if !self.showDeviceWifiSet {
if !self.blufDeviceList.isEmpty {
self.showDeviceWifiSet = true
}
}
}
}
// webSocket Message Monitoring
func webSocketMessageMonitoring() {
WebSocketUtil.shared.addObserver(for: "App") { (message: URLSessionWebSocketTask.Message) in
switch message {
case .data(let data):
let result = self.parseMessage(message: data)
if let msgType = result.0 {
switch msgType {
case MsgType.deviceOnline:
self.deviceIsOnline = false
case MsgType.deviceOffline:
self.deviceIsOnline = true
default:
break
}
}
case .string(let text):
print("Received a regular message: \(text)")
@unknown default:
break
}
}
}
}
@@ -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
}
}
@@ -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
}
}
@@ -1,6 +0,0 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -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
}
}
-29
View File
@@ -1,29 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<key>NSBonjourServices</key>
<array>
<string>_stackchan-mpc._tcp</string>
</array>
<key>com.apple.developer.networking.wifi-info</key>
<true/>
<key>NSLocalNetworkUsageDescription</key>
<string>This app requires access to the local network to communicate with devices.</string>
<key>NSMicrophoneUsageDescription</key>
<string>We need access to the microphone to capture audio data.</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>CFBundleDisplayName</key>
<string>StackChan World</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>Save the photo to the album</string>
</dict>
</plist>
-62
View File
@@ -1,62 +0,0 @@
/*
* SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD
*
* SPDX-License-Identifier: MIT
*/
import Foundation
struct BlufiModel<T:Codable>: Codable {
var cmd: String? = nil
var data: T? = nil
func toJson() -> String? {
let encoder = JSONEncoder()
encoder.outputFormatting = .prettyPrinted
guard let jsonData = try? encoder.encode(self) else { return nil }
return String(data: jsonData, encoding: .utf8)
}
static func fromJson(_ json: String) -> BlufiModel<T>? {
guard let jsonData = json.data(using: .utf8) else { return nil }
let decoder = JSONDecoder()
return try? decoder.decode(BlufiModel<T>.self, from: jsonData)
}
}
struct BlufiWifi : Codable {
var ssid: String?
var password: String?
func toJson() -> String? {
let encoder = JSONEncoder()
encoder.outputFormatting = .prettyPrinted
guard let jsonData = try? encoder.encode(self) else { return nil }
return String(data: jsonData, encoding: .utf8)
}
static func fromJson(_ json: String) -> BlufiWifi? {
guard let jsonData = json.data(using: .utf8) else { return nil }
let decoder = JSONDecoder()
return try? decoder.decode(BlufiWifi.self, from: jsonData)
}
}
struct BlufiNotifyState : Codable {
var type: Int?
var state: String?
func toJson() -> String? {
let encoder = JSONEncoder()
encoder.outputFormatting = .prettyPrinted
guard let jsonData = try? encoder.encode(self) else { return nil }
return String(data: jsonData, encoding: .utf8)
}
static func fromJson(_ json: String) -> BlufiNotifyState? {
guard let jsonData = json.data(using: .utf8) else { return nil }
let decoder = JSONDecoder()
return try? decoder.decode(BlufiNotifyState.self, from: jsonData)
}
}
-12
View File
@@ -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
}
-39
View File
@@ -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
}
-32
View File
@@ -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
}
-79
View File
@@ -1,79 +0,0 @@
/*
* SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD
*
* SPDX-License-Identifier: MIT
*/
import Foundation
struct Response<T: Codable>: Codable {
let code: Int?
let message: String?
let data: T?
var isSuccess: Bool {
return code == 0
}
func unwrap(or defaultValue: T) -> T {
return data ?? defaultValue
}
static func decode(from jsonData: Data) throws -> Response<T> {
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
do {
return try decoder.decode(Response<T>.self, from: jsonData)
} catch let DecodingError.dataCorrupted(context) {
print("🔴 Data corrupted: \(context.debugDescription)")
printCodingPath(context.codingPath)
printJSON(jsonData)
throw DecodingError.dataCorrupted(context)
} catch let DecodingError.keyNotFound(key, context) {
print("🔴 Key '\(key.stringValue)' not found: \(context.debugDescription)")
printCodingPath(context.codingPath)
printJSON(jsonData)
throw DecodingError.keyNotFound(key, context)
} catch let DecodingError.typeMismatch(type, context) {
print("🔴 Type '\(type)' mismatch: \(context.debugDescription)")
printCodingPath(context.codingPath)
printJSON(jsonData)
throw DecodingError.typeMismatch(type, context)
} catch let DecodingError.valueNotFound(value, context) {
print("🔴 Value '\(value)' not found: \(context.debugDescription)")
printCodingPath(context.codingPath)
printJSON(jsonData)
throw DecodingError.valueNotFound(value, context)
} catch {
print("🔴 Other errors in the analysis: \(error)")
printJSON(jsonData)
throw error
}
}
static func decode(from json: [String: Any]) throws -> Response<T> {
let data = try JSONSerialization.data(withJSONObject: json, options: [])
return try decode(from: data)
}
func debugDescription() -> String {
return "Response(code: \(code ?? 0), message: \(message ?? ""), data: \(String(describing: data)))"
}
}
fileprivate func printCodingPath(_ codingPath: [CodingKey]) {
let path = codingPath.map { $0.stringValue }.joined(separator: ".")
print("📍 Error path: \(path)")
}
fileprivate func printJSON(_ data: Data) {
if let obj = try? JSONSerialization.jsonObject(with: data, options: []),
let prettyData = try? JSONSerialization.data(withJSONObject: obj, options: [.prettyPrinted]),
let str = String(data: prettyData, encoding: .utf8) {
print("📄 Original JSON:\n\(str)")
} else if let str = String(data: data, encoding: .utf8) {
print("📄 Original JSON:\n\(str)")
} else {
print("⚠️ Unable to parse the original JSON")
}
}
-9
View File
@@ -1,9 +0,0 @@
/*
* SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD
*
* SPDX-License-Identifier: MIT
*/
struct UploadFile : Codable {
var path: String? = nil
}
-328
View File
@@ -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<Data, Error>) -> Void
) {
var finalURLString = urlString
var httpBody: Data? = nil
if method == .GET {
if let params = parameters as? [String: Any], !params.isEmpty {
var components = URLComponents(string: urlString)
components?.queryItems = params.map { URLQueryItem(name: $0.key, value: "\($0.value)") }
if let urlWithQuery = components?.url?.absoluteString {
finalURLString = urlWithQuery
}
}
} else {
if let params = parameters {
requestSetContentType: do {
requestSetBody: do {
do {
if let dict = params as? [String: Any] {
httpBody = try JSONSerialization.data(withJSONObject: dict, options: [])
} else if let array = params as? [Any] {
httpBody = try JSONSerialization.data(withJSONObject: array, options: [])
} else {
httpBody = try JSONSerialization.data(withJSONObject: params, options: [])
}
} catch {
completion(.failure(error))
return
}
}
}
}
}
guard let url = URL(string: finalURLString) else {
completion(.failure(NSError(domain: "Invalid URL", code: -1)))
return
}
var request = URLRequest(url: url)
request.httpMethod = method.rawValue
if method != .GET, httpBody != nil {
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = httpBody
}
setHandler(request: &request, headers: headers)
logRequest(request)
URLSession.shared.dataTask(with: request) { data, response, error in
DispatchQueue.main.async {
if let error = error {
completion(.failure(error))
return
}
guard let data = data else {
completion(.failure(NSError(domain: "No data returned", code: -2)))
return
}
self.logResponse(data: data)
completion(.success(data))
}
}.resume()
}
func get(pathUrl: String, parameters: [String: Any] = [:], headers: [String: String] = [:], baseUrlString: String? = nil, completion: @escaping (Result<Data, Error>) -> Void) {
let finalUrl = (baseUrlString ?? Urls.getBaseUrl()) + pathUrl
request(urlString: finalUrl, method: .GET, parameters: parameters, headers: headers, completion: completion)
}
func post(pathUrl: String, parameters: Any? = nil, headers: [String: String] = [:], baseUrlString: String? = nil, completion: @escaping (Result<Data, Error>) -> Void) {
let finalUrl = (baseUrlString ?? Urls.getBaseUrl()) + pathUrl
request(urlString: finalUrl, method: .POST, parameters: parameters, headers: headers, completion: completion)
}
private func setHandler(request: inout URLRequest, headers: [String: String]) {
for (key, value) in headers {
request.setValue(value, forHTTPHeaderField: key)
}
if let token = UserDefaults.standard.string(forKey: ValueConstant.token), !token.isEmpty {
request.setValue(token, forHTTPHeaderField: ValueConstant.Authorization)
}
}
func postFromData(pathUrl: String,
parameters: [String: Any?] = [:],
headers: [String: String] = [:],
baseUrlString: String? = nil,
suffix:String? = nil,
completion: @escaping (Result<Data, Error>) -> Void) {
let finalUrl = (baseUrlString ?? Urls.getBaseUrl()) + pathUrl
guard let url = URL(string: finalUrl) else {
completion(.failure(NSError(domain: "Invalid URL", code: -1)))
return
}
var request = URLRequest(url: url)
request.httpMethod = HTTPMethod.POST.rawValue
let boundary = "Boundary-\(UUID().uuidString)"
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
setHandler(request: &request, headers: headers)
var requestBody = Data()
for (key, value) in parameters {
if let value = value {
if let fileData = value as? Data {
let type = mimeType(for: fileData)
let fileName = UUID().uuidString + (suffix ?? "")
requestBody.append("--\(boundary)\r\n".data(using: .utf8)!)
requestBody.append("Content-Disposition: form-data; name=\"\(key)\"; filename=\"\(fileName)\"\r\n".data(using: .utf8)!)
requestBody.append("Content-Type: \(type)\r\n\r\n".data(using: .utf8)!)
requestBody.append(fileData)
requestBody.append("\r\n".data(using: .utf8)!)
} else if let array = value as? [Any] {
if let jsonData = try? JSONSerialization.data(withJSONObject: array, options: []) {
let jsonString = String(data: jsonData, encoding: .utf8)
requestBody.append("--\(boundary)\r\n".data(using: .utf8)!)
requestBody.append("Content-Disposition: form-data; name=\"\(key)\"\r\n\r\n".data(using: .utf8)!)
requestBody.append("\(jsonString ?? "[]")\r\n".data(using: .utf8)!)
}
} else if let dict = value as? [String:Any] {
if let jsonData = try? JSONSerialization.data(withJSONObject: dict, options: []) {
let jsonString = String(data: jsonData, encoding: .utf8)
requestBody.append("--\(boundary)\r\n".data(using: .utf8)!)
requestBody.append("Content-Disposition: form-data; name=\"\(key)\"\r\n\r\n".data(using: .utf8)!)
requestBody.append("\(jsonString ?? "{}")\r\n".data(using: .utf8)!)
}
} else {
let str = "\(value)"
requestBody.append("--\(boundary)\r\n".data(using: .utf8)!)
requestBody.append("Content-Disposition: form-data; name=\"\(key)\"\r\n\r\n".data(using: .utf8)!)
requestBody.append("\(str)\r\n".data(using: .utf8)!)
}
}
}
requestBody.append("--\(boundary)--\r\n".data(using: .utf8)!)
request.httpBody = requestBody
logRequest(request)
URLSession.shared.dataTask(with: request) { data, response, error in
DispatchQueue.main.async {
if let error = error {
completion(.failure(error))
return
}
guard let data = data else {
completion(.failure(NSError(domain: "No data returned", code: -2)))
return
}
self.logResponse(data: data)
completion(.success(data))
}
}.resume()
}
func anyToJson(data: Any) -> String {
func convert(_ value: Any) -> Any {
if let dict = value as? [String: Any] {
var newDict: [String: Any] = [:]
for (k, v) in dict {
newDict[k] = convert(v)
}
return newDict
} else if let dict = value as? [String: Any?] {
var newDict: [String: Any] = [:]
for (k, v) in dict {
if let unwrapped = v {
newDict[k] = convert(unwrapped)
} else {
newDict[k] = NSNull()
}
}
return newDict
} else if let array = value as? [Any] {
return array.map { convert($0) }
} else if let array = value as? [Any?] {
return array.map { $0 == nil ? NSNull() : convert($0!) }
} else if value is Int || value is Double || value is Bool || value is String {
return value
} else {
return "\(value)"
}
}
let converted = convert(data)
if JSONSerialization.isValidJSONObject(converted) {
do {
let jsonData = try JSONSerialization.data(withJSONObject: converted, options: [])
return String(data: jsonData, encoding: .utf8) ?? "[]"
} catch {
print("JSON Serialization error: \(error)")
return "[]"
}
} else {
if let str = converted as? String {
return "\"\(str)\""
} else {
return "\(converted)"
}
}
}
func put(pathUrl: String, parameters: Any? = nil, headers: [String: String] = [:], baseUrlString: String? = nil, completion: @escaping (Result<Data, Error>) -> Void) {
let finalUrl = (baseUrlString ?? Urls.getBaseUrl()) + pathUrl
request(urlString: finalUrl, method: .PUT, parameters: parameters, headers: headers, completion: completion)
}
func delete(pathUrl: String, parameters: Any? = nil, headers: [String: String] = [:], baseUrlString: String? = nil, completion: @escaping (Result<Data, Error>) -> Void) {
let finalUrl = (baseUrlString ?? Urls.getBaseUrl()) + pathUrl
request(urlString: finalUrl, method: .DELETE, parameters: parameters, headers: headers, completion: completion)
}
func download(pathUrl: String,
parameters: [String: Any] = [:],
headers: [String: String] = [:],
baseUrlString: String? = nil,
completion: @escaping (Result<String, Error>) -> Void) {
let finalUrl = (baseUrlString ?? Urls.getBaseUrl()) + pathUrl
let key = FileUtils.shared.hashedKey(for: finalUrl)
let cacheURL = FileUtils.shared.cacheDirectory().appendingPathComponent(key)
if FileManager.default.fileExists(atPath: cacheURL.path) {
completion(.success(cacheURL.path))
return
}
var request = URLRequest(url: URL(string: finalUrl)!)
request.httpMethod = "GET"
setHandler(request: &request, headers: headers)
URLSession.shared.downloadTask(with: request) { tempURL, response, error in
DispatchQueue.main.async {
if let error = error {
completion(.failure(error))
return
}
guard let tempURL = tempURL else {
completion(.failure(NSError(domain: "No file downloaded", code: -3)))
return
}
do {
let directory = cacheURL.deletingLastPathComponent()
if !FileManager.default.fileExists(atPath: directory.path) {
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
}
if FileManager.default.fileExists(atPath: cacheURL.path) {
try FileManager.default.removeItem(at: cacheURL)
}
try FileManager.default.moveItem(at: tempURL, to: cacheURL)
completion(.success(cacheURL.path))
} catch {
completion(.failure(error))
}
}
}.resume()
}
private func logRequest(_ request: URLRequest) {
print("➡️ Request URL: \(request.url?.absoluteString ?? "")")
print("➡️ Method: \(request.httpMethod ?? "")")
print("➡️ Headers: \(request.allHTTPHeaderFields ?? [:])")
if let body = request.httpBody {
if let bodyString = String(data: body, encoding: .utf8) {
print("➡️ Body:")
bodyString.jsonPrint()
} else {
let sizeInMB = Double(body.count) / (1024 * 1024)
print(String(format: "➡️ Body (binary data, size: %.2f MB)", sizeInMB))
}
}
}
private func logResponse(data: Data) {
if let responseString = String(data: data, encoding: .utf8) {
print("⬅️ Response:")
responseString.jsonPrint()
} else {
print("⬅️ Response (binary data, length: \(data.count) bytes)")
}
}
private func mimeType(for data: Data) -> String {
var bytes = [UInt8](repeating: 0, count: 1)
data.copyBytes(to: &bytes, count: 1)
switch bytes[0] {
case 0xFF: return "image/jpeg"
case 0x89: return "image/png"
case 0x47: return "image/gif"
case 0x25: return "application/pdf"
case 0x49, 0x4D: return "image/tiff"
default: return "application/octet-stream"
}
}
}
-45
View File
@@ -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"
}
-154
View File
@@ -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<URLSessionWebSocketTask.Message, Error>) {
switch result {
case .success(let message):
let isPing = replyPong(message: message)
if !isPing {
self.notifyObservers(message: message)
}
self.webSocketTask?.receive { [weak self] next in
self?.handleReceive(result: next)
}
case .failure(let error):
print("❌ Failed to receive the message: \(error.localizedDescription)")
print("⚠️ WebSocket Connection lost. Attempting to reconnect.…")
if !self.reconnectingNow && !self.urlString.isEmpty {
self.connect(urlString: self.urlString)
}
}
}
func replyPong(message: URLSessionWebSocketTask.Message) -> Bool {
switch message {
case .data(let data):
let result = AppState.shared.parseMessage(message: data)
if let msgType = result.0, let _ = result.1 {
switch msgType {
case MsgType.ping:
AppState.shared.sendWebSocketMessage(.pong)
return true
default:
return false
}
}
case .string(_):
return false
@unknown default:
return false
}
return false
}
func send(message: String) {
let wsMessage = URLSessionWebSocketTask.Message.string(message)
print("This is the message being sent.: \(message)")
webSocketTask?.send(wsMessage) { error in
if let error = error {
print("❌ Message sending failed: \(error.localizedDescription)")
if !self.reconnectingNow && !self.urlString.isEmpty {
self.connect(urlString: self.urlString)
}
}
}
}
func send(data: Data) {
let wsMessage = URLSessionWebSocketTask.Message.data(data)
webSocketTask?.send(wsMessage) { error in
if let error = error {
print("❌ Failed to send binary message: \(error.localizedDescription)")
if !self.reconnectingNow && !self.urlString.isEmpty {
self.connect(urlString: self.urlString)
}
}
}
}
func disconnect() {
webSocketTask?.cancel(with: .goingAway, reason: nil)
self.reconnectingNow = false
print("webSocket Disconnected")
}
func addObserver(for key: String, observer: @escaping (URLSessionWebSocketTask.Message) -> Void) {
observers[key] = observer
}
func removeObserver(for key: String) {
observers.removeValue(forKey: key)
}
func removeAllObservers() {
observers.removeAll()
}
func notifyObservers(message: URLSessionWebSocketTask.Message) {
for observer in observers.values {
observer(message)
}
}
}
-20
View File
@@ -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)
}
}
}
@@ -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()
}
}
-431
View File
@@ -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()
}
}
@@ -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..<dotCount {
let pos = CGPoint(x: CGFloat.random(in: 0..<size.width), y: CGFloat.random(in: 0..<size.height))
let target = CGPoint(x: CGFloat.random(in: 0..<size.width), y: CGFloat.random(in: 0..<size.height))
let dotSize: CGFloat = CGFloat.random(in: 200...300)
dots.append(Dot(position: pos, target: target,dotSize: dotSize))
}
}
private func updateDots(size: CGSize) {
guard size.width > 0, size.height > 0 else { return }
for i in dots.indices {
var dot = dots[i]
let dx = dot.target.x - dot.position.x
let dy = dot.target.y - dot.position.y
let distance = sqrt(dx*dx + dy*dy)
if distance < speed {
dot.target = CGPoint(x: CGFloat.random(in: 0..<size.width), y: CGFloat.random(in: 0..<size.height))
} else {
dot.position.x += dx / distance * speed
dot.position.y += dy / distance * speed
}
dots[i] = dot
}
}
}
struct Dot {
var position: CGPoint
var target: CGPoint
var dotSize: CGFloat
}
struct DazzlingBackgroundPreview : PreviewProvider {
static var previews: some View {
DazzlingBackground(backColors: [Color.accent.opacity(0.5), Color.pink.opacity(0.2),Color.blue.opacity(0.5)],background: .white)
.ignoresSafeArea()
}
}
-245
View File
@@ -1,245 +0,0 @@
/*
* SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD
*
* SPDX-License-Identifier: MIT
*/
import ARKit
import AVFoundation
class DistanceDetector {
private var arSession: ARSession?
private var isDetectionActive = false
private var distanceCallback: ((Float) -> Void)?
private var thresholdCallback: (() -> Void)?
private let thresholdDistance: Float = 0.05 // 5cm in meters
private var timer: Timer?
func startDistanceDetection(
distanceUpdate: ((Float) -> Void)? = nil,
belowThreshold: (() -> Void)? = nil
) {
guard ARWorldTrackingConfiguration.isSupported else {
return
}
if #available(iOS 13.0, *) {
guard ARWorldTrackingConfiguration.supportsFrameSemantics(.sceneDepth) else {
return
}
} else {
return
}
checkCameraPermission { [weak self] granted in
guard granted else {
return
}
self?.setupARSession()
self?.setupCallbacks(distanceUpdate: distanceUpdate, belowThreshold: belowThreshold)
self?.startDetectionTimer()
}
}
///
func stopDistanceDetection() {
isDetectionActive = false
timer?.invalidate()
timer = nil
arSession?.pause()
arSession = nil
}
// MARK: - Private Methods
private func checkCameraPermission(completion: @escaping (Bool) -> Void) {
let status = AVCaptureDevice.authorizationStatus(for: .video)
switch status {
case .authorized:
completion(true)
case .notDetermined:
AVCaptureDevice.requestAccess(for: .video) { granted in
DispatchQueue.main.async {
completion(granted)
}
}
default:
completion(false)
}
}
private func setupARSession() {
arSession = ARSession()
let configuration = ARWorldTrackingConfiguration()
if #available(iOS 13.0, *) {
configuration.frameSemantics.insert(.sceneDepth)
}
arSession?.run(configuration)
isDetectionActive = true
}
private func setupCallbacks(
distanceUpdate: ((Float) -> Void)?,
belowThreshold: (() -> Void)?
) {
self.distanceCallback = distanceUpdate
self.thresholdCallback = belowThreshold
}
private func startDetectionTimer() {
timer?.invalidate()
timer = Timer.scheduledTimer(
timeInterval: 0.1,
target: self,
selector: #selector(performDistanceCheck),
userInfo: nil,
repeats: true
)
}
@objc private func performDistanceCheck() {
guard isDetectionActive,
let frame = arSession?.currentFrame else {
return
}
let distance = getCurrentDistance(from: frame)
if let distance = distance {
distanceCallback?(distance)
if distance < thresholdDistance {
handleBelowThreshold()
}
}
}
private func getCurrentDistance(from frame: ARFrame) -> Float? {
if #available(iOS 13.0, *) {
return getDistanceUsingSceneDepth(from: frame)
} else {
return getDistanceUsingHitTest(from: frame)
}
}
@available(iOS 13.0, *)
private func getDistanceUsingSceneDepth(from frame: ARFrame) -> Float? {
guard let depthData = frame.sceneDepth else {
return nil
}
let depthPixelBuffer = depthData.depthMap
let width = CVPixelBufferGetWidth(depthPixelBuffer)
let height = CVPixelBufferGetHeight(depthPixelBuffer)
let centerX = width / 2
let centerY = height / 2
CVPixelBufferLockBaseAddress(depthPixelBuffer, .readOnly)
guard let baseAddress = CVPixelBufferGetBaseAddress(depthPixelBuffer) else {
CVPixelBufferUnlockBaseAddress(depthPixelBuffer, .readOnly)
return nil
}
let floatBuffer = baseAddress.assumingMemoryBound(to: Float32.self)
var totalDistance: Float = 0
var validSamples = 0
let sampleRadius = 5
for x in max(0, centerX - sampleRadius)...min(width - 1, centerX + sampleRadius) {
for y in max(0, centerY - sampleRadius)...min(height - 1, centerY + sampleRadius) {
let distance = floatBuffer[y * width + x]
if distance.isFinite && distance > 0 {
totalDistance += distance
validSamples += 1
}
}
}
CVPixelBufferUnlockBaseAddress(depthPixelBuffer, .readOnly)
guard validSamples > 0 else {
return nil
}
return totalDistance / Float(validSamples)
}
private func getDistanceUsingHitTest(from frame: ARFrame) -> Float? {
return nil
}
private func handleBelowThreshold() {
NSObject.cancelPreviousPerformRequests(withTarget: self, selector: #selector(executeThresholdCallback), object: nil)
}
@objc private func executeThresholdCallback() {
let generator = UIImpactFeedbackGenerator(style: .heavy)
generator.impactOccurred()
AudioServicesPlaySystemSound(1013)
thresholdCallback?()
}
deinit {
stopDistanceDetection()
}
}
func exampleBasicUsage() {
let detector = DistanceDetector()
detector.startDistanceDetection(
distanceUpdate: { distance in
let distanceInCm = distance * 100
print(String(distanceInCm))
},
belowThreshold: {
}
)
}
class ProximityMonitor {
private let detector = DistanceDetector()
private var isMonitoring = false
func startMonitoring() {
detector.startDistanceDetection(
distanceUpdate: { [weak self] distance in
self?.handleDistanceUpdate(distance)
},
belowThreshold: { [weak self] in
self?.handleProximityAlert()
}
)
isMonitoring = true
}
func stopMonitoring() {
detector.stopDistanceDetection()
isMonitoring = false
}
private func handleDistanceUpdate(_ distance: Float) {
let distanceInCm = distance * 100
if distanceInCm < 10 {
} else if distanceInCm < 30 {
}
}
private func handleProximityAlert() {
NotificationCenter.default.post(
name: NSNotification.Name("ProximityAlert"),
object: nil,
userInfo: ["alert": "object_too_close"]
)
}
}
-43
View File
@@ -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
}
}
-49
View File
@@ -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
}
}
-130
View File
@@ -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)?) {
}
}
-51
View File
@@ -1,51 +0,0 @@
/*
* SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD
*
* SPDX-License-Identifier: MIT
*/
import SwiftUI
struct RippleDiffusion<Content: View> : View {
let content: () -> Content
init(@ViewBuilder content: @escaping () -> Content) {
self.content = content
}
@State private var animate = false
var body: some View {
ZStack(alignment: .center) {
ForEach(0..<3) { index in
Circle()
.stroke(Color.accentColor.opacity(0.7), lineWidth: 2)
.frame(width: CGFloat(index + 1) * 100, height: CGFloat(index + 1) * 100)
.scaleEffect(animate ? 2.0 : 0.1)
.opacity(animate ? 0 : 1)
.animation(
Animation.easeOut(duration: 1.8)
.repeatForever(autoreverses: false)
.delay(Double(index) * 0.3),
value: animate
)
}
content()
}
.onAppear {
DispatchQueue.main.async {
animate = true
}
}
}
}
struct RippleDiffusionPreview : PreviewProvider {
static var previews: some View {
RippleDiffusion {
}
}
}
-52
View File
@@ -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<Self._Label>) -> some View {
configuration
.padding(.all,15)
.frame(maxWidth: .infinity,minHeight: 44)
.background(
RoundedRectangle(cornerRadius: 20.0)
.fill(.background)
)
.shadow(color: .black.opacity(0.2), radius: 5, x: 0, y: 0)
}
}
struct SopDirectoryButtonStyle: ButtonStyle {
let select: Bool
func makeBody(configuration: Configuration) -> some View {
configuration.label
.padding(.all, 20)
.frame(maxWidth: .infinity, minHeight: 44)
.foregroundColor(select ? .accentColor : .primary)
.background(
RoundedRectangle(cornerRadius: 20)
.fill(select ? Color.blue.opacity(0.2) : Color.clear)
)
.opacity(configuration.isPressed ? 0.6 : 1.0)
}
}
-28
View File
@@ -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"
}
-227
View File
@@ -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
}
}
}
}
}
-17
View File
@@ -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
}
}
@@ -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)
}
}
}
-320
View File
@@ -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?()
}
}
}
-571
View File
@@ -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()
}
}
-74
View File
@@ -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()
}
}
-815
View File
@@ -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<String>.decode(from: success)
if response.isSuccess, let data = response.data {
modelDanceList = danceList
print(data)
}
} catch {
print("Failed to parse response data")
}
case .failure(let failure):
print("Request failed:", failure)
}
}
}
}
}
struct AddAvatarMotion : View {
@Binding var isPresented: Bool
@Binding var editDanceDataIndex: Int?
@State private var selectedItem: ControlItem = .avatar
@EnvironmentObject var appState: AppState
@Binding var danceData: DanceData
let onCallBack : ((DanceData) -> Void)?
enum ControlItem: String,CaseIterable, Identifiable {
case avatar = "Avatar"
case motion = "Motion"
var id: String { rawValue }
}
var body: some View {
NavigationStack {
VStack {
HStack {
Spacer()
StackChanRobot(data: danceData,allowsCameraControl: false)
.frame(width: 300,height: 300)
Spacer()
}
HStack {
Text("duration")
.frame(width: 100,alignment: .leading)
Slider(
value: Binding(
get: { Double(danceData.durationMs) },
set: { danceData.durationMs = Int($0) }
),
in: 0...3000
)
.frame(maxWidth: .infinity)
Text(String(danceData.durationMs))
.frame(width: 50,alignment: .trailing)
}
HStack {
Picker("Select", selection: $selectedItem) {
ForEach(ControlItem.allCases) { item in
Text(item.rawValue)
.tag(item)
}
}
.pickerStyle(.segmented)
Button {
withAnimation {
danceData = DanceData(leftEye: ExpressionItem(weight: 100), rightEye: ExpressionItem(weight: 100), mouth: ExpressionItem(), yawServo: MotionDataItem(), pitchServo: MotionDataItem(), durationMs: 1000)
}
saveData()
} label: {
Image(systemName: "arrow.counterclockwise")
}
.glassButtonStyle()
}
if selectedItem == .avatar {
List {
Section("Left Eye") {
HStack {
Text("x")
.frame(width: 60,alignment: .leading)
Slider(
value: Binding(
get: { Double(danceData.leftEye.x) },
set: { danceData.leftEye.x = Int($0) }
),
in: -100...100,
onEditingChanged: { editing in
if !editing {
saveData()
}
}
)
.frame(maxWidth: .infinity)
Text(String(danceData.leftEye.x))
.frame(width: 50,alignment: .trailing)
}
HStack {
Text("y")
.frame(width: 60,alignment: .leading)
Slider(
value: Binding(
get: { Double(danceData.leftEye.y) },
set: { danceData.leftEye.y = Int($0) }
),
in: -100...100,
onEditingChanged: { editing in
if !editing {
saveData()
}
}
)
.frame(maxWidth: .infinity)
Text(String(danceData.leftEye.y))
.frame(width: 50,alignment: .trailing)
}
HStack {
Text("rotation")
.frame(width: 60,alignment: .leading)
Slider(
value: Binding(
get: { Double(danceData.leftEye.rotation) },
set: { danceData.leftEye.rotation = Int($0) }
),
in: -1800...1800,
onEditingChanged: { editing in
if !editing {
saveData()
}
}
)
.frame(maxWidth: .infinity)
Text(String(danceData.leftEye.rotation))
.frame(width: 50,alignment: .trailing)
}
HStack {
Text("weight")
.frame(width: 60,alignment: .leading)
Slider(
value: Binding(
get: { Double(danceData.leftEye.weight) },
set: { danceData.leftEye.weight = Int($0) }
),
in: 0...100,
onEditingChanged: { editing in
if !editing {
saveData()
}
}
)
.frame(maxWidth: .infinity)
Text(String(danceData.leftEye.weight))
.frame(width: 50,alignment: .trailing)
}
HStack {
Text("size")
.frame(width: 60,alignment: .leading)
Slider(
value: Binding(
get: { Double(danceData.leftEye.size) },
set: { danceData.leftEye.size = Int($0) }
),
in: -100...100,
onEditingChanged: { editing in
if !editing {
saveData()
}
}
)
.frame(maxWidth: .infinity)
Text(String(danceData.leftEye.size))
.frame(width: 50,alignment: .trailing)
}
}
.listRowBackground(Color.clear)
Section("Right Eye") {
HStack {
Text("x")
.frame(width: 60,alignment: .leading)
Slider(
value: Binding(
get: { Double(danceData.rightEye.x) },
set: { danceData.rightEye.x = Int($0) }
),
in: -100...100,
onEditingChanged: { editing in
if !editing {
saveData()
}
}
)
.frame(maxWidth: .infinity)
Text(String(danceData.rightEye.x))
.frame(width: 50,alignment: .trailing)
}
HStack {
Text("y")
.frame(width: 60,alignment: .leading)
Slider(
value: Binding(
get: { Double(danceData.rightEye.y) },
set: { danceData.rightEye.y = Int($0) }
),
in: -100...100,
onEditingChanged: { editing in
if !editing {
saveData()
}
}
)
.frame(maxWidth: .infinity)
Text(String(danceData.rightEye.y))
.frame(width: 50,alignment: .trailing)
}
HStack {
Text("rotation")
.frame(width: 60,alignment: .leading)
Slider(
value: Binding(
get: { Double(danceData.rightEye.rotation) },
set: { danceData.rightEye.rotation = Int($0) }
),
in: -1800...1800,
onEditingChanged: { editing in
if !editing {
saveData()
}
}
)
.frame(maxWidth: .infinity)
Text(String(danceData.rightEye.rotation))
.frame(width: 50,alignment: .trailing)
}
HStack {
Text("weight")
.frame(width: 60,alignment: .leading)
Slider(
value: Binding(
get: { Double(danceData.rightEye.weight) },
set: { danceData.rightEye.weight = Int($0) }
),
in: 0...100,
onEditingChanged: { editing in
if !editing {
saveData()
}
}
)
.frame(maxWidth: .infinity)
Text(String(danceData.rightEye.weight))
.frame(width: 50,alignment: .trailing)
}
HStack {
Text("size")
.frame(width: 60,alignment: .leading)
Slider(
value: Binding(
get: { Double(danceData.rightEye.size) },
set: { danceData.rightEye.size = Int($0) }
),
in: -100...100,
onEditingChanged: { editing in
if !editing {
saveData()
}
}
)
.frame(maxWidth: .infinity)
Text(String(danceData.rightEye.size))
.frame(width: 50,alignment: .trailing)
}
}
.listRowBackground(Color.clear)
Section("Mouth") {
HStack {
Text("x")
.frame(width: 60,alignment: .leading)
Slider(
value: Binding(
get: { Double(danceData.mouth.x) },
set: { danceData.mouth.x = Int($0) }
),
in: -100...100,
onEditingChanged: { editing in
if !editing {
saveData()
}
}
)
.frame(maxWidth: .infinity)
Text(String(danceData.mouth.x))
.frame(width: 50,alignment: .trailing)
}
HStack {
Text("y")
.frame(width: 60,alignment: .leading)
Slider(
value: Binding(
get: { Double(danceData.mouth.y) },
set: { danceData.mouth.y = Int($0) }
),
in: -100...100,
onEditingChanged: { editing in
if !editing {
saveData()
}
}
)
.frame(maxWidth: .infinity)
Text(String(danceData.mouth.y))
.frame(width: 50,alignment: .trailing)
}
HStack {
Text("rotation")
.frame(width: 60,alignment: .leading)
Slider(
value: Binding(
get: { Double(danceData.mouth.rotation) },
set: { danceData.mouth.rotation = Int($0) }
),
in: -1800...1800,
onEditingChanged: { editing in
if !editing {
saveData()
}
}
)
.frame(maxWidth: .infinity)
Text(String(danceData.mouth.rotation))
.frame(width: 50,alignment: .trailing)
}
HStack {
Text("weight")
.frame(width: 60,alignment: .leading)
Slider(
value: Binding(
get: { Double(danceData.mouth.weight) },
set: { danceData.mouth.weight = Int($0) }
),
in: 0...100,
onEditingChanged: { editing in
if !editing {
saveData()
}
}
)
.frame(maxWidth: .infinity)
Text(String(danceData.mouth.weight))
.frame(width: 50,alignment: .trailing)
}
}
.listRowBackground(Color.clear)
}
.listStyle(.grouped)
.scrollContentBackground(.hidden)
.background(.clear)
} else if selectedItem == .motion {
List {
Section("Yaw Servo") {
HStack {
Text("angle")
.frame(width: 60,alignment: .leading)
Slider(
value: Binding(
get: { Double(danceData.yawServo.angle) },
set: {
danceData.yawServo.angle = Int($0)
}
),
in: -1280...1280,
onEditingChanged: { editing in
if !editing {
saveData()
}
}
)
.frame(maxWidth: .infinity)
Text(String(danceData.yawServo.angle))
.frame(width: 50,alignment: .trailing)
}
HStack {
Text("speed")
.frame(width: 60,alignment: .leading)
Slider(
value: Binding(
get: { Double(danceData.yawServo.speed) },
set: { danceData.yawServo.speed = Int($0) }
),
in: 0...1000,
onEditingChanged: { editing in
if !editing {
saveData()
}
}
)
.frame(maxWidth: .infinity)
Text(String(danceData.yawServo.speed))
.frame(width: 50,alignment: .trailing)
}
}
.listRowBackground(Color.clear)
Section("Pitch Servo") {
HStack {
Text("angle")
.frame(width: 60,alignment: .leading)
Slider(
value: Binding(
get: { Double(danceData.pitchServo.angle) },
set: { danceData.pitchServo.angle = Int($0) }
),
in: 0...900,
onEditingChanged: { editing in
if !editing {
saveData()
}
}
)
.frame(maxWidth: .infinity)
Text(String(danceData.pitchServo.angle))
.frame(width: 50,alignment: .trailing)
}
HStack {
Text("speed")
.frame(width: 60,alignment: .leading)
Slider(
value: Binding(
get: { Double(danceData.pitchServo.speed) },
set: { danceData.pitchServo.speed = Int($0) }
),
in: 0...1000,
onEditingChanged: { editing in
if !editing {
saveData()
}
}
)
.frame(maxWidth: .infinity)
Text(String(danceData.pitchServo.speed))
.frame(width: 50,alignment: .trailing)
}
}
.listRowBackground(Color.clear)
}
.listStyle(.grouped)
.scrollContentBackground(.hidden)
.background(.clear)
}
Spacer()
}
.padding()
.ignoresSafeArea(edges: .bottom)
.navigationTitle(editDanceDataIndex == nil ? "Add Dance" : "Edit Dance")
.navigationBarTitleDisplayMode(.inline)
.toolbar{
ToolbarItem(placement: .confirmationAction) {
Button {
self.onCallBack?(danceData)
isPresented = false
} label: {
Image(systemName: "checkmark")
}
}
ToolbarItem(placement: .cancellationAction) {
Button {
isPresented = false
} label: {
Image(systemName: "xmark")
}
}
}
}
}
private func saveData() {
if !appState.deviceMac.isEmpty {
}
}
}
-250
View File
@@ -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<BlufiNotifyState>.fromJson(json), let state = model.data?.state {
if state == "wifiConnecting" {
// Configuring Wi-Fi
title = "In the configuration..."
} else if state == "wifiConnected" {
// Configuration succeeded
title = "Configuration successful"
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
appState.showDeviceWifiSet = false
}
} else if state == "wifiConnectFailed" {
// Configuration failed
title = "Configuration failed"
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
alertMessage = "Configuration failed, please re-enter wifi name and password"
showAlert = true
focusedField = .Password
}
}
}
}
locationDelegate.onAuthorized = {
getPermission()
}
locationManager.delegate = locationDelegate
getPermission()
}
.onDisappear {
locationManager.delegate = nil
}
}
private func getWifiInfo() {
NEHotspotNetwork.fetchCurrent { network in
if let network = network {
wifiName = network.ssid
focusedField = .Password
}
}
}
private func confirmWifi() {
if wifiName.isEmpty || wifiPassword.isEmpty {
alertMessage = "Please enter the full name and password"
showAlert = true
return
}
let model = BlufiModel<BlufiWifi>(cmd: "setWifi",data: BlufiWifi(ssid: wifiName,password: wifiPassword))
if let json = model.toJson() {
BlufiUtil.shared.sendWifiSetData(json)
}
}
private func getPermission() {
if #available(iOS 14.0, *) {
switch locationManager.authorizationStatus {
case .authorizedWhenInUse, .authorizedAlways:
getWifiInfo()
break
case .denied, .restricted:
break
case .notDetermined:
locationManager.requestWhenInUseAuthorization()
break
default:
break
}
} else {
locationManager.requestWhenInUseAuthorization()
}
}
}
-84
View File
@@ -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)
}
}
}
-509
View File
@@ -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<GetPostComment>.decode(from: success)
if response.isSuccess,let list = response.data?.list {
for index in posts.indices {
if posts[index].id == postId {
posts[index].postCommentList = list
break
}
}
}
} catch {
// Failed to parse data
}
case .failure(let failure):
// Request failed:
print("Request failed:", failure)
}
}
}
}
private func addPost(post: Post) {
let map: [String:Any] = [
ValueConstant.mac: appState.deviceMac,
ValueConstant.content_text: post.contentText ?? "",
ValueConstant.content_image: post.contentImage ?? "",
]
Networking.shared.post(pathUrl: Urls.postAdd, parameters: map) { result in
switch result {
case .success(let success):
do {
let response = try Response<[String:Int]>.decode(from: success)
if response.isSuccess {
// Refresh posts
page = 1
posts.removeAll()
getPost()
}
} catch {
// Failed to parse data
}
case .failure(let failure):
// Request failed:
print("Request failed:", failure)
}
}
}
/// Delete a post
private func deletePost(_ post: Post) {
let map: [String: Any] = [
ValueConstant.id: post.id
]
Networking.shared.delete(pathUrl: Urls.postDelete, parameters: map) { result in
switch result {
case .success(let success):
do {
let response = try Response<String>.decode(from: success)
if response.isSuccess {
// Remove post locally
withAnimation {
posts.removeAll { $0.id == post.id }
}
}
} catch {
// Failed to parse data
}
case .failure(let failure):
// Delete failed:
print("Delete failed:", failure)
}
}
}
/// Fetch post list
private func getPost() {
isLoadingMore = true
let map:[String:Any] = [
ValueConstant.page: page,
ValueConstant.pageSize: pageSize
]
Networking.shared.get(pathUrl: Urls.postGet,parameters: map) { result in
isLoadingMore = false
switch result {
case .success(let success):
do {
let response = try Response<[Post]>.decode(from: success)
if response.isSuccess,let list = response.data {
withAnimation {
if list.count < pageSize {
hasMore = false
}
posts.append(contentsOf: list)
}
}
} catch {
// Failed to parse data
}
case .failure(let failure):
// Request failed:
print("Request failed:", failure)
}
}
}
private func loadMoreIfNeeded() {
guard !isLoadingMore, hasMore else { return }
page += 1
getPost()
}
}
struct AddMoment : View {
@Binding var showAddMoment: Bool
var callBack: ((Post) -> Void)?
@State private var post: Post = Post(id: 0)
@State private var photoItem: PhotosPickerItem?
@State private var isUploading: Bool = false
@EnvironmentObject var appState: AppState
var body: some View {
NavigationStack {
List {
Section("Text") {
TextField("Please enter the post content", text: Binding(
get: {
post.contentText ?? ""
},
set: {
post.contentText = $0
}
), axis: .vertical)
.textFieldStyle(.plain)
}
Section("image") {
PhotosPicker(selection: $photoItem, matching: .images) {
if isUploading {
ProgressView("Uploading...")
} else {
HStack {
Spacer()
if let urlString = post.contentImage,
let url = URL(string: urlString) {
AsyncImage(url: url) { phase in
switch phase {
case .empty:
ProgressView()
.frame(width: 200, height: 200)
case .success(let image):
image
.resizable()
.scaledToFit()
.frame(maxHeight: 300)
case .failure:
Image(systemName: "photo")
.frame(width: 200, height: 200)
@unknown default:
EmptyView()
}
}
} else {
Label("Select Image", systemImage: "plus.circle")
}
Spacer()
}
}
}
.onChange(of: photoItem) { _ in
updateImage()
}
}
}
.navigationTitle("Add Post")
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button {
self.showAddMoment = false
} label: {
Label("Cancel", systemImage: "xmark")
}
}
ToolbarItem(placement: .confirmationAction) {
Button {
callBack?(post)
self.showAddMoment = false
} label: {
Label("Confirm", systemImage: "checkmark")
}
}
}
}
}
/// Upload file
private func updateImage() {
guard let photoItem else { return }
isUploading = true
Task {
do {
let data = try await photoItem.loadTransferable(type: Data.self)
guard var imageData = data else {
// Failed to get image data
print("Failed to get image data")
isUploading = false
return
}
// Compress the image to no more than 2MB
if let uiImage = UIImage(data: imageData),
let compressedData = uiImage.compress(toMemorySize: 2.0) {
imageData = compressedData
}
let map: [String:Any] = [
ValueConstant.file: imageData,
ValueConstant.directory: ValueConstant.moments,
ValueConstant.name: UUID().uuidString + ".jpg",
]
Networking.shared.postFromData(pathUrl: Urls.uploadFile,parameters: map) { result in
isUploading = false
switch result {
case .success(let success):
do {
let response = try Response<UploadFile>.decode(from: success)
if response.isSuccess, let url = response.data?.path {
let fileUrl = Urls.getFileUrl() + url
DispatchQueue.main.async {
post.contentImage = fileUrl
}
}
} catch {
// Failed to parse data
}
case .failure(let failure):
// Request failed:
print("Request failed:", failure)
}
}
} catch {
isUploading = false
// Failed to load image data:
print("Failed to load image data:", error)
}
}
}
}
-405
View File
@@ -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..<maxAttempts {
let randomX = CGFloat.random(in: 50...(proxySize.width - 50))
let randomY = CGFloat.random(in: 65...(proxySize.height - 65))
let candidate = CGPoint(x: randomX, y: randomY)
// Avoid own avatar
if hypot(candidate.x - center.x, candidate.y - center.y) < selfSafeRadius {
continue
}
// Avoid existing devices
if existingPositions.allSatisfy({ hypot($0.x - candidate.x, $0.y - candidate.y) > otherSafeRadius }) {
return candidate
}
}
// If all attempts fail, randomly offset from the center to avoid overlap
var offsetX = CGFloat.random(in: selfSafeRadius...(selfSafeRadius + 50))
var offsetY = CGFloat.random(in: selfSafeRadius...(selfSafeRadius + 50))
// Randomly determine direction
offsetX *= Bool.random() ? 1 : -1
offsetY *= Bool.random() ? 1 : -1
return CGPoint(x: center.x + offsetX, y: center.y + offsetY)
}
}
struct DeviceInfo {
let device: Device
let postion: CGPoint
}
struct AvatarView : View {
let name: String
var body: some View {
VStack {
Color.clear.frame(
height: 15
)
Image("logo_icon")
.resizable()
.scaledToFit()
.frame(width: 100, height: 100)
.clipShape(Circle())
.shadow(color: Color.gray, radius: 10, x: 0, y: 0)
Text(name)
.frame(height: 15)
.foregroundColor(Color(UIColor.label))
}
}
}
struct NearbyPreview : PreviewProvider {
static var previews: some View {
Nearby()
}
}
-213
View File
@@ -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<String,Error>) -> Void
var completion: ScanCompletion
func makeUIViewController(context: Context) -> ScannerViewController {
let vc = ScannerViewController()
vc.completion = completion
return vc
}
}
class ScannerViewController: UIViewController, AVCaptureMetadataOutputObjectsDelegate {
var completion: ScanView.ScanCompletion?
var captureSession: AVCaptureSession!
var previewLayer: AVCaptureVideoPreviewLayer!
// Flag to control whether callbacks are allowed
private var isProcessing = false
@objc private func toggleFlashlight() {
guard let device = AVCaptureDevice.default(for: .video),
device.hasTorch else { return }
do {
try device.lockForConfiguration()
if device.torchMode == .on {
device.torchMode = .off
} else {
try device.setTorchModeOn(level: 1.0)
}
} catch {
print("Failed to toggle flashlight: \(error)")
}
}
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = UIColor.black
// Initialize guide view
let guideImageView = UIImageView(image: UIImage(systemName: "viewfinder"))
guideImageView.tintColor = .white
guideImageView.contentMode = .scaleAspectFit
guideImageView.tag = 998
// Add breathing animation
let pulse = CABasicAnimation(keyPath: "transform.scale")
pulse.fromValue = 0.9
pulse.toValue = 1.1
pulse.duration = 0.7
pulse.autoreverses = true
pulse.repeatCount = .infinity
guideImageView.layer.add(pulse, forKey: "breathingAnimation")
view.addSubview(guideImageView)
// Initialize flashlight button
let flashlightButton = UIButton(type: .system)
flashlightButton.setImage(UIImage(systemName: "flashlight.off.fill"), for: .normal)
flashlightButton.tintColor = .white
flashlightButton.addTarget(self, action: #selector(toggleFlashlight), for: .touchUpInside)
flashlightButton.tag = 999
view.addSubview(flashlightButton)
switch AVCaptureDevice.authorizationStatus(for: .video) {
case .authorized:
setupSession()
case .notDetermined:
AVCaptureDevice.requestAccess(for: .video) { granted in
DispatchQueue.main.async {
if granted {
self.setupSession()
} else {
self.completion?(.failure(NSError(domain: "Camera access not authorized", code: 0)))
}
}
}
default:
completion?(.failure(NSError(domain: "Camera access not authorized", code: 0)))
}
}
private func setupSession() {
captureSession = AVCaptureSession()
captureSession.sessionPreset = .photo
guard let videoCaptureDevice = AVCaptureDevice.default(for: .video),
let videoInput = try? AVCaptureDeviceInput(device: videoCaptureDevice),
captureSession.canAddInput(videoInput)
else {
completion?(.failure(NSError(domain: "Failed to initialize camera", code: 0)))
return
}
captureSession.addInput(videoInput)
let metadataOutput = AVCaptureMetadataOutput()
guard captureSession.canAddOutput(metadataOutput) else {
completion?(.failure(NSError(domain: "Unable to add capture output", code: 0)))
return
}
captureSession.addOutput(metadataOutput)
metadataOutput.setMetadataObjectsDelegate(self, queue: DispatchQueue.main)
metadataOutput.metadataObjectTypes = [.qr, .ean13, .code128]
previewLayer = AVCaptureVideoPreviewLayer(session: captureSession)
previewLayer.videoGravity = .resizeAspectFill
view.layer.insertSublayer(previewLayer, at: 0)
DispatchQueue.global(qos: .userInitiated).async {
self.captureSession.startRunning()
}
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
previewLayer?.frame = view.bounds
if let connection = previewLayer?.connection, connection.isVideoOrientationSupported {
let deviceOrientation = UIDevice.current.orientation
switch deviceOrientation {
case .portrait:
connection.videoOrientation = .portrait
case .portraitUpsideDown:
connection.videoOrientation = .portraitUpsideDown
case .landscapeLeft:
connection.videoOrientation = .landscapeRight // Note: device left equals camera right
case .landscapeRight:
connection.videoOrientation = .landscapeLeft // Note: device right equals camera left
default:
connection.videoOrientation = .portrait
}
}
let guideImageViewSize: CGFloat = min(view.bounds.width, view.bounds.height) / 2
let buttonSize: CGFloat = 44
if let guideImageView = view.viewWithTag(998) as? UIImageView {
guideImageView.frame = CGRect(
x: (view.bounds.width - guideImageViewSize) / 2,
y: (view.bounds.height - guideImageViewSize) / 2,
width: guideImageViewSize,
height: guideImageViewSize
)
}
if let flashlightButton = view.viewWithTag(999) as? UIButton {
var targetX = CGFloat(0)
var targetY = CGFloat(0)
if view.bounds.width > view.bounds.height {
// Landscape
let guideRightX = (view.bounds.width + guideImageViewSize) / 2
let rightEdgeX = view.bounds.width - buttonSize
targetX = (guideRightX + rightEdgeX) / 2
targetY = (view.bounds.height / 2) - (buttonSize / 2)
} else {
// Portrait
let guideBottomY = (view.bounds.height + guideImageViewSize) / 2
let bottomEdgeY = view.bounds.height - buttonSize
targetX = (view.bounds.width - buttonSize) / 2
targetY = (guideBottomY + bottomEdgeY) / 2
}
flashlightButton.frame = CGRect(
x: targetX,
y: targetY,
width: buttonSize,
height: buttonSize
)
}
}
func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) {
guard !isProcessing else { return }
isProcessing = true // Mark as processing to avoid duplicate triggers
if let metadataObject = metadataObjects.first as? AVMetadataMachineReadableCodeObject,
let code = metadataObject.stringValue {
AudioServicesPlaySystemSound(SystemSoundID(1057))
completion?(.success(code))
// Do not stop captureSession immediately
// Call stopScanning() after external processing is finished
} else {
completion?(.failure(NSError(domain: "No QR code detected", code: 0)))
isProcessing = false // Allow next scan
}
}
// Provide a method for external callers to stop scanning
func stopScanning() {
if captureSession.isRunning {
captureSession.stopRunning()
}
isProcessing = false
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
stopScanning()
}
}
-119
View File
@@ -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()
}
}
-368
View File
@@ -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..<faceList.count, id: \.self) { index in
FaceCell(expression: faceList[index], isSelected: selectedIndex == index)
.onTapGesture {
withAnimation(.easeInOut(duration: 0.2)) {
selectedIndex = index
}
let jsonString = appState.deviceMac + faceList[index].toJsonString()
let data = jsonString.toData()
appState.sendWebSocketMessage(.controlAvatar, data)
}
}
}
.padding(20)
}
.ignoresSafeArea()
}
}
struct FaceCell: View {
let expression: ExpressionData
let isSelected: Bool
let expressionLayer: ExpressionLayer
init(expression: ExpressionData, isSelected: Bool) {
self.expression = expression
self.isSelected = isSelected
self.expressionLayer = ExpressionLayer(data: expression)
self.expressionLayer.frame = CGRect(origin: .zero, size: CGSize(width: 320, height: 240))
self.expressionLayer.setNeedsDisplay()
}
var body: some View {
let newImage = expressionRenderer().image { ctx in
self.expressionLayer.render(in: ctx.cgContext)
}
Image(uiImage: newImage)
.resizable()
.aspectRatio(4/3, contentMode: .fit)
.clipShape(RoundedRectangle(cornerRadius: 12))
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(isSelected ? Color.blue : Color.gray.opacity(0.3),
lineWidth: isSelected ? 3 : 1)
)
.scaleEffect(isSelected ? 1.05 : 1.0)
}
private func expressionRenderer() -> UIGraphicsImageRenderer {
let format = UIGraphicsImageRendererFormat.default()
format.scale = UIScreen.main.scale
format.opaque = false
return UIGraphicsImageRenderer(
size: expressionLayer.bounds.size,
format: format
)
}
}
struct StackChanSwitchFacePreview : PreviewProvider {
static var previews: some View {
SwitchFace()
}
}
-165
View File
@@ -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)
}
}
-360
View File
@@ -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 (0900)
let clampedPitch = max(0, min(900, data.pitchServo.angle))
let pitchRatio = Float(clampedPitch) / 900.0
let pitchAngle = -Float.pi / 2 * (1 + pitchRatio)
stackNode.eulerAngles.x = pitchAngle
// Cancel previous auto-rotation
stackNode.removeAction(forKey: rotateKey)
if data.yawServo.rotate == 0 {
/// Set yaw angle (-128 to 128, left to right)
let clampedYaw = max(-1280, min(1280, data.yawServo.angle)) // Clamp to -128~128
let yawAngle = Float(clampedYaw) * Float.pi / 1800 // Convert to radians
stackNode.eulerAngles.y = yawAngle
} else {
let rotateSpeed = max(-1000, min(1000, data.yawServo.rotate))
let radiansPerSecond = Float(rotateSpeed) / 1000.0 * Float.pi * 2
// Rotate continuously using angular velocity (not a fixed-loop animation)
let rotateAction = SCNAction.customAction(duration: .infinity) { node, _ in
let deltaTime: Float = 1.0 / 60.0 // Approximate frame duration
node.eulerAngles.y += radiansPerSecond * deltaTime
}
stackNode.runAction(rotateAction, forKey: rotateKey)
}
}
// Find the plane node
if let planeNode = uiView.scene?.rootNode.childNode(withName: planeNodeName, recursively: true),
let plane = planeNode.geometry as? SCNPlane {
// Render new expression image
let expressionData = ExpressionData(leftEye: data.leftEye, rightEye: data.rightEye, mouth: data.mouth)
expressionLayer.data = expressionData
expressionLayer.setNeedsDisplay()
let newImage = expressionRenderer().image { ctx in
self.expressionLayer.render(in: ctx.cgContext)
}
plane.firstMaterial?.diffuse.contents = newImage
}
}
static func dismantleUIView(_ uiView: SCNView, coordinator: ()) {
// Remove all nodes from the scene
uiView.scene?.rootNode.childNodes.forEach { $0.removeFromParentNode() }
// Clean up scene and materials
uiView.scene = nil
uiView.delegate = nil
// Stop any rendering or animations
uiView.isPlaying = false
uiView.scene?.isPaused = true
}
private func expressionRenderer() -> UIGraphicsImageRenderer {
let format = UIGraphicsImageRendererFormat.default()
format.scale = UIScreen.main.scale
format.opaque = false
return UIGraphicsImageRenderer(
size: expressionLayer.bounds.size,
format: format
)
}
}
struct StackChanRotaryRobot : UIViewRepresentable {
private let expressionLayer = ExpressionLayer(data: ExpressionData(leftEye: ExpressionItem(weight: 100), rightEye: ExpressionItem(weight: 100), mouth: ExpressionItem()))
func updateUIView(_ uiView: SCNView, context: Context) {
}
private func expressionRenderer() -> UIGraphicsImageRenderer {
let format = UIGraphicsImageRendererFormat.default()
format.scale = UIScreen.main.scale
format.opaque = false
return UIGraphicsImageRenderer(
size: expressionLayer.bounds.size,
format: format
)
}
func makeUIView(context: Context) -> SCNView {
let sceneView = SCNView()
if let scene = SCNScene(named: "stackChanModel.scn") {
scene.rootNode.eulerAngles = SCNVector3Zero
scene.rootNode.eulerAngles.x = -Float.pi / 2
scene.rootNode.position.y = scene.rootNode.position.y + 25
scene.rootNode.position.z = scene.rootNode.position.z - 45
let clampedPitch = max(0, min(900, 200))
let pitchRatio = Float(clampedPitch) / 900.0
let pitchAngle = -Float.pi / 2 * (1 + pitchRatio)
scene.rootNode.eulerAngles.x = pitchAngle
// Add plane
let plane = SCNPlane(width: 42, height: 32)
let magnification: CGFloat = 5
let size = CGSize(width: magnification * plane.width, height: magnification * plane.height)
expressionLayer.frame = CGRect(origin: .zero, size: size)
expressionLayer.setNeedsDisplay()
let newImage = expressionRenderer().image { ctx in
self.expressionLayer.render(in: ctx.cgContext)
}
let material = SCNMaterial()
material.diffuse.contents = newImage
plane.materials = [material]
let planeNode = SCNNode(geometry: plane)
planeNode.position = SCNVector3(0, -16, 0)
planeNode.eulerAngles = SCNVector3(Float.pi / 2, 0, 0)
scene.rootNode.addChildNode(planeNode)
// Add infinite rotation animation around Y axis
let rotateAction = SCNAction.rotateBy(x: 0, y: CGFloat(2 * Double.pi), z: 0, duration: 5)
let repeatAction = SCNAction.repeatForever(rotateAction)
scene.rootNode.runAction(repeatAction)
sceneView.scene = scene
} else {
print("Model not found")
}
sceneView.autoenablesDefaultLighting = true
sceneView.allowsCameraControl = false
sceneView.backgroundColor = UIColor.clear
return sceneView
}
static func dismantleUIView(_ uiView: UIViewType, coordinator: ()) {
// Remove all nodes from the scene
uiView.scene?.rootNode.childNodes.forEach { $0.removeFromParentNode() }
// Clean up scene and materials
uiView.scene = nil
uiView.delegate = nil
// Stop any rendering or animations
uiView.isPlaying = false
uiView.scene?.isPaused = true
}
}
struct StackChanRobotPreview : PreviewProvider {
static var previews: some View {
StackChanRotaryRobot()
.frame(maxWidth: .infinity,maxHeight: 400)
}
}
class ExpressionLayer: CALayer {
var data: ExpressionData
let reverse: Bool
init(data: ExpressionData, reverse: Bool = false) {
self.data = data
self.reverse = reverse
super.init()
self.contentsScale = UIScreen.main.scale
self.setNeedsDisplay()
}
override init(layer: Any) {
if let layer = layer as? ExpressionLayer {
self.data = layer.data
self.reverse = layer.reverse
} else {
self.data = ExpressionData(leftEye: ExpressionItem(), rightEye: ExpressionItem(), mouth: ExpressionItem())
self.reverse = false
}
super.init(layer: layer)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func draw(in ctx: CGContext) {
let rect = self.frame
// Background
ctx.setFillColor(UIColor.black.withAlphaComponent(0.7).cgColor)
ctx.fill(rect)
let eyeSize = rect.width / 10
func drawEye(_ item: ExpressionItem, at point: CGPoint) {
// Calculate scale based on size (-100 to 100)
// 0 -> 1.0 (keep current size)
// -100 -> 0.5 (half normal radius)
// 100 -> 2.0 (double normal radius)
let clampedSize = max(-100, min(100, item.size))
let sizeScale: CGFloat
if clampedSize >= 0 {
sizeScale = 1.0 + CGFloat(clampedSize) / 100.0
} else {
sizeScale = 1.0 + CGFloat(clampedSize) / 200.0
}
let scaledEyeSize = eyeSize * sizeScale
let visibleHeight = scaledEyeSize * (CGFloat(item.weight) / 100)
let centerX = point.x + CGFloat(item.x / 10) + eyeSize / 2
let centerY = point.y + CGFloat(item.y / 10) + eyeSize / 2
let eyeRect = CGRect(
x: centerX - scaledEyeSize / 2,
y: centerY - scaledEyeSize / 2,
width: scaledEyeSize,
height: scaledEyeSize
)
ctx.saveGState()
// Rotation
let rotationDegrees = CGFloat(item.rotation) / 10.0
let center = CGPoint(x: eyeRect.midX, y: eyeRect.midY)
ctx.translateBy(x: center.x, y: center.y)
ctx.rotate(by: rotationDegrees * .pi / 180)
ctx.translateBy(x: -center.x, y: -center.y)
// Clip height
let maskRect = CGRect(
x: eyeRect.minX,
y: eyeRect.maxY - visibleHeight,
width: scaledEyeSize,
height: visibleHeight
)
ctx.addRect(maskRect)
ctx.clip()
ctx.setFillColor(UIColor.white.cgColor)
ctx.fillEllipse(in: eyeRect)
ctx.restoreGState()
}
let eyeY = (rect.height * 0.35) - (eyeSize / 2)
let leftEyePoint = CGPoint(x: (rect.width / 3) - (eyeSize / 2), y: eyeY)
let rightEyePoint = CGPoint(x: (rect.width / 3 * 2) - (eyeSize / 2), y: eyeY)
if reverse {
// Temporarily swap rotation angles
let leftEyeRotation = data.leftEye.rotation
let rightEyeRotation = data.rightEye.rotation
var leftEye = data.leftEye
var rightEye = data.rightEye
leftEye.rotation = rightEyeRotation
rightEye.rotation = leftEyeRotation
drawEye(leftEye, at: rightEyePoint)
drawEye(rightEye, at: leftEyePoint)
} else {
drawEye(data.leftEye, at: leftEyePoint)
drawEye(data.rightEye, at: rightEyePoint)
}
// Draw mouth
ctx.saveGState()
let width = rect.width * 0.3 - CGFloat(data.mouth.weight / 10)
let height = 3 + CGFloat(data.mouth.weight) * 0.2
let x = ((rect.width - width) / 2) + CGFloat(data.mouth.x / 10)
let y = (rect.height * 0.65) + CGFloat(data.mouth.y / 10)
let rotationDegrees = CGFloat(data.mouth.rotation) / 10.0
let center = CGPoint(x: x + width / 2, y: y + height / 2)
ctx.translateBy(x: center.x, y: center.y)
ctx.rotate(by: rotationDegrees * .pi / 180)
ctx.translateBy(x: -center.x, y: -center.y)
let mouthRect = CGRect(x: x, y: y, width: width, height: height)
let mouthPath = UIBezierPath(roundedRect: mouthRect, cornerRadius: height / 2)
ctx.addPath(mouthPath.cgPath)
ctx.setFillColor(UIColor.white.cgColor)
ctx.fillPath()
ctx.restoreGState()
}
}
+28
View File
@@ -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
+14
View File
@@ -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
+82
View File
@@ -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
}
@@ -0,0 +1,75 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
>
<!-- 网络权限 -->
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<!-- 蓝牙权限 -->
<!-- Android 12 (API 31) 及以上需要下面三个权限 -->
<uses-permission
android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation"/>
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/>
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE"/>
<!-- Android 11 及以下旧权限 -->
<uses-permission android:name="android.permission.BLUETOOTH"/>
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN"/>
<!-- 基础WiFi状态权限(所有Android版本必需) -->
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE"/>
<!-- 网络状态权限(获取网络连接信息) -->
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<!-- Android 6.0+ 定位权限(获取WiFi名称必需) -->
<!-- 注意:Android 10+ 必须用 ACCESS_FINE_LOCATIONCOARSE 无效 -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE"/>
<application
android:name="${applicationName}"
android:icon="@mipmap/app_logo"
android:label="StackChan World"
android:usesCleartextTraffic="true">
<activity
android:name=".MainActivity"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:exported="true"
android:hardwareAccelerated="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/Theme.AppCompat.NoActionBar"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2"/>
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>
@@ -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<ByteBuffer>
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
}
}
@@ -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
)
@@ -0,0 +1,7 @@
package com.m5stack.stackchan.model
data class DanceList(
var danceData: MutableList<DanceData>?,
var danceIndex: Int?,
var danceName: String?
)
@@ -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
)
@@ -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,
)
@@ -0,0 +1,7 @@
package com.m5stack.stackchan.model
data class MotionData(
var type: String = "bleMotion",
var pitchServo: MotionDataItem,
var yawServo: MotionDataItem,
)
@@ -0,0 +1,7 @@
package com.m5stack.stackchan.model
data class MotionDataItem(
var angle: Int = 0,
var speed: Int = 0,
var rotate: Int = 0,
)
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/linearLayout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<io.github.sceneview.SceneView
android:id="@+id/sceneView"
android:layout_width="300dp"
android:layout_height="300dp" />
</LinearLayout>
Binary file not shown.

After

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Before

Width:  |  Height:  |  Size: 167 KiB

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
<style name="frameRatePowerSavingsBalancedDisabled">
<item name="android:windowIsFrameRatePowerSavingsBalanced">false</item>
</style>
</resources>
+27
View File
@@ -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<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}
+14
View File
@@ -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
Binary file not shown.
+5
View File
@@ -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
Vendored Executable
+160
View File
@@ -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 "$@"
+90
View File
@@ -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
+5
View File
@@ -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
+26
View File
@@ -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")
+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--Generator: Apple Native CoreSVG 341-->
<!DOCTYPE svg
PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24.2578 23.918">
<g>
<rect height="23.918" opacity="0" width="24.2578" x="0" y="0"/>
<path d="M23.9062 11.9531C23.9062 18.5391 18.5508 23.9062 11.9531 23.9062C5.36719 23.9062 0 18.5391 0 11.9531C0 5.35547 5.36719 0 11.9531 0C18.5508 0 23.9062 5.35547 23.9062 11.9531ZM11.2734 6.66797L9.09375 8.14453C8.83594 8.34375 8.71875 8.51953 8.71875 8.82422C8.71875 9.19922 9.02344 9.55078 9.41016 9.55078C9.59766 9.55078 9.72656 9.52734 10.0078 9.33984L11.5664 8.30859L11.6484 8.30859L11.6484 16.6172C11.6484 17.2969 12 17.707 12.6094 17.707C13.2305 17.707 13.582 17.2969 13.582 16.6172L13.582 7.28906C13.582 6.58594 13.207 6.19922 12.5625 6.19922C12.1055 6.19922 11.7891 6.31641 11.2734 6.66797Z"
fill="black" fill-opacity="0.85"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--Generator: Apple Native CoreSVG 341-->
<!DOCTYPE svg
PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24.2578 23.918">
<g>
<rect height="23.918" opacity="0" width="24.2578" x="0" y="0"/>
<path d="M23.9062 11.9531C23.9062 18.5391 18.5508 23.9062 11.9531 23.9062C5.36719 23.9062 0 18.5391 0 11.9531C0 5.35547 5.36719 0 11.9531 0C18.5508 0 23.9062 5.35547 23.9062 11.9531ZM8.36719 8.58984C8.30859 8.77734 8.29688 8.91797 8.29688 9.08203C8.29688 9.55078 8.61328 9.85547 9.09375 9.85547C9.51562 9.85547 9.73828 9.58594 9.94922 9.24609C10.2656 8.51953 10.9102 7.74609 12.0703 7.74609C13.2188 7.74609 13.9805 8.41406 13.9805 9.41016C13.9805 10.2891 13.2422 11.0273 12.5273 11.7773L8.66016 15.9141C8.46094 16.1484 8.34375 16.3945 8.34375 16.6406C8.34375 17.1328 8.67188 17.4375 9.21094 17.4375L15.4219 17.4375C15.9023 17.4375 16.2422 17.1328 16.2422 16.6406C16.2422 16.1367 15.9023 15.8438 15.4219 15.8438L11.0273 15.8438L11.0273 15.7617L13.8867 12.7031C14.9531 11.5664 15.7031 10.6055 15.7031 9.25781C15.7031 7.42969 14.2266 6.19922 11.9883 6.19922C10.2773 6.19922 8.76562 7.24219 8.36719 8.58984Z"
fill="black" fill-opacity="0.85"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--Generator: Apple Native CoreSVG 341-->
<!DOCTYPE svg
PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24.2578 23.918">
<g>
<rect height="23.918" opacity="0" width="24.2578" x="0" y="0"/>
<path d="M23.9062 11.9531C23.9062 18.5391 18.5508 23.9062 11.9531 23.9062C5.36719 23.9062 0 18.5391 0 11.9531C0 5.35547 5.36719 0 11.9531 0C18.5508 0 23.9062 5.35547 23.9062 11.9531ZM8.30859 8.54297C8.27344 8.71875 8.26172 8.84766 8.26172 9.02344C8.26172 9.50391 8.60156 9.83203 9.08203 9.83203C9.51562 9.83203 9.77344 9.62109 9.91406 9.19922C10.1836 8.29688 10.9336 7.73438 12.0586 7.73438C13.2656 7.73438 14.0156 8.33203 14.0156 9.30469C14.0156 10.3008 13.1719 11.0273 12.0234 11.0273L11.2617 11.0273C10.8164 11.0273 10.5 11.332 10.5 11.7891C10.5 12.2109 10.8164 12.5273 11.2617 12.5273L12.082 12.5273C13.4414 12.5273 14.3789 13.2773 14.3789 14.3672C14.3789 15.457 13.4297 16.1602 12.0234 16.1602C10.7109 16.1602 9.98438 15.5273 9.69141 14.6953C9.52734 14.2734 9.28125 14.0742 8.85938 14.0742C8.36719 14.0742 8.01562 14.4141 8.01562 14.9062C8.01562 15.082 8.03906 15.1875 8.07422 15.3633C8.4375 16.7578 10.0078 17.707 11.9883 17.707C14.5898 17.707 16.1953 16.3477 16.1953 14.4375C16.1953 13.0195 15.1172 11.8828 13.6523 11.7422L13.6523 11.6484C14.8711 11.4727 15.8086 10.3594 15.8086 9.09375C15.8086 7.40625 14.2617 6.19922 12.0938 6.19922C10.125 6.19922 8.63672 7.11328 8.30859 8.54297Z"
fill="black" fill-opacity="0.85"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

+15
View File
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--Generator: Apple Native CoreSVG 341-->
<!DOCTYPE svg
PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24.2578 23.918">
<g>
<rect height="23.918" opacity="0" width="24.2578" x="0" y="0"/>
<path d="M11.9531 23.9062C18.5508 23.9062 23.9062 18.5508 23.9062 11.9531C23.9062 5.35547 18.5508 0 11.9531 0C5.35547 0 0 5.35547 0 11.9531C0 18.5508 5.35547 23.9062 11.9531 23.9062ZM11.9531 21.9141C6.44531 21.9141 1.99219 17.4609 1.99219 11.9531C1.99219 6.44531 6.44531 1.99219 11.9531 1.99219C17.4609 1.99219 21.9141 6.44531 21.9141 11.9531C21.9141 17.4609 17.4609 21.9141 11.9531 21.9141Z"
fill="black" fill-opacity="0.85"/>
<path d="M11.9531 6.04688C11.4492 6.04688 11.0273 6.45703 11.0273 6.94922L11.0273 13.5352L11.1094 15.9844C11.1328 16.5117 11.5312 16.793 11.9531 16.793C12.375 16.793 12.7852 16.5117 12.7969 15.9844L12.8906 13.5352L12.8906 6.94922C12.8906 6.45703 12.457 6.04688 11.9531 6.04688ZM11.9531 17.8594C12.1875 17.8594 12.3984 17.7891 12.6328 17.5547L16.4883 13.8281C16.6758 13.6523 16.7695 13.4648 16.7695 13.207C16.7695 12.7266 16.4062 12.3867 15.9141 12.3867C15.6914 12.3867 15.4453 12.4805 15.2812 12.668L13.5 14.5664L11.9531 16.207L11.9531 16.207L10.4062 14.5664L8.625 12.668C8.46094 12.4805 8.20312 12.3867 7.98047 12.3867C7.48828 12.3867 7.13672 12.7266 7.13672 13.207C7.13672 13.4648 7.23047 13.6523 7.40625 13.8281L11.2734 17.5547C11.5078 17.7891 11.7188 17.8594 11.9531 17.8594Z"
fill="black" fill-opacity="0.85"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--Generator: Apple Native CoreSVG 341-->
<!DOCTYPE svg
PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 27.3547 27.1028">
<g>
<rect height="27.1028" opacity="0" width="27.3547" x="0" y="0"/>
<path d="M9.96252 5.95177L17.0406 5.95177C18.0602 5.95177 18.6578 4.92052 17.8961 4.13536L14.3453 0.385365C13.8531-0.141979 13.1383-0.118541 12.6461 0.397084L9.10705 4.13536C8.35705 4.92052 8.9547 5.95177 9.96252 5.95177ZM21.0485 9.99474L21.0485 17.0729C21.0485 18.0924 22.0797 18.6901 22.8766 17.9283L26.6149 14.3893C27.1188 13.8971 27.1422 13.194 26.6266 12.6901L22.8766 9.13927C22.0797 8.38927 21.0485 8.98693 21.0485 9.99474ZM17.0406 21.1158L9.96252 21.1158C8.9547 21.1158 8.35705 22.1471 9.10705 22.9322L12.6578 26.6822C13.1617 27.2096 13.8766 27.1861 14.357 26.6705L17.8961 22.9322C18.6578 22.1471 18.0602 21.1158 17.0406 21.1158ZM5.9547 17.0729L5.9547 9.99474C5.9547 8.98693 4.91173 8.38927 4.12658 9.13927L0.388297 12.6783C-0.115609 13.1705-0.139047 13.8736 0.376578 14.3776L4.12658 17.9283C4.91173 18.6901 5.9547 18.0924 5.9547 17.0729ZM13.5133 23.8815C14.0875 23.8815 14.5563 23.401 14.5563 22.8268L14.5563 4.24083C14.5563 3.66661 14.0875 3.19786 13.5133 3.19786C12.9391 3.19786 12.4586 3.66661 12.4586 4.24083L12.4586 22.8268C12.4586 23.401 12.9391 23.8815 13.5133 23.8815ZM3.17736 13.5338C3.17736 14.108 3.64611 14.5768 4.22033 14.5768L22.8063 14.5768C23.3805 14.5768 23.861 14.108 23.861 13.5338C23.861 12.9596 23.3805 12.4791 22.8063 12.4791L4.22033 12.4791C3.64611 12.4791 3.17736 12.9596 3.17736 13.5338Z" fill="black" fill-opacity="0.85"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--Generator: Apple Native CoreSVG 341-->
<!DOCTYPE svg
PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 36.5156 30.0352">
<g>
<rect height="30.0352" opacity="0" width="36.5156" x="0" y="0"/>
<path d="M27.0139 5.95313L18.8906 5.95312C14.6016 5.95312 12.1406 8.49609 12.1406 12.6914L12.1406 19.0312C12.1406 20.3735 12.3925 21.5467 12.8784 22.5191L10.1484 24.9492C9.44531 25.5703 9.04688 25.875 8.46094 25.875C7.65234 25.875 7.18359 25.3008 7.18359 24.3984L7.18359 21.3398L6.62109 21.3398C3.14062 21.3398 1.25391 19.418 1.25391 15.9844L1.25391 6.99609C1.25391 3.5625 3.14062 1.62891 6.62109 1.62891L21.7148 1.62891C24.8346 1.62891 26.6638 3.17211 27.0139 5.95313Z"
fill="black" fill-opacity="0.85"/>
<path d="M18.8906 24.1641L22.6758 24.1641L26.5898 27.5156C27.3047 28.125 27.6914 28.4414 28.2656 28.4414C29.0742 28.4414 29.5312 27.8672 29.5312 26.9766L29.5312 24.1641L29.7656 24.1641C33.1406 24.1641 34.9102 22.3477 34.9102 19.0312L34.9102 12.6914C34.9102 9.36328 33.1406 7.54688 29.7656 7.54688L18.8906 7.54688C15.5156 7.54688 13.7461 9.36328 13.7461 12.6914L13.7461 19.0312C13.7461 22.3594 15.5156 24.1641 18.8906 24.1641Z"
fill="black" fill-opacity="0.85"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--Generator: Apple Native CoreSVG 341-->
<!DOCTYPE svg
PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 14.5781 17.332">
<g>
<rect height="17.332" opacity="0" width="14.5781" x="0" y="0"/>
<path d="M0.984375 17.3086C1.60547 17.3086 1.91016 17.0742 2.13281 16.418L7.06641 2.61328L7.125 2.61328L12.0703 16.418C12.293 17.0742 12.5977 17.3086 13.207 17.3086C13.8281 17.3086 14.2266 16.9336 14.2266 16.3477C14.2266 16.1484 14.1914 15.9609 14.0977 15.7148L8.60156 1.07812C8.33203 0.363281 7.85156 0 7.10156 0C6.375 0 5.88281 0.351562 5.625 1.06641L0.128906 15.7266C0.0351562 15.9727 0 16.1602 0 16.3594C0 16.9453 0.375 17.3086 0.984375 17.3086ZM3.43359 12.2812L10.793 12.2812C11.2734 12.2812 11.6719 11.8828 11.6719 11.4023C11.6719 10.9102 11.2734 10.5234 10.793 10.5234L3.43359 10.5234C2.95312 10.5234 2.55469 10.9102 2.55469 11.4023C2.55469 11.8828 2.95312 12.2812 3.43359 12.2812Z"
fill="black" fill-opacity="0.85"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--Generator: Apple Native CoreSVG 341-->
<!DOCTYPE svg
PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20.543 20.6836">
<g>
<rect height="20.6836" opacity="0" width="20.543" x="0" y="0"/>
<path d="M7.64062 20.6836C8.14453 20.6836 8.54297 20.4609 8.82422 20.0273L19.8984 2.58984C20.1094 2.25 20.1914 1.99219 20.1914 1.72266C20.1914 1.07812 19.7695 0.65625 19.125 0.65625C18.6562 0.65625 18.3984 0.808594 18.1172 1.25391L7.59375 18.0234L2.13281 10.875C1.83984 10.4648 1.54688 10.3008 1.125 10.3008C0.457031 10.3008 0 10.7578 0 11.4023C0 11.6719 0.117188 11.9766 0.339844 12.2578L6.42188 20.0039C6.77344 20.4609 7.13672 20.6836 7.64062 20.6836Z"
fill="black" fill-opacity="0.85"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 894 B

+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--Generator: Apple Native CoreSVG 341-->
<!DOCTYPE svg
PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 14.1094 20.3555">
<g>
<rect height="20.3555" opacity="0" width="14.1094" x="0" y="0"/>
<path d="M14.1094 10.1719C14.1094 9.87891 13.9922 9.60938 13.7695 9.39844L4.48828 0.304688C4.27734 0.105469 4.01953 0 3.71484 0C3.11719 0 2.64844 0.457031 2.64844 1.06641C2.64844 1.35938 2.76562 1.62891 2.95312 1.82812L11.4844 10.1719L2.95312 18.5156C2.76562 18.7148 2.64844 18.9727 2.64844 19.2773C2.64844 19.8867 3.11719 20.3438 3.71484 20.3438C4.01953 20.3438 4.27734 20.2383 4.48828 20.0273L13.7695 10.9453C13.9922 10.7227 14.1094 10.4648 14.1094 10.1719Z"
fill="black" fill-opacity="0.85"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 902 B

+17
View File
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--Generator: Apple Native CoreSVG 341-->
<!DOCTYPE svg
PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 22.2188 4.46484">
<g>
<rect height="4.46484" opacity="0" width="22.2188" x="0" y="0"/>
<path d="M19.6406 4.44141C20.8711 4.44141 21.8672 3.45703 21.8672 2.22656C21.8672 0.996094 20.8711 0 19.6406 0C18.4102 0 17.4141 0.996094 17.4141 2.22656C17.4141 3.45703 18.4102 4.44141 19.6406 4.44141Z"
fill="black" fill-opacity="0.85"/>
<path d="M10.9336 4.44141C12.1641 4.44141 13.1484 3.45703 13.1484 2.22656C13.1484 0.996094 12.1641 0 10.9336 0C9.70312 0 8.70703 0.996094 8.70703 2.22656C8.70703 3.45703 9.70312 4.44141 10.9336 4.44141Z"
fill="black" fill-opacity="0.85"/>
<path d="M2.22656 4.44141C3.45703 4.44141 4.44141 3.45703 4.44141 2.22656C4.44141 0.996094 3.45703 0 2.22656 0C0.996094 0 0 0.996094 0 2.22656C0 3.45703 0.996094 4.44141 2.22656 4.44141Z"
fill="black" fill-opacity="0.85"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--Generator: Apple Native CoreSVG 341-->
<!DOCTYPE svg
PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 28.9485 23.918">
<g>
<rect height="23.918" opacity="0" width="28.9485" x="0" y="0"/>
<path d="M27.7633 10.0078L22.3844 10.0078C21.5524 10.0078 21.3414 10.5703 21.7867 11.2148L24.4117 14.9531C24.7867 15.4922 25.3492 15.5039 25.736 14.9531L28.361 11.2266C28.818 10.5703 28.5953 10.0078 27.7633 10.0078ZM14.2985 1.99219C19.8063 1.99219 24.2594 6.44531 24.2594 11.9531C24.2594 12.4922 24.7047 12.9375 25.2672 12.9375C25.8063 12.9375 26.2399 12.5039 26.2516 11.9648C26.2399 5.34375 20.8961 0 14.2985 0C10.7828 0 7.59533 1.52344 5.40392 3.97266C4.92346 4.5 5.08752 5.19141 5.53283 5.49609C5.90783 5.77734 6.41174 5.80078 6.90392 5.27344C8.73205 3.24609 11.3688 1.99219 14.2985 1.99219ZM0.833612 13.8984L6.21252 13.8984C7.04455 13.8984 7.25549 13.3242 6.81017 12.6914L4.18517 8.94141C3.81017 8.41406 3.24767 8.40234 2.86096 8.94141L0.235956 12.6797C-0.221075 13.3242-0.0101376 13.8984 0.833612 13.8984ZM14.2985 21.9141C8.79064 21.9141 4.33752 17.4609 4.33752 11.9531C4.33752 11.4141 3.88049 10.957 3.32971 10.957C2.79064 10.957 2.35705 11.4023 2.34533 11.9414C2.35705 18.5625 7.7008 23.9062 14.2985 23.9062C17.8141 23.9062 21.0016 22.3828 23.193 19.9336C23.6735 19.3945 23.5094 18.7148 23.0641 18.3984C22.6891 18.1289 22.1852 18.1055 21.693 18.6211C19.8649 20.6484 17.2281 21.9141 14.2985 21.9141Z" fill="black" fill-opacity="0.85"/>
<path d="M14.2985 14.0625C14.8492 14.0625 15.1774 13.7461 15.1891 13.1367L15.3649 6.94922C15.3766 6.35156 14.9078 5.90625 14.2867 5.90625C13.6539 5.90625 13.2086 6.33984 13.2203 6.9375L13.3727 13.1367C13.3844 13.7344 13.7125 14.0625 14.2985 14.0625ZM14.2985 17.8711C14.9664 17.8711 15.5524 17.332 15.5524 16.6523C15.5524 15.9727 14.9781 15.4336 14.2985 15.4336C13.607 15.4336 13.0328 15.9844 13.0328 16.6523C13.0328 17.3203 13.6188 17.8711 14.2985 17.8711Z" fill="black" fill-opacity="0.85"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--Generator: Apple Native CoreSVG 341-->
<!DOCTYPE svg
PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 15.3867 29.543">
<g>
<rect height="29.543" opacity="0" width="15.3867" x="0" y="0"/>
<path d="M1.71094 28.7812C2.39062 28.7812 2.89453 28.2656 2.89453 27.6328L2.89453 22.0195L6.17578 18.5273C6.23438 18.4688 6.33984 18.4688 6.39844 18.5391L9.53906 22.7461L12.2109 28.0547C12.9492 29.543 15.0352 28.4766 14.3203 27.0586L8.83594 16.0664C8.34375 15.082 8.09766 13.7578 7.98047 12.4102C7.94531 11.9766 8.22656 11.7656 8.60156 11.9531L10.7695 13.0547L12.7734 17.0625C13.4883 18.4805 15.6328 17.5195 14.8711 16.0078L12.6914 11.6602C12.5742 11.4258 12.3984 11.25 12.1641 11.1328L9.04688 9.57422C8.12109 9.10547 7.17188 8.92969 6.29297 8.70703C5.28516 8.46094 4.38281 8.14453 3.77344 7.27734L2.35547 5.21484L2.35547 1.16016C2.35547 0.527344 1.81641 0 1.17188 0C0.527344 0 0 0.527344 0 1.16016L0 5.58984C0 5.84766 0.0585938 6.02344 0.210938 6.24609L2.69531 9.83203C2.91797 10.1602 3.05859 10.3711 3.10547 10.9219C3.19922 12.2227 3.48047 14.6367 4.00781 16.3828L0.773438 20.8711C0.621094 21.1055 0.5625 21.3281 0.5625 21.5508L0.5625 27.6328C0.5625 28.2656 1.06641 28.7812 1.71094 28.7812ZM7.10156 7.44141C8.50781 7.44141 9.64453 6.30469 9.64453 4.875C9.64453 3.46875 8.50781 2.33203 7.10156 2.33203C5.67188 2.33203 4.53516 3.46875 4.53516 4.875C4.53516 6.30469 5.67188 7.44141 7.10156 7.44141Z"
fill="black" fill-opacity="0.85"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--Generator: Apple Native CoreSVG 341-->
<!DOCTYPE svg
PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 11.8477 27.7148">
<g>
<rect height="27.7148" opacity="0" width="11.8477" x="0" y="0"/>
<path d="M0 2.70703L11.4961 2.70703L11.4961 2.30859C11.4961 0.75 10.7344 0 9.23438 0L2.25 0C0.75 0 0 0.75 0 2.30859ZM4.65234 27.7031L6.84375 27.7031C8.32031 27.7031 9.10547 26.918 9.10547 25.3945L9.10547 11.8008C9.10547 10.5 9.39844 9.62109 9.86719 8.89453L10.5352 7.85156C11.1094 6.94922 11.4961 6.17578 11.4961 5.13281L11.4961 4.06641L0 4.06641L0 5.13281C0 6.17578 0.386719 6.94922 0.960938 7.85156L1.62891 8.89453C2.09766 9.62109 2.39062 10.5 2.39062 11.8008L2.39062 25.3945C2.39062 26.918 3.17578 27.7031 4.65234 27.7031ZM3.67969 12.9141C3.67969 11.7656 4.58203 10.8516 5.75391 10.8516C6.91406 10.8516 7.81641 11.7656 7.81641 12.9141L7.81641 16.4531C7.81641 17.6133 6.91406 18.5156 5.75391 18.5156C4.58203 18.5156 3.67969 17.6133 3.67969 16.4531ZM5.75391 17.7891C6.52734 17.7891 7.08984 17.2266 7.08984 16.4531C7.08984 15.7266 6.49219 15.1289 5.75391 15.1289C5.00391 15.1289 4.40625 15.7266 4.40625 16.4531C4.40625 17.2266 4.98047 17.7891 5.75391 17.7891Z"
fill="black" fill-opacity="0.85"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--Generator: Apple Native CoreSVG 341-->
<!DOCTYPE svg
PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 26.2617 25.5613">
<g>
<rect height="25.5613" opacity="0" width="26.2617" x="0" y="0"/>
<path d="M12.9492 23.9779C13.2422 23.9779 13.5234 23.9662 13.8047 23.9428L14.4492 25.1498C14.6016 25.4545 14.8711 25.5717 15.1992 25.5365C15.5391 25.4779 15.7383 25.2553 15.7734 24.9271L15.9727 23.5678C16.5352 23.4154 17.0625 23.2162 17.5781 22.9818L18.5859 23.8959C18.832 24.1303 19.1367 24.142 19.4297 23.9896C19.7227 23.8256 19.8398 23.5443 19.7695 23.2162L19.4766 21.8803C19.9453 21.5521 20.3789 21.1771 20.7891 20.7904L22.043 21.306C22.3594 21.4232 22.6523 21.3529 22.875 21.0951C23.0977 20.8373 23.1211 20.5443 22.9336 20.2514L22.207 19.1029C22.5234 18.6225 22.8047 18.1303 23.0625 17.6264L24.4102 17.6732C24.75 17.685 25.0078 17.5209 25.125 17.1928C25.2422 16.8881 25.1602 16.5951 24.8906 16.3959L23.8008 15.5639C23.9531 15.0131 24.0469 14.4506 24.1055 13.8646L25.3945 13.4545C25.7227 13.349 25.9102 13.1146 25.9102 12.7748C25.9102 12.435 25.7227 12.1889 25.3945 12.0951L24.1055 11.685C24.0469 11.099 23.9531 10.5365 23.8008 9.98573L24.8789 9.1537C25.1602 8.95448 25.2305 8.66152 25.125 8.34511C25.0078 8.01698 24.75 7.86464 24.4102 7.87636L23.0625 7.91152C22.8047 7.40761 22.5234 6.9037 22.207 6.43495L22.9219 5.29823C23.1094 5.00527 23.0859 4.70058 22.8867 4.44277C22.6523 4.18495 22.3594 4.12636 22.0547 4.24355L20.7891 4.75917C20.3789 4.34902 19.9336 3.98573 19.4766 3.65761L19.7695 2.33339C19.8398 1.99355 19.7227 1.72402 19.4414 1.55995C19.1367 1.38417 18.832 1.43105 18.5859 1.6537L17.5664 2.55605C17.0625 2.32167 16.5234 2.13417 15.9609 1.97011L15.7734 0.622453C15.7383 0.294328 15.5273 0.0833907 15.2109 0.0130782C14.8711-0.0455156 14.6016 0.0951094 14.4492 0.388078L13.8047 1.59511C13.5234 1.58339 13.2422 1.57167 12.9492 1.57167C12.668 1.57167 12.3867 1.58339 12.0938 1.59511L11.4609 0.399797C11.2969 0.0951094 11.0273-0.0337968 10.7109 0.0130782C10.3711 0.0716719 10.1719 0.294328 10.125 0.622453L9.92578 1.97011C9.375 2.13417 8.83594 2.33339 8.32031 2.56777L7.32422 1.6537C7.07812 1.41933 6.77344 1.39589 6.46875 1.55995C6.17578 1.72402 6.07031 2.00527 6.14062 2.33339L6.42188 3.66933C5.95312 3.99745 5.51953 4.36073 5.10938 4.75917L3.85547 4.24355C3.55078 4.11464 3.24609 4.19667 3.02344 4.45448C2.80078 4.70058 2.78906 5.00527 2.97656 5.29823L3.70312 6.44667C3.375 6.91542 3.09375 7.41933 2.84766 7.92323L1.5 7.87636C1.14844 7.85292 0.902344 8.0287 0.785156 8.34511C0.65625 8.66152 0.75 8.95448 1.01953 9.1537L2.09766 9.98573C1.95703 10.5365 1.85156 11.099 1.80469 11.685L0.503906 12.0951C0.175781 12.1889 0 12.435 0 12.7748C0 13.1146 0.175781 13.349 0.503906 13.4545L1.80469 13.8646C1.85156 14.4506 1.95703 15.0131 2.09766 15.5639L1.01953 16.3959C0.75 16.5951 0.679688 16.8881 0.773438 17.2045C0.902344 17.5326 1.14844 17.685 1.48828 17.6732L2.84766 17.6264C3.09375 18.142 3.375 18.6342 3.70312 19.1146L2.97656 20.2514C2.78906 20.5443 2.82422 20.849 3.02344 21.0951C3.25781 21.3646 3.55078 21.4232 3.85547 21.306L5.10938 20.7787C5.51953 21.1889 5.96484 21.5521 6.42188 21.892L6.14062 23.2162C6.07031 23.5443 6.1875 23.8256 6.46875 23.9896C6.77344 24.1654 7.07812 24.1303 7.3125 23.8959L8.33203 22.9818C8.84766 23.2279 9.38672 23.4154 9.9375 23.5678L10.125 24.9154C10.1719 25.2553 10.3828 25.4662 10.6992 25.5365C11.0391 25.5951 11.2969 25.4545 11.4609 25.1498L12.0938 23.9428C12.3867 23.9662 12.668 23.9779 12.9492 23.9779ZM12.9492 22.0326C7.83984 22.0326 3.69141 17.8842 3.69141 12.7748C3.69141 7.6537 7.83984 3.51698 12.9492 3.51698C18.0703 3.51698 22.207 7.6537 22.207 12.7748C22.207 17.8842 18.0703 22.0326 12.9492 22.0326ZM10.9102 10.9818L12.293 10.1029L8.37891 3.38808L6.9375 4.19667ZM15.5625 13.5717L23.3555 13.5717L23.3555 11.9545L15.5625 11.9545ZM12.3164 15.4818L10.9453 14.5912L6.82031 21.3178L8.25 22.1615ZM12.9609 15.8451C14.6602 15.8451 16.0312 14.474 16.0312 12.7748C16.0312 11.0756 14.6602 9.70448 12.9609 9.70448C11.2617 9.70448 9.89062 11.0756 9.89062 12.7748C9.89062 14.474 11.2617 15.8451 12.9609 15.8451ZM12.9609 14.0873C12.2344 14.0873 11.6484 13.5014 11.6484 12.7748C11.6484 12.0482 12.2344 11.4623 12.9609 11.4623C13.6875 11.4623 14.2734 12.0482 14.2734 12.7748C14.2734 13.5014 13.6875 14.0873 12.9609 14.0873Z" fill="black" fill-opacity="0.85"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.4 KiB

Before

Width:  |  Height:  |  Size: 2.7 MiB

After

Width:  |  Height:  |  Size: 2.7 MiB

+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--Generator: Apple Native CoreSVG 341-->
<!DOCTYPE svg
PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 27.9727 31.0664">
<g>
<rect height="31.0664" opacity="0" width="27.9727" x="0" y="0"/>
<path d="M21.3398 6.16406L21.3398 16.0128C21.0375 15.9687 20.7279 15.9492 20.4141 15.9492C20.0881 15.9492 19.7666 15.9704 19.4531 16.0186L19.4531 7.08984L8.16797 7.08984L8.16797 23.9648L12.8904 23.9648C12.9706 25.4451 13.4889 26.8114 14.3183 27.9375L9.46875 27.9375C7.55859 27.9375 6.28125 26.7188 6.28125 24.8906L6.28125 6.16406C6.28125 4.33594 7.55859 3.11719 9.46875 3.11719L18.1523 3.11719C20.0625 3.11719 21.3398 4.33594 21.3398 6.16406ZM12.0352 4.72266C11.7773 4.72266 11.5898 4.89844 11.5898 5.16797C11.5898 5.4375 11.7773 5.60156 12.0352 5.60156L15.5977 5.60156C15.8555 5.60156 16.043 5.4375 16.043 5.16797C16.043 4.89844 15.8555 4.72266 15.5977 4.72266Z" fill="black" fill-opacity="0.85"/>
<path d="M26.3789 23.5078C26.3789 26.7656 23.6484 29.4609 20.4141 29.4609C17.1562 29.4609 14.4609 26.7891 14.4609 23.5078C14.4609 20.25 17.1562 17.5547 20.4141 17.5547C23.6836 17.5547 26.3789 20.2383 26.3789 23.5078ZM18.4922 21.1289L18.4922 25.875C18.4922 26.3672 18.9844 26.5664 19.4062 26.3086L23.3086 24.0117C23.7305 23.7656 23.6953 23.2266 23.2383 22.957L19.4062 20.6953C18.9844 20.4375 18.4922 20.6367 18.4922 21.1289Z" fill="black" fill-opacity="0.85"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--Generator: Apple Native CoreSVG 341-->
<!DOCTYPE svg
PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 16.1602 23.3789">
<g>
<rect height="23.3789" opacity="0" width="16.1602" x="0" y="0"/>
<path d="M1.88672 8.92969L1.88672 3.35156C1.88672 2.41406 2.4375 1.88672 3.43359 1.88672L12.375 1.88672C13.3594 1.88672 13.9219 2.41406 13.9219 3.35156L13.9219 8.92969C13.9219 9.87891 13.3594 10.4062 12.375 10.4062L3.43359 10.4062C2.4375 10.4062 1.88672 9.87891 1.88672 8.92969ZM7.91016 21.0234C5.47266 21.0234 3.48047 19.0547 3.48047 16.6172C3.48047 14.168 5.47266 12.1992 7.91016 12.1992C10.3359 12.1992 12.3281 14.168 12.3281 16.6172C12.3281 19.0547 10.3359 21.0234 7.91016 21.0234ZM7.91016 19.3711C6.375 19.3711 5.13281 18.1289 5.13281 16.6172C5.13281 15.0938 6.375 13.8516 7.91016 13.8516C9.43359 13.8516 10.6641 15.0938 10.6641 16.6172C10.6641 18.1289 9.43359 19.3711 7.91016 19.3711Z" fill="black" fill-opacity="0.2125"/>
<path d="M0 20.332C0 22.1602 1.27734 23.3789 3.1875 23.3789L12.6094 23.3789C14.5195 23.3789 15.8086 22.1602 15.8086 20.332L15.8086 3.04688C15.8086 1.21875 14.5195 0 12.6094 0L3.1875 0C1.27734 0 0 1.21875 0 3.04688ZM1.88672 8.92969L1.88672 3.35156C1.88672 2.41406 2.4375 1.88672 3.43359 1.88672L12.375 1.88672C13.3594 1.88672 13.9219 2.41406 13.9219 3.35156L13.9219 8.92969C13.9219 9.87891 13.3594 10.4062 12.375 10.4062L3.43359 10.4062C2.4375 10.4062 1.88672 9.87891 1.88672 8.92969ZM7.91016 21.0234C5.47266 21.0234 3.48047 19.0547 3.48047 16.6172C3.48047 14.168 5.47266 12.1992 7.91016 12.1992C10.3359 12.1992 12.3281 14.168 12.3281 16.6172C12.3281 19.0547 10.3359 21.0234 7.91016 21.0234ZM7.91016 19.3711C6.375 19.3711 5.13281 18.1289 5.13281 16.6172C5.13281 15.0938 6.375 13.8516 7.91016 13.8516C9.43359 13.8516 10.6641 15.0938 10.6641 16.6172C10.6641 18.1289 9.43359 19.3711 7.91016 19.3711Z" fill="black" fill-opacity="0.85"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--Generator: Apple Native CoreSVG 341-->
<!DOCTYPE svg
PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 25.2041 25.1045">
<g>
<rect height="25.1045" opacity="0" width="25.2041" x="0" y="0"/>
<path d="M12.5728 7.62451L11.0142 9.20655C12.561 9.33545 13.5688 9.79248 14.3306 10.5542C16.3813 12.605 16.3696 15.5112 14.3423 17.5386L10.5103 21.3589C8.47119 23.398 5.58838 23.4097 3.5376 21.3706C1.48682 19.3081 1.49854 16.4253 3.5376 14.3862L5.83447 12.0894C5.50635 11.3511 5.42432 10.4722 5.55322 9.71045L2.10791 13.144C-0.692869 15.9565-0.716306 19.9409 2.11963 22.7769C4.96729 25.6245 8.95166 25.6011 11.7524 22.8003L15.7603 18.7808C18.5728 15.9683 18.5962 11.9839 15.7485 9.14795C15.0103 8.40967 14.0728 7.88233 12.5728 7.62451ZM12.2798 17.2808L13.8384 15.6987C12.2915 15.5815 11.2837 15.1128 10.522 14.3511C8.47119 12.3003 8.48291 9.39405 10.5103 7.3667L14.3306 3.54639C16.3813 1.50733 19.2642 1.49561 21.3149 3.54639C23.3657 5.59717 23.3423 8.4917 21.3149 10.519L19.0181 12.8159C19.3462 13.5659 19.4165 14.4331 19.2993 15.1948L22.7446 11.7612C25.5454 8.94873 25.5688 4.97608 22.7329 2.12842C19.8853-0.719236 15.9009-0.695799 13.0884 2.1167L9.09229 6.12451C6.27979 8.93701 6.25635 12.9214 9.10401 15.7573C9.84229 16.4956 10.7798 17.023 12.2798 17.2808Z"
fill="black" fill-opacity="0.85"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--Generator: Apple Native CoreSVG 341-->
<!DOCTYPE svg
PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 22.6729 26.7539">
<g>
<rect height="26.7539" opacity="0" width="22.6729" x="0" y="0"/>
<path d="M4.55128 10.0078L4.55128 12.3164C4.55128 16.3125 7.15284 18.9609 11.1607 18.9609C12.3675 18.9609 13.4479 18.7208 14.3655 18.272L15.6096 19.5137C14.5855 20.0933 13.3845 20.4541 12.0513 20.564L12.0513 23.3086L16.3989 23.3086C16.8911 23.3086 17.2778 23.707 17.2778 24.1992C17.2778 24.6914 16.8911 25.0781 16.3989 25.0781L5.92237 25.0781C5.43018 25.0781 5.04346 24.6914 5.04346 24.1992C5.04346 23.707 5.43018 23.3086 5.92237 23.3086L10.2817 23.3086L10.2817 20.5645C5.77478 20.1958 2.78175 16.9696 2.78175 12.3867L2.78175 10.0078C2.78175 9.51562 3.16846 9.12891 3.66065 9.12891C4.15284 9.12891 4.55128 9.51562 4.55128 10.0078ZM8.72066 12.6381C8.92963 13.8856 9.73642 14.6901 10.8863 14.7996L12.4632 16.3734C12.06 16.4951 11.6237 16.5586 11.1607 16.5586C8.64112 16.5586 6.89503 14.6602 6.89503 11.9648L6.89503 10.816ZM19.5513 10.0078L19.5513 12.3867C19.5513 13.806 19.2632 15.0952 18.724 16.2043L17.3714 14.8535C17.6419 14.0934 17.7817 13.241 17.7817 12.3164L17.7817 10.0078C17.7817 9.51562 18.1685 9.12891 18.6607 9.12891C19.1528 9.12891 19.5513 9.51562 19.5513 10.0078ZM15.4263 4.59375L15.4263 11.9648C15.4263 12.2688 15.4042 12.5626 15.3584 12.8431L13.6567 11.1436L13.6567 4.59375C13.6567 2.85938 12.6607 1.74609 11.1607 1.74609C9.66065 1.74609 8.66456 2.85938 8.66456 4.59375L8.66456 6.15788L6.90296 4.39856C6.98109 1.79536 8.70288 0 11.1607 0C13.6919 0 15.4263 1.88672 15.4263 4.59375Z" fill="black" fill-opacity="0.85"/>
<path d="M19.7739 21.7383C20.1255 22.0898 20.6997 22.0898 21.0513 21.7383C21.3911 21.375 21.4028 20.8125 21.0513 20.4609L2.92237 2.34375C2.57081 2.00391 1.98487 1.99219 1.63331 2.34375C1.29346 2.69531 1.29346 3.28125 1.63331 3.62109Z" fill="black" fill-opacity="0.85"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

Some files were not shown because too many files have changed in this diff Show More