app code 4/27
@@ -1,62 +1,372 @@
|
||||
# Project Setup and Running Instructions
|
||||
# StackChan App
|
||||
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://flutter.dev/)
|
||||
[](https://dart.dev/)
|
||||
|
||||
A powerful Flutter application for controlling and interacting with the StackChan AI robot companion. Features include
|
||||
Bluetooth connectivity, AI conversation capabilities, facial expression rendering, and dance choreography.
|
||||
|
||||
## Features
|
||||
|
||||
- 🤖 **Bluetooth Device Management** - Connect and control StackChan robots via BLE
|
||||
- 💬 **AI Conversation** - Natural language interaction powered by XiaoZhi AI
|
||||
- 🎭 **Facial Expression Rendering** - Real-time 3D face animation using Three.js
|
||||
- 🎵 **Music & Dance** - Create and play dance choreographies with music
|
||||
- 📷 **Camera Integration** - AR features and face detection
|
||||
- 🔐 **Secure Communication** - RSA encryption for data transmission
|
||||
|
||||
## System Requirements
|
||||
|
||||
- **Flutter SDK**: 3.0+
|
||||
- **Dart SDK**: 3.0+
|
||||
- **iOS**: 14.0+ (for iOS deployment)
|
||||
- **Android**: API 21+ (for Android deployment)
|
||||
- **macOS**: 11.0+ (for macOS deployment)
|
||||
## Installation
|
||||
|
||||
### 1. Install Flutter
|
||||
|
||||
Follow the official Flutter installation guide for your operating system:
|
||||
|
||||
#### macOS
|
||||
|
||||
## 1. Clone the repository
|
||||
```bash
|
||||
git clone https://github.com/m5stack/StackChan
|
||||
cd StackChan/app
|
||||
# Download Flutter SDK
|
||||
git clone https://github.com/flutter/flutter.git -b stable
|
||||
export PATH="$PATH:`pwd`/flutter/bin"
|
||||
|
||||
# Verify installation
|
||||
flutter doctor
|
||||
```
|
||||
|
||||
## 2. Open the project in Xcode
|
||||
Open the project in Xcode:
|
||||
#### Windows
|
||||
|
||||
Double‑click the `.xcodeproj` file, or open Xcode → File → Open, then select the project.
|
||||
```bash
|
||||
# Download Flutter SDK from https://flutter.dev/docs/get-started/install/windows
|
||||
# Extract and add to PATH
|
||||
|
||||
1. Select your target device or simulator.
|
||||
|
||||
### Connect an iPhone (Optional but Recommended)
|
||||
- Connect your iPhone to the Mac using a USB cable.
|
||||
- Unlock the iPhone and tap **Trust This Computer** if prompted.
|
||||
- In Xcode, select your iPhone as the run destination at the top.
|
||||
|
||||
### Enable Developer Mode on iPhone (iOS 16+)
|
||||
> **Important:** Developer Mode will only appear after the iPhone has been connected to Xcode at least once.
|
||||
If you do not see this option, make sure your iPhone is connected to the Mac, unlocked, trusted, and recognized by Xcode.
|
||||
- On the iPhone, go to **Settings → Privacy & Security → Developer Mode**.
|
||||
- Turn on Developer Mode and restart the iPhone.
|
||||
- After restart, confirm enabling Developer Mode.
|
||||
|
||||
## 3. Configure Signing & Capabilities
|
||||
This step allows Xcode to install the app on your iPhone.
|
||||
|
||||
1. In Xcode, select the project in the left sidebar.
|
||||
2. Select the app target.
|
||||
3. Open the **Signing & Capabilities** tab.
|
||||
4. Sign in with your Apple ID (Xcode → Settings → Accounts → Add Apple ID).
|
||||
5. Set **Team** to your Apple ID.
|
||||
6. Change **Bundle Identifier** to a unique value, for example:
|
||||
`com.yourname.stackchan`
|
||||
7. Ensure no red error messages remain.
|
||||
|
||||
> **Note:** A free Apple ID is sufficient for testing on your own iPhone.
|
||||
|
||||
## 4. Modify network configuration
|
||||
Before running the app, you need to set the correct server IP:
|
||||
|
||||
1. Open the file `Network/Urls.swift`.
|
||||
2. Find the line defining the base URL, for example:
|
||||
```swift
|
||||
// Base URL configured according to the server's IP
|
||||
static let url = "192.168.51.24:12800/"
|
||||
# Verify installation
|
||||
flutter doctor
|
||||
```
|
||||
3. Replace the IP address (`192.168.51.24`) with the IP of the computer where the server is running.
|
||||
4. Save the file.
|
||||
|
||||
## 5. Run the project
|
||||
Press `Cmd + R` to build and run the app.
|
||||
#### Linux
|
||||
|
||||
> **Note:** The first build may take several minutes as Xcode prepares the environment.
|
||||
```bash
|
||||
# Download Flutter SDK
|
||||
git clone https://github.com/flutter/flutter.git -b stable
|
||||
export PATH="$PATH:`pwd`/flutter/bin"
|
||||
|
||||
If running on an iPhone for the first time, you may need to trust yourself as a developer:
|
||||
- On your iPhone, go to **Settings → General → VPN & Device Management → Trust Developer** and trust the developer profile that appears.
|
||||
# Install dependencies
|
||||
sudo apt-get install clang cmake ninja-build pkg-config libgtk-3-dev liblzma-dev
|
||||
|
||||
The app will now connect to the server at the IP you configured.
|
||||
# Verify installation
|
||||
flutter doctor
|
||||
```
|
||||
|
||||
### 2. Set Up Project
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone <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.
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD
|
||||
*
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
struct UploadFile : Codable {
|
||||
var path: String? = nil
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)?) {
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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?()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -1,360 +0,0 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD
|
||||
*
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import SwiftUI
|
||||
import SceneKit
|
||||
import Combine
|
||||
|
||||
struct StackChanRobot : UIViewRepresentable {
|
||||
|
||||
var data: DanceData
|
||||
|
||||
var allowsCameraControl: Bool = false
|
||||
|
||||
@State private var expressionLayer = ExpressionLayer(data: ExpressionData(leftEye: ExpressionItem(weight: 100), rightEye: ExpressionItem(weight: 100), mouth: ExpressionItem()))
|
||||
|
||||
private let planeNodeName = "expressionPlane"
|
||||
|
||||
private let rotateKey = "autoRotate"
|
||||
|
||||
func makeUIView(context: Context) -> SCNView {
|
||||
let sceneView = SCNView()
|
||||
|
||||
if let scene = SCNScene(named: "stackChanModel.scn") {
|
||||
scene.rootNode.eulerAngles = SCNVector3Zero
|
||||
scene.rootNode.eulerAngles.x = -Float.pi / 2
|
||||
scene.rootNode.position.y = scene.rootNode.position.y + 25
|
||||
scene.rootNode.position.z = scene.rootNode.position.z - 35
|
||||
|
||||
let plane = SCNPlane(width: 42, height: 32)
|
||||
let magnification: CGFloat = 5
|
||||
let size = CGSize(width: magnification * plane.width, height: magnification * plane.height)
|
||||
expressionLayer.frame = CGRect(origin: .zero, size: size)
|
||||
expressionLayer.setNeedsDisplay()
|
||||
|
||||
let material = SCNMaterial()
|
||||
plane.materials = [material]
|
||||
let planeNode = SCNNode(geometry: plane)
|
||||
planeNode.name = planeNodeName
|
||||
planeNode.position = SCNVector3(0, -16, 0)
|
||||
planeNode.eulerAngles = SCNVector3(Float.pi / 2, 0, 0)
|
||||
scene.rootNode.addChildNode(planeNode)
|
||||
|
||||
sceneView.scene = scene
|
||||
} else {
|
||||
print("Model not found")
|
||||
}
|
||||
|
||||
sceneView.autoenablesDefaultLighting = true
|
||||
sceneView.allowsCameraControl = allowsCameraControl
|
||||
sceneView.backgroundColor = UIColor.clear
|
||||
setData(sceneView)
|
||||
return sceneView
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: SCNView, context: Context) {
|
||||
setData(uiView)
|
||||
}
|
||||
|
||||
/// Refresh model position and expression
|
||||
private func setData(_ uiView: SCNView) {
|
||||
if let stackNode = uiView.scene?.rootNode {
|
||||
/// Set pitch angle (0–900)
|
||||
let clampedPitch = max(0, min(900, data.pitchServo.angle))
|
||||
let pitchRatio = Float(clampedPitch) / 900.0
|
||||
let pitchAngle = -Float.pi / 2 * (1 + pitchRatio)
|
||||
stackNode.eulerAngles.x = pitchAngle
|
||||
|
||||
// Cancel previous auto-rotation
|
||||
stackNode.removeAction(forKey: rotateKey)
|
||||
|
||||
if data.yawServo.rotate == 0 {
|
||||
/// Set yaw angle (-128 to 128, left to right)
|
||||
let clampedYaw = max(-1280, min(1280, data.yawServo.angle)) // Clamp to -128~128
|
||||
let yawAngle = Float(clampedYaw) * Float.pi / 1800 // Convert to radians
|
||||
stackNode.eulerAngles.y = yawAngle
|
||||
} else {
|
||||
let rotateSpeed = max(-1000, min(1000, data.yawServo.rotate))
|
||||
let radiansPerSecond = Float(rotateSpeed) / 1000.0 * Float.pi * 2
|
||||
// Rotate continuously using angular velocity (not a fixed-loop animation)
|
||||
let rotateAction = SCNAction.customAction(duration: .infinity) { node, _ in
|
||||
let deltaTime: Float = 1.0 / 60.0 // Approximate frame duration
|
||||
node.eulerAngles.y += radiansPerSecond * deltaTime
|
||||
}
|
||||
stackNode.runAction(rotateAction, forKey: rotateKey)
|
||||
}
|
||||
}
|
||||
// Find the plane node
|
||||
if let planeNode = uiView.scene?.rootNode.childNode(withName: planeNodeName, recursively: true),
|
||||
let plane = planeNode.geometry as? SCNPlane {
|
||||
|
||||
// Render new expression image
|
||||
let expressionData = ExpressionData(leftEye: data.leftEye, rightEye: data.rightEye, mouth: data.mouth)
|
||||
expressionLayer.data = expressionData
|
||||
expressionLayer.setNeedsDisplay()
|
||||
let newImage = expressionRenderer().image { ctx in
|
||||
self.expressionLayer.render(in: ctx.cgContext)
|
||||
}
|
||||
plane.firstMaterial?.diffuse.contents = newImage
|
||||
}
|
||||
}
|
||||
|
||||
static func dismantleUIView(_ uiView: SCNView, coordinator: ()) {
|
||||
// Remove all nodes from the scene
|
||||
uiView.scene?.rootNode.childNodes.forEach { $0.removeFromParentNode() }
|
||||
|
||||
// Clean up scene and materials
|
||||
uiView.scene = nil
|
||||
uiView.delegate = nil
|
||||
|
||||
// Stop any rendering or animations
|
||||
uiView.isPlaying = false
|
||||
uiView.scene?.isPaused = true
|
||||
}
|
||||
|
||||
private func expressionRenderer() -> UIGraphicsImageRenderer {
|
||||
let format = UIGraphicsImageRendererFormat.default()
|
||||
format.scale = UIScreen.main.scale
|
||||
format.opaque = false
|
||||
return UIGraphicsImageRenderer(
|
||||
size: expressionLayer.bounds.size,
|
||||
format: format
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
struct StackChanRotaryRobot : UIViewRepresentable {
|
||||
|
||||
private let expressionLayer = ExpressionLayer(data: ExpressionData(leftEye: ExpressionItem(weight: 100), rightEye: ExpressionItem(weight: 100), mouth: ExpressionItem()))
|
||||
|
||||
func updateUIView(_ uiView: SCNView, context: Context) {
|
||||
|
||||
}
|
||||
|
||||
private func expressionRenderer() -> UIGraphicsImageRenderer {
|
||||
let format = UIGraphicsImageRendererFormat.default()
|
||||
format.scale = UIScreen.main.scale
|
||||
format.opaque = false
|
||||
return UIGraphicsImageRenderer(
|
||||
size: expressionLayer.bounds.size,
|
||||
format: format
|
||||
)
|
||||
}
|
||||
|
||||
func makeUIView(context: Context) -> SCNView {
|
||||
let sceneView = SCNView()
|
||||
|
||||
if let scene = SCNScene(named: "stackChanModel.scn") {
|
||||
scene.rootNode.eulerAngles = SCNVector3Zero
|
||||
scene.rootNode.eulerAngles.x = -Float.pi / 2
|
||||
scene.rootNode.position.y = scene.rootNode.position.y + 25
|
||||
scene.rootNode.position.z = scene.rootNode.position.z - 45
|
||||
|
||||
let clampedPitch = max(0, min(900, 200))
|
||||
let pitchRatio = Float(clampedPitch) / 900.0
|
||||
let pitchAngle = -Float.pi / 2 * (1 + pitchRatio)
|
||||
scene.rootNode.eulerAngles.x = pitchAngle
|
||||
|
||||
// Add plane
|
||||
let plane = SCNPlane(width: 42, height: 32)
|
||||
let magnification: CGFloat = 5
|
||||
let size = CGSize(width: magnification * plane.width, height: magnification * plane.height)
|
||||
expressionLayer.frame = CGRect(origin: .zero, size: size)
|
||||
expressionLayer.setNeedsDisplay()
|
||||
let newImage = expressionRenderer().image { ctx in
|
||||
self.expressionLayer.render(in: ctx.cgContext)
|
||||
}
|
||||
let material = SCNMaterial()
|
||||
material.diffuse.contents = newImage
|
||||
plane.materials = [material]
|
||||
let planeNode = SCNNode(geometry: plane)
|
||||
planeNode.position = SCNVector3(0, -16, 0)
|
||||
planeNode.eulerAngles = SCNVector3(Float.pi / 2, 0, 0)
|
||||
scene.rootNode.addChildNode(planeNode)
|
||||
|
||||
// Add infinite rotation animation around Y axis
|
||||
let rotateAction = SCNAction.rotateBy(x: 0, y: CGFloat(2 * Double.pi), z: 0, duration: 5)
|
||||
let repeatAction = SCNAction.repeatForever(rotateAction)
|
||||
scene.rootNode.runAction(repeatAction)
|
||||
|
||||
sceneView.scene = scene
|
||||
} else {
|
||||
print("Model not found")
|
||||
}
|
||||
|
||||
sceneView.autoenablesDefaultLighting = true
|
||||
sceneView.allowsCameraControl = false
|
||||
sceneView.backgroundColor = UIColor.clear
|
||||
return sceneView
|
||||
}
|
||||
|
||||
static func dismantleUIView(_ uiView: UIViewType, coordinator: ()) {
|
||||
// Remove all nodes from the scene
|
||||
uiView.scene?.rootNode.childNodes.forEach { $0.removeFromParentNode() }
|
||||
|
||||
// Clean up scene and materials
|
||||
uiView.scene = nil
|
||||
uiView.delegate = nil
|
||||
|
||||
// Stop any rendering or animations
|
||||
uiView.isPlaying = false
|
||||
uiView.scene?.isPaused = true
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
struct StackChanRobotPreview : PreviewProvider {
|
||||
|
||||
static var previews: some View {
|
||||
StackChanRotaryRobot()
|
||||
.frame(maxWidth: .infinity,maxHeight: 400)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
class ExpressionLayer: CALayer {
|
||||
var data: ExpressionData
|
||||
|
||||
let reverse: Bool
|
||||
|
||||
init(data: ExpressionData, reverse: Bool = false) {
|
||||
self.data = data
|
||||
self.reverse = reverse
|
||||
super.init()
|
||||
self.contentsScale = UIScreen.main.scale
|
||||
self.setNeedsDisplay()
|
||||
}
|
||||
|
||||
override init(layer: Any) {
|
||||
if let layer = layer as? ExpressionLayer {
|
||||
self.data = layer.data
|
||||
self.reverse = layer.reverse
|
||||
} else {
|
||||
self.data = ExpressionData(leftEye: ExpressionItem(), rightEye: ExpressionItem(), mouth: ExpressionItem())
|
||||
self.reverse = false
|
||||
}
|
||||
super.init(layer: layer)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func draw(in ctx: CGContext) {
|
||||
let rect = self.frame
|
||||
|
||||
// Background
|
||||
ctx.setFillColor(UIColor.black.withAlphaComponent(0.7).cgColor)
|
||||
ctx.fill(rect)
|
||||
|
||||
let eyeSize = rect.width / 10
|
||||
|
||||
func drawEye(_ item: ExpressionItem, at point: CGPoint) {
|
||||
|
||||
// Calculate scale based on size (-100 to 100)
|
||||
// 0 -> 1.0 (keep current size)
|
||||
// -100 -> 0.5 (half normal radius)
|
||||
// 100 -> 2.0 (double normal radius)
|
||||
let clampedSize = max(-100, min(100, item.size))
|
||||
let sizeScale: CGFloat
|
||||
if clampedSize >= 0 {
|
||||
sizeScale = 1.0 + CGFloat(clampedSize) / 100.0
|
||||
} else {
|
||||
sizeScale = 1.0 + CGFloat(clampedSize) / 200.0
|
||||
}
|
||||
|
||||
let scaledEyeSize = eyeSize * sizeScale
|
||||
|
||||
let visibleHeight = scaledEyeSize * (CGFloat(item.weight) / 100)
|
||||
|
||||
let centerX = point.x + CGFloat(item.x / 10) + eyeSize / 2
|
||||
let centerY = point.y + CGFloat(item.y / 10) + eyeSize / 2
|
||||
let eyeRect = CGRect(
|
||||
x: centerX - scaledEyeSize / 2,
|
||||
y: centerY - scaledEyeSize / 2,
|
||||
width: scaledEyeSize,
|
||||
height: scaledEyeSize
|
||||
)
|
||||
|
||||
ctx.saveGState()
|
||||
|
||||
// Rotation
|
||||
let rotationDegrees = CGFloat(item.rotation) / 10.0
|
||||
let center = CGPoint(x: eyeRect.midX, y: eyeRect.midY)
|
||||
ctx.translateBy(x: center.x, y: center.y)
|
||||
ctx.rotate(by: rotationDegrees * .pi / 180)
|
||||
ctx.translateBy(x: -center.x, y: -center.y)
|
||||
|
||||
// Clip height
|
||||
let maskRect = CGRect(
|
||||
x: eyeRect.minX,
|
||||
y: eyeRect.maxY - visibleHeight,
|
||||
width: scaledEyeSize,
|
||||
height: visibleHeight
|
||||
)
|
||||
ctx.addRect(maskRect)
|
||||
ctx.clip()
|
||||
|
||||
ctx.setFillColor(UIColor.white.cgColor)
|
||||
ctx.fillEllipse(in: eyeRect)
|
||||
|
||||
ctx.restoreGState()
|
||||
}
|
||||
|
||||
let eyeY = (rect.height * 0.35) - (eyeSize / 2)
|
||||
let leftEyePoint = CGPoint(x: (rect.width / 3) - (eyeSize / 2), y: eyeY)
|
||||
let rightEyePoint = CGPoint(x: (rect.width / 3 * 2) - (eyeSize / 2), y: eyeY)
|
||||
|
||||
|
||||
if reverse {
|
||||
// Temporarily swap rotation angles
|
||||
let leftEyeRotation = data.leftEye.rotation
|
||||
let rightEyeRotation = data.rightEye.rotation
|
||||
|
||||
var leftEye = data.leftEye
|
||||
var rightEye = data.rightEye
|
||||
|
||||
leftEye.rotation = rightEyeRotation
|
||||
rightEye.rotation = leftEyeRotation
|
||||
|
||||
drawEye(leftEye, at: rightEyePoint)
|
||||
drawEye(rightEye, at: leftEyePoint)
|
||||
} else {
|
||||
drawEye(data.leftEye, at: leftEyePoint)
|
||||
drawEye(data.rightEye, at: rightEyePoint)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
// Draw mouth
|
||||
ctx.saveGState()
|
||||
|
||||
let width = rect.width * 0.3 - CGFloat(data.mouth.weight / 10)
|
||||
let height = 3 + CGFloat(data.mouth.weight) * 0.2
|
||||
let x = ((rect.width - width) / 2) + CGFloat(data.mouth.x / 10)
|
||||
let y = (rect.height * 0.65) + CGFloat(data.mouth.y / 10)
|
||||
|
||||
let rotationDegrees = CGFloat(data.mouth.rotation) / 10.0
|
||||
let center = CGPoint(x: x + width / 2, y: y + height / 2)
|
||||
ctx.translateBy(x: center.x, y: center.y)
|
||||
ctx.rotate(by: rotationDegrees * .pi / 180)
|
||||
ctx.translateBy(x: -center.x, y: -center.y)
|
||||
|
||||
let mouthRect = CGRect(x: x, y: y, width: width, height: height)
|
||||
let mouthPath = UIBezierPath(roundedRect: mouthRect, cornerRadius: height / 2)
|
||||
ctx.addPath(mouthPath.cgPath)
|
||||
ctx.setFillColor(UIColor.white.cgColor)
|
||||
ctx.fillPath()
|
||||
|
||||
ctx.restoreGState()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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_LOCATION,COARSE 无效 -->
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 544 B |
|
After Width: | Height: | Size: 442 B |
|
After Width: | Height: | Size: 721 B |
|
After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 167 KiB After Width: | Height: | Size: 167 KiB |
|
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>
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 "$@"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
|
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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
|
After Width: | Height: | Size: 2.7 MiB |
@@ -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 |
@@ -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 |