mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-06-14 22:55:12 +10:00
Compare commits
227 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4239fb11ed | |||
| 5fae2e5f73 | |||
| 947fafbbb7 | |||
| 72f0aa7208 | |||
| f87d4896ab | |||
| 9250dfec31 | |||
| 37db955ab2 | |||
| 739d9475c0 | |||
| b526175be4 | |||
| 73081862ad | |||
| fac062a100 | |||
| ef6bd78632 | |||
| 01c8390989 | |||
| c05f813d65 | |||
| c52b19b09f | |||
| 6a666839b6 | |||
| bc77f7e287 | |||
| 9332d8126f | |||
| 9ce00556ec | |||
| 4995f5f380 | |||
| 4e6e7b6061 | |||
| aa350aa4ae | |||
| dfd38b19e9 | |||
| 4afab3f629 | |||
| 67816130ac | |||
| d573f0c312 | |||
| 5b699cd624 | |||
| a4d3d248a5 | |||
| 2a3f2b3a24 | |||
| 675083fa01 | |||
| 5fc4b80b16 | |||
| 84a32c1e67 | |||
| 607583060a | |||
| 71cf556b61 | |||
| c26174ad18 | |||
| 04021a39a1 | |||
| fe23e9f7a0 | |||
| d7ec1876af | |||
| 87a2807f5b | |||
| daca42701c | |||
| ea43cf17eb | |||
| 8ef6e2c656 | |||
| 24de98d5ee | |||
| 0fd841b5b5 | |||
| c365b7889b | |||
| 2db30ace6a | |||
| 0d8801fa75 | |||
| bcae6ac19f | |||
| 2f4b230b31 | |||
| 98e0b05e73 | |||
| 2a909e6081 | |||
| d1009d3c20 | |||
| 91b1696bc5 | |||
| 978ea4790d | |||
| 8b1228bf8d | |||
| ddee76ced2 | |||
| 6a3c59fa2c | |||
| a54cc78691 | |||
| 05fb5a13fa | |||
| c320378be1 | |||
| b3645481c7 | |||
| 589707aa13 | |||
| 6070802213 | |||
| 2525b9425b | |||
| b786c90514 | |||
| a35590a407 | |||
| 8d15f7cef6 | |||
| e449f5e1d5 | |||
| b34d684e67 | |||
| 488a286701 | |||
| c742d98fbb | |||
| 1d4c9ad9bd | |||
| 818f514702 | |||
| be54419e5b | |||
| 00eb1a68a6 | |||
| 79ffc21bd6 | |||
| 0374f4f5da | |||
| 4650584f9b | |||
| 8d8b938878 | |||
| e3a0bd3b13 | |||
| 4f83d87f8c | |||
| 6d7d51f0a4 | |||
| 33680f0cb9 | |||
| 5115d8bbe3 | |||
| d30e7c4e2c | |||
| 8470171e88 | |||
| ede3142d40 | |||
| 6712088fcd | |||
| 7b519854d7 | |||
| 90ce46392a | |||
| d61ec217fc | |||
| 3ac81a5448 | |||
| 7004067839 | |||
| 935b7b07eb | |||
| cdacc54421 | |||
| bf8f002d55 | |||
| 998ff50495 | |||
| 92d2b224e7 | |||
| 34a6b5d895 | |||
| c953a1a798 | |||
| 42115bf200 | |||
| d0c8fab6fb | |||
| eeb8ff34e8 | |||
| 641307a316 | |||
| c37abb63e3 | |||
| 898ef1c11c | |||
| 749f9d4dfd | |||
| 9c1b5899fb | |||
| cacb9bc677 | |||
| 0ebd688787 | |||
| bb18038f60 | |||
| fcf741b20a | |||
| 88aa104ae5 | |||
| 90f90ad7cf | |||
| 8b0bdd9a46 | |||
| 45d914de57 | |||
| 2c49534955 | |||
| c56cf9c3ed | |||
| fee4cd13be | |||
| a53d5ccfb6 | |||
| e5d06b1c7e | |||
| e95a55e4f0 | |||
| 422ca941c2 | |||
| 3098d860e9 | |||
| f0d34f7503 | |||
| daa0c3f9c3 | |||
| 09e1cd2b8d | |||
| fa514533eb | |||
| 75b8b8af70 | |||
| 115667a27c | |||
| cfb51d96ff | |||
| 75356fe20d | |||
| 2089613696 | |||
| c43df67fac | |||
| e2b9b58d7d | |||
| d6794bc8d7 | |||
| 72216e2cf7 | |||
| 2a2275ec31 | |||
| dff037535d | |||
| 297e609b3e | |||
| 20171c491f | |||
| cc43f4d198 | |||
| 537384ea5b | |||
| a0be63b2e7 | |||
| 1cc887e5bb | |||
| 26d9029538 | |||
| 30bcbedf5e | |||
| 4003519deb | |||
| 3fdd8f5eaf | |||
| f4ec732de8 | |||
| f790604d23 | |||
| 8e3b563aba | |||
| ee3b0a3126 | |||
| 31d633ee0b | |||
| c269365d81 | |||
| 9a9f59e53f | |||
| 9cb667fad0 | |||
| 3fef594fe5 | |||
| 8387304d2a | |||
| 2acba9eb84 | |||
| 30ba1799e1 | |||
| 13f9c5058a | |||
| 98fc2d6e0a | |||
| 2becbb342c | |||
| 5b2d5a494c | |||
| 153736d36e | |||
| 6c8a149e1b | |||
| b41ccee4f9 | |||
| 04a713bb76 | |||
| 714aecd7e6 | |||
| 2e1a5e0fbf | |||
| 1f0b7d8d7b | |||
| dffea23ce2 | |||
| e0a8fb7ec0 | |||
| 06fc08c41f | |||
| c22bfed680 | |||
| 316c76e5b4 | |||
| 4b215ad574 | |||
| 09e60cebd9 | |||
| 6782347cf4 | |||
| 1726119c3e | |||
| 988806dccd | |||
| 14ff8250c0 | |||
| 2a04ebb8b6 | |||
| a14462978d | |||
| df7fb45683 | |||
| f01eff07ff | |||
| 7cc7183e0c | |||
| a6b2756d0d | |||
| 614f3d4601 | |||
| 7c33647119 | |||
| fde8b686f5 | |||
| 9bc3a27b53 | |||
| a8f387b0da | |||
| dd1a73c247 | |||
| e36f6b7eb9 | |||
| fcef82be63 | |||
| 6ddb8f1a3d | |||
| 7a22223756 | |||
| dba639abdc | |||
| 1483fb7f1c | |||
| df04f315b4 | |||
| c0f0c58518 | |||
| 01bd8243da | |||
| b2ce82fe7e | |||
| 2495cd840f | |||
| bc6c1f1fab | |||
| 310818f9d3 | |||
| 8c3ffa5472 | |||
| be3b920b3f | |||
| 7703aaafc6 | |||
| 1ba3f3ac49 | |||
| ffbfd1a40c | |||
| ab7cc84db5 | |||
| f3aef42331 | |||
| 367f89fb1b | |||
| fe57963a26 | |||
| fca810737d | |||
| 35e866abfb | |||
| ffce582b3b | |||
| 8c73359125 | |||
| 401a3842ca | |||
| 2993ec1f49 | |||
| c306ad798c | |||
| f5be9b9691 | |||
| e3d7607db9 | |||
| c44f0d1ae2 |
@@ -0,0 +1,78 @@
|
||||
name: Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
android:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: "temurin"
|
||||
java-version: "17"
|
||||
- uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: "stable"
|
||||
cache: true
|
||||
- name: Cache Gradle
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: ${{ runner.os }}-gradle-${{ hashFiles('android/gradle/wrapper/gradle-wrapper.properties', 'android/build.gradle', 'android/settings.gradle', 'android/app/build.gradle', 'pubspec.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gradle-
|
||||
- run: flutter pub get
|
||||
- run: flutter build apk --release --no-pub
|
||||
|
||||
ios:
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: "stable"
|
||||
cache: true
|
||||
- run: flutter pub get
|
||||
- run: flutter build ios --release --no-codesign --no-pub
|
||||
|
||||
linux:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: "stable"
|
||||
cache: true
|
||||
- name: Install Linux build deps
|
||||
run: sudo apt-get update && sudo apt-get install -y clang cmake ninja-build pkg-config libgtk-3-dev
|
||||
- run: flutter pub get
|
||||
- run: flutter build linux --release --no-pub
|
||||
|
||||
macos:
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: "stable"
|
||||
cache: true
|
||||
- run: flutter pub get
|
||||
- run: flutter build macos --release --no-pub
|
||||
|
||||
web:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: "stable"
|
||||
cache: true
|
||||
- run: flutter pub get
|
||||
- run: flutter build web --release --no-pub
|
||||
@@ -0,0 +1,31 @@
|
||||
name: Flutter and Dart
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: stable
|
||||
|
||||
- name: Install dependencies
|
||||
run: flutter pub get
|
||||
|
||||
- name: Analyze code
|
||||
run: flutter analyze --fatal-infos --fatal-warnings
|
||||
|
||||
- name: Verify formatting
|
||||
run: dart format --output=none --set-exit-if-changed .
|
||||
|
||||
- name: Run tests
|
||||
run: flutter test -r github
|
||||
@@ -65,11 +65,13 @@ secrets.dart
|
||||
**/ios/Flutter/Flutter.podspec
|
||||
|
||||
# Android
|
||||
.gradle/
|
||||
**/android/.gradle/
|
||||
**/android/captures/
|
||||
**/android/local.properties
|
||||
**/android/.externalNativeBuild/
|
||||
*.jks
|
||||
key.properties
|
||||
keystore.properties
|
||||
|
||||
# Generated files
|
||||
|
||||
@@ -6,6 +6,10 @@ Open-source Flutter client for MeshCore LoRa mesh networking devices.
|
||||
|
||||
MeshCore Open is a cross-platform mobile application for communicating with MeshCore LoRa mesh network devices via Bluetooth Low Energy (BLE). The app enables long-range, off-grid communication through peer-to-peer messaging, public channels, and mesh networking capabilities.
|
||||
|
||||
<a href="http://apps.obtainium.imranr.dev/redirect.html?r=obtainium://add/https://github.com/zjs81/meshcore-open">
|
||||
<img src="assets/badges/badge_obtainium.png" height="80" align="center" alt="Get it on Obtainium"/>
|
||||
</a>
|
||||
|
||||
## Screenshots
|
||||
|
||||
<table>
|
||||
@@ -21,6 +25,7 @@ MeshCore Open is a cross-platform mobile application for communicating with Mesh
|
||||
## Features
|
||||
|
||||
### Core Functionality
|
||||
|
||||
- **Direct Messaging**: Private encrypted conversations with individual contacts
|
||||
- **Public Channels**: Broadcast messages to channel subscribers on the mesh network
|
||||
- **Contact Management**: Organize contacts, track last seen times, and manage conversation history
|
||||
@@ -29,6 +34,7 @@ MeshCore Open is a cross-platform mobile application for communicating with Mesh
|
||||
- **Message Replies**: Thread conversations with inline reply functionality
|
||||
|
||||
### Mesh Network
|
||||
|
||||
- **Path Visualization**: View routing paths and signal quality for each contact
|
||||
- **Route Management**: Manual path overriding and automatic route rotation
|
||||
- **Signal Metrics**: Real-time SNR (Signal-to-Noise Ratio) tracking
|
||||
@@ -36,6 +42,7 @@ MeshCore Open is a cross-platform mobile application for communicating with Mesh
|
||||
- **Repeater Support**: Connect to and manage repeater nodes for extended range
|
||||
|
||||
### Map & Location
|
||||
|
||||
- **Live Map View**: Real-time visualization of mesh network nodes on an interactive map
|
||||
- **Node Filtering**: Filter by node type (chat, repeater, sensor) and time range
|
||||
- **Location Sharing**: Share GPS coordinates and custom markers with contacts
|
||||
@@ -43,12 +50,14 @@ MeshCore Open is a cross-platform mobile application for communicating with Mesh
|
||||
- **MGRS Coordinates**: Support for Military Grid Reference System coordinate format
|
||||
|
||||
### Device Management
|
||||
|
||||
- **BLE Connection**: Scan and connect to MeshCore devices via Bluetooth
|
||||
- **Device Settings**: Configure radio parameters, power settings, and network options
|
||||
- **Battery Monitoring**: Real-time battery status with chemistry-specific voltage curves
|
||||
- **Firmware Updates**: Over-the-air firmware updates via BLE (coming soon)
|
||||
|
||||
### Repeater Hub
|
||||
|
||||
- **CLI Access**: Full command-line interface to repeater nodes
|
||||
- **Settings Management**: Configure repeater behavior, power limits, and network settings
|
||||
- **Statistics Dashboard**: View repeater traffic, connected clients, and system health
|
||||
@@ -57,6 +66,7 @@ MeshCore Open is a cross-platform mobile application for communicating with Mesh
|
||||
## Technical Details
|
||||
|
||||
### Architecture
|
||||
|
||||
- **Framework**: Flutter 3.38.5 / Dart 3.10.4
|
||||
- **State Management**: Provider pattern with ChangeNotifier
|
||||
- **BLE Protocol**: Nordic UART Service (NUS) over Bluetooth Low Energy
|
||||
@@ -64,11 +74,13 @@ MeshCore Open is a cross-platform mobile application for communicating with Mesh
|
||||
- **Encryption**: End-to-end encryption for private messages using the MeshCore protocol
|
||||
|
||||
### Platform Support
|
||||
|
||||
- ✅ **Android**: Full support (API 21+)
|
||||
- ✅ **iOS**: Full support (iOS 12+)
|
||||
- 🚧 **Desktop**: Limited support (macOS/Linux/Windows)
|
||||
|
||||
### Dependencies
|
||||
|
||||
| Package | Purpose |
|
||||
|---------|---------|
|
||||
| flutter_blue_plus | Bluetooth Low Energy communication |
|
||||
@@ -84,6 +96,7 @@ MeshCore Open is a cross-platform mobile application for communicating with Mesh
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Flutter SDK 3.38.5 or later
|
||||
- Android Studio / Xcode (for mobile development)
|
||||
- A MeshCore-compatible LoRa device
|
||||
@@ -91,17 +104,20 @@ MeshCore Open is a cross-platform mobile application for communicating with Mesh
|
||||
### Installation
|
||||
|
||||
1. **Clone the repository**
|
||||
|
||||
```bash
|
||||
git clone https://github.com/zjs81/meshcore-open.git
|
||||
cd meshcore-open
|
||||
```
|
||||
|
||||
2. **Install dependencies**
|
||||
|
||||
```bash
|
||||
flutter pub get
|
||||
```
|
||||
|
||||
3. **Run the app**
|
||||
|
||||
```bash
|
||||
flutter run
|
||||
```
|
||||
@@ -109,11 +125,13 @@ MeshCore Open is a cross-platform mobile application for communicating with Mesh
|
||||
### Building for Release
|
||||
|
||||
**Android APK:**
|
||||
|
||||
```bash
|
||||
flutter build apk --release
|
||||
```
|
||||
|
||||
**iOS:**
|
||||
|
||||
```bash
|
||||
flutter build ios --release
|
||||
```
|
||||
@@ -152,25 +170,30 @@ lib/
|
||||
## BLE Protocol
|
||||
|
||||
### Nordic UART Service (NUS)
|
||||
|
||||
- **Service UUID**: `6e400001-b5a3-f393-e0a9-e50e24dcca9e`
|
||||
- **RX Characteristic**: `6e400002-b5a3-f393-e0a9-e50e24dcca9e` (Write to device)
|
||||
- **TX Characteristic**: `6e400003-b5a3-f393-e0a9-e50e24dcca9e` (Notify from device)
|
||||
|
||||
### Device Discovery
|
||||
|
||||
Devices are discovered by scanning for BLE advertisements with the name prefix `MeshCore-`
|
||||
|
||||
### Message Format
|
||||
|
||||
Messages are transmitted as binary frames using a custom protocol optimized for LoRa transmission. See `meshcore_protocol.dart` for frame structure definitions.
|
||||
|
||||
## Configuration
|
||||
|
||||
### App Settings
|
||||
|
||||
- **Theme**: System default, light, or dark mode
|
||||
- **Notifications**: Configurable for messages, channels, and node advertisements
|
||||
- **Battery Chemistry**: Support for NMC, LiFePO4, and LiPo battery types
|
||||
- **Message Retry**: Automatic retry with configurable path clearing
|
||||
|
||||
### Device Settings
|
||||
|
||||
- **Radio Power**: Transmit power adjustment (10-30 dBm)
|
||||
- **Frequency**: LoRa frequency configuration
|
||||
- **Bandwidth**: Channel bandwidth selection
|
||||
@@ -182,22 +205,24 @@ Messages are transmitted as binary frames using a custom protocol optimized for
|
||||
This is an open-source project. Contributions are welcome!
|
||||
|
||||
### Development Guidelines
|
||||
|
||||
- Follow the Flutter style guide
|
||||
- Use Material 3 design components
|
||||
- Write clear commit messages
|
||||
- Test on both Android and iOS before submitting PRs
|
||||
|
||||
### Code Style
|
||||
|
||||
- Prefer `StatelessWidget` with `Consumer` for reactive UI
|
||||
- Use `const` constructors where possible
|
||||
- Keep functions small and focused
|
||||
- Avoid premature abstractions
|
||||
|
||||
- Run dart format on all changes before submitting
|
||||
|
||||
## Support
|
||||
|
||||
For issues, questions, or feature requests, please open an issue on GitHub:
|
||||
https://github.com/zjs81/meshcore-open/issues
|
||||
<https://github.com/zjs81/meshcore-open/issues>
|
||||
|
||||
## Donate
|
||||
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
# TestFlight and App Store Deployment Guide
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [x] Apple Developer Account ($99/year) - [developer.apple.com](https://developer.apple.com)
|
||||
- [x] Xcode installed
|
||||
- [x] Apple Transporter app installed
|
||||
- [x] App icons ready (1024x1024px)
|
||||
- [x] Bundle ID configured: `com.monitormx.meshcoreopen`
|
||||
|
||||
## Step 1: Register Bundle Identifier
|
||||
|
||||
1. Go to [Apple Developer - Identifiers](https://developer.apple.com/account/resources/identifiers/list)
|
||||
2. Click the **"+"** button
|
||||
3. Select **"App IDs"** → Continue
|
||||
4. Select **"App"** → Continue
|
||||
5. Fill in:
|
||||
- **Description**: Meshcore Open
|
||||
- **Bundle ID**: Explicit - `com.monitormx.meshcoreopen`
|
||||
- **Capabilities**: Leave defaults (or add as needed)
|
||||
6. Click **Continue** → **Register**
|
||||
|
||||
## Step 2: Create App in App Store Connect
|
||||
|
||||
1. Go to [App Store Connect](https://appstoreconnect.apple.com)
|
||||
2. Sign in with your Apple ID
|
||||
3. Click **"My Apps"**
|
||||
4. Click the **"+"** button → **"New App"**
|
||||
5. Fill in the form:
|
||||
- **Platforms**: iOS
|
||||
- **Name**: Meshcore Open
|
||||
- **Primary Language**: English (U.S.)
|
||||
- **Bundle ID**: Select `com.monitormx.meshcoreopen` from dropdown
|
||||
- **SKU**: `meshcore-open-001` (or any unique identifier)
|
||||
- **User Access**: Full Access
|
||||
6. Click **"Create"**
|
||||
|
||||
## Step 3: Build the IPA
|
||||
|
||||
Run these commands from the project directory:
|
||||
|
||||
```bash
|
||||
# Add CocoaPods to PATH
|
||||
export PATH="/opt/homebrew/lib/ruby/gems/4.0.0/bin:$PATH"
|
||||
|
||||
# Clean previous builds
|
||||
../flutter/bin/flutter clean
|
||||
|
||||
# Build IPA for App Store
|
||||
../flutter/bin/flutter build ipa
|
||||
```
|
||||
|
||||
The IPA will be created at: `build/ios/ipa/meshcore_open.ipa`
|
||||
|
||||
## Step 4: Upload to App Store Connect via Transporter
|
||||
|
||||
1. **Open Apple Transporter**
|
||||
- Launch from Applications folder
|
||||
- Sign in with your Apple ID
|
||||
|
||||
2. **Upload the IPA**
|
||||
- Drag and drop `build/ios/ipa/meshcore_open.ipa` into Transporter
|
||||
- Click **"Deliver"**
|
||||
- Wait for upload to complete (usually 1-5 minutes)
|
||||
|
||||
3. **Processing**
|
||||
- Apple will process your build (10-30 minutes)
|
||||
- You'll receive an email when processing is complete
|
||||
|
||||
## Step 5: Configure App Store Connect Metadata
|
||||
|
||||
### App Information
|
||||
1. In App Store Connect, go to your app
|
||||
2. Fill in required information:
|
||||
- **Subtitle**: Short description (30 chars max)
|
||||
- **Privacy Policy URL**: Required for Bluetooth apps
|
||||
- **Category**: Utilities or Productivity
|
||||
- **Age Rating**: Complete questionnaire
|
||||
|
||||
### App Store Listing
|
||||
1. Go to **App Store** tab
|
||||
2. Upload **Screenshots** (required):
|
||||
- iPhone 6.7" display (1290 x 2796 pixels) - At least 1 screenshot
|
||||
- iPhone 6.5" display (1242 x 2688 pixels) - At least 1 screenshot
|
||||
- Optional: iPad screenshots
|
||||
|
||||
3. Fill in **Description**:
|
||||
```
|
||||
Meshcore Open is a Flutter client for MeshCore LoRa mesh networking devices.
|
||||
|
||||
Features:
|
||||
- BLE connectivity to MeshCore devices
|
||||
- Real-time mesh network communication
|
||||
- Map visualization with OpenStreetMap
|
||||
- Community management with QR code scanning
|
||||
- Message tracking and retry system
|
||||
|
||||
Connect to your MeshCore LoRa device and start communicating over the mesh network.
|
||||
```
|
||||
|
||||
4. **Keywords**: `lora,mesh,networking,bluetooth,communication`
|
||||
5. **Support URL**: Your GitHub or website URL
|
||||
6. **Marketing URL**: (Optional)
|
||||
|
||||
### Version Information
|
||||
1. **What's New in This Version**:
|
||||
```
|
||||
Initial release of Meshcore Open
|
||||
|
||||
- BLE device connectivity
|
||||
- Mesh network messaging
|
||||
- Map integration
|
||||
- Community features
|
||||
```
|
||||
|
||||
2. **Build**: Select the uploaded build once processing completes
|
||||
|
||||
## Step 6: TestFlight Setup
|
||||
|
||||
### Internal Testing (No Review Required)
|
||||
1. Go to **TestFlight** tab in App Store Connect
|
||||
2. Click **Internal Testing** → **"+"** to create a group
|
||||
3. Name your group (e.g., "Internal Testers")
|
||||
4. Add yourself as a tester using your email
|
||||
5. Select the build you uploaded
|
||||
6. Testers will receive an email with TestFlight invitation
|
||||
|
||||
### External Testing (Requires Beta Review)
|
||||
1. Click **External Testing** → **"+"** to create a group
|
||||
2. Add build and testers
|
||||
3. Fill in **Test Information**:
|
||||
- **What to Test**: Brief description of features
|
||||
- **Feedback Email**: Your email address
|
||||
4. Click **Submit for Review**
|
||||
5. Beta review typically takes 24-48 hours
|
||||
|
||||
## Step 7: App Store Submission
|
||||
|
||||
Once you're ready for public release:
|
||||
|
||||
1. Go to **App Store** tab
|
||||
2. Complete all required metadata (if not done)
|
||||
3. Select your build
|
||||
4. Fill in **App Review Information**:
|
||||
- **Contact Information**: Your name, phone, email
|
||||
- **Demo Account**: If app requires login
|
||||
- **Notes**: Any special instructions for reviewers
|
||||
5. Answer **Export Compliance** questions:
|
||||
- Does your app use encryption? **Yes** (uses TLS/HTTPS)
|
||||
- Is encryption registration required? **No** (standard encryption)
|
||||
6. Click **Add for Review**
|
||||
7. Review summary and click **Submit to App Review**
|
||||
|
||||
## Step 8: After Submission
|
||||
|
||||
- **App Review**: Typically 24-48 hours
|
||||
- **Common Rejection Reasons**:
|
||||
- Missing privacy policy
|
||||
- Incomplete app information
|
||||
- Crashes or bugs
|
||||
- Misleading app description
|
||||
|
||||
- **If Approved**: You can release immediately or schedule a release date
|
||||
- **If Rejected**: Address issues and resubmit
|
||||
|
||||
## Updating the App
|
||||
|
||||
When you need to release an update:
|
||||
|
||||
1. **Update version** in `pubspec.yaml`:
|
||||
```yaml
|
||||
version: 0.5.0+6 # Increment version (0.5.0) and build number (+6)
|
||||
```
|
||||
|
||||
2. **Build new IPA**:
|
||||
```bash
|
||||
export PATH="/opt/homebrew/lib/ruby/gems/4.0.0/bin:$PATH"
|
||||
../flutter/bin/flutter clean
|
||||
../flutter/bin/flutter build ipa
|
||||
```
|
||||
|
||||
3. **Upload via Transporter** (same process as above)
|
||||
|
||||
4. **Create new version** in App Store Connect:
|
||||
- Click **"+"** next to versions
|
||||
- Select version number
|
||||
- Update "What's New" text
|
||||
- Select new build
|
||||
- Submit for review
|
||||
|
||||
## macOS Build (Bonus)
|
||||
|
||||
To build for macOS:
|
||||
|
||||
```bash
|
||||
export PATH="/opt/homebrew/lib/ruby/gems/4.0.0/bin:$PATH"
|
||||
../flutter/bin/flutter build macos --release
|
||||
cd build/macos/Build/Products/Release
|
||||
zip -r meshcore_open-macos.zip meshcore_open.app
|
||||
```
|
||||
|
||||
Distribution:
|
||||
- Share the zip file directly
|
||||
- Users unzip and drag to Applications
|
||||
- First run: Right-click → Open (to bypass Gatekeeper)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Build Errors
|
||||
- **CocoaPods not found**: Ensure PATH includes `/opt/homebrew/lib/ruby/gems/4.0.0/bin`
|
||||
- **No signing certificate**: Configure Team in Xcode (Signing & Capabilities)
|
||||
- **Bundle ID mismatch**: Check `ios/Runner.xcodeproj/project.pbxproj`
|
||||
|
||||
### Upload Errors
|
||||
- **No profiles found**: Create app in App Store Connect first
|
||||
- **Bundle ID not registered**: Register in Apple Developer portal
|
||||
- **Authentication failed**: Use Transporter app instead of CLI
|
||||
|
||||
### TestFlight Issues
|
||||
- **Build not appearing**: Wait 10-30 minutes for processing
|
||||
- **Can't add testers**: Check you have available slots (100 internal, 10,000 external)
|
||||
- **TestFlight crashes**: Check device logs in Xcode → Devices & Simulators
|
||||
|
||||
## Important Files
|
||||
|
||||
- **iOS IPA**: `build/ios/ipa/meshcore_open.ipa`
|
||||
- **macOS App**: `build/macos/Build/Products/Release/meshcore_open.app`
|
||||
- **Bundle ID Config**: `ios/Runner.xcodeproj/project.pbxproj`
|
||||
- **Version Info**: `pubspec.yaml`
|
||||
|
||||
## Useful Links
|
||||
|
||||
- [App Store Connect](https://appstoreconnect.apple.com)
|
||||
- [Apple Developer Portal](https://developer.apple.com/account)
|
||||
- [TestFlight Documentation](https://developer.apple.com/testflight/)
|
||||
- [App Store Review Guidelines](https://developer.apple.com/app-store/review/guidelines/)
|
||||
- [Flutter iOS Deployment](https://docs.flutter.dev/deployment/ios)
|
||||
|
||||
## Support
|
||||
|
||||
For issues with:
|
||||
- **App Store Process**: [Apple Developer Support](https://developer.apple.com/contact/)
|
||||
- **Flutter Build Issues**: [Flutter GitHub](https://github.com/flutter/flutter/issues)
|
||||
- **Meshcore Open App**: [GitHub Issues](https://github.com/wel97459/meshcore-open/issues)
|
||||
@@ -1,3 +1,5 @@
|
||||
import java.util.Properties
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("kotlin-android")
|
||||
@@ -5,6 +7,12 @@ plugins {
|
||||
id("dev.flutter.flutter-gradle-plugin")
|
||||
}
|
||||
|
||||
val keystoreProperties = Properties()
|
||||
val keystorePropertiesFile = rootProject.file("key.properties")
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
keystorePropertiesFile.inputStream().use { keystoreProperties.load(it) }
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.meshcore.meshcore_open"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
@@ -40,11 +48,25 @@ android {
|
||||
// }
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
create("release") {
|
||||
val storeFilePath = keystoreProperties["storeFile"] as String?
|
||||
if (storeFilePath != null) {
|
||||
storeFile = file(storeFilePath)
|
||||
storePassword = keystoreProperties["storePassword"] as String?
|
||||
keyAlias = keystoreProperties["keyAlias"] as String?
|
||||
keyPassword = keystoreProperties["keyPassword"] as String?
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
// TODO: Add your own signing config for the release build.
|
||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
signingConfig = if (keystorePropertiesFile.exists()) {
|
||||
signingConfigs.getByName("release")
|
||||
} else {
|
||||
signingConfigs.getByName("debug")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,5 +83,5 @@ flutter {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4")
|
||||
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
|
||||
}
|
||||
|
||||
@@ -16,6 +16,9 @@
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE"/>
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK"/>
|
||||
<!-- Camera permission for QR code scanning -->
|
||||
<uses-permission android:name="android.permission.CAMERA"/>
|
||||
<uses-feature android:name="android.hardware.camera" android:required="false"/>
|
||||
|
||||
<application
|
||||
android:label="meshcore_open"
|
||||
@@ -64,5 +67,14 @@
|
||||
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||
<data android:mimeType="text/plain"/>
|
||||
</intent>
|
||||
<!-- URL launcher intents for opening links -->
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
<data android:scheme="http"/>
|
||||
</intent>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
<data android:scheme="https"/>
|
||||
</intent>
|
||||
</queries>
|
||||
</manifest>
|
||||
|
||||
File diff suppressed because one or more lines are too long
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 579 KiB |
@@ -0,0 +1,104 @@
|
||||
# Privacy Policy for MeshCore Open
|
||||
|
||||
**Last Updated:** January 11, 2026
|
||||
|
||||
## Introduction
|
||||
|
||||
MeshCore Open ("the App") is an open-source Flutter application for communicating with MeshCore LoRa mesh networking devices. This Privacy Policy explains how the App handles your information.
|
||||
|
||||
## Data Collection
|
||||
|
||||
### Data We Do NOT Collect
|
||||
|
||||
MeshCore Open does **not**:
|
||||
- Collect personal information
|
||||
- Send data to external servers (except map tile requests)
|
||||
- Track your usage or behavior
|
||||
- Use analytics services
|
||||
- Require account creation
|
||||
- Share any data with third parties
|
||||
|
||||
### Data Stored Locally on Your Device
|
||||
|
||||
The App stores the following data **locally on your device only**:
|
||||
|
||||
- **Messages**: Chat messages sent and received through the mesh network
|
||||
- **Contacts**: Names and identifiers of mesh network contacts
|
||||
- **App Settings**: Your preferences (theme, language, notification settings)
|
||||
- **Channel Settings**: Configuration for mesh network channels
|
||||
- **Message History**: Path history for message routing
|
||||
- **Debug Logs**: Optional BLE and app debug logs (if enabled by user)
|
||||
- **Cached Map Tiles**: Offline map data for the mapping feature
|
||||
|
||||
All locally stored data remains on your device and is never transmitted to us or any third party.
|
||||
|
||||
## Permissions
|
||||
|
||||
The App requires certain device permissions to function:
|
||||
|
||||
### Bluetooth Permissions
|
||||
- **BLUETOOTH, BLUETOOTH_ADMIN** (Android 11 and below)
|
||||
- **BLUETOOTH_SCAN, BLUETOOTH_CONNECT, BLUETOOTH_ADVERTISE** (Android 12+)
|
||||
|
||||
These permissions are used solely to discover and communicate with MeshCore hardware devices via Bluetooth Low Energy (BLE).
|
||||
|
||||
### Location Permission
|
||||
- **ACCESS_FINE_LOCATION, ACCESS_COARSE_LOCATION**
|
||||
|
||||
Required by Android for BLE scanning on Android 11 and below. The App does not track or store your location. Location data may be optionally shared over the mesh network if you choose to enable location sharing features.
|
||||
|
||||
### Internet Permission
|
||||
- **INTERNET**
|
||||
|
||||
Used only for downloading map tiles from OpenStreetMap tile servers when using the map feature. No personal data is transmitted.
|
||||
|
||||
### Notification Permission
|
||||
- **POST_NOTIFICATIONS** (Android 13+)
|
||||
|
||||
Used to display notifications for incoming messages when the app is in the background.
|
||||
|
||||
### Background Service Permissions
|
||||
- **FOREGROUND_SERVICE, FOREGROUND_SERVICE_CONNECTED_DEVICE, WAKE_LOCK**
|
||||
|
||||
Used to maintain BLE connection with your MeshCore device while the app is in the background.
|
||||
|
||||
## Third-Party Services
|
||||
|
||||
### Map Tiles
|
||||
The App uses OpenStreetMap tile servers to display maps. When viewing maps, your device's IP address may be visible to the tile server. No other data is shared. See [OpenStreetMap's Privacy Policy](https://wiki.osmfoundation.org/wiki/Privacy_Policy) for more information.
|
||||
|
||||
### GIF Search (Giphy)
|
||||
The App includes a GIF picker feature powered by Giphy. When you use the GIF search feature:
|
||||
- Your search queries are sent to Giphy's API servers
|
||||
- Your device's IP address is visible to Giphy
|
||||
- Giphy may collect usage data according to their privacy policy
|
||||
|
||||
GIF search is optional and only activated when you choose to use it. See [Giphy's Privacy Policy](https://support.giphy.com/hc/en-us/articles/360032872931-GIPHY-Privacy-Policy) for more information about how they handle data.
|
||||
|
||||
## Mesh Network Communications
|
||||
|
||||
Messages sent through the MeshCore mesh network are transmitted over radio frequencies to other mesh devices. The App itself does not control or monitor these communications beyond facilitating the connection between your mobile device and your MeshCore hardware.
|
||||
|
||||
## Data Security
|
||||
|
||||
All data is stored locally on your device using standard Flutter/Android storage mechanisms. The App does not implement additional encryption for locally stored data beyond what the operating system provides.
|
||||
|
||||
## Children's Privacy
|
||||
|
||||
The App does not knowingly collect any personal information from children under 13 years of age.
|
||||
|
||||
## Open Source
|
||||
|
||||
MeshCore Open is open-source software. You can review the complete source code to verify these privacy practices at [the project repository].
|
||||
|
||||
## Changes to This Policy
|
||||
|
||||
We may update this Privacy Policy from time to time. Any changes will be reflected in the "Last Updated" date at the top of this policy.
|
||||
|
||||
## Contact
|
||||
|
||||
If you have questions about this Privacy Policy or the App's privacy practices, please open an issue on the project's GitHub repository.
|
||||
|
||||
---
|
||||
|
||||
**Summary**: MeshCore Open is a privacy-respecting app that stores all data locally on your device. We do not collect, track, or share your personal information.
|
||||
Generated
+61
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"nodes": {
|
||||
"flake-utils": {
|
||||
"inputs": {
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1731533236,
|
||||
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1770562336,
|
||||
"narHash": "sha256-ub1gpAONMFsT/GU2hV6ZWJjur8rJ6kKxdm9IlCT0j84=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "d6c71932130818840fc8fe9509cf50be8c64634f",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nixos",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
{
|
||||
description = "MeshCore Flutter Application";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
};
|
||||
|
||||
outputs = { self, nixpkgs, flake-utils }:
|
||||
flake-utils.lib.eachDefaultSystem (system:
|
||||
let
|
||||
pkgs = nixpkgs.legacyPackages.${system};
|
||||
in
|
||||
{
|
||||
devShells.default = pkgs.mkShell {
|
||||
buildInputs = with pkgs; [
|
||||
# Flutter and Dart
|
||||
flutter
|
||||
dart
|
||||
|
||||
# Java (required for Android development)
|
||||
jdk17
|
||||
|
||||
# Android development tools
|
||||
android-tools
|
||||
gradle
|
||||
|
||||
# For the shell hook to set up the environment for Flutter development
|
||||
gtk3
|
||||
glib
|
||||
sysprof
|
||||
libclang
|
||||
cmake
|
||||
ninja
|
||||
pkg-config
|
||||
libdatrie
|
||||
|
||||
# Additional tools for installing Android SDK if not present
|
||||
curl
|
||||
unzip
|
||||
];
|
||||
|
||||
shellHook = ''
|
||||
echo "MeshCore Flutter Development Environment"
|
||||
export PKG_CONFIG_PATH="${pkgs.gtk3}/lib/pkgconfig:${pkgs.glib}/lib/pkgconfig:${pkgs.sysprof}/lib/pkgconfig:$PKG_CONFIG_PATH"
|
||||
export LD_LIBRARY_PATH="${pkgs.lib.makeLibraryPath [pkgs.gtk3 pkgs.glib pkgs.sysprof pkgs.libdatrie]}:$LD_LIBRARY_PATH"
|
||||
export CMAKE_INSTALL_PREFIX="$PWD/build/bundle"
|
||||
|
||||
# Setup Android SDK in home directory (standard location)
|
||||
export ANDROID_HOME="$HOME/Android/Sdk"
|
||||
export ANDROID_SDK_ROOT="$ANDROID_HOME"
|
||||
export PATH="$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:$ANDROID_HOME/tools/bin:$PATH"
|
||||
|
||||
echo "Android SDK: $ANDROID_HOME"
|
||||
echo ""
|
||||
|
||||
# Check if Android SDK exists and offer to download if not
|
||||
if [ ! -d "$ANDROID_HOME" ]; then
|
||||
echo "WARNING: Android SDK not found at $ANDROID_HOME"
|
||||
echo ""
|
||||
echo "To download and set up the Android SDK, run this command:"
|
||||
echo ""
|
||||
cat << 'EOF'
|
||||
mkdir -p ~/Android/Sdk && cd ~/Android/Sdk && \
|
||||
curl -o cmdline-tools.zip ${if pkgs.stdenv.isDarwin then "https://dl.google.com/android/repository/commandlinetools-mac-10406996_latest.zip" else "https://dl.google.com/android/repository/commandlinetools-linux-10406996_latest.zip"} && \
|
||||
unzip -q cmdline-tools.zip && \
|
||||
mkdir -p cmdline-tools/latest && \
|
||||
mv cmdline-tools/* cmdline-tools/latest/ 2>/dev/null || echo "Warning: failed to move Android cmdline-tools into 'latest' directory; please check your SDK layout." >&2 && \
|
||||
rm cmdline-tools.zip && \
|
||||
cd cmdline-tools/latest/bin && \
|
||||
yes | ./sdkmanager --sdk_root=~/Android/Sdk 'platform-tools' && \
|
||||
echo "Android SDK setup complete!"
|
||||
EOF
|
||||
echo ""
|
||||
echo "Then run 'flutter doctor' again to verify."
|
||||
echo ""
|
||||
else
|
||||
echo "Android SDK found at $ANDROID_HOME"
|
||||
fi
|
||||
|
||||
echo "To check that everything is set up correctly, run 'flutter doctor' and ensure there are no issues."
|
||||
'';
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
|
||||
#include "Generated.xcconfig"
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
|
||||
#include "Generated.xcconfig"
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
platform :ios, '12.0'
|
||||
platform :ios, '15.5'
|
||||
|
||||
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
|
||||
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
PODS:
|
||||
- Flutter (1.0.0)
|
||||
- flutter_blue_plus_darwin (0.0.2):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- flutter_foreground_task (0.0.1):
|
||||
- Flutter
|
||||
- flutter_local_notifications (0.0.1):
|
||||
- Flutter
|
||||
- GoogleDataTransport (10.1.0):
|
||||
- nanopb (~> 3.30910.0)
|
||||
- PromisesObjC (~> 2.4)
|
||||
- GoogleMLKit/BarcodeScanning (7.0.0):
|
||||
- GoogleMLKit/MLKitCore
|
||||
- MLKitBarcodeScanning (~> 6.0.0)
|
||||
- GoogleMLKit/MLKitCore (7.0.0):
|
||||
- MLKitCommon (~> 12.0.0)
|
||||
- GoogleToolboxForMac/Defines (4.2.1)
|
||||
- GoogleToolboxForMac/Logger (4.2.1):
|
||||
- GoogleToolboxForMac/Defines (= 4.2.1)
|
||||
- "GoogleToolboxForMac/NSData+zlib (4.2.1)":
|
||||
- GoogleToolboxForMac/Defines (= 4.2.1)
|
||||
- GoogleUtilities/Environment (8.1.0):
|
||||
- GoogleUtilities/Privacy
|
||||
- GoogleUtilities/Logger (8.1.0):
|
||||
- GoogleUtilities/Environment
|
||||
- GoogleUtilities/Privacy
|
||||
- GoogleUtilities/Privacy (8.1.0)
|
||||
- GoogleUtilities/UserDefaults (8.1.0):
|
||||
- GoogleUtilities/Logger
|
||||
- GoogleUtilities/Privacy
|
||||
- GTMSessionFetcher/Core (3.5.0)
|
||||
- MLImage (1.0.0-beta6)
|
||||
- MLKitBarcodeScanning (6.0.0):
|
||||
- MLKitCommon (~> 12.0)
|
||||
- MLKitVision (~> 8.0)
|
||||
- MLKitCommon (12.0.0):
|
||||
- GoogleDataTransport (~> 10.0)
|
||||
- GoogleToolboxForMac/Logger (< 5.0, >= 4.2.1)
|
||||
- "GoogleToolboxForMac/NSData+zlib (< 5.0, >= 4.2.1)"
|
||||
- GoogleUtilities/Logger (~> 8.0)
|
||||
- GoogleUtilities/UserDefaults (~> 8.0)
|
||||
- GTMSessionFetcher/Core (< 4.0, >= 3.3.2)
|
||||
- MLKitVision (8.0.0):
|
||||
- GoogleToolboxForMac/Logger (< 5.0, >= 4.2.1)
|
||||
- "GoogleToolboxForMac/NSData+zlib (< 5.0, >= 4.2.1)"
|
||||
- GTMSessionFetcher/Core (< 4.0, >= 3.3.2)
|
||||
- MLImage (= 1.0.0-beta6)
|
||||
- MLKitCommon (~> 12.0)
|
||||
- mobile_scanner (6.0.2):
|
||||
- Flutter
|
||||
- GoogleMLKit/BarcodeScanning (~> 7.0.0)
|
||||
- nanopb (3.30910.0):
|
||||
- nanopb/decode (= 3.30910.0)
|
||||
- nanopb/encode (= 3.30910.0)
|
||||
- nanopb/decode (3.30910.0)
|
||||
- nanopb/encode (3.30910.0)
|
||||
- package_info_plus (0.4.5):
|
||||
- Flutter
|
||||
- PromisesObjC (2.4.0)
|
||||
- shared_preferences_foundation (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- sqflite_darwin (0.0.4):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- url_launcher_ios (0.0.1):
|
||||
- Flutter
|
||||
- wakelock_plus (0.0.1):
|
||||
- Flutter
|
||||
|
||||
DEPENDENCIES:
|
||||
- Flutter (from `Flutter`)
|
||||
- flutter_blue_plus_darwin (from `.symlinks/plugins/flutter_blue_plus_darwin/darwin`)
|
||||
- flutter_foreground_task (from `.symlinks/plugins/flutter_foreground_task/ios`)
|
||||
- flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`)
|
||||
- mobile_scanner (from `.symlinks/plugins/mobile_scanner/ios`)
|
||||
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
|
||||
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
|
||||
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
|
||||
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
|
||||
- wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`)
|
||||
|
||||
SPEC REPOS:
|
||||
trunk:
|
||||
- GoogleDataTransport
|
||||
- GoogleMLKit
|
||||
- GoogleToolboxForMac
|
||||
- GoogleUtilities
|
||||
- GTMSessionFetcher
|
||||
- MLImage
|
||||
- MLKitBarcodeScanning
|
||||
- MLKitCommon
|
||||
- MLKitVision
|
||||
- nanopb
|
||||
- PromisesObjC
|
||||
|
||||
EXTERNAL SOURCES:
|
||||
Flutter:
|
||||
:path: Flutter
|
||||
flutter_blue_plus_darwin:
|
||||
:path: ".symlinks/plugins/flutter_blue_plus_darwin/darwin"
|
||||
flutter_foreground_task:
|
||||
:path: ".symlinks/plugins/flutter_foreground_task/ios"
|
||||
flutter_local_notifications:
|
||||
:path: ".symlinks/plugins/flutter_local_notifications/ios"
|
||||
mobile_scanner:
|
||||
:path: ".symlinks/plugins/mobile_scanner/ios"
|
||||
package_info_plus:
|
||||
:path: ".symlinks/plugins/package_info_plus/ios"
|
||||
shared_preferences_foundation:
|
||||
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
|
||||
sqflite_darwin:
|
||||
:path: ".symlinks/plugins/sqflite_darwin/darwin"
|
||||
url_launcher_ios:
|
||||
:path: ".symlinks/plugins/url_launcher_ios/ios"
|
||||
wakelock_plus:
|
||||
:path: ".symlinks/plugins/wakelock_plus/ios"
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
||||
flutter_blue_plus_darwin: 20a08bfeaa0f7804d524858d3d8744bcc1b6dbc3
|
||||
flutter_foreground_task: a159d2c2173b33699ddb3e6c2a067045d7cebb89
|
||||
flutter_local_notifications: 395056b3175ba4f08480a7c5de30cd36d69827e4
|
||||
GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
|
||||
GoogleMLKit: eff9e23ec1d90ea4157a1ee2e32a4f610c5b3318
|
||||
GoogleToolboxForMac: d1a2cbf009c453f4d6ded37c105e2f67a32206d8
|
||||
GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1
|
||||
GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6
|
||||
MLImage: 0ad1c5f50edd027672d8b26b0fee78a8b4a0fc56
|
||||
MLKitBarcodeScanning: 0a3064da0a7f49ac24ceb3cb46a5bc67496facd2
|
||||
MLKitCommon: 07c2c33ae5640e5380beaaa6e4b9c249a205542d
|
||||
MLKitVision: 45e79d68845a2de77e2dd4d7f07947f0ed157b0e
|
||||
mobile_scanner: af8f71879eaba2bbcb4d86c6a462c3c0e7f23036
|
||||
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
|
||||
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
|
||||
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
|
||||
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
|
||||
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
|
||||
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
|
||||
wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556
|
||||
|
||||
PODFILE CHECKSUM: 570da2a631486c6bd6496bed1e605e63e2471be5
|
||||
|
||||
COCOAPODS: 1.16.2
|
||||
@@ -14,6 +14,7 @@
|
||||
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
|
||||
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
||||
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
|
||||
9A698254711B63C3940A64CB /* libPods-Runner.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 4268181FCF3E12817B700E9C /* libPods-Runner.a */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
@@ -42,9 +43,13 @@
|
||||
/* Begin PBXFileReference section */
|
||||
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
|
||||
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
|
||||
24A76623340E493BD4C25C5C /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
|
||||
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
|
||||
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
|
||||
40AC50CE3E1D4278E82498CF /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
4268181FCF3E12817B700E9C /* libPods-Runner.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Runner.a"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
718BC7DCCFC5C370705C12E5 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
|
||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
|
||||
@@ -62,6 +67,7 @@
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
9A698254711B63C3940A64CB /* libPods-Runner.a in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -94,6 +100,8 @@
|
||||
97C146F01CF9000F007C117D /* Runner */,
|
||||
97C146EF1CF9000F007C117D /* Products */,
|
||||
331C8082294A63A400263BE5 /* RunnerTests */,
|
||||
DEE6F094D3B70E76087722E1 /* Pods */,
|
||||
DAE613E34DF694C2E33B64C7 /* Frameworks */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -121,6 +129,25 @@
|
||||
path = Runner;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
DAE613E34DF694C2E33B64C7 /* Frameworks */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
4268181FCF3E12817B700E9C /* libPods-Runner.a */,
|
||||
);
|
||||
name = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
DEE6F094D3B70E76087722E1 /* Pods */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
40AC50CE3E1D4278E82498CF /* Pods-Runner.debug.xcconfig */,
|
||||
24A76623340E493BD4C25C5C /* Pods-Runner.release.xcconfig */,
|
||||
718BC7DCCFC5C370705C12E5 /* Pods-Runner.profile.xcconfig */,
|
||||
);
|
||||
name = Pods;
|
||||
path = Pods;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
@@ -145,12 +172,14 @@
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
|
||||
buildPhases = (
|
||||
DE3B2E091393835C0B38492E /* [CP] Check Pods Manifest.lock */,
|
||||
9740EEB61CF901F6004384FC /* Run Script */,
|
||||
97C146EA1CF9000F007C117D /* Sources */,
|
||||
97C146EB1CF9000F007C117D /* Frameworks */,
|
||||
97C146EC1CF9000F007C117D /* Resources */,
|
||||
9705A1C41CF9048500538489 /* Embed Frameworks */,
|
||||
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
|
||||
B788CEDB957A87EE8AC593BB /* [CP] Copy Pods Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
@@ -253,6 +282,45 @@
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
|
||||
};
|
||||
B788CEDB957A87EE8AC593BB /* [CP] Copy Pods Resources */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
|
||||
);
|
||||
name = "[CP] Copy Pods Resources";
|
||||
outputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
DE3B2E091393835C0B38492E /* [CP] Check Pods Manifest.lock */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
);
|
||||
inputPaths = (
|
||||
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
|
||||
"${PODS_ROOT}/Manifest.lock",
|
||||
);
|
||||
name = "[CP] Check Pods Manifest.lock";
|
||||
outputFileListPaths = (
|
||||
);
|
||||
outputPaths = (
|
||||
"$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
/* End PBXShellScriptBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
@@ -368,7 +436,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.meshcore.meshcoreOpen;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.monitormx.meshcoreopen;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
SWIFT_VERSION = 5.0;
|
||||
@@ -384,7 +452,7 @@
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.meshcore.meshcoreOpen.RunnerTests;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.monitormx.meshcoreopen.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
@@ -401,7 +469,7 @@
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.meshcore.meshcoreOpen.RunnerTests;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.monitormx.meshcoreopen.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||
@@ -416,7 +484,7 @@
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.meshcore.meshcoreOpen.RunnerTests;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.monitormx.meshcoreopen.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||
@@ -547,7 +615,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.meshcore.meshcoreOpen;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.monitormx.meshcoreopen;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
@@ -569,7 +637,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.meshcore.meshcoreOpen;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.monitormx.meshcoreopen;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
SWIFT_VERSION = 5.0;
|
||||
|
||||
+3
@@ -4,4 +4,7 @@
|
||||
<FileRef
|
||||
location = "group:Runner.xcodeproj">
|
||||
</FileRef>
|
||||
<FileRef
|
||||
location = "group:Pods/Pods.xcodeproj">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
|
||||
@@ -53,5 +53,12 @@
|
||||
<string>This app uses Bluetooth to communicate with MeshCore devices.</string>
|
||||
<key>NSBluetoothPeripheralUsageDescription</key>
|
||||
<string>This app uses Bluetooth to communicate with MeshCore devices.</string>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>This app uses the camera to scan QR codes for joining communities.</string>
|
||||
<key>LSApplicationQueriesSchemes</key>
|
||||
<array>
|
||||
<string>http</string>
|
||||
<string>https</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
arb-dir: lib/l10n
|
||||
template-arb-file: app_en.arb
|
||||
output-localization-file: app_localizations.dart
|
||||
output-class: AppLocalizations
|
||||
nullable-getter: false
|
||||
untranslated-messages-file: untranslated.json
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,128 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
// Buffer Reader - sequential binary data reader with pointer tracking
|
||||
class BufferReader {
|
||||
int _pointer = 0;
|
||||
final Uint8List _buffer;
|
||||
|
||||
BufferReader(Uint8List data) : _buffer = Uint8List.fromList(data);
|
||||
|
||||
int get remaining => _buffer.length - _pointer;
|
||||
|
||||
int readByte() => readBytes(1)[0];
|
||||
|
||||
Uint8List readBytes(int count) {
|
||||
final data = _buffer.sublist(_pointer, _pointer + count);
|
||||
_pointer += count;
|
||||
return data;
|
||||
}
|
||||
|
||||
void skipBytes(int count) {
|
||||
_pointer += count;
|
||||
}
|
||||
|
||||
Uint8List readRemainingBytes() => readBytes(remaining);
|
||||
|
||||
String readString() =>
|
||||
utf8.decode(readRemainingBytes(), allowMalformed: true);
|
||||
|
||||
String readCString(int maxLength) {
|
||||
final value = <int>[];
|
||||
final bytes = readBytes(maxLength);
|
||||
for (final byte in bytes) {
|
||||
if (byte == 0) break;
|
||||
value.add(byte);
|
||||
}
|
||||
try {
|
||||
return utf8.decode(Uint8List.fromList(value), allowMalformed: true);
|
||||
} catch (e) {
|
||||
return String.fromCharCodes(value); // Latin-1 fallback
|
||||
}
|
||||
}
|
||||
|
||||
int readUInt8() => readBytes(1).buffer.asByteData().getUint8(0);
|
||||
int readInt8() => readBytes(1).buffer.asByteData().getInt8(0);
|
||||
int readUInt16LE() =>
|
||||
readBytes(2).buffer.asByteData().getUint16(0, Endian.little);
|
||||
int readUInt16BE() =>
|
||||
readBytes(2).buffer.asByteData().getUint16(0, Endian.big);
|
||||
int readUInt32LE() =>
|
||||
readBytes(4).buffer.asByteData().getUint32(0, Endian.little);
|
||||
int readUInt32BE() =>
|
||||
readBytes(4).buffer.asByteData().getUint32(0, Endian.big);
|
||||
int readInt16LE() =>
|
||||
readBytes(2).buffer.asByteData().getInt16(0, Endian.little);
|
||||
int readInt16BE() => readBytes(2).buffer.asByteData().getInt16(0, Endian.big);
|
||||
int readInt32LE() =>
|
||||
readBytes(4).buffer.asByteData().getInt32(0, Endian.little);
|
||||
|
||||
int readInt24BE() {
|
||||
var value = (readByte() << 16) | (readByte() << 8) | readByte();
|
||||
if ((value & 0x800000) != 0) value -= 0x1000000;
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
// Buffer Writer - accumulating binary data builder
|
||||
class BufferWriter {
|
||||
final BytesBuilder _builder = BytesBuilder();
|
||||
|
||||
Uint8List toBytes() => _builder.toBytes();
|
||||
|
||||
void writeByte(int byte) => _builder.addByte(byte);
|
||||
void writeBytes(Uint8List bytes) => _builder.add(bytes);
|
||||
|
||||
void writeUInt16LE(int num) {
|
||||
final bytes = Uint8List(2)
|
||||
..buffer.asByteData().setUint16(0, num, Endian.little);
|
||||
writeBytes(bytes);
|
||||
}
|
||||
|
||||
void writeUInt32LE(int num) {
|
||||
final bytes = Uint8List(4)
|
||||
..buffer.asByteData().setUint32(0, num, Endian.little);
|
||||
writeBytes(bytes);
|
||||
}
|
||||
|
||||
void writeInt32LE(int num) {
|
||||
final bytes = Uint8List(4)
|
||||
..buffer.asByteData().setInt32(0, num, Endian.little);
|
||||
writeBytes(bytes);
|
||||
}
|
||||
|
||||
void writeString(String string) =>
|
||||
writeBytes(Uint8List.fromList(utf8.encode(string)));
|
||||
|
||||
void writeCString(String string, int maxLength) {
|
||||
final bytes = Uint8List(maxLength);
|
||||
final encoded = utf8.encode(string);
|
||||
for (var i = 0; i < maxLength - 1 && i < encoded.length; i++) {
|
||||
bytes[i] = encoded[i];
|
||||
}
|
||||
writeBytes(bytes);
|
||||
}
|
||||
|
||||
void writeHex(String hex) {
|
||||
// Validate hex string length is even and not empty
|
||||
if (hex.isEmpty || hex.length % 2 != 0) {
|
||||
throw FormatException('Invalid hex string length: ${hex.length}');
|
||||
}
|
||||
List<int> result = [];
|
||||
for (int i = 0; i < hex.length ~/ 2; i++) {
|
||||
final hexByte = hex.substring(i * 2, i * 2 + 2);
|
||||
final byte = int.tryParse(hexByte, radix: 16);
|
||||
if (byte == null) {
|
||||
throw FormatException(
|
||||
'Invalid hex characters at position $i: $hexByte',
|
||||
);
|
||||
}
|
||||
result.add(byte);
|
||||
}
|
||||
writeBytes(Uint8List.fromList(result));
|
||||
}
|
||||
}
|
||||
|
||||
// Command codes (to device)
|
||||
const int cmdAppStart = 1;
|
||||
const int cmdSendTxtMsg = 2;
|
||||
@@ -28,7 +150,12 @@ const int cmdSendStatusReq = 27;
|
||||
const int cmdGetContactByKey = 30;
|
||||
const int cmdGetChannel = 31;
|
||||
const int cmdSetChannel = 32;
|
||||
const int cmdSendTracePath = 36;
|
||||
const int cmdGetRadioSettings = 57;
|
||||
const int cmdGetTelemetryReq = 39;
|
||||
const int cmdGetCustomVar = 40;
|
||||
const int cmdSetCustomVar = 41;
|
||||
const int cmdSendBinaryReq = 50;
|
||||
|
||||
// Text message types
|
||||
const int txtTypePlain = 0;
|
||||
@@ -56,12 +183,14 @@ const int respCodeContactMsgRecv = 7;
|
||||
const int respCodeChannelMsgRecv = 8;
|
||||
const int respCodeCurrTime = 9;
|
||||
const int respCodeNoMoreMessages = 10;
|
||||
const int respCodeExportContact = 11;
|
||||
const int respCodeBattAndStorage = 12;
|
||||
const int respCodeDeviceInfo = 13;
|
||||
const int respCodeContactMsgRecvV3 = 16;
|
||||
const int respCodeChannelMsgRecvV3 = 17;
|
||||
const int respCodeChannelInfo = 18;
|
||||
const int respCodeRadioSettings = 25;
|
||||
const int respCodeCustomVars = 21;
|
||||
|
||||
// Push codes (async from device)
|
||||
const int pushCodeAdvert = 0x80;
|
||||
@@ -72,7 +201,10 @@ const int pushCodeLoginSuccess = 0x85;
|
||||
const int pushCodeLoginFail = 0x86;
|
||||
const int pushCodeStatusResponse = 0x87;
|
||||
const int pushCodeLogRxData = 0x88;
|
||||
const int pushCodeTraceData = 0x89;
|
||||
const int pushCodeNewAdvert = 0x8A;
|
||||
const int pushCodeTelemetryResponse = 0x8B;
|
||||
const int pushCodeBinaryResponse = 0x8C;
|
||||
|
||||
// Contact/advertisement types
|
||||
const int advTypeChat = 1;
|
||||
@@ -89,8 +221,10 @@ const int maxFrameSize = 172;
|
||||
const int appProtocolVersion = 3;
|
||||
// Matches firmware MAX_TEXT_LEN (10 * CIPHER_BLOCK_SIZE).
|
||||
const int maxTextPayloadBytes = 160;
|
||||
const int _sendTextMsgOverheadBytes = 1 + 1 + 1 + 4 + 6 + 1;
|
||||
const int _sendChannelTextMsgOverheadBytes = 1 + 1 + 1 + 4 + 1;
|
||||
const int _sendTextMsgOverheadBytes =
|
||||
1 + 1 + 1 + 4 + 6 + 1 + 2; // +2 safety margin
|
||||
const int _sendChannelTextMsgOverheadBytes =
|
||||
1 + 1 + 1 + 4 + 1 + 2; // +2 safety margin
|
||||
|
||||
int maxContactMessageBytes() {
|
||||
final byFrame = maxFrameSize - _sendTextMsgOverheadBytes;
|
||||
@@ -140,10 +274,7 @@ class ParsedContactText {
|
||||
final Uint8List senderPrefix;
|
||||
final String text;
|
||||
|
||||
const ParsedContactText({
|
||||
required this.senderPrefix,
|
||||
required this.text,
|
||||
});
|
||||
const ParsedContactText({required this.senderPrefix, required this.text});
|
||||
}
|
||||
|
||||
ParsedContactText? parseContactMessageText(Uint8List frame) {
|
||||
@@ -172,10 +303,17 @@ ParsedContactText? parseContactMessageText(Uint8List frame) {
|
||||
return null;
|
||||
}
|
||||
|
||||
var text = readCString(frame, baseTextOffset, frame.length - baseTextOffset).trim();
|
||||
var text = readCString(
|
||||
frame,
|
||||
baseTextOffset,
|
||||
frame.length - baseTextOffset,
|
||||
).trim();
|
||||
if (text.isEmpty && frame.length > baseTextOffset + 4) {
|
||||
text =
|
||||
readCString(frame, baseTextOffset + 4, frame.length - (baseTextOffset + 4)).trim();
|
||||
text = readCString(
|
||||
frame,
|
||||
baseTextOffset + 4,
|
||||
frame.length - (baseTextOffset + 4),
|
||||
).trim();
|
||||
}
|
||||
if (text.isEmpty) return null;
|
||||
|
||||
@@ -203,19 +341,6 @@ int readInt32LE(Uint8List data, int offset) {
|
||||
return val;
|
||||
}
|
||||
|
||||
// Helper to write uint32 little-endian
|
||||
void writeUint32LE(Uint8List data, int offset, int value) {
|
||||
data[offset] = value & 0xFF;
|
||||
data[offset + 1] = (value >> 8) & 0xFF;
|
||||
data[offset + 2] = (value >> 16) & 0xFF;
|
||||
data[offset + 3] = (value >> 24) & 0xFF;
|
||||
}
|
||||
|
||||
// Helper to write int32 little-endian
|
||||
void writeInt32LE(Uint8List data, int offset, int value) {
|
||||
writeUint32LE(data, offset, value & 0xFFFFFFFF);
|
||||
}
|
||||
|
||||
// Helper to read null-terminated UTF-8 string
|
||||
String readCString(Uint8List data, int offset, int maxLen) {
|
||||
int end = offset;
|
||||
@@ -246,34 +371,32 @@ Uint8List hexToPubKey(String hex) {
|
||||
|
||||
// Build CMD_GET_CONTACTS frame
|
||||
Uint8List buildGetContactsFrame({int? since}) {
|
||||
final writer = BufferWriter();
|
||||
writer.writeByte(cmdGetContacts);
|
||||
if (since != null) {
|
||||
final frame = Uint8List(5);
|
||||
frame[0] = cmdGetContacts;
|
||||
writeUint32LE(frame, 1, since);
|
||||
return frame;
|
||||
writer.writeUInt32LE(since);
|
||||
}
|
||||
return Uint8List.fromList([cmdGetContacts]);
|
||||
return writer.toBytes();
|
||||
}
|
||||
|
||||
// Build CMD_SEND_LOGIN frame
|
||||
// Format: [cmd][pub_key x32][password...]\0
|
||||
Uint8List buildSendLoginFrame(Uint8List recipientPubKey, String password) {
|
||||
final passwordBytes = utf8.encode(password);
|
||||
final frame = Uint8List(1 + pubKeySize + passwordBytes.length + 1);
|
||||
frame[0] = cmdSendLogin;
|
||||
frame.setRange(1, 1 + pubKeySize, recipientPubKey);
|
||||
frame.setRange(1 + pubKeySize, 1 + pubKeySize + passwordBytes.length, passwordBytes);
|
||||
frame[frame.length - 1] = 0;
|
||||
return frame;
|
||||
final writer = BufferWriter();
|
||||
writer.writeByte(cmdSendLogin);
|
||||
writer.writeBytes(recipientPubKey);
|
||||
writer.writeString(password);
|
||||
writer.writeByte(0);
|
||||
return writer.toBytes();
|
||||
}
|
||||
|
||||
// Build CMD_SEND_STATUS_REQ frame
|
||||
// Format: [cmd][pub_key x32]
|
||||
Uint8List buildSendStatusRequestFrame(Uint8List recipientPubKey) {
|
||||
final frame = Uint8List(1 + pubKeySize);
|
||||
frame[0] = cmdSendStatusReq;
|
||||
frame.setRange(1, 1 + pubKeySize, recipientPubKey);
|
||||
return frame;
|
||||
final writer = BufferWriter();
|
||||
writer.writeByte(cmdSendStatusReq);
|
||||
writer.writeBytes(recipientPubKey);
|
||||
return writer.toBytes();
|
||||
}
|
||||
|
||||
// Build CMD_SEND_TXT_MSG frame (companion_radio format)
|
||||
@@ -284,48 +407,39 @@ Uint8List buildSendTextMsgFrame(
|
||||
int attempt = 0,
|
||||
int? timestampSeconds,
|
||||
}) {
|
||||
final textBytes = utf8.encode(text);
|
||||
final timestamp = timestampSeconds ?? (DateTime.now().millisecondsSinceEpoch ~/ 1000);
|
||||
const prefixSize = 6;
|
||||
final safeAttempt = attempt.clamp(0, 3);
|
||||
final frame = Uint8List(1 + 1 + 1 + 4 + prefixSize + textBytes.length + 1);
|
||||
int offset = 0;
|
||||
|
||||
frame[offset++] = cmdSendTxtMsg;
|
||||
frame[offset++] = txtTypePlain;
|
||||
frame[offset++] = safeAttempt;
|
||||
writeUint32LE(frame, offset, timestamp);
|
||||
offset += 4;
|
||||
|
||||
frame.setRange(offset, offset + prefixSize, recipientPubKey.sublist(0, prefixSize));
|
||||
offset += prefixSize;
|
||||
|
||||
frame.setRange(offset, offset + textBytes.length, textBytes);
|
||||
frame[frame.length - 1] = 0; // null terminator
|
||||
return frame;
|
||||
final timestamp =
|
||||
timestampSeconds ?? (DateTime.now().millisecondsSinceEpoch ~/ 1000);
|
||||
final writer = BufferWriter();
|
||||
writer.writeByte(cmdSendTxtMsg);
|
||||
writer.writeByte(txtTypePlain);
|
||||
writer.writeByte(attempt.clamp(0, 3));
|
||||
writer.writeUInt32LE(timestamp);
|
||||
writer.writeBytes(recipientPubKey.sublist(0, 6));
|
||||
writer.writeString(text);
|
||||
writer.writeByte(0);
|
||||
return writer.toBytes();
|
||||
}
|
||||
|
||||
// Build CMD_SEND_CHANNEL_TXT_MSG frame
|
||||
// Format: [cmd][txt_type][channel_idx][timestamp x4][text...]
|
||||
Uint8List buildSendChannelTextMsgFrame(int channelIndex, String text) {
|
||||
final textBytes = utf8.encode(text);
|
||||
final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||
final frame = Uint8List(1 + 1 + 1 + 4 + textBytes.length + 1);
|
||||
frame[0] = cmdSendChannelTxtMsg;
|
||||
frame[1] = 0; // TXT_TYPE_PLAIN
|
||||
frame[2] = channelIndex;
|
||||
writeUint32LE(frame, 3, timestamp);
|
||||
frame.setRange(7, 7 + textBytes.length, textBytes);
|
||||
frame[frame.length - 1] = 0; // null terminator
|
||||
return frame;
|
||||
final writer = BufferWriter();
|
||||
writer.writeByte(cmdSendChannelTxtMsg);
|
||||
writer.writeByte(txtTypePlain);
|
||||
writer.writeByte(channelIndex);
|
||||
writer.writeUInt32LE(timestamp);
|
||||
writer.writeString(text);
|
||||
writer.writeByte(0);
|
||||
return writer.toBytes();
|
||||
}
|
||||
|
||||
// Build CMD_REMOVE_CONTACT frame
|
||||
Uint8List buildRemoveContactFrame(Uint8List pubKey) {
|
||||
final frame = Uint8List(1 + pubKeySize);
|
||||
frame[0] = cmdRemoveContact;
|
||||
frame.setRange(1, 1 + pubKeySize, pubKey);
|
||||
return frame;
|
||||
final writer = BufferWriter();
|
||||
writer.writeByte(cmdRemoveContact);
|
||||
writer.writeBytes(pubKey);
|
||||
return writer.toBytes();
|
||||
}
|
||||
|
||||
// Build CMD_APP_START frame
|
||||
@@ -334,14 +448,13 @@ Uint8List buildAppStartFrame({
|
||||
String appName = 'MeshCoreOpen',
|
||||
int appVersion = 1,
|
||||
}) {
|
||||
final nameBytes = utf8.encode(appName);
|
||||
final frame = Uint8List(8 + nameBytes.length + 1);
|
||||
frame[0] = cmdAppStart;
|
||||
frame[1] = appVersion;
|
||||
// bytes 2-7 are reserved (zeros)
|
||||
frame.setRange(8, 8 + nameBytes.length, nameBytes);
|
||||
frame[frame.length - 1] = 0; // null terminator
|
||||
return frame;
|
||||
final writer = BufferWriter();
|
||||
writer.writeByte(cmdAppStart);
|
||||
writer.writeByte(appVersion);
|
||||
writer.writeBytes(Uint8List(6)); // reserved bytes
|
||||
writer.writeString(appName);
|
||||
writer.writeByte(0);
|
||||
return writer.toBytes();
|
||||
}
|
||||
|
||||
// Build CMD_DEVICE_QUERY frame
|
||||
@@ -361,10 +474,10 @@ Uint8List buildGetBattAndStorageFrame() {
|
||||
|
||||
// Build CMD_SET_DEVICE_TIME frame
|
||||
Uint8List buildSetDeviceTimeFrame(int timestamp) {
|
||||
final frame = Uint8List(5);
|
||||
frame[0] = cmdSetDeviceTime;
|
||||
writeUint32LE(frame, 1, timestamp);
|
||||
return frame;
|
||||
final writer = BufferWriter();
|
||||
writer.writeByte(cmdSetDeviceTime);
|
||||
writer.writeUInt32LE(timestamp);
|
||||
return writer.toBytes();
|
||||
}
|
||||
|
||||
// Build CMD_SEND_SELF_ADVERT frame
|
||||
@@ -377,21 +490,31 @@ Uint8List buildSendSelfAdvertFrame({bool flood = false}) {
|
||||
// Format: [cmd][name...]
|
||||
Uint8List buildSetAdvertNameFrame(String name) {
|
||||
final nameBytes = utf8.encode(name);
|
||||
final nameLen = nameBytes.length < maxNameSize ? nameBytes.length : maxNameSize - 1;
|
||||
final frame = Uint8List(1 + nameLen);
|
||||
frame[0] = cmdSetAdvertName;
|
||||
frame.setRange(1, 1 + nameLen, nameBytes.sublist(0, nameLen));
|
||||
return frame;
|
||||
final nameLen = nameBytes.length < maxNameSize
|
||||
? nameBytes.length
|
||||
: maxNameSize - 1;
|
||||
final writer = BufferWriter();
|
||||
writer.writeByte(cmdSetAdvertName);
|
||||
writer.writeBytes(Uint8List.fromList(nameBytes.sublist(0, nameLen)));
|
||||
return writer.toBytes();
|
||||
}
|
||||
|
||||
// Build CMD_SET_ADVERT_LATLON frame
|
||||
// Format: [cmd][lat x4][lon x4]
|
||||
Uint8List buildSetAdvertLatLonFrame(double lat, double lon) {
|
||||
final frame = Uint8List(9);
|
||||
frame[0] = cmdSetAdvertLatLon;
|
||||
writeInt32LE(frame, 1, (lat * 1000000).round());
|
||||
writeInt32LE(frame, 5, (lon * 1000000).round());
|
||||
return frame;
|
||||
final writer = BufferWriter();
|
||||
writer.writeByte(cmdSetAdvertLatLon);
|
||||
writer.writeInt32LE((lat * 1000000).round());
|
||||
writer.writeInt32LE((lon * 1000000).round());
|
||||
return writer.toBytes();
|
||||
}
|
||||
|
||||
Uint8List buildSetCustomVarFrame(String value) {
|
||||
final writer = BufferWriter();
|
||||
writer.writeByte(cmdSetCustomVar);
|
||||
writer.writeString(value);
|
||||
writer.writeByte(0);
|
||||
return writer.toBytes();
|
||||
}
|
||||
|
||||
// Build CMD_REBOOT frame
|
||||
@@ -413,37 +536,44 @@ Uint8List buildGetChannelFrame(int channelIndex) {
|
||||
// Build CMD_SET_CHANNEL frame
|
||||
// Format: [cmd][idx][name x32][psk x16]
|
||||
Uint8List buildSetChannelFrame(int channelIndex, String name, Uint8List psk) {
|
||||
final frame = Uint8List(2 + 32 + 16);
|
||||
frame[0] = cmdSetChannel;
|
||||
frame[1] = channelIndex;
|
||||
// Write name (max 32 bytes UTF-8, null-padded)
|
||||
final nameBytes = utf8.encode(name);
|
||||
final nameLen = nameBytes.length < 32 ? nameBytes.length : 31; // Reserve 1 byte for null
|
||||
for (int i = 0; i < nameLen; i++) {
|
||||
frame[2 + i] = nameBytes[i];
|
||||
}
|
||||
// frame[2 + nameLen] is already 0 (null terminator)
|
||||
// Write PSK (16 bytes)
|
||||
final writer = BufferWriter();
|
||||
writer.writeByte(cmdSetChannel);
|
||||
writer.writeByte(channelIndex);
|
||||
writer.writeCString(name, 32);
|
||||
// Write PSK (16 bytes, zero-padded)
|
||||
final pskPadded = Uint8List(16);
|
||||
for (int i = 0; i < 16 && i < psk.length; i++) {
|
||||
frame[34 + i] = psk[i];
|
||||
pskPadded[i] = psk[i];
|
||||
}
|
||||
return frame;
|
||||
writer.writeBytes(pskPadded);
|
||||
return writer.toBytes();
|
||||
}
|
||||
|
||||
// Build CMD_SET_RADIO_PARAMS frame
|
||||
// Format: [cmd][freq x4][bw x4][sf][cr]
|
||||
// Format: [cmd][freq x4][bw x4][sf][cr] (pre-v9)
|
||||
// [cmd][freq x4][bw x4][sf][cr][repeat] (firmware v9+)
|
||||
// freq: frequency in Hz (300000-2500000)
|
||||
// bw: bandwidth in Hz (7000-500000)
|
||||
// sf: spreading factor (5-12)
|
||||
// cr: coding rate (5-8)
|
||||
Uint8List buildSetRadioParamsFrame(int freqHz, int bwHz, int sf, int cr) {
|
||||
final frame = Uint8List(11);
|
||||
frame[0] = cmdSetRadioParams;
|
||||
writeUint32LE(frame, 1, freqHz);
|
||||
writeUint32LE(frame, 5, bwHz);
|
||||
frame[9] = sf;
|
||||
frame[10] = cr;
|
||||
return frame;
|
||||
// clientRepeat: enable off-grid packet repeat (firmware v9+, omit for older)
|
||||
Uint8List buildSetRadioParamsFrame(
|
||||
int freqHz,
|
||||
int bwHz,
|
||||
int sf,
|
||||
int cr, {
|
||||
bool? clientRepeat,
|
||||
}) {
|
||||
final writer = BufferWriter();
|
||||
writer.writeByte(cmdSetRadioParams);
|
||||
writer.writeUInt32LE(freqHz);
|
||||
writer.writeUInt32LE(bwHz);
|
||||
writer.writeByte(sf);
|
||||
writer.writeByte(cr);
|
||||
if (clientRepeat != null) {
|
||||
writer.writeByte(clientRepeat ? 1 : 0);
|
||||
}
|
||||
return writer.toBytes();
|
||||
}
|
||||
|
||||
// Build CMD_SET_RADIO_TX_POWER frame
|
||||
@@ -455,10 +585,10 @@ Uint8List buildSetRadioTxPowerFrame(int powerDbm) {
|
||||
// Build CMD_RESET_PATH frame
|
||||
// Format: [cmd][pub_key x32]
|
||||
Uint8List buildResetPathFrame(Uint8List pubKey) {
|
||||
final frame = Uint8List(1 + pubKeySize);
|
||||
frame[0] = cmdResetPath;
|
||||
frame.setRange(1, 1 + pubKeySize, pubKey);
|
||||
return frame;
|
||||
final writer = BufferWriter();
|
||||
writer.writeByte(cmdResetPath);
|
||||
writer.writeBytes(pubKey);
|
||||
return writer.toBytes();
|
||||
}
|
||||
|
||||
// Build CMD_ADD_UPDATE_CONTACT frame to set custom path
|
||||
@@ -471,50 +601,42 @@ Uint8List buildUpdateContactPathFrame(
|
||||
int flags = 0,
|
||||
String name = '',
|
||||
}) {
|
||||
// Frame size: 1 + 32 + 1 + 1 + 1 + 64 + 32 + 4 = 136 bytes minimum
|
||||
final frame = Uint8List(1 + pubKeySize + 1 + 1 + 1 + maxPathSize + maxNameSize + 4);
|
||||
int offset = 0;
|
||||
final writer = BufferWriter();
|
||||
writer.writeByte(cmdAddUpdateContact);
|
||||
writer.writeBytes(pubKey);
|
||||
writer.writeByte(type);
|
||||
writer.writeByte(flags);
|
||||
writer.writeByte(pathLen);
|
||||
|
||||
frame[offset++] = cmdAddUpdateContact;
|
||||
|
||||
// Public key (32 bytes)
|
||||
frame.setRange(offset, offset + pubKeySize, pubKey);
|
||||
offset += pubKeySize;
|
||||
|
||||
// Type and flags
|
||||
frame[offset++] = type;
|
||||
frame[offset++] = flags;
|
||||
|
||||
// Path length and path data
|
||||
frame[offset++] = pathLen;
|
||||
// Path data (64 bytes, zero-padded)
|
||||
final pathPadded = Uint8List(maxPathSize);
|
||||
if (customPath.isNotEmpty && pathLen > 0) {
|
||||
final copyLen = customPath.length < maxPathSize ? customPath.length : maxPathSize;
|
||||
frame.setRange(offset, offset + copyLen, customPath.sublist(0, copyLen));
|
||||
final copyLen = customPath.length < maxPathSize
|
||||
? customPath.length
|
||||
: maxPathSize;
|
||||
for (int i = 0; i < copyLen; i++) {
|
||||
pathPadded[i] = customPath[i];
|
||||
}
|
||||
}
|
||||
offset += maxPathSize;
|
||||
writer.writeBytes(pathPadded);
|
||||
|
||||
// Name (32 bytes, null-padded)
|
||||
if (name.isNotEmpty) {
|
||||
final nameBytes = utf8.encode(name);
|
||||
final nameLen = nameBytes.length < maxNameSize ? nameBytes.length : maxNameSize - 1;
|
||||
frame.setRange(offset, offset + nameLen, nameBytes.sublist(0, nameLen));
|
||||
}
|
||||
offset += maxNameSize;
|
||||
writer.writeCString(name, maxNameSize);
|
||||
|
||||
// Timestamp (current time)
|
||||
// Timestamp
|
||||
final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||
writeUint32LE(frame, offset, timestamp);
|
||||
writer.writeUInt32LE(timestamp);
|
||||
|
||||
return frame;
|
||||
return writer.toBytes();
|
||||
}
|
||||
|
||||
// Build CMD_GET_CONTACT_BY_KEY frame
|
||||
// Format: [cmd][pub_key x32]
|
||||
Uint8List buildGetContactByKeyFrame(Uint8List pubKey) {
|
||||
final frame = Uint8List(1 + pubKeySize);
|
||||
frame[0] = cmdGetContactByKey;
|
||||
frame.setRange(1, 1 + pubKeySize, pubKey);
|
||||
return frame;
|
||||
final writer = BufferWriter();
|
||||
writer.writeByte(cmdGetContactByKey);
|
||||
writer.writeBytes(pubKey);
|
||||
return writer.toBytes();
|
||||
}
|
||||
|
||||
// Build CMD_GET_RADIO_SETTINGS frame
|
||||
@@ -522,6 +644,11 @@ Uint8List buildGetRadioSettingsFrame() {
|
||||
return Uint8List.fromList([cmdGetRadioSettings]);
|
||||
}
|
||||
|
||||
//Build CMD_GET_CUSTOM_VARS frame
|
||||
Uint8List buildGetCustomVarsFrame() {
|
||||
return Uint8List.fromList([cmdGetCustomVar]);
|
||||
}
|
||||
|
||||
// Calculate LoRa airtime for a packet
|
||||
// Based on Semtech SX127x datasheet formula
|
||||
// Returns airtime in milliseconds
|
||||
@@ -545,9 +672,11 @@ int calculateLoRaAirtime({
|
||||
final crc = 1; // CRC enabled
|
||||
final de = lowDataRateOptimize ? 1 : 0;
|
||||
|
||||
final numerator = 8 * payloadBytes - 4 * spreadingFactor + 28 + 16 * crc - headerBytes;
|
||||
final numerator =
|
||||
8 * payloadBytes - 4 * spreadingFactor + 28 + 16 * crc - headerBytes;
|
||||
final denominator = 4 * (spreadingFactor - 2 * de);
|
||||
var payloadSymbols = 8 + ((numerator / denominator).ceil()) * (codingRate + 4);
|
||||
var payloadSymbols =
|
||||
8 + ((numerator / denominator).ceil()) * (codingRate + 4);
|
||||
|
||||
if (payloadSymbols < 0) {
|
||||
payloadSymbols = 8;
|
||||
@@ -594,23 +723,68 @@ Uint8List buildSendCliCommandFrame(
|
||||
int attempt = 0,
|
||||
int? timestampSeconds,
|
||||
}) {
|
||||
final textBytes = utf8.encode(command);
|
||||
final timestamp = timestampSeconds ?? (DateTime.now().millisecondsSinceEpoch ~/ 1000);
|
||||
const prefixSize = 6;
|
||||
final safeAttempt = attempt.clamp(0, 3);
|
||||
final frame = Uint8List(1 + 1 + 1 + 4 + prefixSize + textBytes.length + 1);
|
||||
int offset = 0;
|
||||
|
||||
frame[offset++] = cmdSendTxtMsg;
|
||||
frame[offset++] = txtTypeCliData;
|
||||
frame[offset++] = safeAttempt;
|
||||
writeUint32LE(frame, offset, timestamp);
|
||||
offset += 4;
|
||||
|
||||
frame.setRange(offset, offset + prefixSize, repeaterPubKey.sublist(0, prefixSize));
|
||||
offset += prefixSize;
|
||||
|
||||
frame.setRange(offset, offset + textBytes.length, textBytes);
|
||||
frame[frame.length - 1] = 0; // null terminator
|
||||
return frame;
|
||||
final timestamp =
|
||||
timestampSeconds ?? (DateTime.now().millisecondsSinceEpoch ~/ 1000);
|
||||
final writer = BufferWriter();
|
||||
writer.writeByte(cmdSendTxtMsg);
|
||||
writer.writeByte(txtTypeCliData);
|
||||
writer.writeByte(attempt.clamp(0, 3));
|
||||
writer.writeUInt32LE(timestamp);
|
||||
writer.writeBytes(repeaterPubKey.sublist(0, 6));
|
||||
writer.writeString(command);
|
||||
writer.writeByte(0);
|
||||
return writer.toBytes();
|
||||
}
|
||||
|
||||
// Build a telemetry request frame
|
||||
// Format: [cmd][pub_key x32][payload]
|
||||
Uint8List buildSendBinaryReq(Uint8List repeaterPubKey, {Uint8List? payload}) {
|
||||
final writer = BufferWriter();
|
||||
writer.writeByte(cmdSendBinaryReq);
|
||||
writer.writeBytes(repeaterPubKey);
|
||||
if (payload != null && payload.isNotEmpty) {
|
||||
writer.writeBytes(payload);
|
||||
}
|
||||
return writer.toBytes();
|
||||
}
|
||||
|
||||
//Build a trace request frame
|
||||
//[cmd][tag x4][auth x4][flag][payload]
|
||||
Uint8List buildTraceReq(int tag, int auth, int flag, {Uint8List? payload}) {
|
||||
final writer = BufferWriter();
|
||||
writer.writeByte(cmdSendTracePath);
|
||||
writer.writeUInt32LE(tag);
|
||||
writer.writeUInt32LE(auth);
|
||||
writer.writeByte(flag);
|
||||
if (payload != null && payload.isNotEmpty) {
|
||||
writer.writeBytes(payload);
|
||||
}
|
||||
return writer.toBytes();
|
||||
}
|
||||
|
||||
// Build a export contact frame
|
||||
// [cmd][pub_key x32 / if empty exports your contact info]
|
||||
Uint8List buildExportContactFrame(Uint8List pubKey) {
|
||||
final writer = BufferWriter();
|
||||
writer.writeByte(cmdExportContact);
|
||||
writer.writeBytes(pubKey);
|
||||
return writer.toBytes();
|
||||
}
|
||||
|
||||
// Build a import contact frame
|
||||
// [cmd][contact_frame x98+]
|
||||
Uint8List buildImportContactFrame(String contactFrame) {
|
||||
final writer = BufferWriter();
|
||||
writer.writeByte(cmdImportContact);
|
||||
writer.writeHex(contactFrame);
|
||||
return writer.toBytes();
|
||||
}
|
||||
|
||||
// Build a export contact frame
|
||||
// [cmd][pub_key x32]
|
||||
Uint8List buildZeroHopContact(Uint8List pubKey) {
|
||||
final writer = BufferWriter();
|
||||
writer.writeByte(cmdShareContact);
|
||||
writer.writeBytes(pubKey);
|
||||
return writer.toBytes();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
import 'dart:typed_data';
|
||||
import '../connector/meshcore_protocol.dart';
|
||||
|
||||
class CayenneLpp {
|
||||
static const int lppDigitalInput = 0; // 1 byte
|
||||
static const int lppDigitalOutput = 1; // 1 byte
|
||||
static const int lppAnalogInput = 2; // 2 bytes, 0.01 signed
|
||||
static const int lppAnalogOutput = 3; // 2 bytes, 0.01 signed
|
||||
static const int lppGenericSensor = 100; // 4 bytes, unsigned
|
||||
static const int lppLuminosity = 101; // 2 bytes, 1 lux unsigned
|
||||
static const int lppPresence = 102; // 1 byte, bool
|
||||
static const int lppTemperature = 103; // 2 bytes, 0.1°C signed
|
||||
static const int lppRelativeHumidity = 104; // 1 byte, 0.5% unsigned
|
||||
static const int lppAccelerometer = 113; // 2 bytes per axis, 0.001G
|
||||
static const int lppBarometricPressure = 115; // 2 bytes 0.1hPa unsigned
|
||||
static const int lppVoltage = 116; // 2 bytes 0.01V unsigned
|
||||
static const int lppCurrent = 117; // 2 bytes 0.001A unsigned
|
||||
static const int lppFrequency = 118; // 4 bytes 1Hz unsigned
|
||||
static const int lppPercentage = 120; // 1 byte 1-100% unsigned
|
||||
static const int lppAltitude = 121; // 2 byte 1m signed
|
||||
static const int lppConcentration = 125; // 2 bytes, 1 ppm unsigned
|
||||
static const int lppPower = 128; // 2 byte, 1W, unsigned
|
||||
static const int lppDistance = 130; // 4 byte, 0.001m, unsigned
|
||||
static const int lppEnergy = 131; // 4 byte, 0.001kWh, unsigned
|
||||
static const int lppDirection = 132; // 2 bytes, 1deg, unsigned
|
||||
static const int lppUnixTime = 133; // 4 bytes, unsigned
|
||||
static const int lppGyrometer = 134; // 2 bytes per axis, 0.01 °/s
|
||||
static const int lppColour = 135; // 1 byte per RGB Color
|
||||
static const int lppGps =
|
||||
136; // 3 byte lon/lat 0.0001 °, 3 bytes alt 0.01 meter
|
||||
static const int lppSwitch = 142; // 1 byte, 0/1
|
||||
static const int lppPolyline =
|
||||
240; // 1 byte size, 1 byte delta factor, 3 byte lon/lat 0.0001° * factor, n (size-8) bytes deltas
|
||||
|
||||
final BufferWriter _writer = BufferWriter();
|
||||
|
||||
Uint8List toBytes() {
|
||||
return _writer.toBytes();
|
||||
}
|
||||
|
||||
void addDigitalInput(int channel, int value) {
|
||||
_writer.writeByte(channel);
|
||||
_writer.writeByte(lppDigitalInput);
|
||||
_writer.writeByte(value);
|
||||
}
|
||||
|
||||
void addTemperature(int channel, double value) {
|
||||
_writer.writeByte(channel);
|
||||
_writer.writeByte(lppTemperature);
|
||||
final val = (value * 10).toInt();
|
||||
_writer.writeBytes(_int16ToBE(val));
|
||||
}
|
||||
|
||||
void addVoltage(int channel, double value) {
|
||||
_writer.writeByte(channel);
|
||||
_writer.writeByte(lppVoltage);
|
||||
final val = (value * 100).toInt();
|
||||
_writer.writeBytes(_int16ToBE(val));
|
||||
}
|
||||
|
||||
void addGps(int channel, double lat, double lon, double alt) {
|
||||
_writer.writeByte(channel);
|
||||
_writer.writeByte(lppGps);
|
||||
_writer.writeBytes(_int24ToBE((lat * 10000).toInt()));
|
||||
_writer.writeBytes(_int24ToBE((lon * 10000).toInt()));
|
||||
_writer.writeBytes(_int24ToBE((alt * 100).toInt()));
|
||||
}
|
||||
|
||||
Uint8List _int16ToBE(int value) {
|
||||
final bytes = Uint8List(2);
|
||||
final data = ByteData.view(bytes.buffer);
|
||||
data.setInt16(0, value, Endian.big);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
Uint8List _int24ToBE(int value) {
|
||||
final bytes = Uint8List(3);
|
||||
bytes[0] = (value >> 16) & 0xFF;
|
||||
bytes[1] = (value >> 8) & 0xFF;
|
||||
bytes[2] = value & 0xFF;
|
||||
return bytes;
|
||||
}
|
||||
|
||||
static List<Map<String, dynamic>> parse(Uint8List bytes) {
|
||||
final buffer = BufferReader(bytes);
|
||||
final telemetry = <Map<String, dynamic>>[];
|
||||
|
||||
while (buffer.remaining >= 2) {
|
||||
final channel = buffer.readUInt8();
|
||||
final type = buffer.readUInt8();
|
||||
|
||||
if (channel == 0 && type == 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case lppGenericSensor:
|
||||
telemetry.add({
|
||||
'channel': channel,
|
||||
'type': type,
|
||||
'value': buffer.readUInt32BE(),
|
||||
});
|
||||
break;
|
||||
case lppLuminosity:
|
||||
telemetry.add({
|
||||
'channel': channel,
|
||||
'type': type,
|
||||
'value': buffer.readUInt16BE(),
|
||||
});
|
||||
break;
|
||||
case lppPresence:
|
||||
telemetry.add({
|
||||
'channel': channel,
|
||||
'type': type,
|
||||
'value': buffer.readUInt8(),
|
||||
});
|
||||
break;
|
||||
case lppTemperature:
|
||||
telemetry.add({
|
||||
'channel': channel,
|
||||
'type': type,
|
||||
'value': buffer.readInt16BE() / 10,
|
||||
});
|
||||
break;
|
||||
case lppRelativeHumidity:
|
||||
telemetry.add({
|
||||
'channel': channel,
|
||||
'type': type,
|
||||
'value': buffer.readUInt8() / 2,
|
||||
});
|
||||
break;
|
||||
case lppBarometricPressure:
|
||||
telemetry.add({
|
||||
'channel': channel,
|
||||
'type': type,
|
||||
'value': buffer.readUInt16BE() / 10,
|
||||
});
|
||||
break;
|
||||
case lppVoltage:
|
||||
telemetry.add({
|
||||
'channel': channel,
|
||||
'type': type,
|
||||
'value': buffer.readInt16BE() / 100,
|
||||
});
|
||||
break;
|
||||
case lppCurrent:
|
||||
telemetry.add({
|
||||
'channel': channel,
|
||||
'type': type,
|
||||
'value': buffer.readInt16BE() / 1000,
|
||||
});
|
||||
break;
|
||||
case lppPercentage:
|
||||
telemetry.add({
|
||||
'channel': channel,
|
||||
'type': type,
|
||||
'value': buffer.readUInt8(),
|
||||
});
|
||||
break;
|
||||
case lppConcentration:
|
||||
telemetry.add({
|
||||
'channel': channel,
|
||||
'type': type,
|
||||
'value': buffer.readUInt16BE(),
|
||||
});
|
||||
break;
|
||||
case lppPower:
|
||||
telemetry.add({
|
||||
'channel': channel,
|
||||
'type': type,
|
||||
'value': buffer.readUInt16BE(),
|
||||
});
|
||||
break;
|
||||
case lppGps:
|
||||
telemetry.add({
|
||||
'channel': channel,
|
||||
'type': type,
|
||||
'value': {
|
||||
'latitude': buffer.readInt24BE() / 10000,
|
||||
'longitude': buffer.readInt24BE() / 10000,
|
||||
'altitude': buffer.readInt24BE() / 100,
|
||||
},
|
||||
});
|
||||
break;
|
||||
default:
|
||||
return telemetry;
|
||||
}
|
||||
}
|
||||
|
||||
return telemetry;
|
||||
}
|
||||
|
||||
static List<Map<String, dynamic>> parseByChannel(Uint8List bytes) {
|
||||
final buffer = BufferReader(bytes);
|
||||
final Map<int, Map<String, dynamic>> channels = {};
|
||||
|
||||
while (buffer.remaining >= 2) {
|
||||
final channel = buffer.readUInt8();
|
||||
final type = buffer.readUInt8();
|
||||
|
||||
// Optional: stop on padding (00 00)
|
||||
if (channel == 0 && type == 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
final channelData = channels.putIfAbsent(
|
||||
channel,
|
||||
() => {'channel': channel, 'values': <String, dynamic>{}},
|
||||
);
|
||||
|
||||
switch (type) {
|
||||
case lppGenericSensor:
|
||||
channelData['values']['generic'] = buffer.readUInt32BE();
|
||||
break;
|
||||
case lppLuminosity:
|
||||
channelData['values']['luminosity'] = buffer.readUInt16BE();
|
||||
break;
|
||||
case lppPresence:
|
||||
channelData['values']['presence'] = buffer.readUInt8() != 0;
|
||||
break;
|
||||
case lppTemperature:
|
||||
channelData['values']['temperature'] = buffer.readInt16BE() / 10.0;
|
||||
break;
|
||||
case lppRelativeHumidity:
|
||||
channelData['values']['humidity'] = buffer.readUInt8() / 2.0;
|
||||
break;
|
||||
case lppBarometricPressure:
|
||||
channelData['values']['pressure'] = buffer.readUInt16BE() / 10.0;
|
||||
break;
|
||||
case lppVoltage:
|
||||
channelData['values']['voltage'] = buffer.readInt16BE() / 100.0;
|
||||
break;
|
||||
case lppCurrent:
|
||||
channelData['values']['current'] = buffer.readInt16BE() / 1000.0;
|
||||
break;
|
||||
case lppPercentage:
|
||||
channelData['values']['percentage'] = buffer.readUInt8();
|
||||
break;
|
||||
case lppConcentration:
|
||||
channelData['values']['concentration'] = buffer.readUInt16BE();
|
||||
break;
|
||||
case lppPower:
|
||||
channelData['values']['power'] = buffer.readUInt16BE();
|
||||
break;
|
||||
case lppGps:
|
||||
channelData['values']['gps'] = {
|
||||
'latitude': buffer.readInt24BE() / 10000.0,
|
||||
'longitude': buffer.readInt24BE() / 10000.0,
|
||||
'altitude': buffer.readInt24BE() / 100.0,
|
||||
};
|
||||
break;
|
||||
// Add more types as needed...
|
||||
default:
|
||||
// Unknown type: skip or handle error?
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
final List<Map<String, dynamic>> channelsOut = channels.values.toList();
|
||||
channelsOut.sort((a, b) => a['channel'].compareTo(b['channel']));
|
||||
return channelsOut;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class ChatScrollController extends ScrollController {
|
||||
final ValueNotifier<bool> showJumpToBottom = ValueNotifier(false);
|
||||
VoidCallback? onScrollNearTop;
|
||||
|
||||
static const _bottomThreshold = 100.0;
|
||||
static const _topThreshold = 50.0;
|
||||
|
||||
ChatScrollController() {
|
||||
addListener(_handleScroll);
|
||||
}
|
||||
|
||||
void _handleScroll() {
|
||||
if (!hasClients) return;
|
||||
final pos = position;
|
||||
|
||||
// With reverse: true, position 0 is bottom, maxScrollExtent is top
|
||||
// Show jump button when scrolled away from bottom (position > threshold)
|
||||
final isAtBottom = pos.pixels <= _bottomThreshold;
|
||||
if (showJumpToBottom.value == isAtBottom) {
|
||||
showJumpToBottom.value = !isAtBottom;
|
||||
}
|
||||
|
||||
// Pagination trigger when scrolled near top (maxScrollExtent)
|
||||
if (pos.pixels >= pos.maxScrollExtent - _topThreshold) {
|
||||
onScrollNearTop?.call();
|
||||
}
|
||||
}
|
||||
|
||||
void jumpToBottom() {
|
||||
if (hasClients && position.maxScrollExtent > 0) {
|
||||
animateTo(
|
||||
0, // With reverse: true, position 0 is bottom
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void handleKeyboardOpen() {
|
||||
// Simple: just scroll to bottom when keyboard opens
|
||||
if (hasClients) {
|
||||
animateTo(
|
||||
0, // With reverse: true, position 0 is bottom
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void scrollToBottomIfAtBottom() {
|
||||
// Only scroll if jump button is NOT showing (i.e., already at bottom)
|
||||
if (!showJumpToBottom.value && hasClients && position.maxScrollExtent > 0) {
|
||||
animateTo(
|
||||
0, // With reverse: true, position 0 is bottom
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
showJumpToBottom.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import '../l10n/l10n.dart';
|
||||
|
||||
class LinkHandler {
|
||||
static Future<void> handleLinkTap(BuildContext context, String url) async {
|
||||
// Show confirmation dialog
|
||||
final shouldOpen = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(context.l10n.chat_openLink),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
context.l10n.chat_openLinkConfirmation,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: SelectableText(
|
||||
url,
|
||||
style: const TextStyle(fontSize: 12, fontFamily: 'monospace'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: Text(context.l10n.common_cancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: Text(context.l10n.chat_open),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (shouldOpen != true) return;
|
||||
|
||||
// Launch URL
|
||||
try {
|
||||
final uri = Uri.parse(url);
|
||||
if (!await launchUrl(uri, mode: LaunchMode.externalApplication)) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(context.l10n.chat_couldNotOpenLink(url)),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(context.l10n.chat_invalidLink),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,53 +1,68 @@
|
||||
class ReactionInfo {
|
||||
final String targetMessageId;
|
||||
final String emoji;
|
||||
final String? reactionKey; // Lightweight key for deduplication: timestamp_senderPrefix
|
||||
import '../widgets/emoji_picker.dart';
|
||||
|
||||
ReactionInfo({
|
||||
required this.targetMessageId,
|
||||
required this.emoji,
|
||||
this.reactionKey,
|
||||
});
|
||||
class ReactionInfo {
|
||||
final String targetHash;
|
||||
final String emoji;
|
||||
|
||||
ReactionInfo({required this.targetHash, required this.emoji});
|
||||
}
|
||||
|
||||
class ReactionHelper {
|
||||
/// Parse reaction format: r:[messageId]:[emoji]
|
||||
/// Supports both old format (full messageId) and new format (timestamp_senderPrefix)
|
||||
static List<String>? _cachedEmojis;
|
||||
|
||||
/// Combined list of all reaction emojis in fixed order.
|
||||
/// Order must stay stable for index compatibility.
|
||||
static List<String> get reactionEmojis {
|
||||
return _cachedEmojis ??= [
|
||||
...EmojiPicker.quickEmojis,
|
||||
...EmojiPicker.smileys,
|
||||
...EmojiPicker.gestures,
|
||||
...EmojiPicker.hearts,
|
||||
...EmojiPicker.objects,
|
||||
];
|
||||
}
|
||||
|
||||
/// Convert emoji to 2-char hex index. Returns null if emoji not in list.
|
||||
static String? emojiToIndex(String emoji) {
|
||||
final idx = reactionEmojis.indexOf(emoji);
|
||||
if (idx < 0) return null;
|
||||
return idx.toRadixString(16).padLeft(2, '0');
|
||||
}
|
||||
|
||||
/// Convert 2-char hex index to emoji. Returns null if invalid index.
|
||||
static String? indexToEmoji(String hexIndex) {
|
||||
final idx = int.tryParse(hexIndex, radix: 16);
|
||||
if (idx == null || idx < 0 || idx >= reactionEmojis.length) return null;
|
||||
return reactionEmojis[idx];
|
||||
}
|
||||
|
||||
/// Compute a 4-char hex hash for a message reaction.
|
||||
/// Hash input: timestampSeconds + [senderName] + first 5 chars of text
|
||||
/// For 1:1 chats, senderName can be null (sender is implicit).
|
||||
static String computeReactionHash(
|
||||
int timestampSeconds,
|
||||
String? senderName,
|
||||
String text,
|
||||
) {
|
||||
final first5 = text.length >= 5 ? text.substring(0, 5) : text;
|
||||
final input = senderName != null
|
||||
? '$timestampSeconds$senderName$first5'
|
||||
: '$timestampSeconds$first5';
|
||||
// Use hashCode and take lower 16 bits, format as 4 hex chars
|
||||
final hash = input.hashCode & 0xFFFF;
|
||||
return hash.toRadixString(16).padLeft(4, '0');
|
||||
}
|
||||
|
||||
/// Parse reaction format: r:HASH:INDEX (where INDEX is 2-char hex emoji index)
|
||||
/// Returns null if text is not a valid reaction format
|
||||
static ReactionInfo? parseReaction(String text) {
|
||||
final regex = RegExp(r'^r:([^:]+):(.+)$');
|
||||
final regex = RegExp(r'^r:([0-9a-f]{4}):([0-9a-f]{2})$');
|
||||
final match = regex.firstMatch(text);
|
||||
if (match == null) return null;
|
||||
|
||||
final targetId = match.group(1)!;
|
||||
final emoji = match.group(2)!;
|
||||
final emoji = indexToEmoji(match.group(2)!);
|
||||
if (emoji == null) return null;
|
||||
|
||||
// Extract reaction key for deduplication
|
||||
// If targetId is in new format (timestamp_senderPrefix), use it directly
|
||||
// Otherwise, extract timestamp from old format (timestamp_nameHash_textHash)
|
||||
String? reactionKey;
|
||||
if (targetId.contains('_')) {
|
||||
final parts = targetId.split('_');
|
||||
if (parts.length >= 2) {
|
||||
// New format: timestamp_senderPrefix, or old format with at least timestamp
|
||||
reactionKey = '${parts[0]}_${parts[1]}';
|
||||
}
|
||||
}
|
||||
|
||||
return ReactionInfo(
|
||||
targetMessageId: targetId,
|
||||
emoji: emoji,
|
||||
reactionKey: reactionKey,
|
||||
);
|
||||
}
|
||||
|
||||
/// Generate a lightweight reaction key for a message
|
||||
/// Format: r:[timestamp]_[senderPrefix]:[emoji]
|
||||
static String buildReactionText(String timestamp, String senderPrefix, String emoji) {
|
||||
return 'r:${timestamp}_$senderPrefix:$emoji';
|
||||
}
|
||||
|
||||
/// Extract sender prefix from public key hex (first 8 chars)
|
||||
static String getSenderPrefix(String senderKeyHex) {
|
||||
return senderKeyHex.substring(0, 8);
|
||||
return ReactionInfo(targetHash: match.group(1)!, emoji: emoji);
|
||||
}
|
||||
}
|
||||
|
||||
+15
-6
@@ -262,8 +262,9 @@ class Smaz {
|
||||
".com",
|
||||
];
|
||||
|
||||
static final List<Uint8List> _rcbBytes =
|
||||
_rcb.map((s) => Uint8List.fromList(ascii.encode(s))).toList(growable: false);
|
||||
static final List<Uint8List> _rcbBytes = _rcb
|
||||
.map((s) => Uint8List.fromList(ascii.encode(s)))
|
||||
.toList(growable: false);
|
||||
static final int _maxEntryLen = _rcbBytes.fold(0, (maxLen, entry) {
|
||||
return entry.length > maxLen ? entry.length : maxLen;
|
||||
});
|
||||
@@ -358,24 +359,32 @@ class Smaz {
|
||||
final code = input[index];
|
||||
if (code == _verbatimSingle) {
|
||||
if (index + 1 >= input.length) {
|
||||
throw const FormatException('Invalid SMAZ stream: truncated verbatim byte.');
|
||||
throw const FormatException(
|
||||
'Invalid SMAZ stream: truncated verbatim byte.',
|
||||
);
|
||||
}
|
||||
out.addByte(input[index + 1]);
|
||||
index += 2;
|
||||
} else if (code == _verbatimRun) {
|
||||
if (index + 1 >= input.length) {
|
||||
throw const FormatException('Invalid SMAZ stream: truncated verbatim length.');
|
||||
throw const FormatException(
|
||||
'Invalid SMAZ stream: truncated verbatim length.',
|
||||
);
|
||||
}
|
||||
final len = input[index + 1] + 1;
|
||||
final end = index + 2 + len;
|
||||
if (end > input.length) {
|
||||
throw const FormatException('Invalid SMAZ stream: truncated verbatim run.');
|
||||
throw const FormatException(
|
||||
'Invalid SMAZ stream: truncated verbatim run.',
|
||||
);
|
||||
}
|
||||
out.add(input.sublist(index + 2, end));
|
||||
index = end;
|
||||
} else {
|
||||
if (code >= _rcbBytes.length) {
|
||||
throw const FormatException('Invalid SMAZ stream: code out of range.');
|
||||
throw const FormatException(
|
||||
'Invalid SMAZ stream: code out of range.',
|
||||
);
|
||||
}
|
||||
out.add(_rcbBytes[code]);
|
||||
index += 1;
|
||||
|
||||
@@ -8,7 +8,10 @@ class Utf8LengthLimitingTextInputFormatter extends TextInputFormatter {
|
||||
const Utf8LengthLimitingTextInputFormatter(this.maxBytes);
|
||||
|
||||
@override
|
||||
TextEditingValue formatEditUpdate(TextEditingValue oldValue, TextEditingValue newValue) {
|
||||
TextEditingValue formatEditUpdate(
|
||||
TextEditingValue oldValue,
|
||||
TextEditingValue newValue,
|
||||
) {
|
||||
if (maxBytes <= 0) return oldValue;
|
||||
final bytes = utf8.encode(newValue.text);
|
||||
if (bytes.length <= maxBytes) return newValue;
|
||||
|
||||
+1600
File diff suppressed because it is too large
Load Diff
+1628
File diff suppressed because it is too large
Load Diff
+1628
File diff suppressed because it is too large
Load Diff
+1628
File diff suppressed because it is too large
Load Diff
+1600
File diff suppressed because it is too large
Load Diff
+1600
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+1600
File diff suppressed because it is too large
Load Diff
+1600
File diff suppressed because it is too large
Load Diff
+1600
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,840 @@
|
||||
{
|
||||
"@@locale": "ru",
|
||||
"appTitle": "MeshCore Open",
|
||||
"nav_contacts": "Контакты",
|
||||
"nav_channels": "Каналы",
|
||||
"nav_map": "Карта",
|
||||
"common_cancel": "Отмена",
|
||||
"common_ok": "OK",
|
||||
"common_connect": "Коннект",
|
||||
"common_unknownDevice": "Неизвестное устройство",
|
||||
"common_save": "Сохранить",
|
||||
"common_delete": "Удалить",
|
||||
"common_close": "Закрыть",
|
||||
"common_edit": "Изменить",
|
||||
"common_add": "Добавить",
|
||||
"common_settings": "Настройки",
|
||||
"common_disconnect": "Отключить",
|
||||
"common_connected": "Подключено",
|
||||
"common_disconnected": "Отключено",
|
||||
"common_create": "Создать",
|
||||
"common_continue": "Продолжить",
|
||||
"common_share": "Поделиться",
|
||||
"common_copy": "Копировать",
|
||||
"common_retry": "Повторить",
|
||||
"common_hide": "Скрыть",
|
||||
"common_remove": "Убрать",
|
||||
"common_enable": "Включить",
|
||||
"common_disable": "Выключить",
|
||||
"common_reboot": "Перезагрузить",
|
||||
"common_loading": "Загрузка...",
|
||||
"common_notAvailable": "—",
|
||||
"common_voltageValue": "{volts} В",
|
||||
"common_percentValue": "{percent}%",
|
||||
"scanner_title": "MeshCore Open",
|
||||
"scanner_scanning": "Поиск устройств...",
|
||||
"scanner_connecting": "Подключение...",
|
||||
"scanner_disconnecting": "Отключение...",
|
||||
"scanner_notConnected": "Не подключено",
|
||||
"scanner_connectedTo": "Подключено к {deviceName}",
|
||||
"scanner_searchingDevices": "Поиск устройств MeshCore...",
|
||||
"scanner_tapToScan": "Нажмите для поиска MeshCore устройств",
|
||||
"scanner_connectionFailed": "Подключение не удалось: {error}",
|
||||
"scanner_stop": "Стоп",
|
||||
"scanner_scan": "Сканирование",
|
||||
"device_quickSwitch": "Быстрое переключение",
|
||||
"device_meshcore": "MeshCore",
|
||||
"settings_title": "Настройки",
|
||||
"settings_deviceInfo": "Информация об устройстве",
|
||||
"settings_appSettings": "Настройки приложения",
|
||||
"settings_appSettingsSubtitle": "Уведомления, сообщения и настройки карты",
|
||||
"settings_nodeSettings": "Настройки ноды",
|
||||
"settings_nodeName": "Имя ноды",
|
||||
"settings_nodeNameNotSet": "Не установлено",
|
||||
"settings_nodeNameHint": "Введите имя ноды",
|
||||
"settings_nodeNameUpdated": "Имя обновлено",
|
||||
"settings_radioSettings": "Настройки радио",
|
||||
"settings_radioSettingsSubtitle": "Частота, мощность и коэффициент распространения",
|
||||
"settings_radioSettingsUpdated": "Настройки радио обновлены",
|
||||
"settings_location": "Позиция",
|
||||
"settings_locationSubtitle": "Координаты GPS",
|
||||
"settings_locationUpdated": "Позиция и настройки GPS обновлены",
|
||||
"settings_locationBothRequired": "Введите широту и долготу.",
|
||||
"settings_locationInvalid": "Неверная широта или долгота.",
|
||||
"settings_locationGPSEnable": "Включить GPS",
|
||||
"settings_locationGPSEnableSubtitle": "Включение GPS для автоматического обновления позиции.",
|
||||
"settings_locationIntervalSec": "Интервал для позиционирования GPS (секунды)",
|
||||
"settings_locationIntervalInvalid": "Интервал должен составлять не менее 60 секунд и не более 86400 секунд.",
|
||||
"settings_latitude": "Широта",
|
||||
"settings_longitude": "Долгота",
|
||||
"settings_privacyMode": "Режим конфиденциальности",
|
||||
"settings_privacyModeSubtitle": "Скрыть имя/позицию в анонсировании",
|
||||
"settings_privacyModeToggle": "Включите режим конфиденциальности, чтобы скрыть свое имя и местоположение в анонсировании.",
|
||||
"settings_privacyModeEnabled": "Режим конфиденциальности включен",
|
||||
"settings_privacyModeDisabled": "Режим конфиденциальности выключен",
|
||||
"settings_actions": "Действия",
|
||||
"settings_sendAdvertisement": "Отправить анонсирование",
|
||||
"settings_sendAdvertisementSubtitle": "Отправить анонсирование о присутствии сейчас",
|
||||
"settings_advertisementSent": "Анонсирование отправлено",
|
||||
"settings_syncTime": "Синхронизация времени",
|
||||
"settings_syncTimeSubtitle": "Синхронизировать время с телефоном",
|
||||
"settings_timeSynchronized": "Время синхронизировано",
|
||||
"settings_refreshContacts": "Обновить контакты",
|
||||
"settings_refreshContactsSubtitle": "Перезагрузить список контактов с устройства",
|
||||
"settings_rebootDevice": "Перезагрузить устройство",
|
||||
"settings_rebootDeviceSubtitle": "Перезапустить устройство MeshCore",
|
||||
"settings_rebootDeviceConfirm": "Вы уверены, что хотите перезагрузить устройство? Вы будете отключены.",
|
||||
"settings_debug": "Отладка",
|
||||
"settings_bleDebugLog": "Журнал отладки BLE",
|
||||
"settings_bleDebugLogSubtitle": "Команды BLE, ответы и сырые данные",
|
||||
"settings_appDebugLog": "Журнал отладки приложения",
|
||||
"settings_appDebugLogSubtitle": "Сообщения отладки приложения",
|
||||
"settings_about": "О программе",
|
||||
"settings_aboutVersion": "MeshCore Open v{version}",
|
||||
"settings_aboutLegalese": "2026 MeshCore Open Source Project",
|
||||
"settings_aboutDescription": "Открытое клиентское приложение на Flutter для устройств MeshCore с LoRa-сетями.",
|
||||
"settings_infoName": "Имя",
|
||||
"settings_infoId": "ID",
|
||||
"settings_infoStatus": "Статус",
|
||||
"settings_infoBattery": "Батарея",
|
||||
"settings_infoPublicKey": "Публичный ключ",
|
||||
"settings_infoContactsCount": "Количество контактов",
|
||||
"settings_infoChannelCount": "Количество каналов",
|
||||
"settings_presets": "Пресеты",
|
||||
"settings_frequency": "Частота (МГц)",
|
||||
"settings_frequencyHelper": "300.0 – 2500.0",
|
||||
"settings_frequencyInvalid": "Недопустимая частота (300–2500 МГц)",
|
||||
"settings_bandwidth": "Полоса пропускания",
|
||||
"settings_spreadingFactor": "Коэффициент расширения",
|
||||
"settings_codingRate": "Коэффициент кодирования",
|
||||
"settings_txPower": "Мощность передачи (дБм)",
|
||||
"settings_txPowerHelper": "0 – 22",
|
||||
"settings_txPowerInvalid": "Недопустимая мощность передачи (0–22 дБм)",
|
||||
"settings_error": "Ошибка: {message}",
|
||||
"appSettings_title": "Настройки приложения",
|
||||
"appSettings_appearance": "Внешний вид",
|
||||
"appSettings_theme": "Тема",
|
||||
"appSettings_themeSystem": "Как в системе",
|
||||
"appSettings_themeLight": "Светлая",
|
||||
"appSettings_themeDark": "Тёмная",
|
||||
"appSettings_language": "Язык",
|
||||
"appSettings_languageSystem": "Как в системе",
|
||||
"appSettings_languageEn": "Английский",
|
||||
"appSettings_languageFr": "Французский",
|
||||
"appSettings_languageEs": "Испанский",
|
||||
"appSettings_languageDe": "Немецкий",
|
||||
"appSettings_languagePl": "Польский",
|
||||
"appSettings_languageSl": "Словенский",
|
||||
"appSettings_languagePt": "Португальский",
|
||||
"appSettings_languageIt": "Итальянский",
|
||||
"appSettings_languageZh": "Китайский",
|
||||
"appSettings_languageSv": "Шведский",
|
||||
"appSettings_languageNl": "Нидерландский",
|
||||
"appSettings_languageSk": "Словацкий",
|
||||
"appSettings_languageBg": "Болгарский",
|
||||
"appSettings_languageRu": "Русский",
|
||||
"appSettings_notifications": "Уведомления",
|
||||
"appSettings_enableNotifications": "Включить уведомления",
|
||||
"appSettings_enableNotificationsSubtitle": "Получать уведомления о сообщениях и оповещениях",
|
||||
"appSettings_notificationPermissionDenied": "Разрешение на уведомления отклонено",
|
||||
"appSettings_notificationsEnabled": "Уведомления включены",
|
||||
"appSettings_notificationsDisabled": "Уведомления отключены",
|
||||
"appSettings_messageNotifications": "Уведомления о сообщениях",
|
||||
"appSettings_messageNotificationsSubtitle": "Показывать уведомление при получении новых сообщений",
|
||||
"appSettings_channelMessageNotifications": "Уведомления о сообщениях в каналах",
|
||||
"appSettings_channelMessageNotificationsSubtitle": "Показывать уведомление при получении сообщений в каналах",
|
||||
"appSettings_advertisementNotifications": "Уведомления об анонсированиях",
|
||||
"appSettings_advertisementNotificationsSubtitle": "Показывать уведомление при обнаружении новых нод",
|
||||
"appSettings_messaging": "Обмен сообщениями",
|
||||
"appSettings_clearPathOnMaxRetry": "Сбросить маршрут после максимального числа попыток",
|
||||
"appSettings_clearPathOnMaxRetrySubtitle": "Сбросить маршрут контакта после 5 неудачных попыток отправки",
|
||||
"appSettings_pathsWillBeCleared": "Маршруты будут сброшены после 5 неудачных попыток",
|
||||
"appSettings_pathsWillNotBeCleared": "Маршруты не будут автоматически сбрасываться",
|
||||
"appSettings_autoRouteRotation": "Автоматическое переключение маршрутов",
|
||||
"appSettings_autoRouteRotationSubtitle": "Циклически переключаться между лучшими маршрутами и режимом рассылки",
|
||||
"appSettings_autoRouteRotationEnabled": "Автоматическое переключение маршрутов включено",
|
||||
"appSettings_autoRouteRotationDisabled": "Автоматическое переключение маршрутов отключено",
|
||||
"appSettings_battery": "Батарея",
|
||||
"appSettings_batteryChemistry": "Химия батареи",
|
||||
"appSettings_batteryChemistryPerDevice": "Установить для устройства ({deviceName})",
|
||||
"appSettings_batteryChemistryConnectFirst": "Подключитесь к устройству, чтобы выбрать",
|
||||
"appSettings_batteryNmc": "18650 NMC (3.0–4.2 В)",
|
||||
"appSettings_batteryLifepo4": "LiFePO4 (2.6–3.65 В)",
|
||||
"appSettings_batteryLipo": "LiPo (3.0–4.2 В)",
|
||||
"appSettings_mapDisplay": "Отображение карты",
|
||||
"appSettings_showRepeaters": "Показывать репитеры",
|
||||
"appSettings_showRepeatersSubtitle": "Отображать репитеры на карте",
|
||||
"appSettings_showChatNodes": "Показывать чат-ноды",
|
||||
"appSettings_showChatNodesSubtitle": "Отображать чат-ноды на карте",
|
||||
"appSettings_showOtherNodes": "Показывать другие ноды",
|
||||
"appSettings_showOtherNodesSubtitle": "Отображать другие типы нод на карте",
|
||||
"appSettings_timeFilter": "Фильтр по времени",
|
||||
"appSettings_timeFilterShowAll": "Показывать все ноды",
|
||||
"appSettings_timeFilterShowLast": "Показывать ноды за последние {hours} ч",
|
||||
"appSettings_mapTimeFilter": "Временной фильтр карты",
|
||||
"appSettings_showNodesDiscoveredWithin": "Показывать ноды, обнаруженные за:",
|
||||
"appSettings_allTime": "Всё время",
|
||||
"appSettings_lastHour": "Последний час",
|
||||
"appSettings_last6Hours": "Последние 6 часов",
|
||||
"appSettings_last24Hours": "Последние 24 часа",
|
||||
"appSettings_lastWeek": "Последнюю неделю",
|
||||
"appSettings_offlineMapCache": "Кэш офлайн-карты",
|
||||
"appSettings_noAreaSelected": "Область не выбрана",
|
||||
"appSettings_areaSelectedZoom": "Область выбрана (масштаб {minZoom}–{maxZoom})",
|
||||
"appSettings_debugCard": "Отладка",
|
||||
"appSettings_appDebugLogging": "Журнал отладки приложения",
|
||||
"appSettings_appDebugLoggingSubtitle": "Записывать отладочные сообщения приложения для диагностики",
|
||||
"appSettings_appDebugLoggingEnabled": "Журнал отладки приложения включён",
|
||||
"appSettings_appDebugLoggingDisabled": "Журнал отладки приложения отключён",
|
||||
"contacts_title": "Контакты",
|
||||
"contacts_noContacts": "Контактов пока нет",
|
||||
"contacts_contactsWillAppear": "Контакты появятся, когда устройства начнут рассылать оповещения",
|
||||
"contacts_searchContacts": "Поиск контактов...",
|
||||
"contacts_noUnreadContacts": "Нет непрочитанных контактов",
|
||||
"contacts_noContactsFound": "Контакты или группы не найдены",
|
||||
"contacts_deleteContact": "Удалить контакт",
|
||||
"contacts_removeConfirm": "Удалить {contactName} из контактов?",
|
||||
"contacts_manageRepeater": "Управление репитером",
|
||||
"contacts_manageRoom": "Управление сервером комнат",
|
||||
"contacts_roomLogin": "Вход на сервер комнат",
|
||||
"contacts_openChat": "Открыть чат",
|
||||
"contacts_editGroup": "Изменить группу",
|
||||
"contacts_deleteGroup": "Удалить группу",
|
||||
"contacts_deleteGroupConfirm": "Удалить \"{groupName}\"?",
|
||||
"contacts_newGroup": "Новая группа",
|
||||
"contacts_groupName": "Имя группы",
|
||||
"contacts_groupNameRequired": "Имя группы обязательно",
|
||||
"contacts_groupAlreadyExists": "Группа \"{name}\" уже существует",
|
||||
"contacts_filterContacts": "Фильтр контактов...",
|
||||
"contacts_noContactsMatchFilter": "Нет контактов, соответствующих фильтру",
|
||||
"contacts_noMembers": "Нет участников",
|
||||
"contacts_lastSeenNow": "Видели только что",
|
||||
"contacts_lastSeenMinsAgo": "Видели {minutes} мин назад",
|
||||
"contacts_lastSeenHourAgo": "Видели 1 час назад",
|
||||
"contacts_lastSeenHoursAgo": "Видели {hours} ч назад",
|
||||
"contacts_lastSeenDayAgo": "Видели 1 день назад",
|
||||
"contacts_lastSeenDaysAgo": "Видели {days} дн. назад",
|
||||
"channels_title": "Каналы",
|
||||
"channels_noChannelsConfigured": "Каналы не настроены",
|
||||
"channels_addPublicChannel": "Добавить публичный канал",
|
||||
"channels_searchChannels": "Поиск каналов...",
|
||||
"channels_noChannelsFound": "Каналы не найдены",
|
||||
"channels_channelIndex": "Канал {index}",
|
||||
"channels_hashtagChannel": "Хэштег-канал",
|
||||
"channels_public": "Публичный",
|
||||
"channels_private": "Приватный",
|
||||
"channels_publicChannel": "Публичный канал",
|
||||
"channels_privateChannel": "Приватный канал",
|
||||
"channels_editChannel": "Изменить канал",
|
||||
"channels_deleteChannel": "Удалить канал",
|
||||
"channels_deleteChannelConfirm": "Удалить \"{name}\"? Это действие нельзя отменить.",
|
||||
"channels_channelDeleted": "Канал \"{name}\" удалён",
|
||||
"channels_addChannel": "Добавить канал",
|
||||
"channels_channelIndexLabel": "Индекс канала",
|
||||
"channels_channelName": "Имя канала",
|
||||
"channels_usePublicChannel": "Использовать публичный канал",
|
||||
"channels_standardPublicPsk": "Стандартный публичный PSK",
|
||||
"channels_pskHex": "PSK (Hex)",
|
||||
"channels_generateRandomPsk": "Сгенерировать случайный PSK",
|
||||
"channels_enterChannelName": "Введите имя канала",
|
||||
"channels_pskMustBe32Hex": "PSK должен содержать 32 шестнадцатеричных символа",
|
||||
"channels_channelAdded": "Канал \"{name}\" добавлен",
|
||||
"channels_editChannelTitle": "Изменить канал {index}",
|
||||
"channels_smazCompression": "Сжатие SMAZ",
|
||||
"channels_channelUpdated": "Канал \"{name}\" обновлён",
|
||||
"channels_publicChannelAdded": "Публичный канал добавлен",
|
||||
"channels_sortBy": "Сортировка",
|
||||
"channels_sortManual": "Вручную",
|
||||
"channels_sortAZ": "По алфавиту",
|
||||
"channels_sortLatestMessages": "По последним сообщениям",
|
||||
"channels_sortUnread": "По непрочитанным",
|
||||
"channels_createPrivateChannel": "Создать приватный канал",
|
||||
"channels_createPrivateChannelDesc": "Защищён секретным ключом.",
|
||||
"channels_joinPrivateChannel": "Присоединиться к приватному каналу",
|
||||
"channels_joinPrivateChannelDesc": "Введите секретный ключ вручную.",
|
||||
"channels_joinPublicChannel": "Присоединиться к публичному каналу",
|
||||
"channels_joinPublicChannelDesc": "К этому каналу может присоединиться любой.",
|
||||
"channels_joinHashtagChannel": "Присоединиться к хэштег-каналу",
|
||||
"channels_joinHashtagChannelDesc": "К хэштег-каналам может присоединиться любой.",
|
||||
"channels_scanQrCode": "Сканировать QR-код",
|
||||
"channels_scanQrCodeComingSoon": "Скоро будет",
|
||||
"channels_enterHashtag": "Введите хэштег",
|
||||
"channels_hashtagHint": "например, #команда",
|
||||
"chat_noMessages": "Сообщений пока нет",
|
||||
"chat_sendMessageToStart": "Отправьте сообщение, чтобы начать",
|
||||
"chat_originalMessageNotFound": "Исходное сообщение не найдено",
|
||||
"chat_replyingTo": "Ответ для {name}",
|
||||
"chat_replyTo": "Ответить {name}",
|
||||
"chat_location": "Местоположение",
|
||||
"chat_sendMessageTo": "Отправить сообщение {contactName}",
|
||||
"chat_typeMessage": "Напишите сообщение...",
|
||||
"chat_messageTooLong": "Сообщение слишком длинное (макс. {maxBytes} байт).",
|
||||
"chat_messageCopied": "Сообщение скопировано",
|
||||
"chat_messageDeleted": "Сообщение удалено",
|
||||
"chat_retryingMessage": "Повтор отправки сообщения",
|
||||
"chat_retryCount": "Попытка {current}/{max}",
|
||||
"chat_sendGif": "Отправить GIF",
|
||||
"chat_reply": "Ответить",
|
||||
"chat_addReaction": "Добавить реакцию",
|
||||
"chat_me": "Я",
|
||||
"emojiCategorySmileys": "Смайлы",
|
||||
"emojiCategoryGestures": "Жесты",
|
||||
"emojiCategoryHearts": "Сердечки",
|
||||
"emojiCategoryObjects": "Предметы",
|
||||
"gifPicker_title": "Выберите GIF",
|
||||
"gifPicker_searchHint": "Поиск GIF...",
|
||||
"gifPicker_poweredBy": "Работает на GIPHY",
|
||||
"gifPicker_noGifsFound": "GIF не найдены",
|
||||
"gifPicker_failedLoad": "Не удалось загрузить GIF",
|
||||
"gifPicker_failedSearch": "Не удалось выполнить поиск GIF",
|
||||
"gifPicker_noInternet": "Нет подключения к интернету",
|
||||
"debugLog_appTitle": "Журнал отладки приложения",
|
||||
"debugLog_bleTitle": "Журнал отладки BLE",
|
||||
"debugLog_copyLog": "Копировать журнал",
|
||||
"debugLog_clearLog": "Очистить журнал",
|
||||
"debugLog_copied": "Журнал отладки скопирован",
|
||||
"debugLog_bleCopied": "Журнал BLE скопирован",
|
||||
"debugLog_noEntries": "Журнал отладки пока пуст",
|
||||
"debugLog_enableInSettings": "Включите запись журнала отладки в настройках",
|
||||
"debugLog_frames": "Фреймы",
|
||||
"debugLog_rawLogRx": "Сырой журнал приёма",
|
||||
"debugLog_noBleActivity": "Активность BLE пока отсутствует",
|
||||
"debugFrame_length": "Длина фрейма: {count} байт",
|
||||
"debugFrame_command": "Команда: 0x{value}",
|
||||
"debugFrame_textMessageHeader": "Фрейм текстового сообщения:",
|
||||
"debugFrame_destinationPubKey": "- Публичный ключ получателя: {pubKey}",
|
||||
"debugFrame_timestamp": "- Временная метка: {timestamp}",
|
||||
"debugFrame_flags": "- Флаги: 0x{value}",
|
||||
"debugFrame_textType": "- Тип текста: {type} ({label})",
|
||||
"debugFrame_textTypeCli": "CLI",
|
||||
"debugFrame_textTypePlain": "Обычный",
|
||||
"debugFrame_text": "- Текст: \"{text}\"",
|
||||
"debugFrame_hexDump": "Шестнадцатеричный дамп:",
|
||||
"chat_pathManagement": "Управление маршрутами",
|
||||
"chat_routingMode": "Режим маршрутизации",
|
||||
"chat_autoUseSavedPath": "Авто (использовать сохранённый маршрут)",
|
||||
"chat_forceFloodMode": "Принудительный режим рассылки",
|
||||
"chat_recentAckPaths": "Недавние подтверждённые маршруты (нажмите, чтобы использовать):",
|
||||
"chat_pathHistoryFull": "История маршрутов заполнена. Удалите записи, чтобы добавить новые.",
|
||||
"chat_hopSingular": "хоп",
|
||||
"chat_hopPlural": "хопов",
|
||||
"chat_hopsCount": "{count} {count, plural, one{хоп} few{хопа} many{хопов} other{хопов}}",
|
||||
"chat_successes": "успешно",
|
||||
"chat_removePath": "Удалить маршрут",
|
||||
"chat_noPathHistoryYet": "История маршрутов пока пуста.\nОтправьте сообщение, чтобы обнаружить маршруты.",
|
||||
"chat_pathActions": "Действия с маршрутом:",
|
||||
"chat_setCustomPath": "Указать маршрут вручную",
|
||||
"chat_setCustomPathSubtitle": "Вручную задать маршрут передачи",
|
||||
"chat_clearPath": "Очистить маршрут",
|
||||
"chat_clearPathSubtitle": "Принудительно обновить маршрут при следующей отправке",
|
||||
"chat_pathCleared": "Маршрут очищен. Следующее сообщение обновит маршрут.",
|
||||
"chat_floodModeSubtitle": "Используйте переключатель маршрутизации в панели приложения",
|
||||
"chat_floodModeEnabled": "Режим рассылки включён. Отключите через значок маршрутизации в панели приложения.",
|
||||
"chat_fullPath": "Полный маршрут",
|
||||
"chat_pathDetailsNotAvailable": "Детали маршрута ещё недоступны. Попробуйте отправить сообщение для обновления.",
|
||||
"chat_pathSetHops": "Маршрут установлен: {hopCount} {hopCount, plural, one{хоп} few{хопа} many{хопов} other{хопов}} — {status}",
|
||||
"chat_pathSavedLocally": "Сохранено локально. Подключитесь для синхронизации.",
|
||||
"chat_pathDeviceConfirmed": "Подтверждено устройством.",
|
||||
"chat_pathDeviceNotConfirmed": "Ещё не подтверждено устройством.",
|
||||
"chat_type": "Тип",
|
||||
"chat_path": "Маршрут",
|
||||
"chat_publicKey": "Публичный ключ",
|
||||
"chat_compressOutgoingMessages": "Сжимать исходящие сообщения",
|
||||
"chat_floodForced": "Рассылка (принудительно)",
|
||||
"chat_directForced": "Прямой (принудительно)",
|
||||
"chat_hopsForced": "{count} хоп(ов) (принудительно)",
|
||||
"chat_floodAuto": "Рассылка (авто)",
|
||||
"chat_direct": "Прямой",
|
||||
"chat_poiShared": "Точка интереса отправлена",
|
||||
"chat_unread": "Непрочитанных: {count}",
|
||||
"map_title": "Карта нод",
|
||||
"map_noNodesWithLocation": "Нет нод с данными о местоположении",
|
||||
"map_nodesNeedGps": "Ноды должны передавать свои GPS-координаты, чтобы отображаться на карте",
|
||||
"map_nodesCount": "Нод: {count}",
|
||||
"map_pinsCount": "Меток: {count}",
|
||||
"map_chat": "Чат",
|
||||
"map_repeater": "Репитер",
|
||||
"map_room": "Комната",
|
||||
"map_sensor": "Сенсор",
|
||||
"map_pinDm": "Метка (ЛС)",
|
||||
"map_pinPrivate": "Метка (Приватная)",
|
||||
"map_pinPublic": "Метка (Публичная)",
|
||||
"map_lastSeen": "Последнее появление",
|
||||
"map_disconnectConfirm": "Вы уверены, что хотите отключиться от этого устройства?",
|
||||
"map_from": "От",
|
||||
"map_source": "Источник",
|
||||
"map_flags": "Флаги",
|
||||
"map_shareMarkerHere": "Поделиться меткой здесь",
|
||||
"map_pinLabel": "Метка",
|
||||
"map_label": "Подпись",
|
||||
"map_pointOfInterest": "Точка интереса",
|
||||
"map_sendToContact": "Отправить контакту",
|
||||
"map_sendToChannel": "Отправить в канал",
|
||||
"map_noChannelsAvailable": "Нет доступных каналов",
|
||||
"map_publicLocationShare": "Публичная передача местоположения",
|
||||
"map_publicLocationShareConfirm": "Вы собираетесь поделиться местоположением в {channelLabel}. Этот канал публичный, и любой, у кого есть PSK, сможет его увидеть.",
|
||||
"map_connectToShareMarkers": "Подключитесь к устройству, чтобы делиться метками",
|
||||
"map_filterNodes": "Фильтр нод",
|
||||
"map_nodeTypes": "Типы нод",
|
||||
"map_chatNodes": "Чат-ноды",
|
||||
"map_repeaters": "Репитеры",
|
||||
"map_otherNodes": "Другие ноды",
|
||||
"map_keyPrefix": "Префикс ключа",
|
||||
"map_filterByKeyPrefix": "Фильтр по префиксу ключа",
|
||||
"map_publicKeyPrefix": "Префикс публичного ключа",
|
||||
"map_markers": "Метки",
|
||||
"map_showSharedMarkers": "Показывать общие метки",
|
||||
"map_lastSeenTime": "Время последнего появления",
|
||||
"map_sharedPin": "Общая метка",
|
||||
"map_joinRoom": "Присоединиться к комнате",
|
||||
"map_manageRepeater": "Управление репитером",
|
||||
"mapCache_title": "Кэш офлайн-карты",
|
||||
"mapCache_selectAreaFirst": "Сначала выберите область для кэширования",
|
||||
"mapCache_noTilesToDownload": "Нет плиток для загрузки в этой области",
|
||||
"mapCache_downloadTilesTitle": "Загрузить плитки",
|
||||
"mapCache_downloadTilesPrompt": "Загрузить {count} плиток для офлайн-использования?",
|
||||
"mapCache_downloadAction": "Загрузить",
|
||||
"mapCache_cachedTiles": "Закэшировано {count} плиток",
|
||||
"mapCache_cachedTilesWithFailed": "Закэшировано {downloaded} плиток ({failed} не загружено)",
|
||||
"mapCache_clearOfflineCacheTitle": "Очистить офлайн-кэш",
|
||||
"mapCache_clearOfflineCachePrompt": "Удалить все закэшированные плитки карты?",
|
||||
"mapCache_offlineCacheCleared": "Офлайн-кэш очищен",
|
||||
"mapCache_noAreaSelected": "Область не выбрана",
|
||||
"mapCache_cacheArea": "Область кэширования",
|
||||
"mapCache_useCurrentView": "Использовать текущий вид",
|
||||
"mapCache_zoomRange": "Диапазон масштаба",
|
||||
"mapCache_estimatedTiles": "Оценочное количество плиток: {count}",
|
||||
"mapCache_downloadedTiles": "Загружено {completed} из {total}",
|
||||
"mapCache_downloadTilesButton": "Загрузить плитки",
|
||||
"mapCache_clearCacheButton": "Очистить кэш",
|
||||
"mapCache_failedDownloads": "Неудачных загрузок: {count}",
|
||||
"mapCache_boundsLabel": "С {north}, Ю {south}, В {east}, З {west}",
|
||||
"time_justNow": "Только что",
|
||||
"time_minutesAgo": "{minutes} мин назад",
|
||||
"time_hoursAgo": "{hours} ч назад",
|
||||
"time_daysAgo": "{days} дн. назад",
|
||||
"time_hour": "час",
|
||||
"time_hours": "часов",
|
||||
"time_day": "день",
|
||||
"time_days": "дней",
|
||||
"time_week": "неделя",
|
||||
"time_weeks": "недель",
|
||||
"time_month": "месяц",
|
||||
"time_months": "месяцев",
|
||||
"time_minutes": "минут",
|
||||
"time_allTime": "Всё время",
|
||||
"dialog_disconnect": "Отключиться",
|
||||
"dialog_disconnectConfirm": "Вы уверены, что хотите отключиться от этого устройства?",
|
||||
"login_repeaterLogin": "Вход в репитер",
|
||||
"login_roomLogin": "Вход на сервер комнат",
|
||||
"login_password": "Пароль",
|
||||
"login_enterPassword": "Введите пароль",
|
||||
"login_savePassword": "Сохранить пароль",
|
||||
"login_savePasswordSubtitle": "Пароль будет надёжно сохранён на этом устройстве",
|
||||
"login_repeaterDescription": "Введите пароль репитера для доступа к настройкам и статусу.",
|
||||
"login_roomDescription": "Введите пароль комнаты для доступа к настройкам и статусу.",
|
||||
"login_routing": "Маршрутизация",
|
||||
"login_routingMode": "Режим маршрутизации",
|
||||
"login_autoUseSavedPath": "Авто (использовать сохранённый маршрут)",
|
||||
"login_forceFloodMode": "Принудительный режим рассылки",
|
||||
"login_managePaths": "Управление маршрутами",
|
||||
"login_login": "Войти",
|
||||
"login_attempt": "Попытка {current}/{max}",
|
||||
"login_failed": "Ошибка входа: {error}",
|
||||
"login_failedMessage": "Не удалось войти. Либо пароль неверен, либо репитер недоступен.",
|
||||
"common_reload": "Обновить",
|
||||
"common_clear": "Очистить",
|
||||
"path_currentPath": "Текущий маршрут: {path}",
|
||||
"path_usingHopsPath": "Используется маршрут из {count} {count, plural, one{хоп} few{хопа} many{хопов} other{хопов}}",
|
||||
"path_enterCustomPath": "Введите маршрут вручную",
|
||||
"path_currentPathLabel": "Текущий маршрут",
|
||||
"path_hexPrefixInstructions": "Введите 2-символьные шестнадцатеричные префиксы для каждого хопа, разделённые запятыми.",
|
||||
"path_hexPrefixExample": "Пример: A1,F2,3C (каждый узел использует первый байт своего публичного ключа)",
|
||||
"path_labelHexPrefixes": "Маршрут (шестнадцатеричные префиксы)",
|
||||
"path_helperMaxHops": "Максимум 64 хопа. Каждый префикс — 2 шестнадцатеричных символа (1 байт)",
|
||||
"path_selectFromContacts": "Или выберите из контактов:",
|
||||
"path_noRepeatersFound": "Репитеры или серверы комнат не найдены.",
|
||||
"path_customPathsRequire": "Пользовательские маршруты требуют промежуточных узлов, способных ретранслировать сообщения.",
|
||||
"path_invalidHexPrefixes": "Недопустимые шестнадцатеричные префиксы: {prefixes}",
|
||||
"path_tooLong": "Маршрут слишком длинный. Максимум 64 хопа.",
|
||||
"path_setPath": "Установить маршрут",
|
||||
"repeater_management": "Управление репитером",
|
||||
"room_management": "Управление сервером комнат",
|
||||
"repeater_managementTools": "Инструменты управления",
|
||||
"repeater_status": "Статус",
|
||||
"repeater_statusSubtitle": "Просмотр статуса, статистики и соседей репитера",
|
||||
"repeater_telemetry": "Телеметрия",
|
||||
"repeater_telemetrySubtitle": "Просмотр телеметрии датчиков и системной статистики",
|
||||
"repeater_cli": "CLI",
|
||||
"repeater_cliSubtitle": "Отправка команд репитеру",
|
||||
"repeater_neighbours": "Соседи",
|
||||
"repeater_neighboursSubtitle": "Просмотр соседей на нулевом хопе.",
|
||||
"repeater_settings": "Настройки",
|
||||
"repeater_settingsSubtitle": "Настройка параметров репитера",
|
||||
"repeater_statusTitle": "Статус репитера",
|
||||
"repeater_routingMode": "Режим маршрутизации",
|
||||
"repeater_autoUseSavedPath": "Авто (использовать сохранённый маршрут)",
|
||||
"repeater_forceFloodMode": "Принудительный режим рассылки",
|
||||
"repeater_pathManagement": "Управление маршрутами",
|
||||
"repeater_refresh": "Обновить",
|
||||
"repeater_statusRequestTimeout": "Время ожидания статуса истекло.",
|
||||
"repeater_errorLoadingStatus": "Ошибка загрузки статуса: {error}",
|
||||
"repeater_systemInformation": "Системная информация",
|
||||
"repeater_battery": "Батарея",
|
||||
"repeater_clockAtLogin": "Время (при входе)",
|
||||
"repeater_uptime": "Время работы",
|
||||
"repeater_queueLength": "Длина очереди",
|
||||
"repeater_debugFlags": "Флаги отладки",
|
||||
"repeater_radioStatistics": "Радиостатистика",
|
||||
"repeater_lastRssi": "Последний RSSI",
|
||||
"repeater_lastSnr": "Последний SNR",
|
||||
"repeater_noiseFloor": "Уровень шума",
|
||||
"repeater_txAirtime": "Время эфира (передача)",
|
||||
"repeater_rxAirtime": "Время эфира (приём)",
|
||||
"repeater_packetStatistics": "Статистика пакетов",
|
||||
"repeater_sent": "Отправлено",
|
||||
"repeater_received": "Получено",
|
||||
"repeater_duplicates": "Дубликаты",
|
||||
"repeater_daysHoursMinsSecs": "{days} дн. {hours}ч {minutes}м {seconds}с",
|
||||
"repeater_packetTxTotal": "Всего: {total}, Рассылка: {flood}, Прямые: {direct}",
|
||||
"repeater_packetRxTotal": "Всего: {total}, Рассылка: {flood}, Прямые: {direct}",
|
||||
"repeater_duplicatesFloodDirect": "Рассылка: {flood}, Прямые: {direct}",
|
||||
"repeater_duplicatesTotal": "Всего: {total}",
|
||||
"repeater_settingsTitle": "Настройки репитера",
|
||||
"repeater_basicSettings": "Основные настройки",
|
||||
"repeater_repeaterName": "Имя репитера",
|
||||
"repeater_repeaterNameHelper": "Отображаемое имя этого репитера",
|
||||
"repeater_adminPassword": "Пароль администратора",
|
||||
"repeater_adminPasswordHelper": "Пароль с полным доступом",
|
||||
"repeater_guestPassword": "Гостевой пароль",
|
||||
"repeater_guestPasswordHelper": "Пароль для доступа только для чтения",
|
||||
"repeater_radioSettings": "Настройки радио",
|
||||
"repeater_frequencyMhz": "Частота (МГц)",
|
||||
"repeater_frequencyHelper": "300–2500 МГц",
|
||||
"repeater_txPower": "Мощность передачи",
|
||||
"repeater_txPowerHelper": "1–30 дБм",
|
||||
"repeater_bandwidth": "Полоса пропускания",
|
||||
"repeater_spreadingFactor": "Коэффициент расширения",
|
||||
"repeater_codingRate": "Коэффициент кодирования",
|
||||
"repeater_locationSettings": "Настройки местоположения",
|
||||
"repeater_latitude": "Широта",
|
||||
"repeater_latitudeHelper": "В десятичных градусах (напр., 37.7749)",
|
||||
"repeater_longitude": "Долгота",
|
||||
"repeater_longitudeHelper": "В десятичных градусах (напр., -122.4194)",
|
||||
"repeater_features": "Функции",
|
||||
"repeater_packetForwarding": "Пересылка пакетов",
|
||||
"repeater_packetForwardingSubtitle": "Разрешить репитеру пересылать пакеты",
|
||||
"repeater_guestAccess": "Гостевой доступ",
|
||||
"repeater_guestAccessSubtitle": "Разрешить гостевой доступ только для чтения",
|
||||
"repeater_privacyMode": "Режим конфиденциальности",
|
||||
"repeater_privacyModeSubtitle": "Скрывать имя/местоположение в оповещениях",
|
||||
"repeater_advertisementSettings": "Настройки анонсирования",
|
||||
"repeater_localAdvertInterval": "Интервал локальных анонсирований",
|
||||
"repeater_localAdvertIntervalMinutes": "{minutes} минут",
|
||||
"repeater_floodAdvertInterval": "Интервал анонсирований рассылкой (flood)",
|
||||
"repeater_floodAdvertIntervalHours": "{hours} часов",
|
||||
"repeater_encryptedAdvertInterval": "Интервал зашифрованных анонсирований",
|
||||
"repeater_dangerZone": "Опасная зона",
|
||||
"repeater_rebootRepeater": "Перезагрузить репитер",
|
||||
"repeater_rebootRepeaterSubtitle": "Перезапустить устройство репитера",
|
||||
"repeater_rebootRepeaterConfirm": "Вы уверены, что хотите перезагрузить этот репитер?",
|
||||
"repeater_regenerateIdentityKey": "Пересоздать ключ идентификации",
|
||||
"repeater_regenerateIdentityKeySubtitle": "Сгенерировать новую пару публичного/приватного ключей",
|
||||
"repeater_regenerateIdentityKeyConfirm": "Это создаст новую идентичность для репитера. Продолжить?",
|
||||
"repeater_eraseFileSystem": "Стереть файловую систему",
|
||||
"repeater_eraseFileSystemSubtitle": "Отформатировать файловую систему репитера",
|
||||
"repeater_eraseFileSystemConfirm": "ВНИМАНИЕ: это удалит все данные на репитере. Действие нельзя отменить!",
|
||||
"repeater_eraseSerialOnly": "Очистка доступна только через последовательную консоль.",
|
||||
"repeater_commandSent": "Команда отправлена: {command}",
|
||||
"repeater_errorSendingCommand": "Ошибка отправки команды: {error}",
|
||||
"repeater_confirm": "Подтвердить",
|
||||
"repeater_settingsSaved": "Настройки успешно сохранены",
|
||||
"repeater_errorSavingSettings": "Ошибка сохранения настроек: {error}",
|
||||
"repeater_refreshBasicSettings": "Обновить основные настройки",
|
||||
"repeater_refreshRadioSettings": "Обновить настройки радио",
|
||||
"repeater_refreshTxPower": "Обновить мощность передачи",
|
||||
"repeater_refreshLocationSettings": "Обновить настройки местоположения",
|
||||
"repeater_refreshPacketForwarding": "Обновить пересылку пакетов",
|
||||
"repeater_refreshGuestAccess": "Обновить гостевой доступ",
|
||||
"repeater_refreshPrivacyMode": "Обновить режим конфиденциальности",
|
||||
"repeater_refreshAdvertisementSettings": "Обновить настройки анонсирований",
|
||||
"repeater_refreshed": "{label} обновлён",
|
||||
"repeater_errorRefreshing": "Ошибка обновления {label}",
|
||||
"repeater_cliTitle": "CLI репитера",
|
||||
"repeater_debugNextCommand": "Отладка следующей команды",
|
||||
"repeater_commandHelp": "Справка по командам",
|
||||
"repeater_clearHistory": "Очистить историю",
|
||||
"repeater_noCommandsSent": "Команды ещё не отправлялись",
|
||||
"repeater_typeCommandOrUseQuick": "Введите команду ниже или используйте быстрые команды",
|
||||
"repeater_enterCommandHint": "Введите команду...",
|
||||
"repeater_previousCommand": "Предыдущая команда",
|
||||
"repeater_nextCommand": "Следующая команда",
|
||||
"repeater_enterCommandFirst": "Сначала введите команду",
|
||||
"repeater_cliCommandFrameTitle": "Фрейм CLI-команды",
|
||||
"repeater_cliCommandError": "Ошибка: {error}",
|
||||
"repeater_cliQuickGetName": "Получить имя",
|
||||
"repeater_cliQuickGetRadio": "Получить радио",
|
||||
"repeater_cliQuickGetTx": "Получить TX",
|
||||
"repeater_cliQuickNeighbors": "Соседи",
|
||||
"repeater_cliQuickVersion": "Версия",
|
||||
"repeater_cliQuickAdvertise": "Анонсировать",
|
||||
"repeater_cliQuickClock": "Время",
|
||||
"repeater_cliHelpAdvert": "Отправляет пакет анонсирования",
|
||||
"repeater_cliHelpReboot": "Перезагружает устройство. (обычно вы получите «Тайм-аут» — это нормально)",
|
||||
"repeater_cliHelpClock": "Показывает текущее время по часам устройства.",
|
||||
"repeater_cliHelpPassword": "Устанавливает новый пароль администратора для устройства.",
|
||||
"repeater_cliHelpVersion": "Показывает версию устройства и дату сборки прошивки.",
|
||||
"repeater_cliHelpClearStats": "Сбрасывает различные счётчики статистики в ноль.",
|
||||
"repeater_cliHelpSetAf": "Устанавливает коэффициент времени в эфире.",
|
||||
"repeater_cliHelpSetTx": "Устанавливает мощность передачи LoRa в дБм. (требуется перезагрузка)",
|
||||
"repeater_cliHelpSetRepeat": "Включает или отключает роль репитера для этой ноды.",
|
||||
"repeater_cliHelpSetAllowReadOnly": "(Сервер комнат) Если «on», то вход без пароля разрешён, но публиковать в комнату нельзя (только чтение)",
|
||||
"repeater_cliHelpSetFloodMax": "Устанавливает максимальное число хопов для входящих пакетов в режиме рассылки (если >= макс., пакет не пересылается)",
|
||||
"repeater_cliHelpSetIntThresh": "Устанавливает порог интерференции (в дБ). По умолчанию 14. Установите 0, чтобы отключить обнаружение помех.",
|
||||
"repeater_cliHelpSetAgcResetInterval": "Устанавливает интервал сброса автоматической регулировки усиления. Установите 0, чтобы отключить.",
|
||||
"repeater_cliHelpSetMultiAcks": "Включает или отключает функцию «двойных ACK».",
|
||||
"repeater_cliHelpSetAdvertInterval": "Устанавливает интервал (в минутах) отправки локального (нулевой хоп) анонсирования. Установите 0, чтобы отключить.",
|
||||
"repeater_cliHelpSetFloodAdvertInterval": "Устанавливает интервал (в часах) отправки анонсирований рассылкой. Установите 0, чтобы отключить.",
|
||||
"repeater_cliHelpSetGuestPassword": "Устанавливает/обновляет гостевой пароль. (для репитеров гости могут отправлять запрос «Get Stats»)",
|
||||
"repeater_cliHelpSetName": "Устанавливает имя в оповещениях.",
|
||||
"repeater_cliHelpSetLat": "Устанавливает широту для карты в оповещениях. (десятичные градусы)",
|
||||
"repeater_cliHelpSetLon": "Устанавливает долготу для карты в оповещениях. (десятичные градусы)",
|
||||
"repeater_cliHelpSetRadio": "Устанавливает полностью новые параметры радио и сохраняет их в настройки. Требуется команда «reboot» для применения.",
|
||||
"repeater_cliHelpSetRxDelay": "Устанавливает (экспериментально) базовую задержку (>1 для эффекта) для принятых пакетов на основе качества сигнала. Установите 0, чтобы отключить.",
|
||||
"repeater_cliHelpSetTxDelay": "Устанавливает множитель времени в эфире для пакета в режиме рассылки и применяет случайную задержку перед пересылкой (чтобы уменьшить коллизии).",
|
||||
"repeater_cliHelpSetDirectTxDelay": "То же, что txdelay, но для случайной задержки пересылки пакетов в прямом режиме.",
|
||||
"repeater_cliHelpSetBridgeEnabled": "Включить/выключить мост.",
|
||||
"repeater_cliHelpSetBridgeDelay": "Установить задержку перед ретрансляцией пакетов.",
|
||||
"repeater_cliHelpSetBridgeSource": "Выбрать, будет ли мост ретранслировать полученные или отправленные пакеты.",
|
||||
"repeater_cliHelpSetBridgeBaud": "Установить скорость последовательного соединения для мостов RS232.",
|
||||
"repeater_cliHelpSetBridgeSecret": "Установить секрет моста для мостов ESP-NOW.",
|
||||
"repeater_cliHelpSetAdcMultiplier": "Устанавливает пользовательский коэффициент коррекции напряжения батареи (поддерживается только на некоторых платах).",
|
||||
"repeater_cliHelpTempRadio": "Устанавливает временные параметры радио на заданное число минут, затем возвращает исходные. (НЕ сохраняется в настройки).",
|
||||
"repeater_cliHelpSetPerm": "Изменяет ACL. Удаляет запись (по префиксу публичного ключа), если «permissions» равен нулю. Добавляет новую запись, если указан полный ключ и он отсутствует в ACL. Обновляет запись по совпадению префикса. Биты прав зависят от роли прошивки, но младшие 2 бита: 0 (Гость), 1 (Только чтение), 2 (Чтение/запись), 3 (Админ)",
|
||||
"repeater_cliHelpGetBridgeType": "Получает тип моста: none, rs232, espnow",
|
||||
"repeater_cliHelpLogStart": "Начинает запись пакетов в файловую систему.",
|
||||
"repeater_cliHelpLogStop": "Останавливает запись пакетов в файловую систему.",
|
||||
"repeater_cliHelpLogErase": "Удаляет журналы пакетов из файловой системы.",
|
||||
"repeater_cliHelpNeighbors": "Показывает список других репитеров, услышанных через оповещения нулевого хопа. Каждая строка: префикс-id-в-hex:временная-метка:snr×4",
|
||||
"repeater_cliHelpNeighborRemove": "Удаляет первую подходящую запись (по префиксу публичного ключа в hex) из списка соседей.",
|
||||
"repeater_cliHelpRegion": "(только через последовательный порт) Показывает все определённые регионы и текущие права на рассылку.",
|
||||
"repeater_cliHelpRegionLoad": "ПРИМЕЧАНИЕ: это специальная многострочная команда. Каждая следующая строка — имя региона (с отступом пробелами для указания иерархии, минимум один пробел). Завершается пустой строкой.",
|
||||
"repeater_cliHelpRegionGet": "Ищет регион по префиксу имени (или «*» для глобальной области). Отвечает: «-> имя-региона (родитель) 'F'»",
|
||||
"repeater_cliHelpRegionPut": "Добавляет или обновляет определение региона с заданным именем.",
|
||||
"repeater_cliHelpRegionRemove": "Удаляет определение региона с заданным именем. (должно точно совпадать и не иметь дочерних регионов)",
|
||||
"repeater_cliHelpRegionAllowf": "Разрешает рассылку («F»lood) для заданного региона. («*» для глобальной/устаревшей области)",
|
||||
"repeater_cliHelpRegionDenyf": "Запрещает рассылку («F»lood) для заданного региона. (НЕ рекомендуется для глобальной области!)",
|
||||
"repeater_cliHelpRegionHome": "Показывает текущий «домашний» регион. (Пока не используется, зарезервировано на будущее)",
|
||||
"repeater_cliHelpRegionHomeSet": "Устанавливает «домашний» регион.",
|
||||
"repeater_cliHelpRegionSave": "Сохраняет список/карту регионов в память.",
|
||||
"repeater_cliHelpGps": "Показывает статус GPS. Если GPS выключен — отвечает только «off». Если включён — показывает статус, фиксацию, количество спутников.",
|
||||
"repeater_cliHelpGpsOnOff": "Переключает состояние питания GPS.",
|
||||
"repeater_cliHelpGpsSync": "Синхронизирует время ноды с часами GPS.",
|
||||
"repeater_cliHelpGpsSetLoc": "Устанавливает позицию ноды по координатам GPS и сохраняет в настройки.",
|
||||
"repeater_cliHelpGpsAdvert": "Показывает конфигурацию передачи местоположения в анонсированиях:\n- none: не включать местоположение\n- share: передавать GPS-координаты (из SensorManager)\n- prefs: передавать координаты из настроек",
|
||||
"repeater_cliHelpGpsAdvertSet": "Устанавливает конфигурацию передачи местоположения.",
|
||||
"repeater_commandsListTitle": "Список команд",
|
||||
"repeater_commandsListNote": "ПРИМЕЧАНИЕ: для большинства команд «set ...» существуют соответствующие команды «get ...».",
|
||||
"repeater_general": "Общие",
|
||||
"repeater_settingsCategory": "Настройки",
|
||||
"repeater_bridge": "Мост",
|
||||
"repeater_logging": "Журналирование",
|
||||
"repeater_neighborsRepeaterOnly": "Соседи (только для репитеров)",
|
||||
"repeater_regionManagementRepeaterOnly": "Управление регионами (только для репитеров)",
|
||||
"repeater_regionNote": "Команды регионов введены для управления определениями регионов и правами доступа.",
|
||||
"repeater_gpsManagement": "Управление GPS",
|
||||
"repeater_gpsNote": "Команда gps введена для управления параметрами, связанными с местоположением.",
|
||||
"telemetry_receivedData": "Полученные телеметрические данные",
|
||||
"telemetry_requestTimeout": "Время ожидания телеметрии истекло.",
|
||||
"telemetry_errorLoading": "Ошибка загрузки телеметрии: {error}",
|
||||
"telemetry_noData": "Данные телеметрии недоступны.",
|
||||
"telemetry_channelTitle": "Канал {channel}",
|
||||
"telemetry_batteryLabel": "Батарея",
|
||||
"telemetry_voltageLabel": "Напряжение",
|
||||
"telemetry_mcuTemperatureLabel": "Температура МК",
|
||||
"telemetry_temperatureLabel": "Температура",
|
||||
"telemetry_currentLabel": "Ток",
|
||||
"telemetry_batteryValue": "{percent}% / {volts}В",
|
||||
"telemetry_voltageValue": "{volts}В",
|
||||
"telemetry_currentValue": "{amps}А",
|
||||
"telemetry_temperatureValue": "{celsius}°C / {fahrenheit}°F",
|
||||
"neighbors_receivedData": "Полученные данные о соседях",
|
||||
"neighbors_requestTimedOut": "Время ожидания данных о соседях истекло.",
|
||||
"neighbors_errorLoading": "Ошибка загрузки соседей: {error}",
|
||||
"neighbors_repeatersNeighbours": "Соседи репитеров",
|
||||
"neighbors_noData": "Данные о соседях недоступны.",
|
||||
"neighbors_unknownContact": "Неизвестный {pubkey}",
|
||||
"neighbors_heardA ago": "Слышали: {time} назад",
|
||||
"channelPath_title": "Путь пакета",
|
||||
"channelPath_viewMap": "Посмотреть на карте",
|
||||
"channelPath_otherObservedPaths": "Другие наблюдаемые пути",
|
||||
"channelPath_repeaterHops": "Хопы через репитеры",
|
||||
"channelPath_noHopDetails": "Детали хопов для этого пакета не предоставлены.",
|
||||
"channelPath_messageDetails": "Детали сообщения",
|
||||
"channelPath_senderLabel": "Отправитель",
|
||||
"channelPath_timeLabel": "Время",
|
||||
"channelPath_repeatsLabel": "Повторы",
|
||||
"channelPath_pathLabel": "Путь {index}",
|
||||
"channelPath_observedLabel": "Наблюдаемый",
|
||||
"channelPath_observedPathTitle": "Наблюдаемый путь {index} • {hops}",
|
||||
"channelPath_noLocationData": "Нет данных о местоположении",
|
||||
"channelPath_timeWithDate": "{day}/{month} {time}",
|
||||
"channelPath_timeOnly": "{time}",
|
||||
"channelPath_unknownPath": "Неизвестный",
|
||||
"channelPath_floodPath": "Рассылка",
|
||||
"channelPath_directPath": "Прямой",
|
||||
"channelPath_observedZeroOf": "0 из {total} хопов",
|
||||
"channelPath_observedSomeOf": "{observed} из {total} хопов",
|
||||
"channelPath_mapTitle": "Карта пути",
|
||||
"channelPath_noRepeaterLocations": "Нет данных о местоположении репитеров для этого пути.",
|
||||
"channelPath_primaryPath": "Путь {index} (Основной)",
|
||||
"channelPath_pathLabelTitle": "Путь",
|
||||
"channelPath_observedPathHeader": "Наблюдаемый путь",
|
||||
"channelPath_selectedPathLabel": "{label} • {prefixes}",
|
||||
"channelPath_noHopDetailsAvailable": "Детали хопов для этого пакета недоступны.",
|
||||
"channelPath_unknownRepeater": "Неизвестный репитер",
|
||||
"community_title": "Сообщество",
|
||||
"community_create": "Создать сообщество",
|
||||
"community_createDesc": "Создать новое сообщество и поделиться через QR-код.",
|
||||
"community_join": "Присоединиться",
|
||||
"community_joinTitle": "Присоединиться к сообществу",
|
||||
"community_joinConfirmation": "Вы хотите присоединиться к сообществу \"{name}\"?",
|
||||
"community_scanQr": "Сканировать QR-код сообщества",
|
||||
"community_scanInstructions": "Наведите камеру на QR-код сообщества",
|
||||
"community_showQr": "Показать QR-код",
|
||||
"community_publicChannel": "Публичный канал сообщества",
|
||||
"community_hashtagChannel": "Хэштег-канал сообщества",
|
||||
"community_name": "Имя сообщества",
|
||||
"community_enterName": "Введите имя сообщества",
|
||||
"community_created": "Сообщество \"{name}\" создано",
|
||||
"community_joined": "Присоединились к сообществу \"{name}\"",
|
||||
"community_qrTitle": "Поделиться сообществом",
|
||||
"community_qrInstructions": "Отсканируйте этот QR-код, чтобы присоединиться к \"{name}\"",
|
||||
"community_hashtagPrivacyHint": "Хэштег-каналы сообщества доступны только его участникам",
|
||||
"community_invalidQrCode": "Недопустимый QR-код сообщества",
|
||||
"community_alreadyMember": "Уже участник",
|
||||
"community_alreadyMemberMessage": "Вы уже участник сообщества \"{name}\".",
|
||||
"community_addPublicChannel": "Добавить публичный канал сообщества",
|
||||
"community_addPublicChannelHint": "Автоматически добавить публичный канал для этого сообщества",
|
||||
"community_noCommunities": "Вы ещё не присоединились ни к одному сообществу",
|
||||
"community_scanOrCreate": "Отсканируйте QR-код или создайте сообщество, чтобы начать",
|
||||
"community_manageCommunities": "Управление сообществами",
|
||||
"community_delete": "Покинуть сообщество",
|
||||
"community_deleteConfirm": "Покинуть \"{name}\"?",
|
||||
"community_deleteChannelsWarning": "Это также удалит {count} канал(ов) и их сообщения.",
|
||||
"community_deleted": "Покинули сообщество \"{name}\"",
|
||||
"community_regenerateSecret": "Пересоздать секрет",
|
||||
"community_regenerateSecretConfirm": "Пересоздать секретный ключ для \"{name}\"? Все участники должны будут отсканировать новый QR-код для продолжения общения.",
|
||||
"community_regenerate": "Пересоздать",
|
||||
"community_secretRegenerated": "Секрет пересоздан для \"{name}\"",
|
||||
"community_updateSecret": "Обновить секрет",
|
||||
"community_secretUpdated": "Секрет обновлён для \"{name}\"",
|
||||
"community_scanToUpdateSecret": "Отсканируйте новый QR-код, чтобы обновить секрет для \"{name}\"",
|
||||
"community_addHashtagChannel": "Добавить хэштег-канал сообщества",
|
||||
"community_addHashtagChannelDesc": "Добавить хэштег-канал для этого сообщества",
|
||||
"community_selectCommunity": "Выбрать сообщество",
|
||||
"community_regularHashtag": "Обычный хэштег",
|
||||
"community_regularHashtagDesc": "Публичный хэштег (любой может присоединиться)",
|
||||
"community_communityHashtag": "Хэштег сообщества",
|
||||
"community_communityHashtagDesc": "Доступен только участникам сообщества",
|
||||
"community_forCommunity": "Для {name}",
|
||||
"listFilter_tooltip": "Фильтр и сортировка",
|
||||
"listFilter_sortBy": "Сортировка по",
|
||||
"listFilter_latestMessages": "Последние сообщения",
|
||||
"listFilter_heardRecently": "Слышали недавно",
|
||||
"listFilter_az": "По алфавиту",
|
||||
"listFilter_filters": "Фильтры",
|
||||
"listFilter_all": "Все",
|
||||
"listFilter_users": "Пользователи",
|
||||
"listFilter_repeaters": "Репитеры",
|
||||
"listFilter_roomServers": "Серверы комнат",
|
||||
"listFilter_unreadOnly": "Только непрочитанные",
|
||||
"listFilter_newGroup": "Новая группа",
|
||||
"@chat_couldNotOpenLink": {
|
||||
"placeholders": {
|
||||
"url": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@neighbors_heardAgo": {
|
||||
"placeholders": {
|
||||
"time": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"chat_open": "Открыть",
|
||||
"chat_couldNotOpenLink": "Не удалось открыть ссылку: {url}",
|
||||
"chat_openLink": "Открыть ссылку?",
|
||||
"chat_openLinkConfirmation": "Хотите открыть эту ссылку в вашем браузере?",
|
||||
"neighbors_heardAgo": "Слушал(а): {time} назад",
|
||||
"chat_invalidLink": "Неправильный формат ссылки",
|
||||
"@contacts_pathTraceTo": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"pathTrace_you": "Вы",
|
||||
"pathTrace_failed": "Путь трассировки не выполнен.",
|
||||
"pathTrace_notAvailable": "Трассировка пути недоступна.",
|
||||
"pathTrace_refreshTooltip": "Обновить Path Trace",
|
||||
"contacts_pathTrace": "Трассировка пути",
|
||||
"contacts_ping": "Пинговать",
|
||||
"contacts_repeaterPathTrace": "Отследить путь к ретранслятору",
|
||||
"contacts_repeaterPing": "Пинговать повторитель",
|
||||
"contacts_roomPathTrace": "Трассировка пути к серверу комнаты",
|
||||
"contacts_roomPing": "Пинговать сервер комнаты",
|
||||
"contacts_chatTraceRoute": "Трассировка маршрута",
|
||||
"contacts_pathTraceTo": "Показать маршрут к {name}",
|
||||
"contacts_contactImported": "Контакт был импортирован",
|
||||
"contacts_contactImportFailed": "Контакт не удалось импортировать",
|
||||
"contacts_invalidAdvertFormat": "Недействительные контактные данные",
|
||||
"contacts_zeroHopAdvert": "Реклама Zero Hop",
|
||||
"appSettings_languageUk": "Українська",
|
||||
"contacts_floodAdvert": "Рекламный поток",
|
||||
"contacts_clipboardEmpty": "Буфер обмена пуст.",
|
||||
"contacts_copyAdvertToClipboard": "Копировать рекламу в буфер обмена",
|
||||
"contacts_ShareContact": "Копировать контакт в буфер обмена",
|
||||
"contacts_zeroHopContactAdvertFailed": "Не удалось отправить контакт.",
|
||||
"contacts_contactAdvertCopied": "Реклама скопирована в буфер обмена.",
|
||||
"contacts_contactAdvertCopyFailed": "Копирование рекламы в буфер обмена не удалось.",
|
||||
"contacts_addContactFromClipboard": "Добавить контакт из буфера обмена",
|
||||
"contacts_ShareContactZeroHop": "Поделиться контактом по объявлению",
|
||||
"contacts_zeroHopContactAdvertSent": "Отправлено сообщение по объявлению.",
|
||||
"notification_activityTitle": "Активность MeshCore",
|
||||
"notification_messagesCount": "{count} {count, plural, =1{сообщение} few{сообщения} many{сообщений} other{сообщений}}",
|
||||
"notification_channelMessagesCount": "{count} {count, plural, =1{сообщение канала} few{сообщения канала} many{сообщений канала} other{сообщений канала}}",
|
||||
"notification_newNodesCount": "{count} {count, plural, =1{новый узел} few{новых узла} many{новых узлов} other{новых узлов}}",
|
||||
"notification_newTypeDiscovered": "Обнаружен новый {contactType}",
|
||||
"notification_receivedNewMessage": "Получено новое сообщение",
|
||||
"settings_gpxExportRepeaters": "Экспортировать рипитеры / сервер комнаты в GPX",
|
||||
"settings_gpxExportRepeatersSubtitle": "Экспортирует ретрансляторы / сервер комнат с местоположением в файл GPX.",
|
||||
"settings_gpxExportContacts": "Экспортировать спутников в GPX",
|
||||
"settings_gpxExportNotAvailable": "Не поддерживается на вашем устройстве/ОС",
|
||||
"settings_gpxExportError": "Произошла ошибка при экспорте.",
|
||||
"settings_gpxExportRepeatersRoom": "Местоположения повторителей и серверов комнат",
|
||||
"settings_gpxExportChat": "Местоположения спутников",
|
||||
"settings_gpxExportContactsSubtitle": "Экспортирует спутников с местоположением в файл GPX.",
|
||||
"settings_gpxExportAll": "Экспортировать все контакты в GPX",
|
||||
"settings_gpxExportAllSubtitle": "Экспортирует все контакты с местоположением в файл GPX.",
|
||||
"settings_gpxExportAllContacts": "Все местоположения контактов",
|
||||
"settings_gpxExportSuccess": "Успешно экспортирован файл GPX.",
|
||||
"settings_gpxExportNoContacts": "Нет контактов для экспорта.",
|
||||
"settings_gpxExportShareText": "Данные карты экспортированы из meshcore-open",
|
||||
"settings_gpxExportShareSubject": "meshcore-open экспорт данных карты GPX",
|
||||
"pathTrace_someHopsNoLocation": "Одному или нескольким хмелям не указано местоположение!",
|
||||
"map_tapToAdd": "Нажимайте на узлы, чтобы добавить их в путь.",
|
||||
"map_removeLast": "Удалить последний",
|
||||
"map_pathTraceCancelled": "Отмена трассировки пути",
|
||||
"pathTrace_clearTooltip": "Очистить путь",
|
||||
"map_runTrace": "Запустить трассировку пути",
|
||||
"scanner_enableBluetooth": "Включите Bluetooth",
|
||||
"scanner_bluetoothOff": "Bluetooth выключен",
|
||||
"scanner_bluetoothOffMessage": "Пожалуйста, включите Bluetooth, чтобы найти устройства.",
|
||||
"settings_clientRepeatFreqWarning": "Для работы в режиме \"без подключения к сети\" требуется частота 433, 869 или 918 МГц.",
|
||||
"settings_clientRepeatSubtitle": "Позвольте этому устройству повторять пакеты данных для других устройств.",
|
||||
"settings_clientRepeat": "Повторение \"вне сети\""
|
||||
}
|
||||
+1600
File diff suppressed because it is too large
Load Diff
+1600
File diff suppressed because it is too large
Load Diff
+1600
File diff suppressed because it is too large
Load Diff
+1600
File diff suppressed because it is too large
Load Diff
+1600
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,6 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'app_localizations.dart';
|
||||
|
||||
extension LocalizationExtension on BuildContext {
|
||||
AppLocalizations get l10n => AppLocalizations.of(this);
|
||||
}
|
||||
+46
-12
@@ -1,4 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'l10n/app_localizations.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'connector/meshcore_connector.dart';
|
||||
@@ -25,7 +27,7 @@ void main() async {
|
||||
final storage = StorageService();
|
||||
final connector = MeshCoreConnector();
|
||||
final pathHistoryService = PathHistoryService(storage);
|
||||
final retryService = MessageRetryService(storage);
|
||||
final retryService = MessageRetryService();
|
||||
final appSettingsService = AppSettingsService();
|
||||
final bleDebugLogService = BleDebugLogService();
|
||||
final appDebugLogService = AppDebugLogService();
|
||||
@@ -58,21 +60,24 @@ void main() async {
|
||||
|
||||
await connector.loadContactCache();
|
||||
await connector.loadChannelSettings();
|
||||
await connector.loadCachedChannels();
|
||||
|
||||
// Load persisted channel messages
|
||||
await connector.loadAllChannelMessages();
|
||||
await connector.loadUnreadState();
|
||||
|
||||
runApp(MeshCoreApp(
|
||||
connector: connector,
|
||||
retryService: retryService,
|
||||
pathHistoryService: pathHistoryService,
|
||||
storage: storage,
|
||||
appSettingsService: appSettingsService,
|
||||
bleDebugLogService: bleDebugLogService,
|
||||
appDebugLogService: appDebugLogService,
|
||||
mapTileCacheService: mapTileCacheService,
|
||||
));
|
||||
runApp(
|
||||
MeshCoreApp(
|
||||
connector: connector,
|
||||
retryService: retryService,
|
||||
pathHistoryService: pathHistoryService,
|
||||
storage: storage,
|
||||
appSettingsService: appSettingsService,
|
||||
bleDebugLogService: bleDebugLogService,
|
||||
appDebugLogService: appDebugLogService,
|
||||
mapTileCacheService: mapTileCacheService,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class MeshCoreApp extends StatelessWidget {
|
||||
@@ -115,9 +120,22 @@ class MeshCoreApp extends StatelessWidget {
|
||||
return MaterialApp(
|
||||
title: 'MeshCore Open',
|
||||
debugShowCheckedModeBanner: false,
|
||||
localizationsDelegates: const [
|
||||
AppLocalizations.delegate,
|
||||
GlobalMaterialLocalizations.delegate,
|
||||
GlobalWidgetsLocalizations.delegate,
|
||||
GlobalCupertinoLocalizations.delegate,
|
||||
],
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
locale: _localeFromSetting(
|
||||
settingsService.settings.languageOverride,
|
||||
),
|
||||
theme: ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
|
||||
useMaterial3: true,
|
||||
snackBarTheme: const SnackBarThemeData(
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
),
|
||||
darkTheme: ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
@@ -125,8 +143,19 @@ class MeshCoreApp extends StatelessWidget {
|
||||
brightness: Brightness.dark,
|
||||
),
|
||||
useMaterial3: true,
|
||||
snackBarTheme: const SnackBarThemeData(
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
),
|
||||
themeMode: _themeModeFromSetting(settingsService.settings.themeMode),
|
||||
themeMode: _themeModeFromSetting(
|
||||
settingsService.settings.themeMode,
|
||||
),
|
||||
builder: (context, child) {
|
||||
// Update notification service with resolved locale
|
||||
final locale = Localizations.localeOf(context);
|
||||
NotificationService().setLocale(locale);
|
||||
return child ?? const SizedBox.shrink();
|
||||
},
|
||||
home: const ScannerScreen(),
|
||||
);
|
||||
},
|
||||
@@ -144,4 +173,9 @@ class MeshCoreApp extends StatelessWidget {
|
||||
return ThemeMode.system;
|
||||
}
|
||||
}
|
||||
|
||||
Locale? _localeFromSetting(String? languageCode) {
|
||||
if (languageCode == null) return null;
|
||||
return Locale(languageCode);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ class AppSettings {
|
||||
final bool notifyOnNewAdvert;
|
||||
final bool autoRouteRotationEnabled;
|
||||
final String themeMode;
|
||||
final String? languageOverride; // null = system default
|
||||
final bool appDebugLogEnabled;
|
||||
final Map<String, String> batteryChemistryByDeviceId;
|
||||
|
||||
@@ -39,6 +40,7 @@ class AppSettings {
|
||||
this.notifyOnNewAdvert = true,
|
||||
this.autoRouteRotationEnabled = false,
|
||||
this.themeMode = 'system',
|
||||
this.languageOverride,
|
||||
this.appDebugLogEnabled = false,
|
||||
Map<String, String>? batteryChemistryByDeviceId,
|
||||
}) : batteryChemistryByDeviceId = batteryChemistryByDeviceId ?? {};
|
||||
@@ -62,6 +64,7 @@ class AppSettings {
|
||||
'notify_on_new_advert': notifyOnNewAdvert,
|
||||
'auto_route_rotation_enabled': autoRouteRotationEnabled,
|
||||
'theme_mode': themeMode,
|
||||
'language_override': languageOverride,
|
||||
'app_debug_log_enabled': appDebugLogEnabled,
|
||||
'battery_chemistry_by_device_id': batteryChemistryByDeviceId,
|
||||
};
|
||||
@@ -73,13 +76,14 @@ class AppSettings {
|
||||
mapShowRepeaters: json['map_show_repeaters'] as bool? ?? true,
|
||||
mapShowChatNodes: json['map_show_chat_nodes'] as bool? ?? true,
|
||||
mapShowOtherNodes: json['map_show_other_nodes'] as bool? ?? true,
|
||||
mapTimeFilterHours: (json['map_time_filter_hours'] as num?)?.toDouble() ?? 0,
|
||||
mapTimeFilterHours:
|
||||
(json['map_time_filter_hours'] as num?)?.toDouble() ?? 0,
|
||||
mapKeyPrefixEnabled: json['map_key_prefix_enabled'] as bool? ?? false,
|
||||
mapKeyPrefix: json['map_key_prefix'] as String? ?? '',
|
||||
mapShowMarkers: json['map_show_markers'] as bool? ?? true,
|
||||
mapCacheBounds: (json['map_cache_bounds'] as Map?)?.map(
|
||||
(key, value) => MapEntry(key.toString(), (value as num).toDouble()),
|
||||
),
|
||||
(key, value) => MapEntry(key.toString(), (value as num).toDouble()),
|
||||
),
|
||||
mapCacheMinZoom: json['map_cache_min_zoom'] as int? ?? 10,
|
||||
mapCacheMaxZoom: json['map_cache_max_zoom'] as int? ?? 15,
|
||||
notificationsEnabled: json['notifications_enabled'] as bool? ?? true,
|
||||
@@ -87,10 +91,13 @@ class AppSettings {
|
||||
notifyOnNewChannelMessage:
|
||||
json['notify_on_new_channel_message'] as bool? ?? true,
|
||||
notifyOnNewAdvert: json['notify_on_new_advert'] as bool? ?? true,
|
||||
autoRouteRotationEnabled: json['auto_route_rotation_enabled'] as bool? ?? false,
|
||||
autoRouteRotationEnabled:
|
||||
json['auto_route_rotation_enabled'] as bool? ?? false,
|
||||
themeMode: json['theme_mode'] as String? ?? 'system',
|
||||
languageOverride: json['language_override'] as String?,
|
||||
appDebugLogEnabled: json['app_debug_log_enabled'] as bool? ?? false,
|
||||
batteryChemistryByDeviceId: (json['battery_chemistry_by_device_id'] as Map?)?.map(
|
||||
batteryChemistryByDeviceId:
|
||||
(json['battery_chemistry_by_device_id'] as Map?)?.map(
|
||||
(key, value) => MapEntry(key.toString(), value.toString()),
|
||||
) ??
|
||||
{},
|
||||
@@ -115,6 +122,7 @@ class AppSettings {
|
||||
bool? notifyOnNewAdvert,
|
||||
bool? autoRouteRotationEnabled,
|
||||
String? themeMode,
|
||||
Object? languageOverride = _unset,
|
||||
bool? appDebugLogEnabled,
|
||||
Map<String, String>? batteryChemistryByDeviceId,
|
||||
}) {
|
||||
@@ -127,8 +135,9 @@ class AppSettings {
|
||||
mapKeyPrefixEnabled: mapKeyPrefixEnabled ?? this.mapKeyPrefixEnabled,
|
||||
mapKeyPrefix: mapKeyPrefix ?? this.mapKeyPrefix,
|
||||
mapShowMarkers: mapShowMarkers ?? this.mapShowMarkers,
|
||||
mapCacheBounds:
|
||||
mapCacheBounds == _unset ? this.mapCacheBounds : mapCacheBounds as Map<String, double>?,
|
||||
mapCacheBounds: mapCacheBounds == _unset
|
||||
? this.mapCacheBounds
|
||||
: mapCacheBounds as Map<String, double>?,
|
||||
mapCacheMinZoom: mapCacheMinZoom ?? this.mapCacheMinZoom,
|
||||
mapCacheMaxZoom: mapCacheMaxZoom ?? this.mapCacheMaxZoom,
|
||||
notificationsEnabled: notificationsEnabled ?? this.notificationsEnabled,
|
||||
@@ -136,10 +145,15 @@ class AppSettings {
|
||||
notifyOnNewChannelMessage:
|
||||
notifyOnNewChannelMessage ?? this.notifyOnNewChannelMessage,
|
||||
notifyOnNewAdvert: notifyOnNewAdvert ?? this.notifyOnNewAdvert,
|
||||
autoRouteRotationEnabled: autoRouteRotationEnabled ?? this.autoRouteRotationEnabled,
|
||||
autoRouteRotationEnabled:
|
||||
autoRouteRotationEnabled ?? this.autoRouteRotationEnabled,
|
||||
themeMode: themeMode ?? this.themeMode,
|
||||
languageOverride: languageOverride == _unset
|
||||
? this.languageOverride
|
||||
: languageOverride as String?,
|
||||
appDebugLogEnabled: appDebugLogEnabled ?? this.appDebugLogEnabled,
|
||||
batteryChemistryByDeviceId: batteryChemistryByDeviceId ?? this.batteryChemistryByDeviceId,
|
||||
batteryChemistryByDeviceId:
|
||||
batteryChemistryByDeviceId ?? this.batteryChemistryByDeviceId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+44
-5
@@ -1,16 +1,21 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:crypto/crypto.dart' as crypto;
|
||||
|
||||
import '../connector/meshcore_protocol.dart';
|
||||
|
||||
class Channel {
|
||||
final int index;
|
||||
final String name;
|
||||
final Uint8List psk; // 16 bytes
|
||||
int unreadCount;
|
||||
|
||||
Channel({
|
||||
required this.index,
|
||||
required this.name,
|
||||
required this.psk,
|
||||
this.unreadCount = 0,
|
||||
});
|
||||
|
||||
String get pskHex => _bytesToHex(psk);
|
||||
@@ -36,11 +41,7 @@ class Channel {
|
||||
}
|
||||
|
||||
static Channel empty(int index) {
|
||||
return Channel(
|
||||
index: index,
|
||||
name: '',
|
||||
psk: Uint8List(16),
|
||||
);
|
||||
return Channel(index: index, name: '', psk: Uint8List(16));
|
||||
}
|
||||
|
||||
static Channel fromHex(int index, String name, String pskHex) {
|
||||
@@ -61,6 +62,44 @@ class Channel {
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/// Derive PSK from hashtag name using SHA256.
|
||||
/// The hashtag is normalized to include '#' prefix.
|
||||
/// Returns first 16 bytes of SHA256 hash as PSK.
|
||||
static Uint8List derivePskFromHashtag(String hashtag) {
|
||||
final name = hashtag.startsWith('#') ? hashtag : '#$hashtag';
|
||||
final hash = crypto.sha256.convert(utf8.encode(name)).bytes;
|
||||
return Uint8List.fromList(hash.sublist(0, 16));
|
||||
}
|
||||
|
||||
/// Derive PSK for community public channel using HMAC-SHA256.
|
||||
/// PSK = HMAC-SHA256(K, "channel:v1:__public__")[:16]
|
||||
///
|
||||
/// This creates a channel that is "public" only to members who have
|
||||
/// the community secret. Outsiders see only opaque IDs.
|
||||
static Uint8List deriveCommunityPublicPsk(Uint8List secret) {
|
||||
final hmac = crypto.Hmac(crypto.sha256, secret);
|
||||
final digest = hmac.convert(utf8.encode('channel:v1:__public__'));
|
||||
return Uint8List.fromList(digest.bytes.sublist(0, 16));
|
||||
}
|
||||
|
||||
/// Derive PSK for community hashtag channel using HMAC-SHA256.
|
||||
/// PSK = HMAC-SHA256(K, "channel:v1:" + normalized_name)[:16]
|
||||
///
|
||||
/// Community hashtag channels are deterministic for all members
|
||||
/// (same name => same id) but impossible to enumerate/guess without K.
|
||||
static Uint8List deriveCommunityHashtagPsk(Uint8List secret, String hashtag) {
|
||||
final normalized = _normalizeCommunityHashtag(hashtag);
|
||||
final hmac = crypto.Hmac(crypto.sha256, secret);
|
||||
final digest = hmac.convert(utf8.encode('channel:v1:$normalized'));
|
||||
return Uint8List.fromList(digest.bytes.sublist(0, 16));
|
||||
}
|
||||
|
||||
/// Normalize a hashtag name for consistent community PSK derivation.
|
||||
/// Strips leading #, converts to lowercase, trims whitespace.
|
||||
static String _normalizeCommunityHashtag(String hashtag) {
|
||||
return hashtag.replaceFirst(RegExp(r'^#'), '').toLowerCase().trim();
|
||||
}
|
||||
|
||||
static String formatPskHex(Uint8List psk) {
|
||||
return _bytesToHex(psk);
|
||||
}
|
||||
|
||||
@@ -59,15 +59,18 @@ class ChannelMessage {
|
||||
this.replyToSenderName,
|
||||
this.replyToText,
|
||||
Map<String, int>? reactions,
|
||||
}) : messageId = messageId ?? '${timestamp.millisecondsSinceEpoch}_${senderName.hashCode}_${text.hashCode}',
|
||||
reactions = reactions ?? {},
|
||||
pathBytes = pathBytes ?? Uint8List(0),
|
||||
pathVariants = _mergePathVariants(
|
||||
pathBytes ?? Uint8List(0),
|
||||
pathVariants,
|
||||
);
|
||||
}) : messageId =
|
||||
messageId ??
|
||||
'${timestamp.millisecondsSinceEpoch}_${senderName.hashCode}_${text.hashCode}',
|
||||
reactions = reactions ?? {},
|
||||
pathBytes = pathBytes ?? Uint8List(0),
|
||||
pathVariants = _mergePathVariants(
|
||||
pathBytes ?? Uint8List(0),
|
||||
pathVariants,
|
||||
);
|
||||
|
||||
String? get senderKeyHex => senderKey != null ? pubKeyToHex(senderKey!) : null;
|
||||
String? get senderKeyHex =>
|
||||
senderKey != null ? pubKeyToHex(senderKey!) : null;
|
||||
|
||||
ChannelMessage copyWith({
|
||||
ChannelMessageStatus? status,
|
||||
@@ -125,8 +128,10 @@ class ChannelMessage {
|
||||
final hasPathBytesFlag = (data[2] & 0x01) != 0;
|
||||
final canFitPath = pathLen > 0 && data.length >= cursor + pathLen + 5;
|
||||
final hasValidTxtType =
|
||||
cursor < data.length && (data[cursor] == txtTypePlain || data[cursor] == txtTypeCliData);
|
||||
if ((hasPathBytesFlag || (canFitPath && !hasValidTxtType)) && canFitPath) {
|
||||
cursor < data.length &&
|
||||
(data[cursor] == txtTypePlain || data[cursor] == txtTypeCliData);
|
||||
if ((hasPathBytesFlag || (canFitPath && !hasValidTxtType)) &&
|
||||
canFitPath) {
|
||||
pathBytes = Uint8List.fromList(data.sublist(cursor, cursor + pathLen));
|
||||
cursor += pathLen;
|
||||
}
|
||||
@@ -162,7 +167,8 @@ class ChannelMessage {
|
||||
final potentialSender = text.substring(0, colonIndex);
|
||||
if (!RegExp(r'[:\[\]]').hasMatch(potentialSender)) {
|
||||
senderName = potentialSender;
|
||||
final offset = (colonIndex + 1 < text.length && text[colonIndex + 1] == ' ')
|
||||
final offset =
|
||||
(colonIndex + 1 < text.length && text[colonIndex + 1] == ' ')
|
||||
? colonIndex + 2
|
||||
: colonIndex + 1;
|
||||
actualText = text.substring(offset);
|
||||
@@ -184,7 +190,11 @@ class ChannelMessage {
|
||||
);
|
||||
}
|
||||
|
||||
static ChannelMessage outgoing(String text, String senderName, int channelIndex) {
|
||||
static ChannelMessage outgoing(
|
||||
String text,
|
||||
String senderName,
|
||||
int channelIndex,
|
||||
) {
|
||||
return ChannelMessage(
|
||||
senderKey: null,
|
||||
senderName: senderName,
|
||||
@@ -249,8 +259,5 @@ class ReplyInfo {
|
||||
final String mentionedNode;
|
||||
final String actualMessage;
|
||||
|
||||
ReplyInfo({
|
||||
required this.mentionedNode,
|
||||
required this.actualMessage,
|
||||
});
|
||||
ReplyInfo({required this.mentionedNode, required this.actualMessage});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:crypto/crypto.dart' as crypto;
|
||||
|
||||
/// Represents a community with a shared secret for deriving channel PSKs.
|
||||
///
|
||||
/// A Community is a namespace with a shared secret K (32 random bytes),
|
||||
/// distributed via QR code. Members can create Community Public Channels
|
||||
/// and Community Hashtag Channels that are opaque to outsiders.
|
||||
class Community {
|
||||
/// Unique identifier for local storage
|
||||
final String id;
|
||||
|
||||
/// Display name for the community
|
||||
final String name;
|
||||
|
||||
/// The 32-byte shared secret (K)
|
||||
final Uint8List secret;
|
||||
|
||||
/// Timestamp when the community was created/joined
|
||||
final DateTime createdAt;
|
||||
|
||||
/// List of hashtag channel names (without #) that have been added
|
||||
final List<String> hashtagChannels;
|
||||
|
||||
Community({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.secret,
|
||||
required this.createdAt,
|
||||
List<String>? hashtagChannels,
|
||||
}) : hashtagChannels = hashtagChannels ?? [];
|
||||
|
||||
/// Generate a new community with a random 32-byte secret
|
||||
factory Community.create({required String id, required String name}) {
|
||||
final random = Random.secure();
|
||||
final secret = Uint8List(32);
|
||||
for (int i = 0; i < 32; i++) {
|
||||
secret[i] = random.nextInt(256);
|
||||
}
|
||||
return Community(
|
||||
id: id,
|
||||
name: name,
|
||||
secret: secret,
|
||||
createdAt: DateTime.now(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Parse a community from QR code JSON data
|
||||
factory Community.fromQrData(String id, String qrData) {
|
||||
final json = jsonDecode(qrData) as Map<String, dynamic>;
|
||||
if (json['type'] != 'meshcore_community') {
|
||||
throw const FormatException('Invalid QR code type');
|
||||
}
|
||||
if (json['v'] != 1) {
|
||||
throw const FormatException('Unsupported QR code version');
|
||||
}
|
||||
|
||||
final name = json['name'] as String;
|
||||
final secretBase64 = json['k'] as String;
|
||||
final secret = base64Url.decode(secretBase64);
|
||||
|
||||
if (secret.length != 32) {
|
||||
throw const FormatException('Invalid secret length');
|
||||
}
|
||||
|
||||
return Community(
|
||||
id: id,
|
||||
name: name,
|
||||
secret: Uint8List.fromList(secret),
|
||||
createdAt: DateTime.now(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Parse a community from storage JSON
|
||||
factory Community.fromJson(Map<String, dynamic> json) {
|
||||
return Community(
|
||||
id: json['id'] as String,
|
||||
name: json['name'] as String,
|
||||
secret: base64Decode(json['secret'] as String),
|
||||
createdAt: DateTime.fromMillisecondsSinceEpoch(json['created_at'] as int),
|
||||
hashtagChannels:
|
||||
(json['hashtag_channels'] as List<dynamic>?)
|
||||
?.map((e) => e as String)
|
||||
.toList() ??
|
||||
[],
|
||||
);
|
||||
}
|
||||
|
||||
/// Convert to JSON for storage
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'secret': base64Encode(secret),
|
||||
'created_at': createdAt.millisecondsSinceEpoch,
|
||||
'hashtag_channels': hashtagChannels,
|
||||
};
|
||||
}
|
||||
|
||||
/// Generate QR code JSON payload for sharing
|
||||
String toQrJson() {
|
||||
return jsonEncode({
|
||||
'v': 1,
|
||||
'type': 'meshcore_community',
|
||||
'name': name,
|
||||
'k': base64Url.encode(secret),
|
||||
});
|
||||
}
|
||||
|
||||
/// Derive the public Community ID from the secret.
|
||||
/// This is safe to display/log since it's one-way derived.
|
||||
/// CID = SHA256("community:v1" || K)
|
||||
String get communityId {
|
||||
final data = utf8.encode('community:v1') + secret;
|
||||
final hash = crypto.sha256.convert(data).bytes;
|
||||
return _bytesToHex(Uint8List.fromList(hash));
|
||||
}
|
||||
|
||||
/// Short version of community ID for display (first 8 chars)
|
||||
String get shortCommunityId => communityId.substring(0, 8);
|
||||
|
||||
/// Derive PSK for community public channel.
|
||||
/// PSK = HMAC-SHA256(K, "channel:v1:__public__")[:16]
|
||||
Uint8List deriveCommunityPublicPsk() {
|
||||
final hmac = crypto.Hmac(crypto.sha256, secret);
|
||||
final digest = hmac.convert(utf8.encode('channel:v1:__public__'));
|
||||
return Uint8List.fromList(digest.bytes.sublist(0, 16));
|
||||
}
|
||||
|
||||
/// Derive PSK for community hashtag channel.
|
||||
/// PSK = HMAC-SHA256(K, "channel:v1:" + normalized_name)[:16]
|
||||
Uint8List deriveCommunityHashtagPsk(String hashtag) {
|
||||
final normalized = _normalizeCommunityHashtag(hashtag);
|
||||
final hmac = crypto.Hmac(crypto.sha256, secret);
|
||||
final digest = hmac.convert(utf8.encode('channel:v1:$normalized'));
|
||||
return Uint8List.fromList(digest.bytes.sublist(0, 16));
|
||||
}
|
||||
|
||||
/// Check if QR data is valid community data
|
||||
static bool isValidQrData(String data) {
|
||||
try {
|
||||
final json = jsonDecode(data) as Map<String, dynamic>;
|
||||
if (json['type'] != 'meshcore_community') return false;
|
||||
if (json['v'] != 1) return false;
|
||||
if (json['name'] == null || (json['name'] as String).isEmpty) {
|
||||
return false;
|
||||
}
|
||||
if (json['k'] == null) return false;
|
||||
final secret = base64Url.decode(json['k'] as String);
|
||||
return secret.length == 32;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalize a hashtag name for consistent PSK derivation.
|
||||
/// Strips leading #, converts to lowercase, trims whitespace.
|
||||
static String _normalizeCommunityHashtag(String hashtag) {
|
||||
return hashtag.replaceFirst(RegExp(r'^#'), '').toLowerCase().trim();
|
||||
}
|
||||
|
||||
/// Add a hashtag channel to this community's list
|
||||
Community addHashtagChannel(String hashtag) {
|
||||
final normalized = _normalizeCommunityHashtag(hashtag);
|
||||
if (hashtagChannels.contains(normalized)) {
|
||||
return this;
|
||||
}
|
||||
return Community(
|
||||
id: id,
|
||||
name: name,
|
||||
secret: secret,
|
||||
createdAt: createdAt,
|
||||
hashtagChannels: [...hashtagChannels, normalized],
|
||||
);
|
||||
}
|
||||
|
||||
/// Remove a hashtag channel from this community's list
|
||||
Community removeHashtagChannel(String hashtag) {
|
||||
final normalized = _normalizeCommunityHashtag(hashtag);
|
||||
return Community(
|
||||
id: id,
|
||||
name: name,
|
||||
secret: secret,
|
||||
createdAt: createdAt,
|
||||
hashtagChannels: hashtagChannels.where((h) => h != normalized).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Create a copy of this community with a new secret
|
||||
Community withNewSecret(Uint8List newSecret) {
|
||||
return Community(
|
||||
id: id,
|
||||
name: name,
|
||||
secret: newSecret,
|
||||
createdAt: createdAt,
|
||||
hashtagChannels: hashtagChannels,
|
||||
);
|
||||
}
|
||||
|
||||
/// Create a copy of this community with a regenerated random secret
|
||||
Community withRegeneratedSecret() {
|
||||
final random = Random.secure();
|
||||
final newSecret = Uint8List(32);
|
||||
for (int i = 0; i < 32; i++) {
|
||||
newSecret[i] = random.nextInt(256);
|
||||
}
|
||||
return withNewSecret(newSecret);
|
||||
}
|
||||
|
||||
/// Extract secret from QR data (for updating existing community)
|
||||
static Uint8List? extractSecretFromQrData(String qrData) {
|
||||
try {
|
||||
final json = jsonDecode(qrData) as Map<String, dynamic>;
|
||||
if (json['type'] != 'meshcore_community') return null;
|
||||
if (json['v'] != 1) return null;
|
||||
final secretBase64 = json['k'] as String;
|
||||
final secret = base64Url.decode(secretBase64);
|
||||
if (secret.length != 32) return null;
|
||||
return Uint8List.fromList(secret);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static String _bytesToHex(Uint8List bytes) {
|
||||
return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is Community && runtimeType == other.runtimeType && id == other.id;
|
||||
|
||||
@override
|
||||
int get hashCode => id.hashCode;
|
||||
}
|
||||
+54
-5
@@ -7,7 +7,8 @@ class Contact {
|
||||
final int type;
|
||||
final int pathLength; // -1 = flood, 0+ = direct hops (from device)
|
||||
final Uint8List path; // Path bytes from device
|
||||
final int? pathOverride; // User's path override: -1 = force flood, null = auto
|
||||
final int?
|
||||
pathOverride; // User's path override: -1 = force flood, null = auto
|
||||
final Uint8List? pathOverrideBytes; // User's path override bytes
|
||||
final double? latitude;
|
||||
final double? longitude;
|
||||
@@ -78,8 +79,12 @@ class Contact {
|
||||
type: type ?? this.type,
|
||||
pathLength: pathLength ?? this.pathLength,
|
||||
path: path ?? this.path,
|
||||
pathOverride: clearPathOverride ? null : (pathOverride ?? this.pathOverride),
|
||||
pathOverrideBytes: clearPathOverride ? null : (pathOverrideBytes ?? this.pathOverrideBytes),
|
||||
pathOverride: clearPathOverride
|
||||
? null
|
||||
: (pathOverride ?? this.pathOverride),
|
||||
pathOverrideBytes: clearPathOverride
|
||||
? null
|
||||
: (pathOverrideBytes ?? this.pathOverrideBytes),
|
||||
latitude: latitude ?? this.latitude,
|
||||
longitude: longitude ?? this.longitude,
|
||||
lastSeen: lastSeen ?? this.lastSeen,
|
||||
@@ -93,15 +98,59 @@ class Contact {
|
||||
final parts = <String>[];
|
||||
final groupSize = pathHashSize;
|
||||
for (int i = 0; i < pathBytes.length; i += groupSize) {
|
||||
final end = (i + groupSize) <= pathBytes.length ? (i + groupSize) : pathBytes.length;
|
||||
final end = (i + groupSize) <= pathBytes.length
|
||||
? (i + groupSize)
|
||||
: pathBytes.length;
|
||||
final chunk = pathBytes.sublist(i, end);
|
||||
parts.add(
|
||||
chunk.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase()).join(),
|
||||
chunk
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase())
|
||||
.join(),
|
||||
);
|
||||
}
|
||||
return parts.join(',');
|
||||
}
|
||||
|
||||
String get shortPubKeyHex {
|
||||
return "<${publicKeyHex.substring(0, 8)}...${publicKeyHex.substring(publicKeyHex.length - 8)}>";
|
||||
}
|
||||
|
||||
Uint8List? get traceRouteBytes {
|
||||
final pathBytes = _pathBytesForDisplay;
|
||||
Uint8List? traceBytes;
|
||||
|
||||
if (pathLength <= 0) {
|
||||
traceBytes = Uint8List(1);
|
||||
traceBytes[0] = publicKey[0];
|
||||
return traceBytes;
|
||||
}
|
||||
|
||||
if (type == advTypeRepeater || type == advTypeRoom) {
|
||||
final len = (pathBytes.length + pathBytes.length + 1);
|
||||
traceBytes = Uint8List(len);
|
||||
traceBytes[pathBytes.length] = publicKey[0];
|
||||
for (int i = 0; i < pathBytes.length; i++) {
|
||||
traceBytes[i] = pathBytes[i];
|
||||
if (i < pathBytes.length) {
|
||||
traceBytes[len - 1 - i] = pathBytes[i];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (pathBytes.length < 2) {
|
||||
return pathBytes[0] == 0 ? null : pathBytes;
|
||||
}
|
||||
final len = (pathBytes.length + pathBytes.length - 1);
|
||||
traceBytes = Uint8List(len);
|
||||
for (int i = 0; i < pathBytes.length; i++) {
|
||||
traceBytes[i] = pathBytes[i];
|
||||
if (i < pathBytes.length - 1) {
|
||||
traceBytes[len - 1 - i] = pathBytes[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
return traceBytes;
|
||||
}
|
||||
|
||||
Uint8List get _pathBytesForDisplay {
|
||||
if (pathOverride != null) {
|
||||
if (pathOverride! < 0) return Uint8List(0);
|
||||
|
||||
@@ -2,15 +2,9 @@ class ContactGroup {
|
||||
final String name;
|
||||
final List<String> memberKeys;
|
||||
|
||||
const ContactGroup({
|
||||
required this.name,
|
||||
required this.memberKeys,
|
||||
});
|
||||
const ContactGroup({required this.name, required this.memberKeys});
|
||||
|
||||
ContactGroup copyWith({
|
||||
String? name,
|
||||
List<String>? memberKeys,
|
||||
}) {
|
||||
ContactGroup copyWith({String? name, List<String>? memberKeys}) {
|
||||
return ContactGroup(
|
||||
name: name ?? this.name,
|
||||
memberKeys: memberKeys ?? List<String>.from(this.memberKeys),
|
||||
@@ -18,16 +12,12 @@ class ContactGroup {
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'name': name,
|
||||
'members': memberKeys,
|
||||
};
|
||||
return {'name': name, 'members': memberKeys};
|
||||
}
|
||||
|
||||
factory ContactGroup.fromJson(Map<String, dynamic> json) {
|
||||
final members = (json['members'] as List?)
|
||||
?.map((value) => value.toString())
|
||||
.toList() ??
|
||||
final members =
|
||||
(json['members'] as List?)?.map((value) => value.toString()).toList() ??
|
||||
<String>[];
|
||||
return ContactGroup(
|
||||
name: json['name'] as String? ?? '',
|
||||
|
||||
@@ -23,6 +23,7 @@ class Message {
|
||||
final int? pathLength;
|
||||
final Uint8List pathBytes;
|
||||
final Map<String, int> reactions;
|
||||
final Uint8List fourByteRoomContactKey;
|
||||
|
||||
Message({
|
||||
required this.senderKey,
|
||||
@@ -40,9 +41,11 @@ class Message {
|
||||
this.tripTimeMs,
|
||||
this.pathLength,
|
||||
Uint8List? pathBytes,
|
||||
Uint8List? fourByteRoomContactKey,
|
||||
Map<String, int>? reactions,
|
||||
}) : pathBytes = pathBytes ?? Uint8List(0),
|
||||
reactions = reactions ?? {};
|
||||
}) : pathBytes = pathBytes ?? Uint8List(0),
|
||||
fourByteRoomContactKey = fourByteRoomContactKey ?? Uint8List(0),
|
||||
reactions = reactions ?? {};
|
||||
|
||||
String get senderKeyHex => pubKeyToHex(senderKey);
|
||||
|
||||
@@ -58,6 +61,7 @@ class Message {
|
||||
Uint8List? pathBytes,
|
||||
bool? isCli,
|
||||
Map<String, int>? reactions,
|
||||
Uint8List? fourByteRoomContactKey,
|
||||
}) {
|
||||
return Message(
|
||||
senderKey: senderKey,
|
||||
@@ -76,6 +80,8 @@ class Message {
|
||||
pathLength: pathLength ?? this.pathLength,
|
||||
pathBytes: pathBytes ?? this.pathBytes,
|
||||
reactions: reactions ?? this.reactions,
|
||||
fourByteRoomContactKey:
|
||||
fourByteRoomContactKey ?? this.fourByteRoomContactKey,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -38,7 +38,8 @@ class PathRecord {
|
||||
tripTimeMs: json['trip_time_ms'] as int,
|
||||
timestamp: DateTime.parse(json['timestamp'] as String),
|
||||
wasFloodDiscovery: json['was_flood'] as bool,
|
||||
pathBytes: (json['path_bytes'] as List?)?.map((b) => b as int).toList() ?? [],
|
||||
pathBytes:
|
||||
(json['path_bytes'] as List?)?.map((b) => b as int).toList() ?? [],
|
||||
successCount: json['success_count'] as int? ?? 0,
|
||||
failureCount: json['failure_count'] as int? ?? 0,
|
||||
);
|
||||
@@ -65,14 +66,15 @@ class ContactPathHistory {
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'recent_paths': recentPaths.map((p) => p.toJson()).toList(),
|
||||
};
|
||||
return {'recent_paths': recentPaths.map((p) => p.toJson()).toList()};
|
||||
}
|
||||
|
||||
factory ContactPathHistory.fromJson(
|
||||
String contactPubKeyHex, Map<String, dynamic> json) {
|
||||
final pathsList = (json['recent_paths'] as List?)
|
||||
String contactPubKeyHex,
|
||||
Map<String, dynamic> json,
|
||||
) {
|
||||
final pathsList =
|
||||
(json['recent_paths'] as List?)
|
||||
?.map((p) => PathRecord.fromJson(p as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
[];
|
||||
|
||||
+183
-29
@@ -59,46 +59,200 @@ class RadioSettings {
|
||||
required this.txPowerDbm,
|
||||
});
|
||||
|
||||
// Preset configurations
|
||||
static RadioSettings get preset915MHz => RadioSettings(
|
||||
frequencyMHz: 915.0,
|
||||
bandwidth: LoRaBandwidth.bw125,
|
||||
// Regional preset configurations
|
||||
static final List<(String, RadioSettings)> presets = [
|
||||
(
|
||||
'Australia',
|
||||
RadioSettings(
|
||||
frequencyMHz: 915.8,
|
||||
bandwidth: LoRaBandwidth.bw250,
|
||||
spreadingFactor: LoRaSpreadingFactor.sf10,
|
||||
codingRate: LoRaCodingRate.cr4_5,
|
||||
txPowerDbm: 20,
|
||||
),
|
||||
),
|
||||
(
|
||||
'Australia (Narrow)',
|
||||
RadioSettings(
|
||||
frequencyMHz: 916.575,
|
||||
bandwidth: LoRaBandwidth.bw62_5,
|
||||
spreadingFactor: LoRaSpreadingFactor.sf7,
|
||||
codingRate: LoRaCodingRate.cr4_5,
|
||||
txPowerDbm: 20,
|
||||
);
|
||||
|
||||
static RadioSettings get preset868MHz => RadioSettings(
|
||||
frequencyMHz: 868.0,
|
||||
bandwidth: LoRaBandwidth.bw125,
|
||||
),
|
||||
),
|
||||
(
|
||||
'Australia SA, WA, QLD',
|
||||
RadioSettings(
|
||||
frequencyMHz: 923.125,
|
||||
bandwidth: LoRaBandwidth.bw62_5,
|
||||
spreadingFactor: LoRaSpreadingFactor.sf8,
|
||||
codingRate: LoRaCodingRate.cr4_5,
|
||||
txPowerDbm: 20,
|
||||
),
|
||||
),
|
||||
(
|
||||
'Czech Republic',
|
||||
RadioSettings(
|
||||
frequencyMHz: 869.432,
|
||||
bandwidth: LoRaBandwidth.bw62_5,
|
||||
spreadingFactor: LoRaSpreadingFactor.sf7,
|
||||
codingRate: LoRaCodingRate.cr4_5,
|
||||
txPowerDbm: 14,
|
||||
);
|
||||
|
||||
static RadioSettings get preset433MHz => RadioSettings(
|
||||
),
|
||||
),
|
||||
(
|
||||
'EU 433MHz',
|
||||
RadioSettings(
|
||||
frequencyMHz: 433.650,
|
||||
bandwidth: LoRaBandwidth.bw250,
|
||||
spreadingFactor: LoRaSpreadingFactor.sf11,
|
||||
codingRate: LoRaCodingRate.cr4_5,
|
||||
txPowerDbm: 20,
|
||||
),
|
||||
),
|
||||
(
|
||||
'EU/UK (Long Range)',
|
||||
RadioSettings(
|
||||
frequencyMHz: 869.525,
|
||||
bandwidth: LoRaBandwidth.bw250,
|
||||
spreadingFactor: LoRaSpreadingFactor.sf11,
|
||||
codingRate: LoRaCodingRate.cr4_5,
|
||||
txPowerDbm: 14,
|
||||
),
|
||||
),
|
||||
(
|
||||
'EU/UK (Medium Range)',
|
||||
RadioSettings(
|
||||
frequencyMHz: 869.525,
|
||||
bandwidth: LoRaBandwidth.bw250,
|
||||
spreadingFactor: LoRaSpreadingFactor.sf10,
|
||||
codingRate: LoRaCodingRate.cr4_5,
|
||||
txPowerDbm: 14,
|
||||
),
|
||||
),
|
||||
(
|
||||
'EU/UK (Narrow)',
|
||||
RadioSettings(
|
||||
frequencyMHz: 869.618,
|
||||
bandwidth: LoRaBandwidth.bw62_5,
|
||||
spreadingFactor: LoRaSpreadingFactor.sf8,
|
||||
codingRate: LoRaCodingRate.cr4_5,
|
||||
txPowerDbm: 14,
|
||||
),
|
||||
),
|
||||
(
|
||||
'New Zealand',
|
||||
RadioSettings(
|
||||
frequencyMHz: 917.375,
|
||||
bandwidth: LoRaBandwidth.bw250,
|
||||
spreadingFactor: LoRaSpreadingFactor.sf11,
|
||||
codingRate: LoRaCodingRate.cr4_5,
|
||||
txPowerDbm: 20,
|
||||
),
|
||||
),
|
||||
(
|
||||
'New Zealand (Narrow)',
|
||||
RadioSettings(
|
||||
frequencyMHz: 917.375,
|
||||
bandwidth: LoRaBandwidth.bw62_5,
|
||||
spreadingFactor: LoRaSpreadingFactor.sf7,
|
||||
codingRate: LoRaCodingRate.cr4_5,
|
||||
txPowerDbm: 20,
|
||||
),
|
||||
),
|
||||
(
|
||||
'Portugal 433',
|
||||
RadioSettings(
|
||||
frequencyMHz: 433.375,
|
||||
bandwidth: LoRaBandwidth.bw62_5,
|
||||
spreadingFactor: LoRaSpreadingFactor.sf9,
|
||||
codingRate: LoRaCodingRate.cr4_5,
|
||||
txPowerDbm: 20,
|
||||
),
|
||||
),
|
||||
(
|
||||
'Portugal 869',
|
||||
RadioSettings(
|
||||
frequencyMHz: 869.618,
|
||||
bandwidth: LoRaBandwidth.bw62_5,
|
||||
spreadingFactor: LoRaSpreadingFactor.sf7,
|
||||
codingRate: LoRaCodingRate.cr4_5,
|
||||
txPowerDbm: 14,
|
||||
),
|
||||
),
|
||||
(
|
||||
'Switzerland',
|
||||
RadioSettings(
|
||||
frequencyMHz: 869.618,
|
||||
bandwidth: LoRaBandwidth.bw62_5,
|
||||
spreadingFactor: LoRaSpreadingFactor.sf8,
|
||||
codingRate: LoRaCodingRate.cr4_5,
|
||||
txPowerDbm: 14,
|
||||
),
|
||||
),
|
||||
(
|
||||
'USA Arizona',
|
||||
RadioSettings(
|
||||
frequencyMHz: 908.205,
|
||||
bandwidth: LoRaBandwidth.bw62_5,
|
||||
spreadingFactor: LoRaSpreadingFactor.sf10,
|
||||
codingRate: LoRaCodingRate.cr4_5,
|
||||
txPowerDbm: 20,
|
||||
),
|
||||
),
|
||||
(
|
||||
'USA/Canada',
|
||||
RadioSettings(
|
||||
frequencyMHz: 910.525,
|
||||
bandwidth: LoRaBandwidth.bw62_5,
|
||||
spreadingFactor: LoRaSpreadingFactor.sf7,
|
||||
codingRate: LoRaCodingRate.cr4_5,
|
||||
txPowerDbm: 20,
|
||||
),
|
||||
),
|
||||
(
|
||||
'Vietnam',
|
||||
RadioSettings(
|
||||
frequencyMHz: 920.250,
|
||||
bandwidth: LoRaBandwidth.bw250,
|
||||
spreadingFactor: LoRaSpreadingFactor.sf11,
|
||||
codingRate: LoRaCodingRate.cr4_5,
|
||||
txPowerDbm: 20,
|
||||
),
|
||||
),
|
||||
// Off-grid repeat presets (valid client_repeat frequencies)
|
||||
(
|
||||
'Off-Grid 433',
|
||||
RadioSettings(
|
||||
frequencyMHz: 433.0,
|
||||
bandwidth: LoRaBandwidth.bw125,
|
||||
spreadingFactor: LoRaSpreadingFactor.sf7,
|
||||
bandwidth: LoRaBandwidth.bw250,
|
||||
spreadingFactor: LoRaSpreadingFactor.sf11,
|
||||
codingRate: LoRaCodingRate.cr4_5,
|
||||
txPowerDbm: 20,
|
||||
);
|
||||
|
||||
static RadioSettings get presetLongRange => RadioSettings(
|
||||
frequencyMHz: 915.0,
|
||||
bandwidth: LoRaBandwidth.bw125,
|
||||
spreadingFactor: LoRaSpreadingFactor.sf12,
|
||||
codingRate: LoRaCodingRate.cr4_8,
|
||||
txPowerDbm: 20,
|
||||
);
|
||||
|
||||
static RadioSettings get presetFastSpeed => RadioSettings(
|
||||
frequencyMHz: 915.0,
|
||||
bandwidth: LoRaBandwidth.bw500,
|
||||
spreadingFactor: LoRaSpreadingFactor.sf7,
|
||||
),
|
||||
),
|
||||
(
|
||||
'Off-Grid 869',
|
||||
RadioSettings(
|
||||
frequencyMHz: 869.0,
|
||||
bandwidth: LoRaBandwidth.bw250,
|
||||
spreadingFactor: LoRaSpreadingFactor.sf11,
|
||||
codingRate: LoRaCodingRate.cr4_5,
|
||||
txPowerDbm: 14,
|
||||
),
|
||||
),
|
||||
(
|
||||
'Off-Grid 918',
|
||||
RadioSettings(
|
||||
frequencyMHz: 918.0,
|
||||
bandwidth: LoRaBandwidth.bw250,
|
||||
spreadingFactor: LoRaSpreadingFactor.sf11,
|
||||
codingRate: LoRaCodingRate.cr4_5,
|
||||
txPowerDbm: 20,
|
||||
);
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
int get frequencyHz => (frequencyMHz * 1000).round();
|
||||
int get bandwidthHz => bandwidth.hz;
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../l10n/l10n.dart';
|
||||
import '../services/app_debug_log_service.dart';
|
||||
|
||||
class AppDebugLogScreen extends StatelessWidget {
|
||||
@@ -16,28 +17,30 @@ class AppDebugLogScreen extends StatelessWidget {
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('App Debug Log'),
|
||||
title: Text(context.l10n.debugLog_appTitle),
|
||||
centerTitle: true,
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: 'Copy log',
|
||||
tooltip: context.l10n.debugLog_copyLog,
|
||||
icon: const Icon(Icons.copy),
|
||||
onPressed: hasEntries
|
||||
? () async {
|
||||
final text = entries
|
||||
.map((entry) =>
|
||||
'[${entry.formattedTime}] [${entry.levelLabel}] [${entry.tag}] ${entry.message}')
|
||||
.map(
|
||||
(entry) =>
|
||||
'[${entry.formattedTime}] [${entry.levelLabel}] [${entry.tag}] ${entry.message}',
|
||||
)
|
||||
.join('\n');
|
||||
await Clipboard.setData(ClipboardData(text: text));
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Debug log copied')),
|
||||
SnackBar(content: Text(context.l10n.debugLog_copied)),
|
||||
);
|
||||
}
|
||||
: null,
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Clear log',
|
||||
tooltip: context.l10n.debugLog_clearLog,
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
onPressed: hasEntries
|
||||
? () {
|
||||
@@ -52,7 +55,7 @@ class AppDebugLogScreen extends StatelessWidget {
|
||||
child: hasEntries
|
||||
? ListView.separated(
|
||||
itemCount: entries.length,
|
||||
separatorBuilder: (_, __) => const Divider(height: 1),
|
||||
separatorBuilder: (_, _) => const Divider(height: 1),
|
||||
itemBuilder: (context, index) {
|
||||
final entry = entries[index];
|
||||
return ListTile(
|
||||
@@ -60,11 +63,17 @@ class AppDebugLogScreen extends StatelessWidget {
|
||||
leading: _buildLevelIcon(entry.level),
|
||||
title: Text(
|
||||
'[${entry.tag}] ${entry.message}',
|
||||
style: const TextStyle(fontSize: 12, fontFamily: 'monospace'),
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
entry.formattedTime,
|
||||
style: TextStyle(fontSize: 10, color: Colors.grey[600]),
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -73,16 +82,26 @@ class AppDebugLogScreen extends StatelessWidget {
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.bug_report_outlined, size: 64, color: Colors.grey[400]),
|
||||
Icon(
|
||||
Icons.bug_report_outlined,
|
||||
size: 64,
|
||||
color: Colors.grey[400],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'No debug logs yet',
|
||||
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
|
||||
context.l10n.debugLog_noEntries,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Enable app debug logging in settings',
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey[500]),
|
||||
context.l10n.debugLog_enableInSettings,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[500],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -98,7 +117,11 @@ class AppDebugLogScreen extends StatelessWidget {
|
||||
case AppDebugLogLevel.info:
|
||||
return const Icon(Icons.info_outline, size: 18, color: Colors.blue);
|
||||
case AppDebugLogLevel.warning:
|
||||
return const Icon(Icons.warning_amber_outlined, size: 18, color: Colors.orange);
|
||||
return const Icon(
|
||||
Icons.warning_amber_outlined,
|
||||
size: 18,
|
||||
color: Colors.orange,
|
||||
);
|
||||
case AppDebugLogLevel.error:
|
||||
return const Icon(Icons.error_outline, size: 18, color: Colors.red);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../connector/meshcore_connector.dart';
|
||||
import '../l10n/l10n.dart';
|
||||
import '../services/app_settings_service.dart';
|
||||
import '../services/notification_service.dart';
|
||||
import 'map_cache_screen.dart';
|
||||
@@ -13,7 +14,7 @@ class AppSettingsScreen extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('App Settings'),
|
||||
title: Text(context.l10n.appSettings_title),
|
||||
centerTitle: true,
|
||||
),
|
||||
body: SafeArea(
|
||||
@@ -42,57 +43,83 @@ class AppSettingsScreen extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAppearanceCard(BuildContext context, AppSettingsService settingsService) {
|
||||
Widget _buildAppearanceCard(
|
||||
BuildContext context,
|
||||
AppSettingsService settingsService,
|
||||
) {
|
||||
return Card(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
child: Text(
|
||||
'Appearance',
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
context.l10n.appSettings_appearance,
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.brightness_6_outlined),
|
||||
title: const Text('Theme'),
|
||||
subtitle: Text(_themeModeLabel(settingsService.settings.themeMode)),
|
||||
title: Text(context.l10n.appSettings_theme),
|
||||
subtitle: Text(
|
||||
_themeModeLabel(context, settingsService.settings.themeMode),
|
||||
),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => _showThemeModeDialog(context, settingsService),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.language_outlined),
|
||||
title: Text(context.l10n.appSettings_language),
|
||||
subtitle: Text(
|
||||
_languageLabel(
|
||||
context,
|
||||
settingsService.settings.languageOverride,
|
||||
),
|
||||
),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => _showLanguageDialog(context, settingsService),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNotificationsCard(BuildContext context, AppSettingsService settingsService) {
|
||||
Widget _buildNotificationsCard(
|
||||
BuildContext context,
|
||||
AppSettingsService settingsService,
|
||||
) {
|
||||
return Card(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
child: Text(
|
||||
'Notifications',
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
context.l10n.appSettings_notifications,
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
SwitchListTile(
|
||||
secondary: const Icon(Icons.notifications_outlined),
|
||||
title: const Text('Enable Notifications'),
|
||||
subtitle: const Text('Receive notifications for messages and adverts'),
|
||||
title: Text(context.l10n.appSettings_enableNotifications),
|
||||
subtitle: Text(
|
||||
context.l10n.appSettings_enableNotificationsSubtitle,
|
||||
),
|
||||
value: settingsService.settings.notificationsEnabled,
|
||||
onChanged: (value) async {
|
||||
if (value) {
|
||||
// Request permission when enabling
|
||||
final granted = await NotificationService().requestPermissions();
|
||||
final granted = await NotificationService()
|
||||
.requestPermissions();
|
||||
if (!granted) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Notification permission denied'),
|
||||
duration: Duration(seconds: 2),
|
||||
SnackBar(
|
||||
content: Text(
|
||||
context.l10n.appSettings_notificationPermissionDenied,
|
||||
),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -104,9 +131,11 @@ class AppSettingsScreen extends StatelessWidget {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(value
|
||||
? 'Notifications enabled'
|
||||
: 'Notifications disabled'),
|
||||
content: Text(
|
||||
value
|
||||
? context.l10n.appSettings_notificationsEnabled
|
||||
: context.l10n.appSettings_notificationsDisabled,
|
||||
),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
@@ -117,18 +146,24 @@ class AppSettingsScreen extends StatelessWidget {
|
||||
SwitchListTile(
|
||||
secondary: Icon(
|
||||
Icons.message_outlined,
|
||||
color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
|
||||
color: settingsService.settings.notificationsEnabled
|
||||
? null
|
||||
: Colors.grey,
|
||||
),
|
||||
title: Text(
|
||||
'Message Notifications',
|
||||
context.l10n.appSettings_messageNotifications,
|
||||
style: TextStyle(
|
||||
color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
|
||||
color: settingsService.settings.notificationsEnabled
|
||||
? null
|
||||
: Colors.grey,
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
'Show notification when receiving new messages',
|
||||
context.l10n.appSettings_messageNotificationsSubtitle,
|
||||
style: TextStyle(
|
||||
color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
|
||||
color: settingsService.settings.notificationsEnabled
|
||||
? null
|
||||
: Colors.grey,
|
||||
),
|
||||
),
|
||||
value: settingsService.settings.notifyOnNewMessage,
|
||||
@@ -142,18 +177,24 @@ class AppSettingsScreen extends StatelessWidget {
|
||||
SwitchListTile(
|
||||
secondary: Icon(
|
||||
Icons.forum_outlined,
|
||||
color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
|
||||
color: settingsService.settings.notificationsEnabled
|
||||
? null
|
||||
: Colors.grey,
|
||||
),
|
||||
title: Text(
|
||||
'Channel Message Notifications',
|
||||
context.l10n.appSettings_channelMessageNotifications,
|
||||
style: TextStyle(
|
||||
color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
|
||||
color: settingsService.settings.notificationsEnabled
|
||||
? null
|
||||
: Colors.grey,
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
'Show notification when receiving channel messages',
|
||||
context.l10n.appSettings_channelMessageNotificationsSubtitle,
|
||||
style: TextStyle(
|
||||
color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
|
||||
color: settingsService.settings.notificationsEnabled
|
||||
? null
|
||||
: Colors.grey,
|
||||
),
|
||||
),
|
||||
value: settingsService.settings.notifyOnNewChannelMessage,
|
||||
@@ -167,18 +208,24 @@ class AppSettingsScreen extends StatelessWidget {
|
||||
SwitchListTile(
|
||||
secondary: Icon(
|
||||
Icons.cell_tower,
|
||||
color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
|
||||
color: settingsService.settings.notificationsEnabled
|
||||
? null
|
||||
: Colors.grey,
|
||||
),
|
||||
title: Text(
|
||||
'Advertisement Notifications',
|
||||
context.l10n.appSettings_advertisementNotifications,
|
||||
style: TextStyle(
|
||||
color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
|
||||
color: settingsService.settings.notificationsEnabled
|
||||
? null
|
||||
: Colors.grey,
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
'Show notification when new nodes are discovered',
|
||||
context.l10n.appSettings_advertisementNotificationsSubtitle,
|
||||
style: TextStyle(
|
||||
color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
|
||||
color: settingsService.settings.notificationsEnabled
|
||||
? null
|
||||
: Colors.grey,
|
||||
),
|
||||
),
|
||||
value: settingsService.settings.notifyOnNewAdvert,
|
||||
@@ -193,30 +240,37 @@ class AppSettingsScreen extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMessagingCard(BuildContext context, AppSettingsService settingsService) {
|
||||
Widget _buildMessagingCard(
|
||||
BuildContext context,
|
||||
AppSettingsService settingsService,
|
||||
) {
|
||||
return Card(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
child: Text(
|
||||
'Messaging',
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
context.l10n.appSettings_messaging,
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
SwitchListTile(
|
||||
secondary: const Icon(Icons.refresh_outlined),
|
||||
title: const Text('Clear Path on Max Retry'),
|
||||
subtitle: const Text('Reset contact path after 5 failed send attempts'),
|
||||
title: Text(context.l10n.appSettings_clearPathOnMaxRetry),
|
||||
subtitle: Text(
|
||||
context.l10n.appSettings_clearPathOnMaxRetrySubtitle,
|
||||
),
|
||||
value: settingsService.settings.clearPathOnMaxRetry,
|
||||
onChanged: (value) {
|
||||
settingsService.setClearPathOnMaxRetry(value);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(value
|
||||
? 'Paths will be cleared after 5 failed retries'
|
||||
: 'Paths will not be auto-cleared'),
|
||||
content: Text(
|
||||
value
|
||||
? context.l10n.appSettings_pathsWillBeCleared
|
||||
: context.l10n.appSettings_pathsWillNotBeCleared,
|
||||
),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
@@ -225,16 +279,18 @@ class AppSettingsScreen extends StatelessWidget {
|
||||
const Divider(height: 1),
|
||||
SwitchListTile(
|
||||
secondary: const Icon(Icons.alt_route),
|
||||
title: const Text('Auto Route Rotation'),
|
||||
subtitle: const Text('Cycle between best paths and flood mode'),
|
||||
title: Text(context.l10n.appSettings_autoRouteRotation),
|
||||
subtitle: Text(context.l10n.appSettings_autoRouteRotationSubtitle),
|
||||
value: settingsService.settings.autoRouteRotationEnabled,
|
||||
onChanged: (value) {
|
||||
settingsService.setAutoRouteRotationEnabled(value);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(value
|
||||
? 'Auto route rotation enabled'
|
||||
: 'Auto route rotation disabled'),
|
||||
content: Text(
|
||||
value
|
||||
? context.l10n.appSettings_autoRouteRotationEnabled
|
||||
: context.l10n.appSettings_autoRouteRotationDisabled,
|
||||
),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
@@ -245,22 +301,25 @@ class AppSettingsScreen extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMapSettingsCard(BuildContext context, AppSettingsService settingsService) {
|
||||
Widget _buildMapSettingsCard(
|
||||
BuildContext context,
|
||||
AppSettingsService settingsService,
|
||||
) {
|
||||
return Card(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
child: Text(
|
||||
'Map Display',
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
context.l10n.appSettings_mapDisplay,
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
SwitchListTile(
|
||||
secondary: const Icon(Icons.router_outlined),
|
||||
title: const Text('Show Repeaters'),
|
||||
subtitle: const Text('Display repeater nodes on the map'),
|
||||
title: Text(context.l10n.appSettings_showRepeaters),
|
||||
subtitle: Text(context.l10n.appSettings_showRepeatersSubtitle),
|
||||
value: settingsService.settings.mapShowRepeaters,
|
||||
onChanged: (value) {
|
||||
settingsService.setMapShowRepeaters(value);
|
||||
@@ -269,8 +328,8 @@ class AppSettingsScreen extends StatelessWidget {
|
||||
const Divider(height: 1),
|
||||
SwitchListTile(
|
||||
secondary: const Icon(Icons.chat_outlined),
|
||||
title: const Text('Show Chat Nodes'),
|
||||
subtitle: const Text('Display chat nodes on the map'),
|
||||
title: Text(context.l10n.appSettings_showChatNodes),
|
||||
subtitle: Text(context.l10n.appSettings_showChatNodesSubtitle),
|
||||
value: settingsService.settings.mapShowChatNodes,
|
||||
onChanged: (value) {
|
||||
settingsService.setMapShowChatNodes(value);
|
||||
@@ -279,8 +338,8 @@ class AppSettingsScreen extends StatelessWidget {
|
||||
const Divider(height: 1),
|
||||
SwitchListTile(
|
||||
secondary: const Icon(Icons.people_outline),
|
||||
title: const Text('Show Other Nodes'),
|
||||
subtitle: const Text('Display other node types on the map'),
|
||||
title: Text(context.l10n.appSettings_showOtherNodes),
|
||||
subtitle: Text(context.l10n.appSettings_showOtherNodesSubtitle),
|
||||
value: settingsService.settings.mapShowOtherNodes,
|
||||
onChanged: (value) {
|
||||
settingsService.setMapShowOtherNodes(value);
|
||||
@@ -289,11 +348,13 @@ class AppSettingsScreen extends StatelessWidget {
|
||||
const Divider(height: 1),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.timer_outlined),
|
||||
title: const Text('Time Filter'),
|
||||
title: Text(context.l10n.appSettings_timeFilter),
|
||||
subtitle: Text(
|
||||
settingsService.settings.mapTimeFilterHours == 0
|
||||
? 'Show all nodes'
|
||||
: 'Show nodes from last ${settingsService.settings.mapTimeFilterHours.toInt()} hours',
|
||||
? context.l10n.appSettings_timeFilterShowAll
|
||||
: context.l10n.appSettings_timeFilterShowLast(
|
||||
settingsService.settings.mapTimeFilterHours.toInt(),
|
||||
),
|
||||
),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => _showTimeFilterDialog(context, settingsService),
|
||||
@@ -301,12 +362,14 @@ class AppSettingsScreen extends StatelessWidget {
|
||||
const Divider(height: 1),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.download_outlined),
|
||||
title: const Text('Offline Map Cache'),
|
||||
title: Text(context.l10n.appSettings_offlineMapCache),
|
||||
subtitle: Text(
|
||||
settingsService.settings.mapCacheBounds == null
|
||||
? 'No area selected'
|
||||
: 'Area selected (zoom ${settingsService.settings.mapCacheMinZoom}'
|
||||
'-${settingsService.settings.mapCacheMaxZoom})',
|
||||
? context.l10n.appSettings_noAreaSelected
|
||||
: context.l10n.appSettings_areaSelectedZoom(
|
||||
settingsService.settings.mapCacheMinZoom,
|
||||
settingsService.settings.mapCacheMaxZoom,
|
||||
),
|
||||
),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () {
|
||||
@@ -321,6 +384,7 @@ class AppSettingsScreen extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
// Fixed rendering issues
|
||||
Widget _buildBatteryCard(
|
||||
BuildContext context,
|
||||
AppSettingsService settingsService,
|
||||
@@ -328,49 +392,69 @@ class AppSettingsScreen extends StatelessWidget {
|
||||
) {
|
||||
final deviceId = connector.deviceId;
|
||||
final isConnected = connector.isConnected && deviceId != null;
|
||||
final selection =
|
||||
isConnected ? settingsService.batteryChemistryForDevice(deviceId) : 'nmc';
|
||||
final selection = isConnected
|
||||
? settingsService.batteryChemistryForDevice(deviceId)
|
||||
: 'nmc';
|
||||
|
||||
return Card(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
const SizedBox(height: 4),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
child: Text(
|
||||
'Battery',
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
context.l10n.appSettings_battery,
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
|
||||
// Main tile (icon + text only)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.battery_full),
|
||||
title: const Text('Battery Chemistry'),
|
||||
title: Text(context.l10n.appSettings_batteryChemistry),
|
||||
subtitle: Text(
|
||||
isConnected
|
||||
? 'Set per device (${connector.deviceDisplayName})'
|
||||
: 'Connect to a device to choose',
|
||||
? context.l10n.appSettings_batteryChemistryPerDevice(
|
||||
connector.deviceDisplayName,
|
||||
)
|
||||
: context.l10n.appSettings_batteryChemistryConnectFirst,
|
||||
),
|
||||
trailing: DropdownButton<String>(
|
||||
value: selection,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
),
|
||||
|
||||
// Dropdown (separate full-width row)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||
child: DropdownButtonFormField<String>(
|
||||
initialValue: selection,
|
||||
isExpanded: true,
|
||||
decoration: const InputDecoration(
|
||||
border: UnderlineInputBorder(),
|
||||
isDense: true,
|
||||
),
|
||||
onChanged: isConnected
|
||||
? (value) {
|
||||
if (value != null) {
|
||||
settingsService.setBatteryChemistryForDevice(deviceId, value);
|
||||
settingsService.setBatteryChemistryForDevice(
|
||||
deviceId,
|
||||
value,
|
||||
);
|
||||
}
|
||||
}
|
||||
: null,
|
||||
items: const [
|
||||
items: [
|
||||
DropdownMenuItem(
|
||||
value: 'nmc',
|
||||
child: Text('18650 NMC (3.0-4.2V)'),
|
||||
child: Text(context.l10n.appSettings_batteryNmc),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: 'lifepo4',
|
||||
child: Text('LiFePO4 (2.6-3.65V)'),
|
||||
child: Text(context.l10n.appSettings_batteryLifepo4),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: 'lipo',
|
||||
child: Text('LiPo (3.0-4.2V)'),
|
||||
child: Text(context.l10n.appSettings_batteryLipo),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -380,11 +464,14 @@ class AppSettingsScreen extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
void _showThemeModeDialog(BuildContext context, AppSettingsService settingsService) {
|
||||
void _showThemeModeDialog(
|
||||
BuildContext context,
|
||||
AppSettingsService settingsService,
|
||||
) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Theme'),
|
||||
title: Text(context.l10n.appSettings_theme),
|
||||
content: RadioGroup<String>(
|
||||
groupValue: settingsService.settings.themeMode,
|
||||
onChanged: (value) {
|
||||
@@ -397,15 +484,15 @@ class AppSettingsScreen extends StatelessWidget {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
RadioListTile<String>(
|
||||
title: const Text('System default'),
|
||||
title: Text(context.l10n.appSettings_themeSystem),
|
||||
value: 'system',
|
||||
),
|
||||
RadioListTile<String>(
|
||||
title: const Text('Light'),
|
||||
title: Text(context.l10n.appSettings_themeLight),
|
||||
value: 'light',
|
||||
),
|
||||
RadioListTile<String>(
|
||||
title: const Text('Dark'),
|
||||
title: Text(context.l10n.appSettings_themeDark),
|
||||
value: 'dark',
|
||||
),
|
||||
],
|
||||
@@ -414,29 +501,165 @@ class AppSettingsScreen extends StatelessWidget {
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Close'),
|
||||
child: Text(context.l10n.common_close),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _themeModeLabel(String value) {
|
||||
String _themeModeLabel(BuildContext context, String value) {
|
||||
switch (value) {
|
||||
case 'light':
|
||||
return 'Light';
|
||||
return context.l10n.appSettings_themeLight;
|
||||
case 'dark':
|
||||
return 'Dark';
|
||||
return context.l10n.appSettings_themeDark;
|
||||
default:
|
||||
return 'System default';
|
||||
return context.l10n.appSettings_themeSystem;
|
||||
}
|
||||
}
|
||||
|
||||
void _showTimeFilterDialog(BuildContext context, AppSettingsService settingsService) {
|
||||
String _languageLabel(BuildContext context, String? languageCode) {
|
||||
switch (languageCode) {
|
||||
case 'en':
|
||||
return context.l10n.appSettings_languageEn;
|
||||
case 'fr':
|
||||
return context.l10n.appSettings_languageFr;
|
||||
case 'es':
|
||||
return context.l10n.appSettings_languageEs;
|
||||
case 'de':
|
||||
return context.l10n.appSettings_languageDe;
|
||||
case 'pl':
|
||||
return context.l10n.appSettings_languagePl;
|
||||
case 'sl':
|
||||
return context.l10n.appSettings_languageSl;
|
||||
case 'pt':
|
||||
return context.l10n.appSettings_languagePt;
|
||||
case 'it':
|
||||
return context.l10n.appSettings_languageIt;
|
||||
case 'zh':
|
||||
return context.l10n.appSettings_languageZh;
|
||||
case 'sv':
|
||||
return context.l10n.appSettings_languageSv;
|
||||
case 'nl':
|
||||
return context.l10n.appSettings_languageNl;
|
||||
case 'sk':
|
||||
return context.l10n.appSettings_languageSk;
|
||||
case 'bg':
|
||||
return context.l10n.appSettings_languageBg;
|
||||
case 'ru':
|
||||
return context.l10n.appSettings_languageRu;
|
||||
case 'uk':
|
||||
return context.l10n.appSettings_languageUk;
|
||||
default:
|
||||
return context.l10n.appSettings_languageSystem;
|
||||
}
|
||||
}
|
||||
|
||||
void _showLanguageDialog(
|
||||
BuildContext context,
|
||||
AppSettingsService settingsService,
|
||||
) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Map Time Filter'),
|
||||
title: Text(context.l10n.appSettings_language),
|
||||
content: SingleChildScrollView(
|
||||
child: RadioGroup<String?>(
|
||||
groupValue: settingsService.settings.languageOverride,
|
||||
onChanged: (value) {
|
||||
settingsService.setLanguageOverride(value);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
RadioListTile<String?>(
|
||||
title: Text(context.l10n.appSettings_languageSystem),
|
||||
value: null,
|
||||
),
|
||||
RadioListTile<String?>(
|
||||
title: Text(context.l10n.appSettings_languageEn),
|
||||
value: 'en',
|
||||
),
|
||||
RadioListTile<String?>(
|
||||
title: Text(context.l10n.appSettings_languageFr),
|
||||
value: 'fr',
|
||||
),
|
||||
RadioListTile<String?>(
|
||||
title: Text(context.l10n.appSettings_languageEs),
|
||||
value: 'es',
|
||||
),
|
||||
RadioListTile<String?>(
|
||||
title: Text(context.l10n.appSettings_languageDe),
|
||||
value: 'de',
|
||||
),
|
||||
RadioListTile<String?>(
|
||||
title: Text(context.l10n.appSettings_languagePl),
|
||||
value: 'pl',
|
||||
),
|
||||
RadioListTile<String?>(
|
||||
title: Text(context.l10n.appSettings_languageSl),
|
||||
value: 'sl',
|
||||
),
|
||||
RadioListTile<String?>(
|
||||
title: Text(context.l10n.appSettings_languagePt),
|
||||
value: 'pt',
|
||||
),
|
||||
RadioListTile<String?>(
|
||||
title: Text(context.l10n.appSettings_languageIt),
|
||||
value: 'it',
|
||||
),
|
||||
RadioListTile<String?>(
|
||||
title: Text(context.l10n.appSettings_languageZh),
|
||||
value: 'zh',
|
||||
),
|
||||
RadioListTile<String?>(
|
||||
title: Text(context.l10n.appSettings_languageSv),
|
||||
value: 'sv',
|
||||
),
|
||||
RadioListTile<String?>(
|
||||
title: Text(context.l10n.appSettings_languageNl),
|
||||
value: 'nl',
|
||||
),
|
||||
RadioListTile<String?>(
|
||||
title: Text(context.l10n.appSettings_languageSk),
|
||||
value: 'sk',
|
||||
),
|
||||
RadioListTile<String?>(
|
||||
title: Text(context.l10n.appSettings_languageBg),
|
||||
value: 'bg',
|
||||
),
|
||||
RadioListTile<String?>(
|
||||
title: Text(context.l10n.appSettings_languageRu),
|
||||
value: 'ru',
|
||||
),
|
||||
RadioListTile<String?>(
|
||||
title: Text(context.l10n.appSettings_languageUk),
|
||||
value: 'uk',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(context.l10n.common_close),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showTimeFilterDialog(
|
||||
BuildContext context,
|
||||
AppSettingsService settingsService,
|
||||
) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(context.l10n.appSettings_mapTimeFilter),
|
||||
content: RadioGroup<double>(
|
||||
groupValue: settingsService.settings.mapTimeFilterHours,
|
||||
onChanged: (value) {
|
||||
@@ -448,37 +671,27 @@ class AppSettingsScreen extends StatelessWidget {
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text('Show nodes discovered within:'),
|
||||
Text(context.l10n.appSettings_showNodesDiscoveredWithin),
|
||||
const SizedBox(height: 16),
|
||||
ListTile(
|
||||
title: const Text('All time'),
|
||||
leading: Radio<double>(
|
||||
value: 0,
|
||||
),
|
||||
title: Text(context.l10n.appSettings_allTime),
|
||||
leading: Radio<double>(value: 0),
|
||||
),
|
||||
ListTile(
|
||||
title: const Text('Last hour'),
|
||||
leading: Radio<double>(
|
||||
value: 1,
|
||||
),
|
||||
title: Text(context.l10n.appSettings_lastHour),
|
||||
leading: Radio<double>(value: 1),
|
||||
),
|
||||
ListTile(
|
||||
title: const Text('Last 6 hours'),
|
||||
leading: Radio<double>(
|
||||
value: 6,
|
||||
),
|
||||
title: Text(context.l10n.appSettings_last6Hours),
|
||||
leading: Radio<double>(value: 6),
|
||||
),
|
||||
ListTile(
|
||||
title: const Text('Last 24 hours'),
|
||||
leading: Radio<double>(
|
||||
value: 24,
|
||||
),
|
||||
title: Text(context.l10n.appSettings_last24Hours),
|
||||
leading: Radio<double>(value: 24),
|
||||
),
|
||||
ListTile(
|
||||
title: const Text('Last week'),
|
||||
leading: Radio<double>(
|
||||
value: 168,
|
||||
),
|
||||
title: Text(context.l10n.appSettings_lastWeek),
|
||||
leading: Radio<double>(value: 168),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -486,38 +699,43 @@ class AppSettingsScreen extends StatelessWidget {
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Close'),
|
||||
child: Text(context.l10n.common_close),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDebugCard(BuildContext context, AppSettingsService settingsService) {
|
||||
Widget _buildDebugCard(
|
||||
BuildContext context,
|
||||
AppSettingsService settingsService,
|
||||
) {
|
||||
return Card(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
child: Text(
|
||||
'Debug',
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
context.l10n.appSettings_debugCard,
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
SwitchListTile(
|
||||
secondary: const Icon(Icons.bug_report_outlined),
|
||||
title: const Text('App Debug Logging'),
|
||||
subtitle: const Text('Log app debug messages for troubleshooting'),
|
||||
title: Text(context.l10n.appSettings_appDebugLogging),
|
||||
subtitle: Text(context.l10n.appSettings_appDebugLoggingSubtitle),
|
||||
value: settingsService.settings.appDebugLogEnabled,
|
||||
onChanged: (value) async {
|
||||
await settingsService.setAppDebugLogEnabled(value);
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(value
|
||||
? 'App debug logging enabled'
|
||||
: 'App debug logging disabled'),
|
||||
content: Text(
|
||||
value
|
||||
? context.l10n.appSettings_appDebugLoggingEnabled
|
||||
: context.l10n.appSettings_appDebugLoggingDisabled,
|
||||
),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import '../l10n/l10n.dart';
|
||||
import '../services/ble_debug_log_service.dart';
|
||||
import '../connector/meshcore_protocol.dart';
|
||||
|
||||
@@ -23,33 +24,43 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
|
||||
final entries = logService.entries.reversed.toList();
|
||||
final rawEntries = logService.rawLogRxEntries.reversed.toList();
|
||||
final showingFrames = _view == _BleLogView.frames;
|
||||
final hasEntries = showingFrames ? entries.isNotEmpty : rawEntries.isNotEmpty;
|
||||
final hasEntries = showingFrames
|
||||
? entries.isNotEmpty
|
||||
: rawEntries.isNotEmpty;
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('BLE Debug Log'),
|
||||
title: Text(context.l10n.debugLog_bleTitle),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: 'Copy log',
|
||||
tooltip: context.l10n.debugLog_copyLog,
|
||||
icon: const Icon(Icons.copy),
|
||||
onPressed: hasEntries
|
||||
? () async {
|
||||
final text = showingFrames
|
||||
? entries
|
||||
.map((entry) => '${entry.description}\n${entry.hexPreview}\n')
|
||||
.join('\n')
|
||||
.map(
|
||||
(entry) =>
|
||||
'${entry.description}\n${entry.hexPreview}\n',
|
||||
)
|
||||
.join('\n')
|
||||
: rawEntries
|
||||
.map((entry) => 'RX RAW_LOG_RX_DATA\n${entry.hexPreview}\n')
|
||||
.join('\n');
|
||||
.map(
|
||||
(entry) =>
|
||||
'RX RAW_LOG_RX_DATA\n${entry.hexPreview}\n',
|
||||
)
|
||||
.join('\n');
|
||||
await Clipboard.setData(ClipboardData(text: text));
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('BLE log copied')),
|
||||
SnackBar(
|
||||
content: Text(context.l10n.debugLog_bleCopied),
|
||||
),
|
||||
);
|
||||
}
|
||||
: null,
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Clear log',
|
||||
tooltip: context.l10n.debugLog_clearLog,
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
onPressed: hasEntries
|
||||
? () {
|
||||
@@ -66,9 +77,15 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||
child: SegmentedButton<_BleLogView>(
|
||||
segments: const [
|
||||
ButtonSegment(value: _BleLogView.frames, label: Text('Frames')),
|
||||
ButtonSegment(value: _BleLogView.rawLogRx, label: Text('Raw Log-RX')),
|
||||
segments: [
|
||||
ButtonSegment(
|
||||
value: _BleLogView.frames,
|
||||
label: Text(context.l10n.debugLog_frames),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: _BleLogView.rawLogRx,
|
||||
label: Text(context.l10n.debugLog_rawLogRx),
|
||||
),
|
||||
],
|
||||
selected: {_view},
|
||||
onSelectionChanged: (selection) {
|
||||
@@ -80,8 +97,10 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
|
||||
Expanded(
|
||||
child: hasEntries
|
||||
? ListView.separated(
|
||||
itemCount: showingFrames ? entries.length : rawEntries.length,
|
||||
separatorBuilder: (_, __) => const Divider(height: 1),
|
||||
itemCount: showingFrames
|
||||
? entries.length
|
||||
: rawEntries.length,
|
||||
separatorBuilder: (_, _) => const Divider(height: 1),
|
||||
itemBuilder: (context, index) {
|
||||
if (showingFrames) {
|
||||
final entry = entries[index];
|
||||
@@ -93,7 +112,9 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
|
||||
subtitle: Text('${entry.hexPreview}\n$time'),
|
||||
isThreeLine: true,
|
||||
leading: Icon(
|
||||
entry.outgoing ? Icons.upload : Icons.download,
|
||||
entry.outgoing
|
||||
? Icons.upload
|
||||
: Icons.download,
|
||||
size: 18,
|
||||
),
|
||||
);
|
||||
@@ -113,8 +134,8 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
|
||||
);
|
||||
},
|
||||
)
|
||||
: const Center(
|
||||
child: Text('No BLE activity yet'),
|
||||
: Center(
|
||||
child: Text(context.l10n.debugLog_noBleActivity),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -130,13 +151,11 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(info.title),
|
||||
content: SingleChildScrollView(
|
||||
child: SelectableText(info.rawHex),
|
||||
),
|
||||
content: SingleChildScrollView(child: SelectableText(info.rawHex)),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Close'),
|
||||
child: Text(context.l10n.common_close),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -194,11 +213,18 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
|
||||
}
|
||||
final payload = raw.sublist(index);
|
||||
|
||||
final title = 'RX ${_payloadTypeLabel(payloadType)} • ${_routeLabel(routeType)} • v$payloadVer';
|
||||
final title =
|
||||
'RX ${_payloadTypeLabel(payloadType)} • ${_routeLabel(routeType)} • v$payloadVer';
|
||||
final summary = _decodePayloadSummary(payloadType, payload);
|
||||
final pathSummary = pathLen > 0 ? 'Path=${_bytesToHex(pathBytes)}' : 'Path=none';
|
||||
final pathSummary = pathLen > 0
|
||||
? 'Path=${_bytesToHex(pathBytes)}'
|
||||
: 'Path=none';
|
||||
final detail = '$summary • $pathSummary • len=${raw.length}';
|
||||
return _RawPacketInfo(title: title, summary: detail, rawHex: _bytesToHex(raw));
|
||||
return _RawPacketInfo(
|
||||
title: title,
|
||||
summary: detail,
|
||||
rawHex: _bytesToHex(raw),
|
||||
);
|
||||
}
|
||||
|
||||
String _decodePayloadSummary(int payloadType, Uint8List payload) {
|
||||
@@ -244,7 +270,10 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
|
||||
return 'ADVERT (short)';
|
||||
}
|
||||
var offset = 0;
|
||||
final pubKey = _bytesToHex(payload.sublist(offset, offset + 32), spaced: false);
|
||||
final pubKey = _bytesToHex(
|
||||
payload.sublist(offset, offset + 32),
|
||||
spaced: false,
|
||||
);
|
||||
offset += 32;
|
||||
final timestamp = readUint32LE(payload, offset);
|
||||
offset += 4;
|
||||
|
||||
@@ -3,17 +3,23 @@ import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_linkify/flutter_linkify.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../connector/meshcore_connector.dart';
|
||||
import '../helpers/chat_scroll_controller.dart';
|
||||
import '../connector/meshcore_protocol.dart';
|
||||
import '../helpers/link_handler.dart';
|
||||
import '../helpers/reaction_helper.dart';
|
||||
import '../helpers/utf8_length_limiter.dart';
|
||||
import '../l10n/l10n.dart';
|
||||
import '../models/channel.dart';
|
||||
import '../models/channel_message.dart';
|
||||
import '../utils/emoji_utils.dart';
|
||||
import '../widgets/emoji_picker.dart';
|
||||
import '../widgets/gif_message.dart';
|
||||
import '../widgets/jump_to_bottom_button.dart';
|
||||
import '../widgets/gif_picker.dart';
|
||||
import 'channel_message_path_screen.dart';
|
||||
import 'map_screen.dart';
|
||||
@@ -21,10 +27,7 @@ import 'map_screen.dart';
|
||||
class ChannelChatScreen extends StatefulWidget {
|
||||
final Channel channel;
|
||||
|
||||
const ChannelChatScreen({
|
||||
super.key,
|
||||
required this.channel,
|
||||
});
|
||||
const ChannelChatScreen({super.key, required this.channel});
|
||||
|
||||
@override
|
||||
State<ChannelChatScreen> createState() => _ChannelChatScreenState();
|
||||
@@ -32,42 +35,54 @@ class ChannelChatScreen extends StatefulWidget {
|
||||
|
||||
class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
final TextEditingController _textController = TextEditingController();
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
final ChatScrollController _scrollController = ChatScrollController();
|
||||
final FocusNode _textFieldFocusNode = FocusNode();
|
||||
ChannelMessage? _replyingToMessage;
|
||||
final Map<String, GlobalKey> _messageKeys = {};
|
||||
bool _isLoadingOlder = false;
|
||||
|
||||
MeshCoreConnector? _connector;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_textFieldFocusNode.addListener(_onTextFieldFocusChange);
|
||||
_scrollController.onScrollNearTop = _loadOlderMessages;
|
||||
SchedulerBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
context.read<MeshCoreConnector>().setActiveChannel(widget.channel.index);
|
||||
|
||||
// Scroll to bottom when opening channel chat - use SchedulerBinding for next frame
|
||||
if (_scrollController.hasClients) {
|
||||
_scrollController.jumpTo(_scrollController.position.maxScrollExtent);
|
||||
}
|
||||
_connector = context.read<MeshCoreConnector>();
|
||||
_connector?.setActiveChannel(widget.channel.index);
|
||||
});
|
||||
}
|
||||
|
||||
void _onTextFieldFocusChange() {
|
||||
if (_textFieldFocusNode.hasFocus && mounted) {
|
||||
_scrollController.handleKeyboardOpen();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadOlderMessages() async {
|
||||
if (_isLoadingOlder) return;
|
||||
setState(() => _isLoadingOlder = true);
|
||||
|
||||
final connector = context.read<MeshCoreConnector>();
|
||||
await connector.loadOlderChannelMessages(widget.channel.index);
|
||||
|
||||
if (mounted) {
|
||||
setState(() => _isLoadingOlder = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
context.read<MeshCoreConnector>().setActiveChannel(null);
|
||||
_connector?.setActiveChannel(null);
|
||||
_textFieldFocusNode.removeListener(_onTextFieldFocusChange);
|
||||
_textFieldFocusNode.dispose();
|
||||
_textController.dispose();
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _scrollToBottom() {
|
||||
if (_scrollController.hasClients) {
|
||||
_scrollController.animateTo(
|
||||
_scrollController.position.maxScrollExtent,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _setReplyingTo(ChannelMessage message) {
|
||||
setState(() {
|
||||
_replyingToMessage = message;
|
||||
@@ -84,9 +99,9 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
final key = _messageKeys[messageId];
|
||||
if (key == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Original message not found'),
|
||||
duration: Duration(seconds: 2),
|
||||
SnackBar(
|
||||
content: Text(context.l10n.chat_originalMessageNotFound),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
return;
|
||||
@@ -120,17 +135,21 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
children: [
|
||||
Text(
|
||||
widget.channel.name.isEmpty
|
||||
? 'Channel ${widget.channel.index}'
|
||||
? context.l10n.channels_channelIndex(
|
||||
widget.channel.index,
|
||||
)
|
||||
: widget.channel.name,
|
||||
style: const TextStyle(fontSize: 16),
|
||||
),
|
||||
Consumer<MeshCoreConnector>(
|
||||
builder: (context, connector, _) {
|
||||
final unreadCount =
|
||||
connector.getUnreadCountForChannelIndex(widget.channel.index);
|
||||
final privacy = widget.channel.isPublicChannel ? 'Public' : 'Private';
|
||||
final unreadCount = connector
|
||||
.getUnreadCountForChannelIndex(widget.channel.index);
|
||||
final privacy = widget.channel.isPublicChannel
|
||||
? context.l10n.channels_public
|
||||
: context.l10n.channels_private;
|
||||
return Text(
|
||||
'$privacy • Unread: $unreadCount',
|
||||
'$privacy • ${context.l10n.chat_unread(unreadCount)}',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontSize: 12),
|
||||
);
|
||||
@@ -152,10 +171,6 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
builder: (context, connector, child) {
|
||||
final messages = connector.getChannelMessages(widget.channel);
|
||||
|
||||
SchedulerBinding.instance.addPostFrameCallback((_) {
|
||||
_scrollToBottom();
|
||||
});
|
||||
|
||||
if (messages.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
@@ -170,7 +185,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'No messages yet',
|
||||
context.l10n.chat_noMessages,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: Colors.grey[600],
|
||||
@@ -178,7 +193,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Send a message to get started',
|
||||
context.l10n.chat_sendMessageToStart,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey[500],
|
||||
@@ -189,20 +204,52 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
controller: _scrollController,
|
||||
padding: const EdgeInsets.all(8),
|
||||
itemCount: messages.length,
|
||||
itemBuilder: (context, index) {
|
||||
final message = messages[index];
|
||||
if (!_messageKeys.containsKey(message.messageId)) {
|
||||
_messageKeys[message.messageId] = GlobalKey();
|
||||
}
|
||||
return Container(
|
||||
key: _messageKeys[message.messageId]!,
|
||||
child: _buildMessageBubble(message),
|
||||
);
|
||||
},
|
||||
// Reverse messages so newest appear at bottom with reverse: true
|
||||
final reversedMessages = messages.reversed.toList();
|
||||
final itemCount =
|
||||
reversedMessages.length + (_isLoadingOlder ? 1 : 0);
|
||||
|
||||
// Auto-scroll to bottom if user is already at bottom
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_scrollController.scrollToBottomIfAtBottom();
|
||||
});
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
ListView.builder(
|
||||
reverse: true, // List grows from bottom up
|
||||
controller: _scrollController,
|
||||
padding: const EdgeInsets.all(8),
|
||||
itemCount: itemCount,
|
||||
itemBuilder: (context, index) {
|
||||
// Loading indicator now appears at end (bottom) of reversed list
|
||||
if (_isLoadingOlder && index == itemCount - 1) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 16),
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
final messageIndex = index;
|
||||
final message = reversedMessages[messageIndex];
|
||||
if (!_messageKeys.containsKey(message.messageId)) {
|
||||
_messageKeys[message.messageId] = GlobalKey();
|
||||
}
|
||||
return Container(
|
||||
key: _messageKeys[message.messageId]!,
|
||||
child: _buildMessageBubble(message),
|
||||
);
|
||||
},
|
||||
),
|
||||
JumpToBottomButton(scrollController: _scrollController),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -220,15 +267,21 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
final poi = _parsePoiMessage(message.text);
|
||||
final displayPath = message.pathBytes.isNotEmpty
|
||||
? message.pathBytes
|
||||
: (message.pathVariants.isNotEmpty ? message.pathVariants.first : Uint8List(0));
|
||||
: (message.pathVariants.isNotEmpty
|
||||
? message.pathVariants.first
|
||||
: Uint8List(0));
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: isOutgoing ? CrossAxisAlignment.end : CrossAxisAlignment.start,
|
||||
crossAxisAlignment: isOutgoing
|
||||
? CrossAxisAlignment.end
|
||||
: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: isOutgoing ? MainAxisAlignment.end : MainAxisAlignment.start,
|
||||
mainAxisAlignment: isOutgoing
|
||||
? MainAxisAlignment.end
|
||||
: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (!isOutgoing) ...[
|
||||
@@ -240,97 +293,160 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
onTap: () => _showMessagePathInfo(message),
|
||||
onLongPress: () => _showMessageActions(message),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: MediaQuery.of(context).size.width * 0.65,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: isOutgoing
|
||||
? Theme.of(context).colorScheme.primaryContainer
|
||||
: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (!isOutgoing) ...[
|
||||
Text(
|
||||
message.senderName,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
],
|
||||
if (message.replyToMessageId != null) ...[
|
||||
_buildReplyPreview(message),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
if (poi != null)
|
||||
_buildPoiMessage(context, poi, isOutgoing)
|
||||
else if (gifId != null)
|
||||
GifMessage(
|
||||
url: 'https://media.giphy.com/media/$gifId/giphy.gif',
|
||||
backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
fallbackTextColor: isOutgoing
|
||||
? Theme.of(context).colorScheme.onPrimaryContainer.withValues(alpha: 0.7)
|
||||
: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
)
|
||||
else
|
||||
Text(
|
||||
message.text,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
if (displayPath.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'via ${_formatPathPrefixes(displayPath)}',
|
||||
style: TextStyle(fontSize: 11, color: Colors.grey[600]),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
padding: gifId != null
|
||||
? const EdgeInsets.all(4)
|
||||
: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 8,
|
||||
),
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: MediaQuery.of(context).size.width * 0.65,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: isOutgoing
|
||||
? Theme.of(context).colorScheme.primaryContainer
|
||||
: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
_formatTime(message.timestamp),
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Colors.grey[600],
|
||||
if (!isOutgoing) ...[
|
||||
Padding(
|
||||
padding: gifId != null
|
||||
? const EdgeInsets.only(
|
||||
left: 8,
|
||||
top: 4,
|
||||
bottom: 4,
|
||||
)
|
||||
: EdgeInsets.zero,
|
||||
child: Text(
|
||||
message.senderName,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (gifId == null) const SizedBox(height: 4),
|
||||
],
|
||||
if (message.replyToMessageId != null) ...[
|
||||
_buildReplyPreview(message),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
if (poi != null)
|
||||
_buildPoiMessage(context, poi, isOutgoing)
|
||||
else if (gifId != null)
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: GifMessage(
|
||||
url:
|
||||
'https://media.giphy.com/media/$gifId/giphy.gif',
|
||||
backgroundColor: Colors.transparent,
|
||||
fallbackTextColor: isOutgoing
|
||||
? Theme.of(context)
|
||||
.colorScheme
|
||||
.onPrimaryContainer
|
||||
.withValues(alpha: 0.7)
|
||||
: Theme.of(context).colorScheme.onSurface
|
||||
.withValues(alpha: 0.6),
|
||||
),
|
||||
)
|
||||
else
|
||||
Linkify(
|
||||
text: message.text,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
linkStyle: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.green,
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
options: const LinkifyOptions(
|
||||
humanize: false,
|
||||
defaultToHttps: false,
|
||||
),
|
||||
linkifiers: const [UrlLinkifier()],
|
||||
onOpen: (link) =>
|
||||
LinkHandler.handleLinkTap(context, link.url),
|
||||
),
|
||||
if (displayPath.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Padding(
|
||||
padding: gifId != null
|
||||
? const EdgeInsets.symmetric(horizontal: 8)
|
||||
: EdgeInsets.zero,
|
||||
child: Text(
|
||||
'via ${_formatPathPrefixes(displayPath)}',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 4),
|
||||
Padding(
|
||||
padding: gifId != null
|
||||
? const EdgeInsets.only(
|
||||
left: 8,
|
||||
right: 8,
|
||||
bottom: 4,
|
||||
)
|
||||
: EdgeInsets.zero,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
_formatTime(message.timestamp),
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
if (message.repeatCount > 0) ...[
|
||||
const SizedBox(width: 6),
|
||||
Icon(
|
||||
Icons.repeat,
|
||||
size: 12,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
const SizedBox(width: 2),
|
||||
Text(
|
||||
'${message.repeatCount}',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
],
|
||||
if (isOutgoing) ...[
|
||||
const SizedBox(width: 4),
|
||||
Icon(
|
||||
message.status == ChannelMessageStatus.sent
|
||||
? Icons.check
|
||||
: message.status ==
|
||||
ChannelMessageStatus.pending
|
||||
? Icons.schedule
|
||||
: Icons.error_outline,
|
||||
size: 14,
|
||||
color:
|
||||
message.status ==
|
||||
ChannelMessageStatus.failed
|
||||
? Colors.red
|
||||
: Colors.grey[600],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
if (message.repeatCount > 0) ...[
|
||||
const SizedBox(width: 6),
|
||||
Icon(Icons.repeat, size: 12, color: Colors.grey[600]),
|
||||
const SizedBox(width: 2),
|
||||
Text(
|
||||
'${message.repeatCount}',
|
||||
style: TextStyle(fontSize: 11, color: Colors.grey[600]),
|
||||
),
|
||||
],
|
||||
if (isOutgoing) ...[
|
||||
const SizedBox(width: 4),
|
||||
Icon(
|
||||
message.status == ChannelMessageStatus.sent
|
||||
? Icons.check
|
||||
: message.status == ChannelMessageStatus.pending
|
||||
? Icons.schedule
|
||||
: Icons.error_outline,
|
||||
size: 14,
|
||||
color: message.status == ChannelMessageStatus.failed
|
||||
? Colors.red
|
||||
: Colors.grey[600],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (message.reactions.isNotEmpty) ...[
|
||||
@@ -363,8 +479,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
url: 'https://media.giphy.com/media/$gifId/giphy.gif',
|
||||
backgroundColor: colorScheme.surfaceContainerHighest,
|
||||
fallbackTextColor: previewTextColor,
|
||||
width: 120,
|
||||
height: 80,
|
||||
maxSize: 80,
|
||||
),
|
||||
);
|
||||
} else if (poi != null) {
|
||||
@@ -372,7 +487,10 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
children: [
|
||||
Icon(Icons.location_on_outlined, size: 14, color: previewTextColor),
|
||||
const SizedBox(width: 4),
|
||||
Text('Location', style: TextStyle(fontSize: 12, color: previewTextColor)),
|
||||
Text(
|
||||
context.l10n.chat_location,
|
||||
style: TextStyle(fontSize: 12, color: previewTextColor),
|
||||
),
|
||||
],
|
||||
);
|
||||
} else {
|
||||
@@ -396,17 +514,14 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border(
|
||||
left: BorderSide(
|
||||
color: colorScheme.primary,
|
||||
width: 3,
|
||||
),
|
||||
left: BorderSide(color: colorScheme.primary, width: 3),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Reply to ${message.replyToSenderName}',
|
||||
context.l10n.chat_replyTo(message.replyToSenderName ?? ''),
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.bold,
|
||||
@@ -437,17 +552,16 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
color: Theme.of(context).colorScheme.secondaryContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3),
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.outline.withValues(alpha: 0.3),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
emoji,
|
||||
style: const TextStyle(fontSize: 16),
|
||||
),
|
||||
Text(emoji, style: const TextStyle(fontSize: 16)),
|
||||
if (count > 1) ...[
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
@@ -474,7 +588,9 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
|
||||
_PoiInfo? _parsePoiMessage(String text) {
|
||||
final trimmed = text.trim();
|
||||
final match = RegExp(r'm:([\-0-9.]+),([\-0-9.]+)\|([^|]*)\|').firstMatch(trimmed);
|
||||
final match = RegExp(
|
||||
r'm:([\-0-9.]+),([\-0-9.]+)\|([^|]*)\|',
|
||||
).firstMatch(trimmed);
|
||||
if (match == null) return null;
|
||||
final lat = double.tryParse(match.group(1) ?? '');
|
||||
final lon = double.tryParse(match.group(2) ?? '');
|
||||
@@ -485,10 +601,13 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
|
||||
Widget _buildPoiMessage(BuildContext context, _PoiInfo poi, bool isOutgoing) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final textColor =
|
||||
isOutgoing ? colorScheme.onPrimaryContainer : colorScheme.onSurface;
|
||||
final textColor = isOutgoing
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSurface;
|
||||
final metaColor = textColor.withValues(alpha: 0.7);
|
||||
final channelColor = widget.channel.isPublicChannel ? Colors.orange : Colors.blue;
|
||||
final channelColor = widget.channel.isPublicChannel
|
||||
? Colors.orange
|
||||
: Colors.blue;
|
||||
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
@@ -515,19 +634,13 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'POI Shared',
|
||||
style: TextStyle(
|
||||
color: textColor,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
context.l10n.chat_poiShared,
|
||||
style: TextStyle(color: textColor, fontWeight: FontWeight.w600),
|
||||
),
|
||||
if (poi.label.isNotEmpty)
|
||||
Text(
|
||||
poi.label,
|
||||
style: TextStyle(
|
||||
color: metaColor,
|
||||
fontSize: 12,
|
||||
),
|
||||
style: TextStyle(color: metaColor, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -604,10 +717,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.secondaryContainer,
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: Theme.of(context).dividerColor,
|
||||
width: 1,
|
||||
),
|
||||
bottom: BorderSide(color: Theme.of(context).dividerColor, width: 1),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
@@ -623,7 +733,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Replying to ${message.senderName}',
|
||||
context.l10n.chat_replyingTo(message.senderName),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
@@ -636,7 +746,9 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Theme.of(context).colorScheme.onSecondaryContainer.withValues(alpha: 0.7),
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSecondaryContainer.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -674,69 +786,76 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.gif_box),
|
||||
onPressed: () => _showGifPicker(context),
|
||||
tooltip: 'Send GIF',
|
||||
),
|
||||
Expanded(
|
||||
child: ValueListenableBuilder<TextEditingValue>(
|
||||
valueListenable: _textController,
|
||||
builder: (context, value, child) {
|
||||
final gifId = _parseGifId(value.text);
|
||||
if (gifId != null) {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: GifMessage(
|
||||
url: 'https://media.giphy.com/media/$gifId/giphy.gif',
|
||||
backgroundColor:
|
||||
Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
fallbackTextColor:
|
||||
Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
width: 160,
|
||||
height: 110,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.gif_box),
|
||||
onPressed: () => _showGifPicker(context),
|
||||
tooltip: context.l10n.chat_sendGif,
|
||||
),
|
||||
Expanded(
|
||||
child: ValueListenableBuilder<TextEditingValue>(
|
||||
valueListenable: _textController,
|
||||
builder: (context, value, child) {
|
||||
final gifId = _parseGifId(value.text);
|
||||
if (gifId != null) {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: GifMessage(
|
||||
url:
|
||||
'https://media.giphy.com/media/$gifId/giphy.gif',
|
||||
backgroundColor: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerHighest,
|
||||
fallbackTextColor: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
maxSize: 160,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => _textController.clear(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return TextField(
|
||||
controller: _textController,
|
||||
focusNode: _textFieldFocusNode,
|
||||
inputFormatters: [
|
||||
Utf8LengthLimitingTextInputFormatter(maxBytes),
|
||||
],
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
decoration: InputDecoration(
|
||||
hintText: context.l10n.chat_typeMessage,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 8,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => _textController.clear(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return TextField(
|
||||
controller: _textController,
|
||||
inputFormatters: [
|
||||
Utf8LengthLimitingTextInputFormatter(maxBytes),
|
||||
],
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Type a message...',
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 8,
|
||||
),
|
||||
),
|
||||
maxLines: null,
|
||||
textInputAction: TextInputAction.send,
|
||||
onSubmitted: (_) => _sendMessage(),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.send),
|
||||
onPressed: _sendMessage,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
],
|
||||
maxLines: null,
|
||||
textInputAction: TextInputAction.send,
|
||||
onSubmitted: (_) => _sendMessage(),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.send),
|
||||
onPressed: _sendMessage,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -757,7 +876,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
final maxBytes = maxChannelMessageBytes(connector.selfName);
|
||||
if (utf8.encode(messageText).length > maxBytes) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Message too long (max $maxBytes bytes).')),
|
||||
SnackBar(content: Text(context.l10n.chat_messageTooLong(maxBytes))),
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -796,23 +915,25 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.reply),
|
||||
title: const Text('Reply'),
|
||||
title: Text(context.l10n.chat_reply),
|
||||
onTap: () {
|
||||
Navigator.pop(sheetContext);
|
||||
_setReplyingTo(message);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.add_reaction_outlined),
|
||||
title: const Text('Add Reaction'),
|
||||
onTap: () {
|
||||
Navigator.pop(sheetContext);
|
||||
_showEmojiPicker(message);
|
||||
},
|
||||
),
|
||||
// Can't react to your own messages
|
||||
if (!message.isOutgoing)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.add_reaction_outlined),
|
||||
title: Text(context.l10n.chat_addReaction),
|
||||
onTap: () {
|
||||
Navigator.pop(sheetContext);
|
||||
_showEmojiPicker(message);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.copy),
|
||||
title: const Text('Copy'),
|
||||
title: Text(context.l10n.common_copy),
|
||||
onTap: () {
|
||||
Navigator.pop(sheetContext);
|
||||
_copyMessageText(message.text);
|
||||
@@ -820,7 +941,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.delete_outline),
|
||||
title: const Text('Delete'),
|
||||
title: Text(context.l10n.common_delete),
|
||||
onTap: () async {
|
||||
Navigator.pop(sheetContext);
|
||||
await _deleteMessage(message);
|
||||
@@ -828,7 +949,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.close),
|
||||
title: const Text('Cancel'),
|
||||
title: Text(context.l10n.common_cancel),
|
||||
onTap: () => Navigator.pop(sheetContext),
|
||||
),
|
||||
],
|
||||
@@ -851,25 +972,31 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
|
||||
void _sendReaction(ChannelMessage message, String emoji) {
|
||||
final connector = context.read<MeshCoreConnector>();
|
||||
// Send reaction with full messageId to find target, but parser will extract
|
||||
// lightweight reactionKey (timestamp_senderPrefix) for deduplication
|
||||
final reactionText = 'r:${message.messageId}:$emoji';
|
||||
final emojiIndex = ReactionHelper.emojiToIndex(emoji);
|
||||
if (emojiIndex == null) return; // Unknown emoji, skip
|
||||
final timestampSecs = message.timestamp.millisecondsSinceEpoch ~/ 1000;
|
||||
final hash = ReactionHelper.computeReactionHash(
|
||||
timestampSecs,
|
||||
message.senderName,
|
||||
message.text,
|
||||
);
|
||||
final reactionText = 'r:$hash:$emojiIndex';
|
||||
connector.sendChannelMessage(widget.channel, reactionText);
|
||||
}
|
||||
|
||||
void _copyMessageText(String text) {
|
||||
Clipboard.setData(ClipboardData(text: text));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Message copied')),
|
||||
);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(context.l10n.chat_messageCopied)));
|
||||
}
|
||||
|
||||
Future<void> _deleteMessage(ChannelMessage message) async {
|
||||
await context.read<MeshCoreConnector>().deleteChannelMessage(message);
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Message deleted')),
|
||||
);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(context.l10n.chat_messageDeleted)));
|
||||
}
|
||||
|
||||
String _formatPathPrefixes(Uint8List pathBytes) {
|
||||
@@ -884,9 +1011,5 @@ class _PoiInfo {
|
||||
final double lon;
|
||||
final String label;
|
||||
|
||||
const _PoiInfo({
|
||||
required this.lat,
|
||||
required this.lon,
|
||||
required this.label,
|
||||
});
|
||||
const _PoiInfo({required this.lat, required this.lon, required this.label});
|
||||
}
|
||||
|
||||
@@ -4,42 +4,62 @@ import 'dart:typed_data';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:meshcore_open/screens/path_trace_map.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../connector/meshcore_connector.dart';
|
||||
import '../services/map_tile_cache_service.dart';
|
||||
import '../connector/meshcore_protocol.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../l10n/l10n.dart';
|
||||
import '../models/channel_message.dart';
|
||||
import '../models/contact.dart';
|
||||
|
||||
class ChannelMessagePathScreen extends StatelessWidget {
|
||||
final ChannelMessage message;
|
||||
|
||||
const ChannelMessagePathScreen({
|
||||
super.key,
|
||||
required this.message,
|
||||
});
|
||||
const ChannelMessagePathScreen({super.key, required this.message});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Consumer<MeshCoreConnector>(
|
||||
builder: (context, connector, _) {
|
||||
final primaryPath = _selectPrimaryPath(message.pathBytes, message.pathVariants);
|
||||
final hops = _buildPathHops(primaryPath, connector.contacts);
|
||||
final l10n = context.l10n;
|
||||
final primaryPath = _selectPrimaryPath(
|
||||
message.pathBytes,
|
||||
message.pathVariants,
|
||||
);
|
||||
final hops = _buildPathHops(primaryPath, connector.contacts, l10n);
|
||||
final hasHopDetails = primaryPath.isNotEmpty;
|
||||
final observedLabel = _formatObservedHops(
|
||||
primaryPath.length,
|
||||
message.pathLength,
|
||||
l10n,
|
||||
);
|
||||
final extraPaths = _otherPaths(primaryPath, message.pathVariants);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Packet Path'),
|
||||
title: Text(l10n.channelPath_title),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.radar_outlined),
|
||||
tooltip: l10n.channelPath_viewMap,
|
||||
onPressed: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => PathTraceMapScreen(
|
||||
title: context.l10n.contacts_repeaterPathTrace,
|
||||
path: Uint8List.fromList(primaryPath),
|
||||
flipPathRound: true,
|
||||
reversePathRound: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.map_outlined),
|
||||
tooltip: 'View map',
|
||||
tooltip: l10n.channelPath_viewMap,
|
||||
onPressed: hasHopDetails
|
||||
? () {
|
||||
_openPathMap(context);
|
||||
@@ -57,7 +77,7 @@ class ChannelMessagePathScreen extends StatelessWidget {
|
||||
const SizedBox(height: 16),
|
||||
if (extraPaths.isNotEmpty) ...[
|
||||
Text(
|
||||
'Other Observed Paths',
|
||||
l10n.channelPath_otherObservedPaths,
|
||||
style: Theme.of(context).textTheme.titleSmall,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
@@ -65,17 +85,17 @@ class ChannelMessagePathScreen extends StatelessWidget {
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
Text(
|
||||
'Repeater Hops',
|
||||
l10n.channelPath_repeaterHops,
|
||||
style: Theme.of(context).textTheme.titleSmall,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (!hasHopDetails)
|
||||
const Text(
|
||||
'Hop details are not provided for this packet.',
|
||||
style: TextStyle(color: Colors.grey),
|
||||
Text(
|
||||
l10n.channelPath_noHopDetails,
|
||||
style: const TextStyle(color: Colors.grey),
|
||||
)
|
||||
else
|
||||
..._buildHopTiles(hops),
|
||||
..._buildHopTiles(context, hops),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -84,10 +104,8 @@ class ChannelMessagePathScreen extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSummaryCard(
|
||||
BuildContext context, {
|
||||
String? observedLabel,
|
||||
}) {
|
||||
Widget _buildSummaryCard(BuildContext context, {String? observedLabel}) {
|
||||
final l10n = context.l10n;
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
@@ -95,26 +113,34 @@ class ChannelMessagePathScreen extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Message Details',
|
||||
l10n.channelPath_messageDetails,
|
||||
style: Theme.of(context).textTheme.titleSmall,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_buildDetailRow('Sender', message.senderName),
|
||||
_buildDetailRow('Time', _formatTime(message.timestamp)),
|
||||
_buildDetailRow(l10n.channelPath_senderLabel, message.senderName),
|
||||
_buildDetailRow(
|
||||
l10n.channelPath_timeLabel,
|
||||
_formatTime(message.timestamp, l10n),
|
||||
),
|
||||
if (message.repeatCount > 0)
|
||||
_buildDetailRow('Repeats', message.repeatCount.toString()),
|
||||
_buildDetailRow('Path', _formatPathLabel(message.pathLength)),
|
||||
if (observedLabel != null) _buildDetailRow('Observed', observedLabel),
|
||||
_buildDetailRow(
|
||||
l10n.channelPath_repeatsLabel,
|
||||
message.repeatCount.toString(),
|
||||
),
|
||||
_buildDetailRow(
|
||||
l10n.channelPath_pathLabelTitle,
|
||||
_formatPathLabel(message.pathLength, l10n),
|
||||
),
|
||||
if (observedLabel != null)
|
||||
_buildDetailRow(l10n.channelPath_observedLabel, observedLabel),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPathVariants(
|
||||
BuildContext context,
|
||||
List<Uint8List> variants,
|
||||
) {
|
||||
Widget _buildPathVariants(BuildContext context, List<Uint8List> variants) {
|
||||
final l10n = context.l10n;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -124,7 +150,10 @@ class ChannelMessagePathScreen extends StatelessWidget {
|
||||
child: ListTile(
|
||||
dense: true,
|
||||
title: Text(
|
||||
'Observed path ${i + 1} • ${_formatHopCount(variants[i].length)}',
|
||||
l10n.channelPath_observedPathTitle(
|
||||
i + 1,
|
||||
_formatHopCount(variants[i].length, l10n),
|
||||
),
|
||||
),
|
||||
subtitle: Text(_formatPathPrefixes(variants[i])),
|
||||
trailing: const Icon(Icons.map_outlined, size: 20),
|
||||
@@ -135,7 +164,8 @@ class ChannelMessagePathScreen extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _buildHopTiles(List<_PathHop> hops) {
|
||||
List<Widget> _buildHopTiles(BuildContext context, List<_PathHop> hops) {
|
||||
final l10n = context.l10n;
|
||||
return [
|
||||
for (final hop in hops)
|
||||
Card(
|
||||
@@ -153,46 +183,53 @@ class ChannelMessagePathScreen extends StatelessWidget {
|
||||
subtitle: Text(
|
||||
hop.hasLocation
|
||||
? '${hop.position!.latitude.toStringAsFixed(5)}, '
|
||||
'${hop.position!.longitude.toStringAsFixed(5)}'
|
||||
: 'No location data',
|
||||
'${hop.position!.longitude.toStringAsFixed(5)}'
|
||||
: l10n.channelPath_noLocationData,
|
||||
),
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
String _formatTime(DateTime time) {
|
||||
String _formatTime(DateTime time, AppLocalizations l10n) {
|
||||
final now = DateTime.now();
|
||||
final diff = now.difference(time);
|
||||
|
||||
if (diff.inDays > 0) {
|
||||
return '${time.day}/${time.month} '
|
||||
final timeLabel =
|
||||
'${time.hour}:${time.minute.toString().padLeft(2, '0')}';
|
||||
return l10n.channelPath_timeWithDate(time.day, time.month, timeLabel);
|
||||
}
|
||||
return '${time.hour}:${time.minute.toString().padLeft(2, '0')}';
|
||||
return l10n.channelPath_timeOnly(
|
||||
'${time.hour}:${time.minute.toString().padLeft(2, '0')}',
|
||||
);
|
||||
}
|
||||
|
||||
String _formatPathLabel(int? pathLength) {
|
||||
if (pathLength == null) return 'Unknown';
|
||||
if (pathLength < 0) return 'Flood';
|
||||
if (pathLength == 0) return 'Direct';
|
||||
return '$pathLength hops';
|
||||
String _formatPathLabel(int? pathLength, AppLocalizations l10n) {
|
||||
if (pathLength == null) return l10n.channelPath_unknownPath;
|
||||
if (pathLength < 0) return l10n.channelPath_floodPath;
|
||||
if (pathLength == 0) return l10n.channelPath_directPath;
|
||||
return l10n.chat_hopsCount(pathLength);
|
||||
}
|
||||
|
||||
String? _formatObservedHops(int observedCount, int? pathLength) {
|
||||
String? _formatObservedHops(
|
||||
int observedCount,
|
||||
int? pathLength,
|
||||
AppLocalizations l10n,
|
||||
) {
|
||||
if (observedCount <= 0 && (pathLength == null || pathLength <= 0)) {
|
||||
return null;
|
||||
}
|
||||
if (pathLength == null || pathLength < 0) {
|
||||
return observedCount > 0 ? '$observedCount hops' : null;
|
||||
return observedCount > 0 ? l10n.chat_hopsCount(observedCount) : null;
|
||||
}
|
||||
if (observedCount == 0) {
|
||||
return '0 of $pathLength hops';
|
||||
return l10n.channelPath_observedZeroOf(pathLength);
|
||||
}
|
||||
if (observedCount == pathLength) {
|
||||
return '$observedCount hops';
|
||||
return l10n.chat_hopsCount(observedCount);
|
||||
}
|
||||
return '$observedCount of $pathLength hops';
|
||||
return l10n.channelPath_observedSomeOf(observedCount, pathLength);
|
||||
}
|
||||
|
||||
Widget _buildDetailRow(String label, String value) {
|
||||
@@ -222,7 +259,6 @@ class ChannelMessagePathScreen extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class ChannelMessagePathMapScreen extends StatefulWidget {
|
||||
@@ -240,8 +276,10 @@ class ChannelMessagePathMapScreen extends StatefulWidget {
|
||||
_ChannelMessagePathMapScreenState();
|
||||
}
|
||||
|
||||
class _ChannelMessagePathMapScreenState extends State<ChannelMessagePathMapScreen> {
|
||||
class _ChannelMessagePathMapScreenState
|
||||
extends State<ChannelMessagePathMapScreen> {
|
||||
Uint8List? _selectedPath;
|
||||
double _pathDistance = 0.0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -253,32 +291,58 @@ class _ChannelMessagePathMapScreenState extends State<ChannelMessagePathMapScree
|
||||
void didUpdateWidget(ChannelMessagePathMapScreen oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.message != widget.message ||
|
||||
!_pathsEqual(oldWidget.initialPath ?? Uint8List(0),
|
||||
widget.initialPath ?? Uint8List(0))) {
|
||||
!_pathsEqual(
|
||||
oldWidget.initialPath ?? Uint8List(0),
|
||||
widget.initialPath ?? Uint8List(0),
|
||||
)) {
|
||||
_selectedPath = widget.initialPath;
|
||||
}
|
||||
}
|
||||
|
||||
double _getPathDistance(List<LatLng> points) {
|
||||
double totalDistance = 0.0;
|
||||
final distanceCalculator = Distance();
|
||||
|
||||
for (int i = 0; i < points.length - 1; i++) {
|
||||
totalDistance += distanceCalculator(points[i], points[i + 1]);
|
||||
}
|
||||
|
||||
return totalDistance;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Consumer<MeshCoreConnector>(
|
||||
builder: (context, connector, _) {
|
||||
final tileCache = context.read<MapTileCacheService>();
|
||||
final primaryPath =
|
||||
_selectPrimaryPath(widget.message.pathBytes, widget.message.pathVariants);
|
||||
final observedPaths =
|
||||
_buildObservedPaths(primaryPath, widget.message.pathVariants);
|
||||
final primaryPath = _selectPrimaryPath(
|
||||
widget.message.pathBytes,
|
||||
widget.message.pathVariants,
|
||||
);
|
||||
final observedPaths = _buildObservedPaths(
|
||||
primaryPath,
|
||||
widget.message.pathVariants,
|
||||
);
|
||||
final selectedPath = _resolveSelectedPath(
|
||||
_selectedPath,
|
||||
observedPaths,
|
||||
primaryPath,
|
||||
);
|
||||
final selectedIndex = _indexForPath(selectedPath, observedPaths);
|
||||
final hops = _buildPathHops(selectedPath, connector.contacts);
|
||||
final points = hops
|
||||
.where((hop) => hop.hasLocation)
|
||||
.map((hop) => hop.position!)
|
||||
.toList();
|
||||
final hops = _buildPathHops(
|
||||
selectedPath,
|
||||
connector.contacts,
|
||||
context.l10n,
|
||||
);
|
||||
|
||||
final points = <LatLng>[];
|
||||
for (final hop in hops) {
|
||||
if (hop.hasLocation) {
|
||||
points.add(hop.position!);
|
||||
}
|
||||
}
|
||||
points.add(LatLng(connector.selfLatitude!, connector.selfLongitude!));
|
||||
|
||||
final polylines = points.length > 1
|
||||
? [
|
||||
Polyline(
|
||||
@@ -289,16 +353,20 @@ class _ChannelMessagePathMapScreenState extends State<ChannelMessagePathMapScree
|
||||
]
|
||||
: <Polyline>[];
|
||||
|
||||
final initialCenter =
|
||||
points.isNotEmpty ? points.first : const LatLng(0, 0);
|
||||
final initialCenter = points.isNotEmpty
|
||||
? points.first
|
||||
: const LatLng(0, 0);
|
||||
final initialZoom = points.isNotEmpty ? 13.0 : 2.0;
|
||||
final bounds = points.length > 1 ? LatLngBounds.fromPoints(points) : null;
|
||||
final mapKey = ValueKey(_formatPathPrefixes(selectedPath));
|
||||
final bounds = points.length > 1
|
||||
? LatLngBounds.fromPoints(points)
|
||||
: null;
|
||||
final mapKey = ValueKey(
|
||||
'${_formatPathPrefixes(selectedPath)},${context.l10n.pathTrace_you}',
|
||||
);
|
||||
_pathDistance = _getPathDistance(points);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Path Map'),
|
||||
),
|
||||
appBar: AppBar(title: Text(context.l10n.channelPath_mapTitle)),
|
||||
body: SafeArea(
|
||||
top: false,
|
||||
child: Stack(
|
||||
@@ -317,6 +385,9 @@ class _ChannelMessagePathMapScreenState extends State<ChannelMessagePathMapScree
|
||||
),
|
||||
minZoom: 2.0,
|
||||
maxZoom: 18.0,
|
||||
interactionOptions: InteractionOptions(
|
||||
flags: ~InteractiveFlag.rotate,
|
||||
),
|
||||
),
|
||||
children: [
|
||||
TileLayer(
|
||||
@@ -326,30 +397,28 @@ class _ChannelMessagePathMapScreenState extends State<ChannelMessagePathMapScree
|
||||
MapTileCacheService.userAgentPackageName,
|
||||
maxZoom: 19,
|
||||
),
|
||||
if (polylines.isNotEmpty) PolylineLayer(polylines: polylines),
|
||||
MarkerLayer(
|
||||
markers: _buildHopMarkers(hops),
|
||||
),
|
||||
if (polylines.isNotEmpty)
|
||||
PolylineLayer(polylines: polylines),
|
||||
MarkerLayer(markers: _buildHopMarkers(hops)),
|
||||
],
|
||||
),
|
||||
if (observedPaths.length > 1)
|
||||
_buildPathSelector(
|
||||
context,
|
||||
observedPaths,
|
||||
selectedIndex,
|
||||
(index) {
|
||||
setState(() {
|
||||
_selectedPath = observedPaths[index].pathBytes;
|
||||
});
|
||||
},
|
||||
),
|
||||
_buildPathSelector(context, observedPaths, selectedIndex, (
|
||||
index,
|
||||
) {
|
||||
setState(() {
|
||||
_selectedPath = observedPaths[index].pathBytes;
|
||||
});
|
||||
}),
|
||||
if (points.isEmpty)
|
||||
Center(
|
||||
child: Card(
|
||||
color: Colors.white.withValues(alpha: 0.9),
|
||||
child: const Padding(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(12),
|
||||
child: Text('No repeater locations available for this path.'),
|
||||
child: Text(
|
||||
context.l10n.channelPath_noRepeaterLocations,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -368,10 +437,11 @@ class _ChannelMessagePathMapScreenState extends State<ChannelMessagePathMapScree
|
||||
int selectedIndex,
|
||||
ValueChanged<int> onSelected,
|
||||
) {
|
||||
final l10n = context.l10n;
|
||||
final selectedPath = paths[selectedIndex];
|
||||
final label = selectedPath.isPrimary
|
||||
? 'Path ${selectedIndex + 1} (Primary)'
|
||||
: 'Path ${selectedIndex + 1}';
|
||||
? l10n.channelPath_primaryPath(selectedIndex + 1)
|
||||
: l10n.channelPath_pathLabel(selectedIndex + 1);
|
||||
return Positioned(
|
||||
left: 16,
|
||||
right: 16,
|
||||
@@ -383,9 +453,9 @@ class _ChannelMessagePathMapScreenState extends State<ChannelMessagePathMapScree
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Observed Path',
|
||||
style: TextStyle(fontWeight: FontWeight.w600),
|
||||
Text(
|
||||
l10n.channelPath_observedPathHeader,
|
||||
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
DropdownButtonHideUnderline(
|
||||
@@ -397,8 +467,8 @@ class _ChannelMessagePathMapScreenState extends State<ChannelMessagePathMapScree
|
||||
DropdownMenuItem(
|
||||
value: i,
|
||||
child: Text(
|
||||
'${paths[i].isPrimary ? 'Path ${i + 1} (Primary)' : 'Path ${i + 1}'}'
|
||||
' • ${_formatHopCount(paths[i].pathBytes.length)}',
|
||||
'${paths[i].isPrimary ? l10n.channelPath_primaryPath(i + 1) : l10n.channelPath_pathLabel(i + 1)}'
|
||||
' • ${_formatHopCount(paths[i].pathBytes.length, l10n)}',
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -410,7 +480,10 @@ class _ChannelMessagePathMapScreenState extends State<ChannelMessagePathMapScree
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'$label • ${_formatPathPrefixes(selectedPath.pathBytes)}',
|
||||
l10n.channelPath_selectedPathLabel(
|
||||
label,
|
||||
_formatPathPrefixes(selectedPath.pathBytes),
|
||||
),
|
||||
style: TextStyle(color: Colors.grey[700], fontSize: 12),
|
||||
),
|
||||
],
|
||||
@@ -427,8 +500,8 @@ class _ChannelMessagePathMapScreenState extends State<ChannelMessagePathMapScree
|
||||
if (hop.hasLocation)
|
||||
Marker(
|
||||
point: hop.position!,
|
||||
width: 40,
|
||||
height: 40,
|
||||
width: 35,
|
||||
height: 35,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.green,
|
||||
@@ -453,10 +526,44 @@ class _ChannelMessagePathMapScreenState extends State<ChannelMessagePathMapScree
|
||||
),
|
||||
),
|
||||
),
|
||||
if (context.read<MeshCoreConnector>().selfLatitude != null &&
|
||||
context.read<MeshCoreConnector>().selfLongitude != null)
|
||||
Marker(
|
||||
point: LatLng(
|
||||
context.read<MeshCoreConnector>().selfLatitude!,
|
||||
context.read<MeshCoreConnector>().selfLongitude!,
|
||||
),
|
||||
width: 35,
|
||||
height: 35,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.teal,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.white, width: 2),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.3),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
context.l10n.pathTrace_you,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
Widget _buildLegendCard(BuildContext context, List<_PathHop> hops) {
|
||||
final l10n = context.l10n;
|
||||
final maxHeight = MediaQuery.of(context).size.height * 0.35;
|
||||
final estimatedHeight = 72.0 + (hops.length * 56.0);
|
||||
final cardHeight = max(96.0, min(maxHeight, estimatedHeight));
|
||||
@@ -471,23 +578,23 @@ class _ChannelMessagePathMapScreenState extends State<ChannelMessagePathMapScree
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(12),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Text(
|
||||
'Repeater Hops',
|
||||
style: TextStyle(fontWeight: FontWeight.w600),
|
||||
'${l10n.channelPath_repeaterHops} (${(_pathDistance / 1609.34).toStringAsFixed(2)} Miles / ${(_pathDistance / 1000).toStringAsFixed(2)} Km)',
|
||||
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Expanded(
|
||||
child: hops.isEmpty
|
||||
? const Center(
|
||||
child: Text('No hop details available for this packet.'),
|
||||
? Center(
|
||||
child: Text(l10n.channelPath_noHopDetailsAvailable),
|
||||
)
|
||||
: ListView.separated(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
itemCount: hops.length,
|
||||
separatorBuilder: (_, __) => const Divider(height: 1),
|
||||
separatorBuilder: (_, _) => const Divider(height: 1),
|
||||
itemBuilder: (context, index) {
|
||||
final hop = hops[index];
|
||||
return ListTile(
|
||||
@@ -503,8 +610,8 @@ class _ChannelMessagePathMapScreenState extends State<ChannelMessagePathMapScree
|
||||
subtitle: Text(
|
||||
hop.hasLocation
|
||||
? '${hop.position!.latitude.toStringAsFixed(5)}, '
|
||||
'${hop.position!.longitude.toStringAsFixed(5)}'
|
||||
: 'No location data',
|
||||
'${hop.position!.longitude.toStringAsFixed(5)}'
|
||||
: l10n.channelPath_noLocationData,
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -523,19 +630,21 @@ class _PathHop {
|
||||
final int prefix;
|
||||
final Contact? contact;
|
||||
final LatLng? position;
|
||||
final AppLocalizations l10n;
|
||||
|
||||
const _PathHop({
|
||||
required this.index,
|
||||
required this.prefix,
|
||||
required this.contact,
|
||||
required this.position,
|
||||
required this.l10n,
|
||||
});
|
||||
|
||||
bool get hasLocation => position != null;
|
||||
|
||||
String get displayLabel {
|
||||
final prefixLabel = _formatPrefix(prefix);
|
||||
return '($prefixLabel) ${_resolveName(contact)}';
|
||||
return '($prefixLabel) ${_resolveName(contact, l10n)}';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -543,13 +652,14 @@ class _ObservedPath {
|
||||
final Uint8List pathBytes;
|
||||
final bool isPrimary;
|
||||
|
||||
const _ObservedPath({
|
||||
required this.pathBytes,
|
||||
required this.isPrimary,
|
||||
});
|
||||
const _ObservedPath({required this.pathBytes, required this.isPrimary});
|
||||
}
|
||||
|
||||
List<_PathHop> _buildPathHops(Uint8List pathBytes, List<Contact> contacts) {
|
||||
List<_PathHop> _buildPathHops(
|
||||
Uint8List pathBytes,
|
||||
List<Contact> contacts,
|
||||
AppLocalizations l10n,
|
||||
) {
|
||||
final hops = <_PathHop>[];
|
||||
for (var i = 0; i < pathBytes.length; i++) {
|
||||
final prefix = pathBytes[i];
|
||||
@@ -560,6 +670,7 @@ List<_PathHop> _buildPathHops(Uint8List pathBytes, List<Contact> contacts) {
|
||||
prefix: prefix,
|
||||
contact: contact,
|
||||
position: _resolvePosition(contact),
|
||||
l10n: l10n,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -568,10 +679,12 @@ List<_PathHop> _buildPathHops(Uint8List pathBytes, List<Contact> contacts) {
|
||||
|
||||
Contact? _matchContactForPrefix(List<Contact> contacts, int prefix) {
|
||||
final matches = contacts
|
||||
.where((contact) =>
|
||||
(contact.type == advTypeRepeater || contact.type == advTypeRoom) &&
|
||||
contact.publicKey.isNotEmpty &&
|
||||
contact.publicKey[0] == prefix)
|
||||
.where(
|
||||
(contact) =>
|
||||
(contact.type == advTypeRepeater || contact.type == advTypeRoom) &&
|
||||
contact.publicKey.isNotEmpty &&
|
||||
contact.publicKey[0] == prefix,
|
||||
)
|
||||
.toList();
|
||||
if (matches.isEmpty) return null;
|
||||
|
||||
@@ -612,15 +725,15 @@ String _formatPathPrefixes(Uint8List pathBytes) {
|
||||
.join(',');
|
||||
}
|
||||
|
||||
String _formatHopCount(int count) {
|
||||
return '$count ${count == 1 ? 'hop' : 'hops'}';
|
||||
String _formatHopCount(int count, AppLocalizations l10n) {
|
||||
return l10n.chat_hopsCount(count);
|
||||
}
|
||||
|
||||
String _resolveName(Contact? contact) {
|
||||
if (contact == null) return 'Unknown Repeater';
|
||||
String _resolveName(Contact? contact, AppLocalizations l10n) {
|
||||
if (contact == null) return l10n.channelPath_unknownRepeater;
|
||||
final name = contact.name.trim();
|
||||
if (name.isEmpty || name.toLowerCase() == 'unknown') {
|
||||
return 'Unknown Repeater';
|
||||
return l10n.channelPath_unknownRepeater;
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
+1205
-261
File diff suppressed because it is too large
Load Diff
+562
-293
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,245 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../connector/meshcore_connector.dart';
|
||||
import '../l10n/l10n.dart';
|
||||
import '../models/community.dart';
|
||||
import '../storage/community_store.dart';
|
||||
import '../widgets/qr_scanner_widget.dart';
|
||||
|
||||
/// Screen for scanning community QR codes to join communities.
|
||||
///
|
||||
/// After successful scan, the user can:
|
||||
/// 1. Join the community (saves to local storage)
|
||||
/// 2. Optionally add the Community Public Channel to the device
|
||||
class CommunityQrScannerScreen extends StatefulWidget {
|
||||
const CommunityQrScannerScreen({super.key});
|
||||
|
||||
@override
|
||||
State<CommunityQrScannerScreen> createState() =>
|
||||
_CommunityQrScannerScreenState();
|
||||
}
|
||||
|
||||
class _CommunityQrScannerScreenState extends State<CommunityQrScannerScreen> {
|
||||
final CommunityStore _communityStore = CommunityStore();
|
||||
bool _isProcessing = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(context.l10n.community_scanQr),
|
||||
centerTitle: true,
|
||||
),
|
||||
body: _isProcessing
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: QrScannerWidget(
|
||||
onScanned: (data) => _handleScannedData(context, data),
|
||||
validator: Community.isValidQrData,
|
||||
onValidationFailed: (_) => _showInvalidQrError(context),
|
||||
instructions: context.l10n.community_scanInstructions,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _handleScannedData(BuildContext context, String data) async {
|
||||
if (_isProcessing) return;
|
||||
|
||||
setState(() {
|
||||
_isProcessing = true;
|
||||
});
|
||||
|
||||
try {
|
||||
// Parse the community data
|
||||
final community = Community.fromQrData(const Uuid().v4(), data);
|
||||
|
||||
// Check if this community already exists
|
||||
final existing = await _communityStore.findByCommunityId(
|
||||
community.communityId,
|
||||
);
|
||||
|
||||
if (existing != null) {
|
||||
if (context.mounted) {
|
||||
_showAlreadyMemberDialog(context, existing);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Show confirmation dialog
|
||||
if (context.mounted) {
|
||||
await _showJoinConfirmationDialog(context, community);
|
||||
}
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(context.l10n.community_invalidQrCode),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isProcessing = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _showInvalidQrError(BuildContext context) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(context.l10n.community_invalidQrCode),
|
||||
backgroundColor: Colors.orange,
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showAlreadyMemberDialog(BuildContext context, Community community) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: Text(context.l10n.community_alreadyMember),
|
||||
content: Text(
|
||||
context.l10n.community_alreadyMemberMessage(community.name),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(dialogContext);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: Text(context.l10n.common_ok),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showJoinConfirmationDialog(
|
||||
BuildContext context,
|
||||
Community community,
|
||||
) async {
|
||||
bool addPublicChannel = true;
|
||||
|
||||
final result = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (dialogContext) => StatefulBuilder(
|
||||
builder: (dialogContext, setDialogState) => AlertDialog(
|
||||
title: Text(context.l10n.community_joinTitle),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(context.l10n.community_joinConfirmation(community.name)),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.groups,
|
||||
color: Theme.of(dialogContext).colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
community.name,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
Text(
|
||||
'ID: ${community.shortCommunityId}...',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Divider(),
|
||||
const SizedBox(height: 8),
|
||||
CheckboxListTile(
|
||||
value: addPublicChannel,
|
||||
onChanged: (value) {
|
||||
setDialogState(() {
|
||||
addPublicChannel = value ?? true;
|
||||
});
|
||||
},
|
||||
title: Text(context.l10n.community_addPublicChannel),
|
||||
subtitle: Text(context.l10n.community_addPublicChannelHint),
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogContext, false),
|
||||
child: Text(context.l10n.common_cancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(dialogContext, true),
|
||||
child: Text(context.l10n.community_join),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (result == true && context.mounted) {
|
||||
await _joinCommunity(context, community, addPublicChannel);
|
||||
} else if (context.mounted) {
|
||||
// User cancelled - go back
|
||||
Navigator.pop(context);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _joinCommunity(
|
||||
BuildContext context,
|
||||
Community community,
|
||||
bool addPublicChannel,
|
||||
) async {
|
||||
// Save community to local storage
|
||||
await _communityStore.addCommunity(community);
|
||||
|
||||
// Optionally add the community public channel to the device
|
||||
if (addPublicChannel && context.mounted) {
|
||||
final connector = context.read<MeshCoreConnector>();
|
||||
final nextIndex = _findNextAvailableChannelIndex(connector);
|
||||
|
||||
if (nextIndex != null) {
|
||||
final psk = community.deriveCommunityPublicPsk();
|
||||
final channelName = '${community.name} Public';
|
||||
connector.setChannel(nextIndex, channelName, psk);
|
||||
}
|
||||
}
|
||||
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(context.l10n.community_joined(community.name)),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
|
||||
// Return to previous screen
|
||||
Navigator.pop(context, community);
|
||||
}
|
||||
}
|
||||
|
||||
int? _findNextAvailableChannelIndex(MeshCoreConnector connector) {
|
||||
final usedIndices = connector.channels.map((c) => c.index).toSet();
|
||||
for (int i = 0; i < connector.maxChannels; i++) {
|
||||
if (!usedIndices.contains(i)) return i;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+587
-133
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../connector/meshcore_connector.dart';
|
||||
import '../l10n/l10n.dart';
|
||||
import '../utils/dialog_utils.dart';
|
||||
import '../utils/disconnect_navigation_mixin.dart';
|
||||
import '../utils/route_transitions.dart';
|
||||
@@ -47,12 +48,12 @@ class _DeviceScreenState extends State<DeviceScreen>
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.bluetooth_disabled),
|
||||
tooltip: 'Disconnect',
|
||||
tooltip: context.l10n.common_disconnect,
|
||||
onPressed: () => _disconnect(context, connector),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.tune),
|
||||
tooltip: 'Settings',
|
||||
tooltip: context.l10n.common_settings,
|
||||
onPressed: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
@@ -68,7 +69,7 @@ class _DeviceScreenState extends State<DeviceScreen>
|
||||
children: [
|
||||
_buildConnectionCard(connector, context),
|
||||
const SizedBox(height: 16),
|
||||
_buildSectionLabel(theme, 'Quick switch'),
|
||||
_buildSectionLabel(theme, context.l10n.device_quickSwitch),
|
||||
const SizedBox(height: 12),
|
||||
_buildQuickSwitchBar(context),
|
||||
],
|
||||
@@ -87,7 +88,7 @@ class _DeviceScreenState extends State<DeviceScreen>
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'MeshCore',
|
||||
context.l10n.device_meshcore,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.8,
|
||||
@@ -126,9 +127,7 @@ class _DeviceScreenState extends State<DeviceScreen>
|
||||
return Card(
|
||||
elevation: 0,
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
@@ -180,7 +179,7 @@ class _DeviceScreenState extends State<DeviceScreen>
|
||||
size: 18,
|
||||
color: colorScheme.onSecondaryContainer,
|
||||
),
|
||||
label: const Text('Connected'),
|
||||
label: Text(context.l10n.common_connected),
|
||||
backgroundColor: colorScheme.secondaryContainer,
|
||||
labelStyle: theme.textTheme.labelMedium?.copyWith(
|
||||
color: colorScheme.onSecondaryContainer,
|
||||
@@ -206,7 +205,6 @@ class _DeviceScreenState extends State<DeviceScreen>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Widget _buildBatteryIndicator(
|
||||
MeshCoreConnector connector,
|
||||
BuildContext context,
|
||||
@@ -223,11 +221,7 @@ class _DeviceScreenState extends State<DeviceScreen>
|
||||
final icon = _batteryIcon(percent);
|
||||
|
||||
return ActionChip(
|
||||
avatar: Icon(
|
||||
icon,
|
||||
size: 16,
|
||||
color: colorScheme.onSecondaryContainer,
|
||||
),
|
||||
avatar: Icon(icon, size: 16, color: colorScheme.onSecondaryContainer),
|
||||
label: Text(displayLabel),
|
||||
labelStyle: theme.textTheme.labelMedium?.copyWith(
|
||||
color: colorScheme.onSecondaryContainer,
|
||||
@@ -259,25 +253,19 @@ class _DeviceScreenState extends State<DeviceScreen>
|
||||
case 0:
|
||||
Navigator.pushReplacement(
|
||||
context,
|
||||
buildQuickSwitchRoute(
|
||||
const ContactsScreen(hideBackButton: true),
|
||||
),
|
||||
buildQuickSwitchRoute(const ContactsScreen(hideBackButton: true)),
|
||||
);
|
||||
break;
|
||||
case 1:
|
||||
Navigator.pushReplacement(
|
||||
context,
|
||||
buildQuickSwitchRoute(
|
||||
const ChannelsScreen(hideBackButton: true),
|
||||
),
|
||||
buildQuickSwitchRoute(const ChannelsScreen(hideBackButton: true)),
|
||||
);
|
||||
break;
|
||||
case 2:
|
||||
Navigator.pushReplacement(
|
||||
context,
|
||||
buildQuickSwitchRoute(
|
||||
const MapScreen(hideBackButton: true),
|
||||
),
|
||||
buildQuickSwitchRoute(const MapScreen(hideBackButton: true)),
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../l10n/l10n.dart';
|
||||
import '../services/app_settings_service.dart';
|
||||
import '../services/map_tile_cache_service.dart';
|
||||
|
||||
@@ -54,10 +56,7 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
|
||||
_updateEstimate();
|
||||
if (bounds != null) {
|
||||
_mapController.fitCamera(
|
||||
CameraFit.bounds(
|
||||
bounds: bounds,
|
||||
padding: const EdgeInsets.all(48),
|
||||
),
|
||||
CameraFit.bounds(bounds: bounds, padding: const EdgeInsets.all(48)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -70,8 +69,11 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
|
||||
return;
|
||||
}
|
||||
final cacheService = context.read<MapTileCacheService>();
|
||||
final count =
|
||||
cacheService.estimateTileCount(_selectedBounds!, _minZoom, _maxZoom);
|
||||
final count = cacheService.estimateTileCount(
|
||||
_selectedBounds!,
|
||||
_minZoom,
|
||||
_maxZoom,
|
||||
);
|
||||
setState(() {
|
||||
_estimatedTiles = count;
|
||||
});
|
||||
@@ -110,14 +112,14 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
|
||||
final bounds = _selectedBounds;
|
||||
if (bounds == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Select an area to cache first')),
|
||||
SnackBar(content: Text(context.l10n.mapCache_selectAreaFirst)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_estimatedTiles == 0) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('No tiles to download for this area')),
|
||||
SnackBar(content: Text(context.l10n.mapCache_noTilesToDownload)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -125,18 +127,18 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: const Text('Download tiles'),
|
||||
title: Text(context.l10n.mapCache_downloadTilesTitle),
|
||||
content: Text(
|
||||
'Download $_estimatedTiles tiles for offline use?',
|
||||
context.l10n.mapCache_downloadTilesPrompt(_estimatedTiles),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogContext, false),
|
||||
child: const Text('Cancel'),
|
||||
child: Text(context.l10n.common_cancel),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogContext, true),
|
||||
child: const Text('Download'),
|
||||
child: Text(context.l10n.mapCache_downloadAction),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -174,27 +176,30 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
|
||||
});
|
||||
|
||||
final message = result.failed > 0
|
||||
? 'Cached ${result.downloaded} tiles (${result.failed} failed)'
|
||||
: 'Cached ${result.downloaded} tiles';
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(message)),
|
||||
);
|
||||
? context.l10n.mapCache_cachedTilesWithFailed(
|
||||
result.downloaded,
|
||||
result.failed,
|
||||
)
|
||||
: context.l10n.mapCache_cachedTiles(result.downloaded);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(message)));
|
||||
}
|
||||
|
||||
Future<void> _clearCache() async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: const Text('Clear offline cache'),
|
||||
content: const Text('Remove all cached map tiles?'),
|
||||
title: Text(context.l10n.mapCache_clearOfflineCacheTitle),
|
||||
content: Text(context.l10n.mapCache_clearOfflineCachePrompt),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogContext, false),
|
||||
child: const Text('Cancel'),
|
||||
child: Text(context.l10n.common_cancel),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogContext, true),
|
||||
child: const Text('Clear'),
|
||||
child: Text(context.l10n.common_clear),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -205,7 +210,7 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
|
||||
await cacheService.clearCache();
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Offline cache cleared')),
|
||||
SnackBar(content: Text(context.l10n.mapCache_offlineCacheCleared)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -213,15 +218,13 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
|
||||
Widget build(BuildContext context) {
|
||||
final tileCache = context.read<MapTileCacheService>();
|
||||
final selectedBounds = _selectedBounds;
|
||||
final l10n = context.l10n;
|
||||
final progressValue = _estimatedTiles == 0
|
||||
? 0.0
|
||||
: (_completedTiles / _estimatedTiles).clamp(0.0, 1.0).toDouble();
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Offline Map Cache'),
|
||||
centerTitle: true,
|
||||
),
|
||||
appBar: AppBar(title: Text(l10n.mapCache_title), centerTitle: true),
|
||||
body: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
@@ -264,8 +267,8 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Text(
|
||||
selectedBounds == null
|
||||
? 'No area selected'
|
||||
: _formatBounds(selectedBounds),
|
||||
? l10n.mapCache_noAreaSelected
|
||||
: _formatBounds(selectedBounds, l10n),
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
),
|
||||
@@ -282,9 +285,12 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text(
|
||||
'Cache Area',
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
|
||||
Text(
|
||||
l10n.mapCache_cacheArea,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
@@ -292,26 +298,32 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
|
||||
Expanded(
|
||||
child: ElevatedButton.icon(
|
||||
icon: const Icon(Icons.crop_free),
|
||||
label: const Text('Use Current View'),
|
||||
label: Text(l10n.mapCache_useCurrentView),
|
||||
onPressed: _isDownloading ? null : _setBoundsFromView,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
TextButton(
|
||||
onPressed:
|
||||
_isDownloading || selectedBounds == null ? null : _clearBounds,
|
||||
child: const Text('Clear'),
|
||||
onPressed: _isDownloading || selectedBounds == null
|
||||
? null
|
||||
: _clearBounds,
|
||||
child: Text(l10n.common_clear),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Text(
|
||||
'Zoom Range',
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
|
||||
Text(
|
||||
l10n.mapCache_zoomRange,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
RangeSlider(
|
||||
values:
|
||||
RangeValues(_minZoom.toDouble(), _maxZoom.toDouble()),
|
||||
values: RangeValues(
|
||||
_minZoom.toDouble(),
|
||||
_maxZoom.toDouble(),
|
||||
),
|
||||
min: 3,
|
||||
max: 18,
|
||||
divisions: 15,
|
||||
@@ -330,12 +342,17 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
|
||||
_saveZoomRange();
|
||||
},
|
||||
),
|
||||
Text('Estimated tiles: $_estimatedTiles'),
|
||||
Text(l10n.mapCache_estimatedTiles(_estimatedTiles)),
|
||||
if (_isDownloading) ...[
|
||||
const SizedBox(height: 8),
|
||||
LinearProgressIndicator(value: progressValue),
|
||||
const SizedBox(height: 4),
|
||||
Text('Downloaded $_completedTiles / $_estimatedTiles'),
|
||||
Text(
|
||||
l10n.mapCache_downloadedTiles(
|
||||
_completedTiles,
|
||||
_estimatedTiles,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
@@ -343,7 +360,7 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
|
||||
Expanded(
|
||||
child: ElevatedButton.icon(
|
||||
icon: const Icon(Icons.download),
|
||||
label: const Text('Download Tiles'),
|
||||
label: Text(l10n.mapCache_downloadTilesButton),
|
||||
onPressed: _isDownloading || selectedBounds == null
|
||||
? null
|
||||
: _startDownload,
|
||||
@@ -352,7 +369,7 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
|
||||
const SizedBox(width: 12),
|
||||
OutlinedButton(
|
||||
onPressed: _isDownloading ? null : _clearCache,
|
||||
child: const Text('Clear Cache'),
|
||||
child: Text(l10n.mapCache_clearCacheButton),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -360,7 +377,7 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Text(
|
||||
'Failed downloads: $_failedTiles',
|
||||
l10n.mapCache_failedDownloads(_failedTiles),
|
||||
style: TextStyle(color: Colors.orange[700]),
|
||||
),
|
||||
),
|
||||
@@ -382,10 +399,12 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
|
||||
];
|
||||
}
|
||||
|
||||
String _formatBounds(LatLngBounds bounds) {
|
||||
return 'N ${bounds.north.toStringAsFixed(4)}, '
|
||||
'S ${bounds.south.toStringAsFixed(4)}, '
|
||||
'E ${bounds.east.toStringAsFixed(4)}, '
|
||||
'W ${bounds.west.toStringAsFixed(4)}';
|
||||
String _formatBounds(LatLngBounds bounds, AppLocalizations l10n) {
|
||||
return l10n.mapCache_boundsLabel(
|
||||
bounds.north.toStringAsFixed(4),
|
||||
bounds.south.toStringAsFixed(4),
|
||||
bounds.east.toStringAsFixed(4),
|
||||
bounds.west.toStringAsFixed(4),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+749
-315
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,456 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../l10n/l10n.dart';
|
||||
import '../models/contact.dart';
|
||||
import '../models/path_selection.dart';
|
||||
import '../connector/meshcore_connector.dart';
|
||||
import '../connector/meshcore_protocol.dart';
|
||||
import '../services/repeater_command_service.dart';
|
||||
import '../widgets/path_management_dialog.dart';
|
||||
import '../widgets/snr_indicator.dart';
|
||||
|
||||
class NeighboursScreen extends StatefulWidget {
|
||||
final Contact repeater;
|
||||
final String password;
|
||||
|
||||
const NeighboursScreen({
|
||||
super.key,
|
||||
required this.repeater,
|
||||
required this.password,
|
||||
});
|
||||
|
||||
@override
|
||||
State<NeighboursScreen> createState() => _NeighboursScreenState();
|
||||
}
|
||||
|
||||
class _NeighboursScreenState extends State<NeighboursScreen> {
|
||||
static const int _reqNeighboursKeyLen = 4;
|
||||
static const int _statusPayloadOffset = 8;
|
||||
static const int _statusStatsSize = 52;
|
||||
static const int _statusResponseBytes =
|
||||
_statusPayloadOffset + _statusStatsSize;
|
||||
Uint8List _tagData = Uint8List(4);
|
||||
int _neighbourCount = 0;
|
||||
|
||||
bool _isLoading = false;
|
||||
bool _isLoaded = false;
|
||||
bool _hasData = false;
|
||||
Timer? _statusTimeout;
|
||||
StreamSubscription<Uint8List>? _frameSubscription;
|
||||
RepeaterCommandService? _commandService;
|
||||
PathSelection? _pendingStatusSelection;
|
||||
List<Map<String, dynamic>>? _parsedNeighbours;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||
_commandService = RepeaterCommandService(connector);
|
||||
_setupMessageListener();
|
||||
_loadNeighbours();
|
||||
_hasData = false;
|
||||
}
|
||||
|
||||
void _setupMessageListener() {
|
||||
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||
|
||||
// Listen for incoming text messages from the repeater
|
||||
_frameSubscription = connector.receivedFrames.listen((frame) {
|
||||
if (frame.isEmpty) return;
|
||||
|
||||
if (frame[0] == respCodeSent) {
|
||||
_tagData = frame.sublist(2, 6);
|
||||
//_timeEstment = frame.buffer.asByteData().getUint32(6, Endian.little);
|
||||
}
|
||||
|
||||
// Check if it's a binary response
|
||||
if (frame[0] == pushCodeBinaryResponse &&
|
||||
listEquals(frame.sublist(2, 6), _tagData)) {
|
||||
_handleNeighboursResponse(connector, frame.sublist(6));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
String fmtDuration(double seconds) {
|
||||
if (seconds < 60) {
|
||||
return '${seconds.toStringAsFixed(1)}s';
|
||||
}
|
||||
|
||||
final int m = (seconds ~/ 60).toInt();
|
||||
final double s = seconds - (60 * m);
|
||||
|
||||
if (m < 60) {
|
||||
return '${m}m ${s.toStringAsFixed(0)}s';
|
||||
}
|
||||
|
||||
final int h = m ~/ 60;
|
||||
final int m2 = m % 60;
|
||||
|
||||
return '${h}h ${m2}m';
|
||||
}
|
||||
|
||||
static List<Map<String, dynamic>> parseNeighboursData(
|
||||
BufferReader buffer,
|
||||
int resultsCount,
|
||||
) {
|
||||
final Map<int, Map<String, dynamic>> neighbours = {};
|
||||
for (var i = 0; i < resultsCount; i++) {
|
||||
final neighbourData = neighbours.putIfAbsent(
|
||||
i,
|
||||
() => {
|
||||
'contact': null,
|
||||
'publicKey': <Uint8List>{},
|
||||
'lastHeard': <int>{},
|
||||
'snr': <double>{},
|
||||
},
|
||||
);
|
||||
neighbourData['publicKey'] = buffer.readBytes(_reqNeighboursKeyLen);
|
||||
neighbourData['lastHeard'] = buffer.readUInt32LE();
|
||||
neighbourData['snr'] = buffer.readInt8() / 4.0;
|
||||
}
|
||||
|
||||
return neighbours.values.toList();
|
||||
}
|
||||
|
||||
void _handleNeighboursResponse(MeshCoreConnector connector, Uint8List frame) {
|
||||
final buffer = BufferReader(frame);
|
||||
final neighbourCount = buffer.readUInt16LE();
|
||||
final parsedNeighbours = parseNeighboursData(buffer, buffer.readUInt16LE());
|
||||
connector.contacts.where((c) => c.type == advTypeRepeater).forEach((
|
||||
repeater,
|
||||
) {
|
||||
for (var neighbourData in parsedNeighbours) {
|
||||
final publicKey = neighbourData['publicKey'];
|
||||
if (listEquals(
|
||||
repeater.publicKey.sublist(0, _reqNeighboursKeyLen),
|
||||
publicKey,
|
||||
)) {
|
||||
neighbourData['contact'] = repeater;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
setState(() {
|
||||
_parsedNeighbours = parsedNeighbours;
|
||||
_neighbourCount = neighbourCount;
|
||||
});
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(context.l10n.neighbors_receivedData),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
_statusTimeout?.cancel();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_isLoaded = true;
|
||||
_hasData = true;
|
||||
});
|
||||
}
|
||||
|
||||
Contact _resolveRepeater(MeshCoreConnector connector) {
|
||||
return connector.contacts.firstWhere(
|
||||
(c) => c.publicKeyHex == widget.repeater.publicKeyHex,
|
||||
orElse: () => widget.repeater,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _loadNeighbours() async {
|
||||
if (_commandService == null) return;
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_isLoaded = false;
|
||||
});
|
||||
try {
|
||||
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||
final repeater = _resolveRepeater(connector);
|
||||
final selection = await connector.preparePathForContactSend(repeater);
|
||||
_pendingStatusSelection = selection;
|
||||
|
||||
//[version][number of requested neighbours][offset_16bit][order by][len of public key]
|
||||
final frame = buildSendBinaryReq(
|
||||
repeater.publicKey,
|
||||
payload: Uint8List.fromList([
|
||||
reqTypeGetNeighbours,
|
||||
0x00,
|
||||
0x0F,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
_reqNeighboursKeyLen,
|
||||
]),
|
||||
);
|
||||
await connector.sendFrame(frame);
|
||||
|
||||
final pathLengthValue = selection.useFlood ? -1 : selection.hopCount;
|
||||
final messageBytes = frame.length >= _statusResponseBytes
|
||||
? frame.length
|
||||
: _statusResponseBytes;
|
||||
final timeoutMs = connector.calculateTimeout(
|
||||
pathLength: pathLengthValue,
|
||||
messageBytes: messageBytes,
|
||||
);
|
||||
_statusTimeout?.cancel();
|
||||
_statusTimeout = Timer(Duration(milliseconds: timeoutMs), () {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_isLoaded = false;
|
||||
});
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(context.l10n.neighbors_requestTimedOut),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
_recordStatusResult(false);
|
||||
});
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_isLoaded = false;
|
||||
});
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(context.l10n.neighbors_errorLoading(e.toString())),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _recordStatusResult(bool success) {
|
||||
final selection = _pendingStatusSelection;
|
||||
if (selection == null) return;
|
||||
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||
final repeater = _resolveRepeater(connector);
|
||||
connector.recordRepeaterPathResult(repeater, selection, success, null);
|
||||
_pendingStatusSelection = null;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_frameSubscription?.cancel();
|
||||
_commandService?.dispose();
|
||||
_statusTimeout?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
final connector = context.watch<MeshCoreConnector>();
|
||||
final repeater = _resolveRepeater(connector);
|
||||
final isFloodMode = repeater.pathOverride == -1;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
l10n.neighbors_repeatersNeighbours,
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
),
|
||||
Text(
|
||||
repeater.name,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
centerTitle: false,
|
||||
actions: [
|
||||
PopupMenuButton<String>(
|
||||
icon: Icon(isFloodMode ? Icons.waves : Icons.route),
|
||||
tooltip: l10n.repeater_routingMode,
|
||||
onSelected: (mode) async {
|
||||
if (mode == 'flood') {
|
||||
await connector.setPathOverride(repeater, pathLen: -1);
|
||||
} else {
|
||||
await connector.setPathOverride(repeater, pathLen: null);
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem(
|
||||
value: 'auto',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.auto_mode,
|
||||
size: 20,
|
||||
color: !isFloodMode
|
||||
? Theme.of(context).primaryColor
|
||||
: null,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
l10n.repeater_autoUseSavedPath,
|
||||
style: TextStyle(
|
||||
fontWeight: !isFloodMode
|
||||
? FontWeight.bold
|
||||
: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 'flood',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.waves,
|
||||
size: 20,
|
||||
color: isFloodMode
|
||||
? Theme.of(context).primaryColor
|
||||
: null,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
l10n.repeater_forceFloodMode,
|
||||
style: TextStyle(
|
||||
fontWeight: isFloodMode
|
||||
? FontWeight.bold
|
||||
: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.timeline),
|
||||
tooltip: l10n.repeater_pathManagement,
|
||||
onPressed: () =>
|
||||
PathManagementDialog.show(context, contact: repeater),
|
||||
),
|
||||
IconButton(
|
||||
icon: _isLoading
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.refresh),
|
||||
onPressed: _isLoading ? null : _loadNeighbours,
|
||||
tooltip: l10n.repeater_refresh,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: SafeArea(
|
||||
top: false,
|
||||
child: RefreshIndicator(
|
||||
onRefresh: _loadNeighbours,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
if (!_isLoaded &&
|
||||
!_hasData &&
|
||||
(_parsedNeighbours == null || _parsedNeighbours!.isEmpty))
|
||||
Center(
|
||||
child: Text(
|
||||
l10n.neighbors_noData,
|
||||
style: TextStyle(fontSize: 16, color: Colors.grey),
|
||||
),
|
||||
),
|
||||
if (_isLoaded ||
|
||||
_hasData &&
|
||||
!(_parsedNeighbours == null ||
|
||||
_parsedNeighbours!.isEmpty))
|
||||
_buildNeighboursInfoCard(
|
||||
"${l10n.repeater_neighbours} - $_neighbourCount",
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNeighboursInfoCard(String title) {
|
||||
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.info_outline,
|
||||
color: Theme.of(context).textTheme.headlineSmall?.color,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Divider(),
|
||||
for (final entry in _parsedNeighbours!.asMap().entries)
|
||||
_buildInfoRow(
|
||||
entry.value['contact'] != null
|
||||
? entry.value['contact'].name
|
||||
: context.l10n.neighbors_unknownContact(
|
||||
"<${pubKeyToHex(entry.value['publicKey'])}>",
|
||||
),
|
||||
context.l10n.neighbors_heardAgo(
|
||||
fmtDuration(entry.value['lastHeard'] + 0.0),
|
||||
),
|
||||
entry.value['snr'],
|
||||
connector.currentSf!,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoRow(
|
||||
String label,
|
||||
String value,
|
||||
double snr,
|
||||
int spreadingFactor,
|
||||
) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 3),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text(
|
||||
label,
|
||||
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||
),
|
||||
subtitle: Text(value),
|
||||
trailing: SNRIcon(
|
||||
snr: snr,
|
||||
snrLevels: getSNRfromSF(spreadingFactor),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,576 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:meshcore_open/connector/meshcore_connector.dart';
|
||||
import 'package:meshcore_open/connector/meshcore_protocol.dart';
|
||||
import 'package:meshcore_open/l10n/l10n.dart';
|
||||
import 'package:meshcore_open/models/contact.dart';
|
||||
import 'package:meshcore_open/services/map_tile_cache_service.dart';
|
||||
import 'package:meshcore_open/widgets/snr_indicator.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
double getPathDistanceMeters(List<LatLng> points) {
|
||||
if (points.length <= 1) return 0.0;
|
||||
|
||||
double distanceMeters = 0.0;
|
||||
final distanceCalculator = Distance();
|
||||
|
||||
for (int i = 0; i < points.length - 1; i++) {
|
||||
distanceMeters += distanceCalculator(points[i], points[i + 1]);
|
||||
}
|
||||
|
||||
return distanceMeters;
|
||||
}
|
||||
|
||||
String formatDistance(double distanceMeters) {
|
||||
return '(${(distanceMeters / 1609.34).toStringAsFixed(2)} Miles / ${(distanceMeters / 1000).toStringAsFixed(2)} Km)';
|
||||
}
|
||||
|
||||
class PathTraceData {
|
||||
final Uint8List pathData;
|
||||
final Uint8List snrData;
|
||||
final Map<int, Contact> pathContacts;
|
||||
|
||||
PathTraceData({
|
||||
required this.pathData,
|
||||
required this.snrData,
|
||||
required this.pathContacts,
|
||||
});
|
||||
}
|
||||
|
||||
class PathTraceMapScreen extends StatefulWidget {
|
||||
final String title;
|
||||
final Uint8List path;
|
||||
final bool flipPathRound;
|
||||
final bool reversePathRound;
|
||||
|
||||
const PathTraceMapScreen({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.path,
|
||||
this.flipPathRound = false,
|
||||
this.reversePathRound = false,
|
||||
});
|
||||
|
||||
@override
|
||||
State<PathTraceMapScreen> createState() => _PathTraceMapScreenState();
|
||||
}
|
||||
|
||||
class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
|
||||
StreamSubscription<Uint8List>? _frameSubscription;
|
||||
Timer? _timeoutTimer;
|
||||
|
||||
bool _isLoading = false;
|
||||
bool _failed2Loaded = false;
|
||||
bool _hasData = false;
|
||||
PathTraceData? _traceData;
|
||||
List<LatLng> _points = <LatLng>[];
|
||||
List<Polyline> _polylines = [];
|
||||
LatLng? _initialCenter = LatLng(0, 0);
|
||||
double _initialZoom = 2.0;
|
||||
LatLngBounds? _bounds;
|
||||
ValueKey<String> _mapKey = const ValueKey('initial');
|
||||
double _pathDistanceMeters = 0.0;
|
||||
|
||||
String _formatPathPrefixes(Uint8List pathBytes) {
|
||||
return pathBytes
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase())
|
||||
.join(',');
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_setupFrameListener();
|
||||
_doPathTrace();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_frameSubscription?.cancel();
|
||||
_timeoutTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Uint8List addReturnpath(Uint8List pathBytes) {
|
||||
Uint8List? traceBytes;
|
||||
final len = (pathBytes.length + pathBytes.length - 1);
|
||||
traceBytes = Uint8List(len);
|
||||
for (int i = 0; i < pathBytes.length; i++) {
|
||||
traceBytes[i] = pathBytes[i];
|
||||
if (i < pathBytes.length - 1) {
|
||||
traceBytes[len - 1 - i] = pathBytes[i];
|
||||
}
|
||||
}
|
||||
return traceBytes;
|
||||
}
|
||||
|
||||
Future<void> _doPathTrace() async {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_failed2Loaded = false;
|
||||
});
|
||||
}
|
||||
|
||||
final Uint8List path;
|
||||
|
||||
Uint8List pathTmp = widget.reversePathRound
|
||||
? Uint8List.fromList(widget.path.reversed.toList())
|
||||
: widget.path;
|
||||
|
||||
if (widget.flipPathRound) {
|
||||
path = addReturnpath(pathTmp);
|
||||
} else {
|
||||
path = pathTmp;
|
||||
}
|
||||
|
||||
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||
final frame = buildTraceReq(
|
||||
DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||
0, //flags
|
||||
0, //auth
|
||||
payload: path,
|
||||
);
|
||||
connector.sendFrame(frame);
|
||||
}
|
||||
|
||||
void _setupFrameListener() {
|
||||
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||
Uint8List tagData = Uint8List(4);
|
||||
// Listen for incoming text messages from the repeater
|
||||
_frameSubscription = connector.receivedFrames.listen((frame) {
|
||||
if (frame.isEmpty) return;
|
||||
final frameBuffer = BufferReader(frame);
|
||||
final code = frameBuffer.readUInt8();
|
||||
|
||||
if (code == respCodeSent) {
|
||||
frameBuffer.skipBytes(1); //reserved
|
||||
tagData = frameBuffer.readBytes(4);
|
||||
final timeoutSeconds = frameBuffer.readUInt32LE();
|
||||
|
||||
// Start timeout timer for trace response
|
||||
_timeoutTimer?.cancel();
|
||||
_timeoutTimer = Timer(Duration(milliseconds: timeoutSeconds), () {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_failed2Loaded = true;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (code == respCodeErr) {
|
||||
_timeoutTimer?.cancel();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_failed2Loaded = true;
|
||||
});
|
||||
}
|
||||
// Check if it's a binary response
|
||||
if (frame.length > 8 &&
|
||||
code == pushCodeTraceData &&
|
||||
listEquals(frame.sublist(4, 8), tagData)) {
|
||||
_timeoutTimer?.cancel();
|
||||
if (!mounted) return;
|
||||
frameBuffer.skipBytes(3); //reserved + path length + flag
|
||||
if (listEquals(frameBuffer.readBytes(4), tagData)) {
|
||||
_handleTraceResponse(frame);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _handleTraceResponse(Uint8List frame) async {
|
||||
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||
|
||||
final buffer = BufferReader(frame);
|
||||
buffer.skipBytes(2); // Skip push code and reserved byte
|
||||
int pathLength = buffer.readUInt8();
|
||||
buffer.skipBytes(5); // Skip Flag byte and tag data
|
||||
buffer.skipBytes(4); // Skip auth code
|
||||
Uint8List pathData = buffer.readBytes(pathLength);
|
||||
Uint8List snrData = buffer.readRemainingBytes();
|
||||
|
||||
Map<int, Contact> pathContacts = {};
|
||||
|
||||
connector.contacts.where((c) => c.type != advTypeChat).forEach((repeater) {
|
||||
for (var repeaterData in pathData) {
|
||||
if (listEquals(
|
||||
repeater.publicKey.sublist(0, 1),
|
||||
Uint8List.fromList([repeaterData]),
|
||||
)) {
|
||||
pathContacts[repeaterData] = repeater;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_hasData = true;
|
||||
_traceData = PathTraceData(
|
||||
pathData: pathData,
|
||||
snrData: snrData,
|
||||
pathContacts: pathContacts,
|
||||
);
|
||||
_points = <LatLng>[];
|
||||
_points.add(LatLng(connector.selfLatitude!, connector.selfLongitude!));
|
||||
for (final hop in _traceData!.pathData) {
|
||||
final contact = _traceData!.pathContacts[hop];
|
||||
if (contact != null &&
|
||||
contact.hasLocation &&
|
||||
contact.latitude != null &&
|
||||
contact.longitude != null) {
|
||||
_points.add(LatLng(contact.latitude!, contact.longitude!));
|
||||
}
|
||||
}
|
||||
_polylines = _points.length > 1
|
||||
? [
|
||||
Polyline(
|
||||
points: _points,
|
||||
strokeWidth: 4,
|
||||
color: Colors.blueAccent,
|
||||
),
|
||||
]
|
||||
: <Polyline>[];
|
||||
|
||||
_initialCenter = _points.isNotEmpty ? _points.first : const LatLng(0, 0);
|
||||
_initialZoom = _points.isNotEmpty ? 13.0 : 2.0;
|
||||
_bounds = _points.length > 1 ? LatLngBounds.fromPoints(_points) : null;
|
||||
_mapKey = ValueKey(
|
||||
'${context.l10n.pathTrace_you},${_formatPathPrefixes(_traceData!.pathData)}',
|
||||
);
|
||||
_pathDistanceMeters = getPathDistanceMeters(_points);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Consumer<MeshCoreConnector>(
|
||||
builder: (context, connector, _) {
|
||||
final tileCache = context.read<MapTileCacheService>();
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Text(
|
||||
widget.title,
|
||||
style: const TextStyle(fontSize: 24),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
centerTitle: false,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: _isLoading
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.refresh),
|
||||
onPressed: _isLoading ? null : _doPathTrace,
|
||||
tooltip: context.l10n.pathTrace_refreshTooltip,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: SafeArea(
|
||||
top: false,
|
||||
child: Stack(
|
||||
children: [
|
||||
if (!_hasData)
|
||||
Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (_isLoading) const CircularProgressIndicator(),
|
||||
const SizedBox(height: 16),
|
||||
if (!_isLoading && _failed2Loaded)
|
||||
Text(context.l10n.pathTrace_notAvailable),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_hasData) _buildMapPathTrace(context, tileCache),
|
||||
if (_points.isEmpty &&
|
||||
!_hasData &&
|
||||
!_isLoading &&
|
||||
!_failed2Loaded)
|
||||
Center(
|
||||
child: Card(
|
||||
color: Colors.white.withValues(alpha: 0.9),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(12),
|
||||
child: Text(
|
||||
context.l10n.channelPath_noRepeaterLocations,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_hasData) _buildLegendCard(context, _traceData!),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
List<Marker> _buildHopMarkers(List<int> pathData) {
|
||||
return [
|
||||
for (final hop in pathData)
|
||||
if (_traceData!.pathContacts[hop] != null &&
|
||||
_traceData!.pathContacts[hop]!.hasLocation)
|
||||
Marker(
|
||||
point: LatLng(
|
||||
_traceData!.pathContacts[hop]!.latitude!,
|
||||
_traceData!.pathContacts[hop]!.longitude!,
|
||||
),
|
||||
width: 35,
|
||||
height: 35,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.green,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.white, width: 2),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.3),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
_traceData!.pathContacts[hop]!.publicKey
|
||||
.sublist(0, 1)
|
||||
.map(
|
||||
(b) => b.toRadixString(16).padLeft(2, '0').toUpperCase(),
|
||||
)
|
||||
.join(),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (context.read<MeshCoreConnector>().selfLatitude != null &&
|
||||
context.read<MeshCoreConnector>().selfLongitude != null)
|
||||
Marker(
|
||||
point: LatLng(
|
||||
context.read<MeshCoreConnector>().selfLatitude!,
|
||||
context.read<MeshCoreConnector>().selfLongitude!,
|
||||
),
|
||||
width: 35,
|
||||
height: 35,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.white, width: 2),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.3),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
context.l10n.pathTrace_you,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
String formatDirectionText(PathTraceData pathTraceData, int index) {
|
||||
if (index == 0 || index == pathTraceData.snrData.length - 1) {
|
||||
if (index == 0) {
|
||||
return context.l10n.pathTrace_you;
|
||||
} else {
|
||||
final contactName = pathTraceData
|
||||
.pathContacts[pathTraceData.pathData[pathTraceData.pathData.length -
|
||||
1]]
|
||||
?.name;
|
||||
final hex = pathTraceData.pathData[pathTraceData.pathData.length - 1]
|
||||
.toRadixString(16)
|
||||
.padLeft(2, '0')
|
||||
.toUpperCase();
|
||||
return contactName != null
|
||||
? "$hex: $contactName"
|
||||
: "$hex: ${context.l10n.channelPath_unknownRepeater}";
|
||||
}
|
||||
} else {
|
||||
final contactName =
|
||||
pathTraceData.pathContacts[pathTraceData.pathData[index - 1]]?.name;
|
||||
final hex = pathTraceData.pathData[index - 1]
|
||||
.toRadixString(16)
|
||||
.padLeft(2, '0')
|
||||
.toUpperCase();
|
||||
return contactName != null
|
||||
? "$hex: $contactName"
|
||||
: "$hex: ${context.l10n.channelPath_unknownRepeater}";
|
||||
}
|
||||
}
|
||||
|
||||
String formatDirectionSubText(PathTraceData pathTraceData, int index) {
|
||||
if (index == 0 || index == pathTraceData.snrData.length - 1) {
|
||||
if (index == 0) {
|
||||
final contactName =
|
||||
pathTraceData.pathContacts[pathTraceData.pathData[0]]?.name;
|
||||
final hex = pathTraceData.pathData[0]
|
||||
.toRadixString(16)
|
||||
.padLeft(2, '0')
|
||||
.toUpperCase();
|
||||
return contactName != null
|
||||
? "$hex: $contactName"
|
||||
: "$hex: ${context.l10n.channelPath_unknownRepeater}";
|
||||
} else {
|
||||
return context.l10n.pathTrace_you;
|
||||
}
|
||||
} else {
|
||||
final contactName =
|
||||
pathTraceData.pathContacts[pathTraceData.pathData[index]]?.name;
|
||||
final hex = pathTraceData.pathData[index]
|
||||
.toRadixString(16)
|
||||
.padLeft(2, '0')
|
||||
.toUpperCase();
|
||||
return contactName != null
|
||||
? "$hex: $contactName"
|
||||
: "$hex: ${context.l10n.channelPath_unknownRepeater}";
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildMapPathTrace(
|
||||
BuildContext context,
|
||||
MapTileCacheService tileCache,
|
||||
) {
|
||||
return FlutterMap(
|
||||
key: _mapKey,
|
||||
options: MapOptions(
|
||||
interactionOptions: InteractionOptions(flags: ~InteractiveFlag.rotate),
|
||||
initialCenter: _initialCenter!,
|
||||
initialZoom: _initialZoom,
|
||||
initialCameraFit: _bounds == null
|
||||
? null
|
||||
: CameraFit.bounds(
|
||||
bounds: _bounds!,
|
||||
padding: const EdgeInsets.all(64),
|
||||
maxZoom: 16,
|
||||
),
|
||||
minZoom: 2.0,
|
||||
maxZoom: 18.0,
|
||||
),
|
||||
children: [
|
||||
TileLayer(
|
||||
urlTemplate: kMapTileUrlTemplate,
|
||||
tileProvider: tileCache.tileProvider,
|
||||
userAgentPackageName: MapTileCacheService.userAgentPackageName,
|
||||
maxZoom: 19,
|
||||
),
|
||||
if (_polylines.isNotEmpty) PolylineLayer(polylines: _polylines),
|
||||
if (_traceData!.pathData.isNotEmpty)
|
||||
MarkerLayer(markers: _buildHopMarkers(_traceData!.pathData)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLegendCard(BuildContext context, PathTraceData pathTraceData) {
|
||||
final l10n = context.l10n;
|
||||
final maxHeight = MediaQuery.of(context).size.height * 0.35;
|
||||
final estimatedHeight = 72.0 + (pathTraceData.pathData.length * 56.0);
|
||||
final cardHeight = max(96.0, min(maxHeight, estimatedHeight));
|
||||
|
||||
return Positioned(
|
||||
left: 16,
|
||||
right: 16,
|
||||
bottom: 16,
|
||||
child: SizedBox(
|
||||
height: cardHeight,
|
||||
child: Card(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Text(
|
||||
'${l10n.channelPath_repeaterHops} ${formatDistance(_pathDistanceMeters)}',
|
||||
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Expanded(
|
||||
child: pathTraceData.pathData.isEmpty
|
||||
? Center(
|
||||
child: Text(l10n.channelPath_noHopDetailsAvailable),
|
||||
)
|
||||
: Scrollbar(
|
||||
child: ListView.separated(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
itemCount: pathTraceData.pathData.length + 1,
|
||||
separatorBuilder: (_, _) => const Divider(height: 1),
|
||||
itemBuilder: (context, index) {
|
||||
return Column(
|
||||
children: [
|
||||
ListTile(
|
||||
leading:
|
||||
index >= pathTraceData.snrData.length / 2
|
||||
? Icon(Icons.call_received)
|
||||
: Icon(Icons.call_made),
|
||||
title: Text(
|
||||
formatDirectionText(pathTraceData, index),
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
subtitle: Text(
|
||||
formatDirectionSubText(
|
||||
pathTraceData,
|
||||
index,
|
||||
),
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
trailing: SNRIcon(
|
||||
snr:
|
||||
pathTraceData.snrData[index].toSigned(
|
||||
8,
|
||||
) /
|
||||
4.0,
|
||||
),
|
||||
onTap: () {
|
||||
// Handle item tap
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../l10n/l10n.dart';
|
||||
import '../models/contact.dart';
|
||||
import '../connector/meshcore_connector.dart';
|
||||
import '../connector/meshcore_protocol.dart';
|
||||
@@ -33,14 +34,14 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
|
||||
RepeaterCommandService? _commandService;
|
||||
|
||||
// Common commands for quick access
|
||||
final List<Map<String, String>> _quickCommands = [
|
||||
{'label': 'Get Name', 'command': 'get name'},
|
||||
{'label': 'Get Radio', 'command': 'get radio'},
|
||||
{'label': 'Get TX', 'command': 'get tx'},
|
||||
{'label': 'Neighbors', 'command': 'neighbors'},
|
||||
{'label': 'Version', 'command': 'ver'},
|
||||
{'label': 'Advertise', 'command': 'advert'},
|
||||
{'label': 'Clock', 'command': 'clock'},
|
||||
late final List<Map<String, String>> _quickCommands = [
|
||||
{'labelKey': 'getName', 'command': 'get name'},
|
||||
{'labelKey': 'getRadio', 'command': 'get radio'},
|
||||
{'labelKey': 'getTx', 'command': 'get tx'},
|
||||
{'labelKey': 'neighbors', 'command': 'neighbors'},
|
||||
{'labelKey': 'version', 'command': 'ver'},
|
||||
{'labelKey': 'advertise', 'command': 'advert'},
|
||||
{'labelKey': 'clock', 'command': 'clock'},
|
||||
];
|
||||
|
||||
@override
|
||||
@@ -118,14 +119,24 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
|
||||
|
||||
// Show debug info if requested
|
||||
if (showDebug && mounted) {
|
||||
final frame = buildSendCliCommandFrame(widget.repeater.publicKey, command);
|
||||
DebugFrameViewer.showFrameDebug(context, frame, 'CLI Command Frame');
|
||||
final frame = buildSendCliCommandFrame(
|
||||
widget.repeater.publicKey,
|
||||
command,
|
||||
);
|
||||
DebugFrameViewer.showFrameDebug(
|
||||
context,
|
||||
frame,
|
||||
context.l10n.repeater_cliCommandFrameTitle,
|
||||
);
|
||||
}
|
||||
|
||||
// Send CLI command to repeater with retry
|
||||
try {
|
||||
if (_commandService != null) {
|
||||
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||
final connector = Provider.of<MeshCoreConnector>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
final repeater = _resolveRepeater(connector);
|
||||
final response = await _commandService!.sendCommand(
|
||||
repeater,
|
||||
@@ -148,7 +159,7 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
|
||||
setState(() {
|
||||
_commandHistory.add({
|
||||
'type': 'response',
|
||||
'text': 'Error: $e',
|
||||
'text': context.l10n.repeater_cliCommandError(e.toString()),
|
||||
'timestamp': DateTime.now().toString(),
|
||||
});
|
||||
});
|
||||
@@ -215,6 +226,7 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
final connector = context.watch<MeshCoreConnector>();
|
||||
final repeater = _resolveRepeater(connector);
|
||||
final isFloodMode = repeater.pathOverride == -1;
|
||||
@@ -225,10 +237,13 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text('Repeater CLI'),
|
||||
Text(l10n.repeater_cliTitle),
|
||||
Text(
|
||||
repeater.name,
|
||||
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.normal),
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -236,7 +251,7 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
|
||||
actions: [
|
||||
PopupMenuButton<String>(
|
||||
icon: Icon(isFloodMode ? Icons.waves : Icons.route),
|
||||
tooltip: 'Routing mode',
|
||||
tooltip: l10n.repeater_routingMode,
|
||||
onSelected: (mode) async {
|
||||
if (mode == 'flood') {
|
||||
await connector.setPathOverride(repeater, pathLen: -1);
|
||||
@@ -249,12 +264,20 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
|
||||
value: 'auto',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.auto_mode, size: 20, color: !isFloodMode ? Theme.of(context).primaryColor : null),
|
||||
Icon(
|
||||
Icons.auto_mode,
|
||||
size: 20,
|
||||
color: !isFloodMode
|
||||
? Theme.of(context).primaryColor
|
||||
: null,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Auto (use saved path)',
|
||||
l10n.repeater_autoUseSavedPath,
|
||||
style: TextStyle(
|
||||
fontWeight: !isFloodMode ? FontWeight.bold : FontWeight.normal,
|
||||
fontWeight: !isFloodMode
|
||||
? FontWeight.bold
|
||||
: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -264,12 +287,20 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
|
||||
value: 'flood',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.waves, size: 20, color: isFloodMode ? Theme.of(context).primaryColor : null),
|
||||
Icon(
|
||||
Icons.waves,
|
||||
size: 20,
|
||||
color: isFloodMode
|
||||
? Theme.of(context).primaryColor
|
||||
: null,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Force Flood Mode',
|
||||
l10n.repeater_forceFloodMode,
|
||||
style: TextStyle(
|
||||
fontWeight: isFloodMode ? FontWeight.bold : FontWeight.normal,
|
||||
fontWeight: isFloodMode
|
||||
? FontWeight.bold
|
||||
: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -279,31 +310,32 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.timeline),
|
||||
tooltip: 'Path management',
|
||||
onPressed: () => PathManagementDialog.show(context, contact: repeater),
|
||||
tooltip: l10n.repeater_pathManagement,
|
||||
onPressed: () =>
|
||||
PathManagementDialog.show(context, contact: repeater),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.bug_report),
|
||||
tooltip: 'Debug Next Command',
|
||||
tooltip: l10n.repeater_debugNextCommand,
|
||||
onPressed: () {
|
||||
// Set a flag or just send next command with debug
|
||||
if (_commandController.text.trim().isNotEmpty) {
|
||||
_sendCommand(showDebug: true);
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Enter a command first')),
|
||||
SnackBar(content: Text(l10n.repeater_enterCommandFirst)),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.help_outline),
|
||||
tooltip: 'Command Help',
|
||||
tooltip: l10n.repeater_commandHelp,
|
||||
onPressed: () => _showCommandHelp(context),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.clear_all),
|
||||
tooltip: 'Clear History',
|
||||
tooltip: l10n.repeater_clearHistory,
|
||||
onPressed: _commandHistory.isEmpty ? null : _clearHistory,
|
||||
),
|
||||
],
|
||||
@@ -331,10 +363,11 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: _quickCommands.map((cmd) {
|
||||
final label = _quickCommandLabel(cmd['labelKey']!);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: ActionChip(
|
||||
label: Text(cmd['label']!),
|
||||
label: Text(label),
|
||||
onPressed: () => _useQuickCommand(cmd['command']!),
|
||||
avatar: const Icon(Icons.play_arrow, size: 16),
|
||||
),
|
||||
@@ -345,7 +378,30 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
String _quickCommandLabel(String key) {
|
||||
final l10n = context.l10n;
|
||||
switch (key) {
|
||||
case 'getName':
|
||||
return l10n.repeater_cliQuickGetName;
|
||||
case 'getRadio':
|
||||
return l10n.repeater_cliQuickGetRadio;
|
||||
case 'getTx':
|
||||
return l10n.repeater_cliQuickGetTx;
|
||||
case 'neighbors':
|
||||
return l10n.repeater_cliQuickNeighbors;
|
||||
case 'version':
|
||||
return l10n.repeater_cliQuickVersion;
|
||||
case 'advertise':
|
||||
return l10n.repeater_cliQuickAdvertise;
|
||||
case 'clock':
|
||||
return l10n.repeater_cliQuickClock;
|
||||
default:
|
||||
return key;
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildEmptyState() {
|
||||
final l10n = context.l10n;
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
@@ -353,12 +409,12 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
|
||||
Icon(Icons.terminal, size: 64, color: Colors.grey[400]),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'No commands sent yet',
|
||||
l10n.repeater_noCommandsSent,
|
||||
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Type a command below or use quick commands',
|
||||
l10n.repeater_typeCommandOrUseQuick,
|
||||
style: TextStyle(fontSize: 14, color: Colors.grey[500]),
|
||||
),
|
||||
],
|
||||
@@ -422,6 +478,7 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
|
||||
}
|
||||
|
||||
Widget _buildCommandInput() {
|
||||
final l10n = context.l10n;
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
@@ -430,12 +487,12 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.arrow_upward, size: 20),
|
||||
tooltip: 'Previous command',
|
||||
tooltip: l10n.repeater_previousCommand,
|
||||
onPressed: () => _navigateHistory(true),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.arrow_downward, size: 20),
|
||||
tooltip: 'Next command',
|
||||
tooltip: l10n.repeater_nextCommand,
|
||||
onPressed: () => _navigateHistory(false),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
@@ -443,10 +500,13 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
|
||||
child: TextField(
|
||||
controller: _commandController,
|
||||
focusNode: _commandFocusNode,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Enter command...',
|
||||
border: OutlineInputBorder(),
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: InputDecoration(
|
||||
hintText: l10n.repeater_enterCommandHint,
|
||||
border: const OutlineInputBorder(),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
prefixText: '> ',
|
||||
),
|
||||
style: const TextStyle(fontFamily: 'monospace'),
|
||||
@@ -479,312 +539,293 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
|
||||
}
|
||||
|
||||
void _showCommandHelp(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
final generalCommands = [
|
||||
const _CommandHelpEntry(
|
||||
_CommandHelpEntry(
|
||||
command: 'advert',
|
||||
description: 'Sends an advertisement packet',
|
||||
description: l10n.repeater_cliHelpAdvert,
|
||||
),
|
||||
const _CommandHelpEntry(
|
||||
_CommandHelpEntry(
|
||||
command: 'reboot',
|
||||
description:
|
||||
"Reboots the device. (note, you'll prob get 'Timeout' which is normal)",
|
||||
description: l10n.repeater_cliHelpReboot,
|
||||
),
|
||||
const _CommandHelpEntry(
|
||||
_CommandHelpEntry(
|
||||
command: 'clock',
|
||||
description: "Displays current time per device's clock.",
|
||||
description: l10n.repeater_cliHelpClock,
|
||||
),
|
||||
const _CommandHelpEntry(
|
||||
_CommandHelpEntry(
|
||||
command: 'password {new-password}',
|
||||
description: 'Sets a new admin password for the device.',
|
||||
description: l10n.repeater_cliHelpPassword,
|
||||
),
|
||||
const _CommandHelpEntry(
|
||||
_CommandHelpEntry(
|
||||
command: 'ver',
|
||||
description: 'Shows the device version and firmware build date.',
|
||||
description: l10n.repeater_cliHelpVersion,
|
||||
),
|
||||
const _CommandHelpEntry(
|
||||
_CommandHelpEntry(
|
||||
command: 'clear stats',
|
||||
description: 'Resets various stats counters to zero.',
|
||||
description: l10n.repeater_cliHelpClearStats,
|
||||
),
|
||||
];
|
||||
|
||||
final settingsCommands = [
|
||||
const _CommandHelpEntry(
|
||||
_CommandHelpEntry(
|
||||
command: 'set af {air-time-factor}',
|
||||
description: 'Sets the air-time-factor.',
|
||||
description: l10n.repeater_cliHelpSetAf,
|
||||
),
|
||||
const _CommandHelpEntry(
|
||||
_CommandHelpEntry(
|
||||
command: 'set tx {tx-power-dbm}',
|
||||
description: 'Sets LoRa transmit power in dBm. (reboot to apply)',
|
||||
description: l10n.repeater_cliHelpSetTx,
|
||||
),
|
||||
const _CommandHelpEntry(
|
||||
_CommandHelpEntry(
|
||||
command: 'set repeat {on|off}',
|
||||
description: 'Enables or disables the repeater role for this node.',
|
||||
description: l10n.repeater_cliHelpSetRepeat,
|
||||
),
|
||||
const _CommandHelpEntry(
|
||||
_CommandHelpEntry(
|
||||
command: 'set allow.read.only {on|off}',
|
||||
description:
|
||||
"(Room server) If 'on', then login in blank password will be allowed, but cannot Post to room. (just read only)",
|
||||
description: l10n.repeater_cliHelpSetAllowReadOnly,
|
||||
),
|
||||
const _CommandHelpEntry(
|
||||
_CommandHelpEntry(
|
||||
command: 'set flood.max {max-hops}',
|
||||
description:
|
||||
'Sets the maximum number of hops of inbound flood packet (if >= max, packet is not forwarded)',
|
||||
description: l10n.repeater_cliHelpSetFloodMax,
|
||||
),
|
||||
const _CommandHelpEntry(
|
||||
_CommandHelpEntry(
|
||||
command: 'set int.thresh {db}',
|
||||
description:
|
||||
'Sets the Interference Threshold (in DB). Default is 14. Set to 0 to disable channel interference detection.',
|
||||
description: l10n.repeater_cliHelpSetIntThresh,
|
||||
),
|
||||
const _CommandHelpEntry(
|
||||
_CommandHelpEntry(
|
||||
command: 'set agc.reset.interval {seconds}',
|
||||
description:
|
||||
'Sets the interval to reset the Auto Gain Controller. Set to 0 to disable.',
|
||||
description: l10n.repeater_cliHelpSetAgcResetInterval,
|
||||
),
|
||||
const _CommandHelpEntry(
|
||||
_CommandHelpEntry(
|
||||
command: 'set multi.acks {0|1}',
|
||||
description: "Enables or disables the 'double ACKs' feature.",
|
||||
description: l10n.repeater_cliHelpSetMultiAcks,
|
||||
),
|
||||
const _CommandHelpEntry(
|
||||
_CommandHelpEntry(
|
||||
command: 'set advert.interval {minutes}',
|
||||
description:
|
||||
'Sets the timer interval in minutes to send a local (zero-hop) advertisement packet. Set to 0 to disable.',
|
||||
description: l10n.repeater_cliHelpSetAdvertInterval,
|
||||
),
|
||||
const _CommandHelpEntry(
|
||||
_CommandHelpEntry(
|
||||
command: 'set flood.advert.interval {hours}',
|
||||
description:
|
||||
'Sets the timer interval in hours to send a flood advertisement packet. Set to 0 to disable.',
|
||||
description: l10n.repeater_cliHelpSetFloodAdvertInterval,
|
||||
),
|
||||
const _CommandHelpEntry(
|
||||
_CommandHelpEntry(
|
||||
command: 'set guest.password {guess-password}',
|
||||
description:
|
||||
'Sets/updates the guest password. (for repeaters, guest logins can send the "Get Stats" request)',
|
||||
description: l10n.repeater_cliHelpSetGuestPassword,
|
||||
),
|
||||
const _CommandHelpEntry(
|
||||
_CommandHelpEntry(
|
||||
command: 'set name {name}',
|
||||
description: 'Sets the advertisement name.',
|
||||
description: l10n.repeater_cliHelpSetName,
|
||||
),
|
||||
const _CommandHelpEntry(
|
||||
_CommandHelpEntry(
|
||||
command: 'set lat {latitude}',
|
||||
description: 'Sets the advertisement map latitude. (decimal degrees)',
|
||||
description: l10n.repeater_cliHelpSetLat,
|
||||
),
|
||||
const _CommandHelpEntry(
|
||||
_CommandHelpEntry(
|
||||
command: 'set lon {longitude}',
|
||||
description: 'Sets the advertisement map longitude. (decimal degrees)',
|
||||
description: l10n.repeater_cliHelpSetLon,
|
||||
),
|
||||
const _CommandHelpEntry(
|
||||
_CommandHelpEntry(
|
||||
command: 'set radio {freq},{bw},{sf},{cr}',
|
||||
description:
|
||||
'Sets completely new radio params, and saves to preferences. Requires a "reboot" command to apply.',
|
||||
description: l10n.repeater_cliHelpSetRadio,
|
||||
),
|
||||
const _CommandHelpEntry(
|
||||
_CommandHelpEntry(
|
||||
command: 'set rxdelay {base}',
|
||||
description:
|
||||
'Sets (experimental) base (must be > 1 for effect) for applying slight delay to received packets, based on signal strength/score. Set to 0 to disable.',
|
||||
description: l10n.repeater_cliHelpSetRxDelay,
|
||||
),
|
||||
const _CommandHelpEntry(
|
||||
_CommandHelpEntry(
|
||||
command: 'set txdelay {factor}',
|
||||
description:
|
||||
'Sets a factor multiplied with time-on-air for a flood-mode packet and with a randomized slot system, to delay its forwarding. (to decrease likelihood of collisions)',
|
||||
description: l10n.repeater_cliHelpSetTxDelay,
|
||||
),
|
||||
const _CommandHelpEntry(
|
||||
_CommandHelpEntry(
|
||||
command: 'set direct.txdelay {factor}',
|
||||
description:
|
||||
'Same as txdelay, but for applying a random delay to the forwarding of direct-mode packets.',
|
||||
description: l10n.repeater_cliHelpSetDirectTxDelay,
|
||||
),
|
||||
const _CommandHelpEntry(
|
||||
_CommandHelpEntry(
|
||||
command: 'set bridge.enabled {on|off}',
|
||||
description: 'Enable/Disable bridge.',
|
||||
description: l10n.repeater_cliHelpSetBridgeEnabled,
|
||||
),
|
||||
const _CommandHelpEntry(
|
||||
_CommandHelpEntry(
|
||||
command: 'set bridge.delay {0-10000}',
|
||||
description: 'Set delay before retransmitting packets.',
|
||||
description: l10n.repeater_cliHelpSetBridgeDelay,
|
||||
),
|
||||
const _CommandHelpEntry(
|
||||
_CommandHelpEntry(
|
||||
command: 'set bridge.source {rx|tx}',
|
||||
description:
|
||||
'Choose wether the bridge will retransmit received packets or transmitted packets.',
|
||||
description: l10n.repeater_cliHelpSetBridgeSource,
|
||||
),
|
||||
const _CommandHelpEntry(
|
||||
_CommandHelpEntry(
|
||||
command: 'set bridge.baud {speed}',
|
||||
description: 'Set serial link baudrate for rs232 bridges.',
|
||||
description: l10n.repeater_cliHelpSetBridgeBaud,
|
||||
),
|
||||
const _CommandHelpEntry(
|
||||
_CommandHelpEntry(
|
||||
command: 'set bridge.secret {shared-secret}',
|
||||
description: 'Set bridge secret for espnow bridges.',
|
||||
description: l10n.repeater_cliHelpSetBridgeSecret,
|
||||
),
|
||||
const _CommandHelpEntry(
|
||||
_CommandHelpEntry(
|
||||
command: 'set adc.multiplier {factor}',
|
||||
description:
|
||||
'Sets custom factor to adjust reported battery voltage (only supported on select boards).',
|
||||
description: l10n.repeater_cliHelpSetAdcMultiplier,
|
||||
),
|
||||
const _CommandHelpEntry(
|
||||
_CommandHelpEntry(
|
||||
command: 'tempradio {freq},{bw},{sf},{cr},{minutes}',
|
||||
description:
|
||||
'Sets temporary radio params for the given number of {minutes}, reverting to original radio params afterward. (does NOT save to preferences).',
|
||||
description: l10n.repeater_cliHelpTempRadio,
|
||||
),
|
||||
const _CommandHelpEntry(
|
||||
_CommandHelpEntry(
|
||||
command: 'setperm {pubkey-hex} {permissions}',
|
||||
description:
|
||||
'Modifies the ACL. Removes matching entry (by pubkey prefix) if "permissions" is zero. Adds new entry if pubkey-hex is full length and is not currently in ACL. Updates entry by matching pubkey prefix. Permission bits vary per firmware role, but low 2 bits are: 0 (Guest), 1 (Read only), 2 (Read write), 3 (Admin)',
|
||||
description: l10n.repeater_cliHelpSetPerm,
|
||||
),
|
||||
];
|
||||
|
||||
final bridgeCommands = [
|
||||
const _CommandHelpEntry(
|
||||
_CommandHelpEntry(
|
||||
command: 'get bridge.type',
|
||||
description: 'Gets bridge type none, rs232, espnow',
|
||||
description: l10n.repeater_cliHelpGetBridgeType,
|
||||
),
|
||||
];
|
||||
|
||||
final loggingCommands = [
|
||||
const _CommandHelpEntry(
|
||||
_CommandHelpEntry(
|
||||
command: 'log start',
|
||||
description: 'Starts packet logging to file system.',
|
||||
description: l10n.repeater_cliHelpLogStart,
|
||||
),
|
||||
const _CommandHelpEntry(
|
||||
_CommandHelpEntry(
|
||||
command: 'log stop',
|
||||
description: 'Stops packet logging to file system.',
|
||||
description: l10n.repeater_cliHelpLogStop,
|
||||
),
|
||||
const _CommandHelpEntry(
|
||||
_CommandHelpEntry(
|
||||
command: 'log erase',
|
||||
description: 'Erases the packet logs from file system.',
|
||||
description: l10n.repeater_cliHelpLogErase,
|
||||
),
|
||||
];
|
||||
|
||||
final neighborCommands = [
|
||||
const _CommandHelpEntry(
|
||||
_CommandHelpEntry(
|
||||
command: 'neighbors',
|
||||
description:
|
||||
'Shows a list of other repeater nodes heard via zero-hop adverts. Each line is {id-prefix-hex}:{timestamp}:{snr-times-4}',
|
||||
description: l10n.repeater_cliHelpNeighbors,
|
||||
),
|
||||
const _CommandHelpEntry(
|
||||
_CommandHelpEntry(
|
||||
command: 'neighbor.remove {pubkey-prefix}',
|
||||
description:
|
||||
'Removes first matching entry (by pubkey prefix (hex)), from neighbors list.',
|
||||
description: l10n.repeater_cliHelpNeighborRemove,
|
||||
),
|
||||
];
|
||||
|
||||
final regionCommands = [
|
||||
const _CommandHelpEntry(
|
||||
_CommandHelpEntry(
|
||||
command: 'region',
|
||||
description:
|
||||
'(serial only) Lists all defined regions and current flood permissions.',
|
||||
description: l10n.repeater_cliHelpRegion,
|
||||
),
|
||||
const _CommandHelpEntry(
|
||||
_CommandHelpEntry(
|
||||
command: 'region load',
|
||||
description:
|
||||
'NOTE: this is a special multi-command invocation. Each subsequent command is a region name (indented with spaces to indicate parent hierarchy, with one space at minimum). Terminated by sending a blank line/command.',
|
||||
description: l10n.repeater_cliHelpRegionLoad,
|
||||
),
|
||||
const _CommandHelpEntry(
|
||||
_CommandHelpEntry(
|
||||
command: 'region get {* | name-prefix}',
|
||||
description:
|
||||
'Searches for region with given name prefix (or "*" for the global scope). Replies with "-> {region-name} ({parent-name}) {\'F\'}"',
|
||||
description: l10n.repeater_cliHelpRegionGet,
|
||||
),
|
||||
const _CommandHelpEntry(
|
||||
_CommandHelpEntry(
|
||||
command: 'region put {name} {* | parent-name-prefix}',
|
||||
description: 'Adds or updates a region definition with given name.',
|
||||
description: l10n.repeater_cliHelpRegionPut,
|
||||
),
|
||||
const _CommandHelpEntry(
|
||||
_CommandHelpEntry(
|
||||
command: 'region remove {name}',
|
||||
description:
|
||||
'Removes a region definition with given name. (must match exactly, and have no child regions)',
|
||||
description: l10n.repeater_cliHelpRegionRemove,
|
||||
),
|
||||
const _CommandHelpEntry(
|
||||
_CommandHelpEntry(
|
||||
command: 'region allowf {* | name-prefix}',
|
||||
description:
|
||||
"Sets the 'F'lood permission for the given region. ('*' for the global/legacy scope)",
|
||||
description: l10n.repeater_cliHelpRegionAllowf,
|
||||
),
|
||||
const _CommandHelpEntry(
|
||||
_CommandHelpEntry(
|
||||
command: 'region denyf {* | name-prefix}',
|
||||
description:
|
||||
"Removes the 'F'lood permission for the given region. (NOTE: at this stage NOT advised to use this on the global/legacy scope!!)",
|
||||
description: l10n.repeater_cliHelpRegionDenyf,
|
||||
),
|
||||
const _CommandHelpEntry(
|
||||
_CommandHelpEntry(
|
||||
command: 'region home',
|
||||
description:
|
||||
"Replies with the current 'home' region. (Note applied anywhere yet, reserved for future)",
|
||||
description: l10n.repeater_cliHelpRegionHome,
|
||||
),
|
||||
const _CommandHelpEntry(
|
||||
_CommandHelpEntry(
|
||||
command: 'region home {* | name-prefix}',
|
||||
description: "Sets the 'home' region.",
|
||||
description: l10n.repeater_cliHelpRegionHomeSet,
|
||||
),
|
||||
const _CommandHelpEntry(
|
||||
_CommandHelpEntry(
|
||||
command: 'region save',
|
||||
description: 'Persists the region list/map to storage.',
|
||||
description: l10n.repeater_cliHelpRegionSave,
|
||||
),
|
||||
];
|
||||
|
||||
final gpsCommands = [
|
||||
const _CommandHelpEntry(
|
||||
command: 'gps',
|
||||
description:
|
||||
'Gives status of gps. When gps is off, it replies only off, if on it replies with on, {status}, {fix}, {sat count}',
|
||||
),
|
||||
const _CommandHelpEntry(
|
||||
_CommandHelpEntry(command: 'gps', description: l10n.repeater_cliHelpGps),
|
||||
_CommandHelpEntry(
|
||||
command: 'gps {on|off}',
|
||||
description: 'Toggles gps power state.',
|
||||
description: l10n.repeater_cliHelpGpsOnOff,
|
||||
),
|
||||
const _CommandHelpEntry(
|
||||
_CommandHelpEntry(
|
||||
command: 'gps sync',
|
||||
description: 'Syncs node time with gps clock.',
|
||||
description: l10n.repeater_cliHelpGpsSync,
|
||||
),
|
||||
const _CommandHelpEntry(
|
||||
_CommandHelpEntry(
|
||||
command: 'gps setloc',
|
||||
description: "Sets node's position to gps coordinates and save preferences.",
|
||||
description: l10n.repeater_cliHelpGpsSetLoc,
|
||||
),
|
||||
const _CommandHelpEntry(
|
||||
_CommandHelpEntry(
|
||||
command: 'gps advert',
|
||||
description:
|
||||
"Gives location advert configuration of the node:\n- none: don't include location in adverts\n- share: share gps location (from SensorManager)\n- prefs: advert the location stored in preferences",
|
||||
description: l10n.repeater_cliHelpGpsAdvert,
|
||||
),
|
||||
const _CommandHelpEntry(
|
||||
_CommandHelpEntry(
|
||||
command: 'gps advert {none|share|prefs}',
|
||||
description: 'Sets location advert configuration.',
|
||||
description: l10n.repeater_cliHelpGpsAdvertSet,
|
||||
),
|
||||
];
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Commands List'),
|
||||
title: Text(l10n.repeater_commandsListTitle),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'NOTE: for the various "set ..." commands, there is also a "get ..." command.',
|
||||
style: TextStyle(fontSize: 13),
|
||||
Text(
|
||||
l10n.repeater_commandsListNote,
|
||||
style: const TextStyle(fontSize: 13),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildHelpSection(context, 'General', generalCommands),
|
||||
const SizedBox(height: 16),
|
||||
_buildHelpSection(context, 'Settings', settingsCommands),
|
||||
const SizedBox(height: 16),
|
||||
_buildHelpSection(context, 'Bridge', bridgeCommands),
|
||||
const SizedBox(height: 16),
|
||||
_buildHelpSection(context, 'Logging', loggingCommands),
|
||||
const SizedBox(height: 16),
|
||||
_buildHelpSection(
|
||||
context,
|
||||
'Neighbors (Repeater only)',
|
||||
l10n.repeater_general,
|
||||
generalCommands,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildHelpSection(
|
||||
context,
|
||||
l10n.repeater_settingsCategory,
|
||||
settingsCommands,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildHelpSection(context, l10n.repeater_bridge, bridgeCommands),
|
||||
const SizedBox(height: 16),
|
||||
_buildHelpSection(
|
||||
context,
|
||||
l10n.repeater_logging,
|
||||
loggingCommands,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildHelpSection(
|
||||
context,
|
||||
l10n.repeater_neighborsRepeaterOnly,
|
||||
neighborCommands,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildHelpSection(
|
||||
context,
|
||||
'Region Management (Repeater only)',
|
||||
l10n.repeater_regionManagementRepeaterOnly,
|
||||
regionCommands,
|
||||
note:
|
||||
'Region commands have been introduced to manage region definitions and permissions.',
|
||||
note: l10n.repeater_regionNote,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildHelpSection(
|
||||
context,
|
||||
'GPS Management',
|
||||
l10n.repeater_gpsManagement,
|
||||
gpsCommands,
|
||||
note:
|
||||
'gps command has been introduced to manage location related topics.',
|
||||
note: l10n.repeater_gpsNote,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -792,7 +833,7 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Close'),
|
||||
child: Text(l10n.common_close),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -814,10 +855,7 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
|
||||
),
|
||||
if (note != null) ...[
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
note,
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
Text(note, style: const TextStyle(fontSize: 12)),
|
||||
],
|
||||
const SizedBox(height: 8),
|
||||
...commands.map((entry) => _buildHelpCommandCard(context, entry)),
|
||||
@@ -872,8 +910,5 @@ class _CommandHelpEntry {
|
||||
final String command;
|
||||
final String description;
|
||||
|
||||
const _CommandHelpEntry({
|
||||
required this.command,
|
||||
required this.description,
|
||||
});
|
||||
const _CommandHelpEntry({required this.command, required this.description});
|
||||
}
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:meshcore_open/connector/meshcore_protocol.dart';
|
||||
import '../l10n/l10n.dart';
|
||||
import '../models/contact.dart';
|
||||
import 'repeater_status_screen.dart';
|
||||
import 'repeater_cli_screen.dart';
|
||||
import 'repeater_settings_screen.dart';
|
||||
import 'telemetry_screen.dart';
|
||||
import 'neighbours_screen.dart';
|
||||
|
||||
class RepeaterHubScreen extends StatelessWidget {
|
||||
final Contact repeater;
|
||||
@@ -16,16 +20,24 @@ class RepeaterHubScreen extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text('Repeater Management'),
|
||||
Text(
|
||||
repeater.type == advTypeRepeater
|
||||
? l10n.repeater_management
|
||||
: l10n.room_management,
|
||||
),
|
||||
Text(
|
||||
repeater.name,
|
||||
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.normal),
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -36,110 +48,167 @@ class RepeaterHubScreen extends StatelessWidget {
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
// Repeater info card
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 40,
|
||||
backgroundColor: Colors.orange,
|
||||
child: const Icon(Icons.cell_tower, size: 40, color: Colors.white),
|
||||
// Repeater info card
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 40,
|
||||
backgroundColor: Colors.orange,
|
||||
child: const Icon(
|
||||
Icons.cell_tower,
|
||||
size: 40,
|
||||
color: Colors.white,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
repeater.name,
|
||||
style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
repeater.name,
|
||||
style: const TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
repeater.pathLabel,
|
||||
style: TextStyle(fontSize: 14, color: Colors.grey[600]),
|
||||
),
|
||||
if (repeater.hasLocation) ...[
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.location_on, size: 14, color: Colors.grey[600]),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${repeater.latitude?.toStringAsFixed(4)}, ${repeater.longitude?.toStringAsFixed(4)}',
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
repeater.shortPubKeyHex,
|
||||
style: TextStyle(fontSize: 14, color: Colors.grey[600]),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
repeater.pathLabel,
|
||||
style: TextStyle(fontSize: 14, color: Colors.grey[600]),
|
||||
),
|
||||
if (repeater.hasLocation) ...[
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.location_on,
|
||||
size: 14,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${repeater.latitude?.toStringAsFixed(4)}, ${repeater.longitude?.toStringAsFixed(4)}',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
const Text(
|
||||
'Management Tools',
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// Status button
|
||||
_buildManagementCard(
|
||||
context,
|
||||
icon: Icons.analytics,
|
||||
title: 'Status',
|
||||
subtitle: 'View repeater status, stats, and neighbors',
|
||||
color: Colors.blue,
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => RepeaterStatusScreen(
|
||||
repeater: repeater,
|
||||
password: password,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
l10n.repeater_managementTools,
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// Status button
|
||||
_buildManagementCard(
|
||||
context,
|
||||
icon: Icons.analytics,
|
||||
title: l10n.repeater_status,
|
||||
subtitle: l10n.repeater_statusSubtitle,
|
||||
color: Colors.blue,
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => RepeaterStatusScreen(
|
||||
repeater: repeater,
|
||||
password: password,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// CLI button
|
||||
_buildManagementCard(
|
||||
context,
|
||||
icon: Icons.terminal,
|
||||
title: 'CLI',
|
||||
subtitle: 'Send commands to the repeater',
|
||||
color: Colors.green,
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => RepeaterCliScreen(
|
||||
repeater: repeater,
|
||||
password: password,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// Telemetry button
|
||||
_buildManagementCard(
|
||||
context,
|
||||
icon: Icons.bar_chart_sharp,
|
||||
title: l10n.repeater_telemetry,
|
||||
subtitle: l10n.repeater_telemetrySubtitle,
|
||||
color: Colors.teal,
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) =>
|
||||
TelemetryScreen(repeater: repeater, password: password),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// CLI button
|
||||
_buildManagementCard(
|
||||
context,
|
||||
icon: Icons.terminal,
|
||||
title: l10n.repeater_cli,
|
||||
subtitle: l10n.repeater_cliSubtitle,
|
||||
color: Colors.green,
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => RepeaterCliScreen(
|
||||
repeater: repeater,
|
||||
password: password,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// Settings button
|
||||
_buildManagementCard(
|
||||
context,
|
||||
icon: Icons.settings,
|
||||
title: 'Settings',
|
||||
subtitle: 'Configure repeater parameters',
|
||||
color: Colors.orange,
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => RepeaterSettingsScreen(
|
||||
repeater: repeater,
|
||||
password: password,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// Neighbors button
|
||||
_buildManagementCard(
|
||||
context,
|
||||
icon: Icons.group,
|
||||
title: l10n.repeater_neighbours,
|
||||
subtitle: l10n.repeater_neighboursSubtitle,
|
||||
color: Colors.orange,
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => NeighboursScreen(
|
||||
repeater: repeater,
|
||||
password: password,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// Settings button
|
||||
_buildManagementCard(
|
||||
context,
|
||||
icon: Icons.settings,
|
||||
title: l10n.repeater_settings,
|
||||
subtitle: l10n.repeater_settingsSubtitle,
|
||||
color: Colors.deepOrange,
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => RepeaterSettingsScreen(
|
||||
repeater: repeater,
|
||||
password: password,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -186,10 +255,7 @@ class RepeaterHubScreen extends StatelessWidget {
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
subtitle,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
style: TextStyle(fontSize: 14, color: Colors.grey[600]),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@ import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../l10n/l10n.dart';
|
||||
import '../models/contact.dart';
|
||||
import '../models/path_selection.dart';
|
||||
import '../connector/meshcore_connector.dart';
|
||||
@@ -27,7 +28,8 @@ class RepeaterStatusScreen extends StatefulWidget {
|
||||
class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
|
||||
static const int _statusPayloadOffset = 8;
|
||||
static const int _statusStatsSize = 52;
|
||||
static const int _statusResponseBytes = _statusPayloadOffset + _statusStatsSize;
|
||||
static const int _statusResponseBytes =
|
||||
_statusPayloadOffset + _statusStatsSize;
|
||||
|
||||
bool _isLoading = false;
|
||||
StreamSubscription<Uint8List>? _frameSubscription;
|
||||
@@ -260,9 +262,12 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
|
||||
await connector.sendFrame(frame);
|
||||
|
||||
final pathLengthValue = selection.useFlood ? -1 : selection.hopCount;
|
||||
final messageBytes = frame.length >= _statusResponseBytes
|
||||
var messageBytes = frame.length >= _statusResponseBytes
|
||||
? frame.length
|
||||
: _statusResponseBytes;
|
||||
if (messageBytes < maxFrameSize) {
|
||||
messageBytes = maxFrameSize;
|
||||
}
|
||||
final timeoutMs = connector.calculateTimeout(
|
||||
pathLength: pathLengthValue,
|
||||
messageBytes: messageBytes,
|
||||
@@ -274,8 +279,8 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
|
||||
_isLoading = false;
|
||||
});
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Status request timed out.'),
|
||||
SnackBar(
|
||||
content: Text(context.l10n.repeater_statusRequestTimeout),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
@@ -289,7 +294,9 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Error loading status: $e'),
|
||||
content: Text(
|
||||
context.l10n.repeater_errorLoadingStatus(e.toString()),
|
||||
),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
@@ -309,6 +316,7 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
final connector = context.watch<MeshCoreConnector>();
|
||||
final repeater = _resolveRepeater(connector);
|
||||
final isFloodMode = repeater.pathOverride == -1;
|
||||
@@ -319,10 +327,13 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text('Repeater Status'),
|
||||
Text(l10n.repeater_statusTitle),
|
||||
Text(
|
||||
repeater.name,
|
||||
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.normal),
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -330,7 +341,7 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
|
||||
actions: [
|
||||
PopupMenuButton<String>(
|
||||
icon: Icon(isFloodMode ? Icons.waves : Icons.route),
|
||||
tooltip: 'Routing mode',
|
||||
tooltip: l10n.repeater_routingMode,
|
||||
onSelected: (mode) async {
|
||||
if (mode == 'flood') {
|
||||
await connector.setPathOverride(repeater, pathLen: -1);
|
||||
@@ -343,12 +354,20 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
|
||||
value: 'auto',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.auto_mode, size: 20, color: !isFloodMode ? Theme.of(context).primaryColor : null),
|
||||
Icon(
|
||||
Icons.auto_mode,
|
||||
size: 20,
|
||||
color: !isFloodMode
|
||||
? Theme.of(context).primaryColor
|
||||
: null,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Auto (use saved path)',
|
||||
l10n.repeater_autoUseSavedPath,
|
||||
style: TextStyle(
|
||||
fontWeight: !isFloodMode ? FontWeight.bold : FontWeight.normal,
|
||||
fontWeight: !isFloodMode
|
||||
? FontWeight.bold
|
||||
: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -358,12 +377,20 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
|
||||
value: 'flood',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.waves, size: 20, color: isFloodMode ? Theme.of(context).primaryColor : null),
|
||||
Icon(
|
||||
Icons.waves,
|
||||
size: 20,
|
||||
color: isFloodMode
|
||||
? Theme.of(context).primaryColor
|
||||
: null,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Force Flood Mode',
|
||||
l10n.repeater_forceFloodMode,
|
||||
style: TextStyle(
|
||||
fontWeight: isFloodMode ? FontWeight.bold : FontWeight.normal,
|
||||
fontWeight: isFloodMode
|
||||
? FontWeight.bold
|
||||
: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -373,8 +400,9 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.timeline),
|
||||
tooltip: 'Path management',
|
||||
onPressed: () => PathManagementDialog.show(context, contact: repeater),
|
||||
tooltip: l10n.repeater_pathManagement,
|
||||
onPressed: () =>
|
||||
PathManagementDialog.show(context, contact: repeater),
|
||||
),
|
||||
IconButton(
|
||||
icon: _isLoading
|
||||
@@ -385,7 +413,7 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
|
||||
)
|
||||
: const Icon(Icons.refresh),
|
||||
onPressed: _isLoading ? null : _loadStatus,
|
||||
tooltip: 'Refresh',
|
||||
tooltip: l10n.repeater_refresh,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -409,6 +437,7 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
|
||||
}
|
||||
|
||||
Widget _buildSystemInfoCard() {
|
||||
final l10n = context.l10n;
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
@@ -417,20 +446,26 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.info_outline, color: Theme.of(context).primaryColor),
|
||||
Icon(
|
||||
Icons.info_outline,
|
||||
color: Theme.of(context).textTheme.headlineSmall?.color,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Text(
|
||||
'System Information',
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
Text(
|
||||
l10n.repeater_systemInformation,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Divider(),
|
||||
_buildInfoRow('Battery', _batteryText()),
|
||||
_buildInfoRow('Clock (at login)', _clockText()),
|
||||
_buildInfoRow('Uptime', _formatDuration(_uptimeSecs)),
|
||||
_buildInfoRow('Queue Length', _formatValue(_queueLen)),
|
||||
_buildInfoRow('Debug Flags', _formatValue(_debugFlags)),
|
||||
_buildInfoRow(l10n.repeater_battery, _batteryText()),
|
||||
_buildInfoRow(l10n.repeater_clockAtLogin, _clockText()),
|
||||
_buildInfoRow(l10n.repeater_uptime, _formatDuration(_uptimeSecs)),
|
||||
_buildInfoRow(l10n.repeater_queueLength, _formatValue(_queueLen)),
|
||||
_buildInfoRow(l10n.repeater_debugFlags, _formatValue(_debugFlags)),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -438,6 +473,7 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
|
||||
}
|
||||
|
||||
Widget _buildRadioStatsCard() {
|
||||
final l10n = context.l10n;
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
@@ -446,20 +482,32 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.radio, color: Theme.of(context).primaryColor),
|
||||
Icon(
|
||||
Icons.radio,
|
||||
color: Theme.of(context).textTheme.headlineSmall?.color,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Text(
|
||||
'Radio Statistics',
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
Text(
|
||||
l10n.repeater_radioStatistics,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Divider(),
|
||||
_buildInfoRow('Last RSSI', _formatValue(_lastRssi, suffix: ' dB')),
|
||||
_buildInfoRow('Last SNR', _formatSnr(_lastSnr)),
|
||||
_buildInfoRow('Noise Floor', _formatValue(_noiseFloor, suffix: ' dB')),
|
||||
_buildInfoRow('TX Airtime', _formatDuration(_txAirSecs)),
|
||||
_buildInfoRow('RX Airtime', _formatDuration(_rxAirSecs)),
|
||||
_buildInfoRow(
|
||||
l10n.repeater_lastRssi,
|
||||
_formatValue(_lastRssi, suffix: ' dB'),
|
||||
),
|
||||
_buildInfoRow(l10n.repeater_lastSnr, _formatSnr(_lastSnr)),
|
||||
_buildInfoRow(
|
||||
l10n.repeater_noiseFloor,
|
||||
_formatValue(_noiseFloor, suffix: ' dB'),
|
||||
),
|
||||
_buildInfoRow(l10n.repeater_txAirtime, _formatDuration(_txAirSecs)),
|
||||
_buildInfoRow(l10n.repeater_rxAirtime, _formatDuration(_rxAirSecs)),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -467,6 +515,7 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
|
||||
}
|
||||
|
||||
Widget _buildPacketStatsCard() {
|
||||
final l10n = context.l10n;
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
@@ -475,18 +524,24 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.analytics, color: Theme.of(context).primaryColor),
|
||||
Icon(
|
||||
Icons.analytics,
|
||||
color: Theme.of(context).textTheme.headlineSmall?.color,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Text(
|
||||
'Packet Statistics',
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
Text(
|
||||
l10n.repeater_packetStatistics,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Divider(),
|
||||
_buildInfoRow('Sent', _packetTxText()),
|
||||
_buildInfoRow('Received', _packetRxText()),
|
||||
_buildInfoRow('Duplicates', _duplicateText()),
|
||||
_buildInfoRow(l10n.repeater_sent, _packetTxText()),
|
||||
_buildInfoRow(l10n.repeater_received, _packetRxText()),
|
||||
_buildInfoRow(l10n.repeater_duplicates, _duplicateText()),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -553,43 +608,50 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
|
||||
if (_statusRequestedAt == null) return '—';
|
||||
final dt = _statusRequestedAt!;
|
||||
final date = '${dt.day}/${dt.month}/${dt.year}';
|
||||
final time = '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}';
|
||||
final time =
|
||||
'${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}';
|
||||
return '$date $time';
|
||||
}
|
||||
|
||||
String _formatDuration(int? seconds) {
|
||||
if (seconds == null) return '—';
|
||||
final l10n = context.l10n;
|
||||
final days = seconds ~/ 86400;
|
||||
final hours = (seconds % 86400) ~/ 3600;
|
||||
final minutes = (seconds % 3600) ~/ 60;
|
||||
final secs = seconds % 60;
|
||||
return '$days days ${hours}h ${minutes}m ${secs}s';
|
||||
return l10n.repeater_daysHoursMinsSecs(days, hours, minutes, secs);
|
||||
}
|
||||
|
||||
String _packetTxText() {
|
||||
if (_packetsSent == null) return '—';
|
||||
final l10n = context.l10n;
|
||||
final flood = _formatValue(_floodTx);
|
||||
final direct = _formatValue(_directTx);
|
||||
return 'Total: $_packetsSent, Flood: $flood, Direct: $direct';
|
||||
return l10n.repeater_packetTxTotal(_packetsSent!, flood, direct);
|
||||
}
|
||||
|
||||
String _packetRxText() {
|
||||
if (_packetsRecv == null) return '—';
|
||||
final l10n = context.l10n;
|
||||
final flood = _formatValue(_floodRx);
|
||||
final direct = _formatValue(_directRx);
|
||||
return 'Total: $_packetsRecv, Flood: $flood, Direct: $direct';
|
||||
return l10n.repeater_packetRxTotal(_packetsRecv!, flood, direct);
|
||||
}
|
||||
|
||||
String _duplicateText() {
|
||||
final l10n = context.l10n;
|
||||
if (_dupFlood != null || _dupDirect != null) {
|
||||
final flood = _formatValue(_dupFlood);
|
||||
final direct = _formatValue(_dupDirect);
|
||||
return 'Flood: $flood, Direct: $direct';
|
||||
return l10n.repeater_duplicatesFloodDirect(flood, direct);
|
||||
}
|
||||
if (_packetsRecv == null || _floodRx == null || _directRx == null) {
|
||||
return '—';
|
||||
}
|
||||
if (_packetsRecv == null || _floodRx == null || _directRx == null) return '—';
|
||||
final dupTotal = _packetsRecv! - _floodRx! - _directRx!;
|
||||
if (dupTotal < 0) return '—';
|
||||
return 'Total: $dupTotal';
|
||||
return l10n.repeater_duplicatesTotal(dupTotal);
|
||||
}
|
||||
|
||||
String _formatValue(num? value, {String? suffix}) {
|
||||
|
||||
+136
-42
@@ -1,20 +1,76 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../connector/meshcore_connector.dart';
|
||||
import '../l10n/l10n.dart';
|
||||
import '../widgets/device_tile.dart';
|
||||
import 'contacts_screen.dart';
|
||||
|
||||
/// Screen for scanning and connecting to MeshCore devices
|
||||
class ScannerScreen extends StatelessWidget {
|
||||
class ScannerScreen extends StatefulWidget {
|
||||
const ScannerScreen({super.key});
|
||||
|
||||
@override
|
||||
State<ScannerScreen> createState() => _ScannerScreenState();
|
||||
}
|
||||
|
||||
class _ScannerScreenState extends State<ScannerScreen> {
|
||||
bool _changedNavigation = false;
|
||||
late final VoidCallback _connectionListener;
|
||||
BluetoothAdapterState _bluetoothState = BluetoothAdapterState.unknown;
|
||||
late StreamSubscription<BluetoothAdapterState> _bluetoothStateSubscription;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||
|
||||
_connectionListener = () {
|
||||
if (connector.state == MeshCoreConnectionState.disconnected) {
|
||||
_changedNavigation = false;
|
||||
} else if (connector.state == MeshCoreConnectionState.connected &&
|
||||
!_changedNavigation) {
|
||||
_changedNavigation = true;
|
||||
if (mounted) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (context) => const ContactsScreen()),
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
connector.addListener(_connectionListener);
|
||||
|
||||
_bluetoothStateSubscription = FlutterBluePlus.adapterState.listen((state) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_bluetoothState = state;
|
||||
});
|
||||
// Cancel scan if Bluetooth turns off while scanning
|
||||
if (state != BluetoothAdapterState.on) {
|
||||
unawaited(connector.stopScan());
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||
connector.removeListener(_connectionListener);
|
||||
unawaited(_bluetoothStateSubscription.cancel());
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('MeshCore Open'),
|
||||
title: Text(context.l10n.scanner_title),
|
||||
centerTitle: true,
|
||||
automaticallyImplyLeading: false,
|
||||
),
|
||||
@@ -24,13 +80,15 @@ class ScannerScreen extends StatelessWidget {
|
||||
builder: (context, connector, child) {
|
||||
return Column(
|
||||
children: [
|
||||
// Bluetooth off warning
|
||||
if (_bluetoothState == BluetoothAdapterState.off)
|
||||
_bluetoothOffWarning(context),
|
||||
|
||||
// Status bar
|
||||
_buildStatusBar(context, connector),
|
||||
|
||||
// Device list
|
||||
Expanded(
|
||||
child: _buildDeviceList(context, connector),
|
||||
),
|
||||
Expanded(child: _buildDeviceList(context, connector)),
|
||||
],
|
||||
);
|
||||
},
|
||||
@@ -38,17 +96,21 @@ class ScannerScreen extends StatelessWidget {
|
||||
),
|
||||
floatingActionButton: Consumer<MeshCoreConnector>(
|
||||
builder: (context, connector, child) {
|
||||
final isScanning = connector.state == MeshCoreConnectionState.scanning;
|
||||
|
||||
final isScanning =
|
||||
connector.state == MeshCoreConnectionState.scanning;
|
||||
final isBluetoothOff = _bluetoothState == BluetoothAdapterState.off;
|
||||
|
||||
return FloatingActionButton.extended(
|
||||
onPressed: () {
|
||||
if (isScanning) {
|
||||
connector.stopScan();
|
||||
} else {
|
||||
connector.startScan();
|
||||
}
|
||||
},
|
||||
icon: isScanning
|
||||
onPressed: isBluetoothOff
|
||||
? null
|
||||
: () {
|
||||
if (isScanning) {
|
||||
connector.stopScan();
|
||||
} else {
|
||||
connector.startScan();
|
||||
}
|
||||
},
|
||||
icon: isScanning
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
@@ -58,7 +120,11 @@ class ScannerScreen extends StatelessWidget {
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.bluetooth_searching),
|
||||
label: Text(isScanning ? 'Stop' : 'Scan'),
|
||||
label: Text(
|
||||
isScanning
|
||||
? context.l10n.scanner_stop
|
||||
: context.l10n.scanner_scan,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -69,25 +135,26 @@ class ScannerScreen extends StatelessWidget {
|
||||
String statusText;
|
||||
Color statusColor;
|
||||
|
||||
final l10n = context.l10n;
|
||||
switch (connector.state) {
|
||||
case MeshCoreConnectionState.scanning:
|
||||
statusText = 'Scanning for devices...';
|
||||
statusText = l10n.scanner_scanning;
|
||||
statusColor = Colors.blue;
|
||||
break;
|
||||
case MeshCoreConnectionState.connecting:
|
||||
statusText = 'Connecting...';
|
||||
statusText = l10n.scanner_connecting;
|
||||
statusColor = Colors.orange;
|
||||
break;
|
||||
case MeshCoreConnectionState.connected:
|
||||
statusText = 'Connected to ${connector.deviceDisplayName}';
|
||||
statusText = l10n.scanner_connectedTo(connector.deviceDisplayName);
|
||||
statusColor = Colors.green;
|
||||
break;
|
||||
case MeshCoreConnectionState.disconnecting:
|
||||
statusText = 'Disconnecting...';
|
||||
statusText = l10n.scanner_disconnecting;
|
||||
statusColor = Colors.orange;
|
||||
break;
|
||||
case MeshCoreConnectionState.disconnected:
|
||||
statusText = 'Not connected';
|
||||
statusText = l10n.scanner_notConnected;
|
||||
statusColor = Colors.grey;
|
||||
break;
|
||||
}
|
||||
@@ -115,20 +182,13 @@ class ScannerScreen extends StatelessWidget {
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.bluetooth,
|
||||
size: 64,
|
||||
color: Colors.grey[400],
|
||||
),
|
||||
Icon(Icons.bluetooth, size: 64, color: Colors.grey[400]),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
connector.state == MeshCoreConnectionState.scanning
|
||||
? 'Searching for MeshCore devices...'
|
||||
: 'Tap Scan to find MeshCore devices',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
? context.l10n.scanner_searchingDevices
|
||||
: context.l10n.scanner_tapToScan,
|
||||
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -159,24 +219,58 @@ class ScannerScreen extends StatelessWidget {
|
||||
? result.device.platformName
|
||||
: result.advertisementData.advName;
|
||||
await connector.connect(result.device, displayName: name);
|
||||
|
||||
if (context.mounted && connector.isConnected) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const ContactsScreen(),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Connection failed: $e'),
|
||||
content: Text(context.l10n.scanner_connectionFailed(e.toString())),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Widget _bluetoothOffWarning(BuildContext context) {
|
||||
final errorColor = Theme.of(context).colorScheme.error;
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
|
||||
color: errorColor.withValues(alpha: 0.15),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.bluetooth_disabled, size: 24, color: errorColor),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
context.l10n.scanner_bluetoothOff,
|
||||
style: TextStyle(
|
||||
color: errorColor,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
context.l10n.scanner_bluetoothOffMessage,
|
||||
style: TextStyle(
|
||||
color: errorColor.withValues(alpha: 0.85),
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (Platform.isAndroid)
|
||||
TextButton(
|
||||
onPressed: () => FlutterBluePlus.turnOn(),
|
||||
child: Text(context.l10n.scanner_enableBluetooth),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+573
-268
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,433 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../l10n/l10n.dart';
|
||||
import '../models/contact.dart';
|
||||
import '../models/path_selection.dart';
|
||||
import '../connector/meshcore_connector.dart';
|
||||
import '../connector/meshcore_protocol.dart';
|
||||
import '../services/repeater_command_service.dart';
|
||||
import '../widgets/path_management_dialog.dart';
|
||||
import '../helpers/cayenne_lpp.dart';
|
||||
|
||||
class TelemetryScreen extends StatefulWidget {
|
||||
final Contact repeater;
|
||||
final String password;
|
||||
|
||||
const TelemetryScreen({
|
||||
super.key,
|
||||
required this.repeater,
|
||||
required this.password,
|
||||
});
|
||||
|
||||
@override
|
||||
State<TelemetryScreen> createState() => _TelemetryScreenState();
|
||||
}
|
||||
|
||||
class _TelemetryScreenState extends State<TelemetryScreen> {
|
||||
static const int _statusPayloadOffset = 8;
|
||||
static const int _statusStatsSize = 52;
|
||||
static const int _statusResponseBytes =
|
||||
_statusPayloadOffset + _statusStatsSize;
|
||||
Uint8List _tagData = Uint8List(4);
|
||||
|
||||
bool _isLoading = false;
|
||||
bool _isLoaded = false;
|
||||
bool _hasData = false;
|
||||
Timer? _statusTimeout;
|
||||
StreamSubscription<Uint8List>? _frameSubscription;
|
||||
RepeaterCommandService? _commandService;
|
||||
PathSelection? _pendingStatusSelection;
|
||||
List<Map<String, dynamic>>? _parsedTelemetry;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||
_commandService = RepeaterCommandService(connector);
|
||||
_setupMessageListener();
|
||||
_loadTelemetry();
|
||||
_hasData = false;
|
||||
}
|
||||
|
||||
void _setupMessageListener() {
|
||||
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||
|
||||
// Listen for incoming text messages from the repeater
|
||||
_frameSubscription = connector.receivedFrames.listen((frame) {
|
||||
if (frame.isEmpty) return;
|
||||
|
||||
if (frame[0] == respCodeSent) {
|
||||
_tagData = frame.sublist(2, 6);
|
||||
}
|
||||
|
||||
// Check if it's a binary response
|
||||
if (frame[0] == pushCodeBinaryResponse &&
|
||||
listEquals(frame.sublist(2, 6), _tagData)) {
|
||||
if (!mounted) return;
|
||||
_handleStatusResponse(frame.sublist(6));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _handleStatusResponse(Uint8List frame) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_parsedTelemetry = CayenneLpp.parseByChannel(frame);
|
||||
});
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(context.l10n.telemetry_receivedData),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
_statusTimeout?.cancel();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_isLoaded = true;
|
||||
_hasData = true;
|
||||
});
|
||||
}
|
||||
|
||||
Contact _resolveRepeater(MeshCoreConnector connector) {
|
||||
return connector.contacts.firstWhere(
|
||||
(c) => c.publicKeyHex == widget.repeater.publicKeyHex,
|
||||
orElse: () => widget.repeater,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _loadTelemetry() async {
|
||||
if (_commandService == null) return;
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_isLoaded = false;
|
||||
});
|
||||
try {
|
||||
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||
final repeater = _resolveRepeater(connector);
|
||||
final selection = await connector.preparePathForContactSend(repeater);
|
||||
_pendingStatusSelection = selection;
|
||||
final frame = buildSendBinaryReq(
|
||||
repeater.publicKey,
|
||||
payload: Uint8List.fromList([reqTypeGetTelemetry]),
|
||||
);
|
||||
await connector.sendFrame(frame);
|
||||
|
||||
final pathLengthValue = selection.useFlood ? -1 : selection.hopCount;
|
||||
var messageBytes = frame.length >= _statusResponseBytes
|
||||
? frame.length
|
||||
: _statusResponseBytes;
|
||||
if (messageBytes < maxFrameSize) {
|
||||
messageBytes = maxFrameSize;
|
||||
}
|
||||
final timeoutMs = connector.calculateTimeout(
|
||||
pathLength: pathLengthValue,
|
||||
messageBytes: messageBytes,
|
||||
);
|
||||
_statusTimeout?.cancel();
|
||||
_statusTimeout = Timer(Duration(milliseconds: timeoutMs), () {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_isLoaded = false;
|
||||
});
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(context.l10n.telemetry_requestTimeout),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
_recordStatusResult(false);
|
||||
});
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_isLoaded = false;
|
||||
});
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(context.l10n.telemetry_errorLoading(e.toString())),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _recordStatusResult(bool success) {
|
||||
final selection = _pendingStatusSelection;
|
||||
if (selection == null) return;
|
||||
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||
final repeater = _resolveRepeater(connector);
|
||||
connector.recordRepeaterPathResult(repeater, selection, success, null);
|
||||
_pendingStatusSelection = null;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_frameSubscription?.cancel();
|
||||
_commandService?.dispose();
|
||||
_statusTimeout?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
final connector = context.watch<MeshCoreConnector>();
|
||||
final repeater = _resolveRepeater(connector);
|
||||
final isFloodMode = repeater.pathOverride == -1;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
l10n.repeater_telemetry,
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
),
|
||||
Text(
|
||||
repeater.name,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
centerTitle: false,
|
||||
actions: [
|
||||
PopupMenuButton<String>(
|
||||
icon: Icon(isFloodMode ? Icons.waves : Icons.route),
|
||||
tooltip: l10n.repeater_routingMode,
|
||||
onSelected: (mode) async {
|
||||
if (mode == 'flood') {
|
||||
await connector.setPathOverride(repeater, pathLen: -1);
|
||||
} else {
|
||||
await connector.setPathOverride(repeater, pathLen: null);
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem(
|
||||
value: 'auto',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.auto_mode,
|
||||
size: 20,
|
||||
color: !isFloodMode
|
||||
? Theme.of(context).primaryColor
|
||||
: null,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
l10n.repeater_autoUseSavedPath,
|
||||
style: TextStyle(
|
||||
fontWeight: !isFloodMode
|
||||
? FontWeight.bold
|
||||
: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 'flood',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.waves,
|
||||
size: 20,
|
||||
color: isFloodMode
|
||||
? Theme.of(context).primaryColor
|
||||
: null,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
l10n.repeater_forceFloodMode,
|
||||
style: TextStyle(
|
||||
fontWeight: isFloodMode
|
||||
? FontWeight.bold
|
||||
: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.timeline),
|
||||
tooltip: l10n.repeater_pathManagement,
|
||||
onPressed: () =>
|
||||
PathManagementDialog.show(context, contact: repeater),
|
||||
),
|
||||
IconButton(
|
||||
icon: _isLoading
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.refresh),
|
||||
onPressed: _isLoading ? null : _loadTelemetry,
|
||||
tooltip: l10n.repeater_refresh,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: SafeArea(
|
||||
top: false,
|
||||
child: RefreshIndicator(
|
||||
onRefresh: _loadTelemetry,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
if (!_isLoaded &&
|
||||
!_hasData &&
|
||||
(_parsedTelemetry == null || _parsedTelemetry!.isEmpty))
|
||||
Center(
|
||||
child: Text(
|
||||
l10n.telemetry_noData,
|
||||
style: const TextStyle(fontSize: 16, color: Colors.grey),
|
||||
),
|
||||
),
|
||||
if ((_isLoaded || _hasData) &&
|
||||
_parsedTelemetry != null &&
|
||||
_parsedTelemetry!.isNotEmpty)
|
||||
for (final entry in _parsedTelemetry ?? [])
|
||||
_buildChannelInfoCard(
|
||||
entry['values'],
|
||||
l10n.telemetry_channelTitle(entry['channel']),
|
||||
entry['channel'],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildChannelInfoCard(
|
||||
Map<String, dynamic> channelData,
|
||||
String title,
|
||||
int channel,
|
||||
) {
|
||||
final l10n = context.l10n;
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.info_outline,
|
||||
color: Theme.of(context).textTheme.headlineSmall?.color,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Divider(),
|
||||
for (final entry in channelData.entries)
|
||||
if (entry.key == 'voltage' && channel == 1)
|
||||
_buildInfoRow(
|
||||
l10n.telemetry_batteryLabel,
|
||||
_batteryText(entry.value),
|
||||
)
|
||||
else if (entry.key == 'voltage')
|
||||
_buildInfoRow(
|
||||
l10n.telemetry_voltageLabel,
|
||||
l10n.telemetry_voltageValue(entry.value.toString()),
|
||||
)
|
||||
else if (entry.key == 'temperature' && channel == 1)
|
||||
_buildInfoRow(
|
||||
l10n.telemetry_mcuTemperatureLabel,
|
||||
_temperatureText(entry.value),
|
||||
)
|
||||
else if (entry.key == 'temperature')
|
||||
_buildInfoRow(
|
||||
l10n.telemetry_temperatureLabel,
|
||||
_temperatureText(entry.value),
|
||||
)
|
||||
else if (entry.key == 'current' && channel == 1)
|
||||
_buildInfoRow(
|
||||
l10n.telemetry_currentLabel,
|
||||
l10n.telemetry_currentValue(entry.value.toString()),
|
||||
)
|
||||
else
|
||||
_buildInfoRow(entry.key, entry.value.toString()),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoRow(String label, String value) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 130,
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: Colors.grey[600],
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
value,
|
||||
style: const TextStyle(fontWeight: FontWeight.w400),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _batteryText(double? batteryMv) {
|
||||
final l10n = context.l10n;
|
||||
if (batteryMv == null) return l10n.common_notAvailable;
|
||||
final percent = _batteryPercentFromMv(batteryMv);
|
||||
final volts = batteryMv.toStringAsFixed(2);
|
||||
return l10n.telemetry_batteryValue(percent, volts);
|
||||
}
|
||||
|
||||
int _batteryPercentFromMv(double millivolts) {
|
||||
const minMv = 2.800;
|
||||
const maxMv = 4.200;
|
||||
if (millivolts <= minMv) return 0;
|
||||
if (millivolts >= maxMv) return 100;
|
||||
return (((millivolts - minMv) * 100) / (maxMv - minMv)).round();
|
||||
}
|
||||
|
||||
String _temperatureText(double? tempC) {
|
||||
final l10n = context.l10n;
|
||||
if (tempC == null) return l10n.common_notAvailable;
|
||||
final tempF = (tempC * 9 / 5) + 32;
|
||||
return l10n.telemetry_temperatureValue(
|
||||
tempC.toStringAsFixed(1),
|
||||
tempF.toStringAsFixed(1),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,6 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
enum AppDebugLogLevel {
|
||||
info,
|
||||
warning,
|
||||
error,
|
||||
}
|
||||
enum AppDebugLogLevel { info, warning, error }
|
||||
|
||||
class AppDebugLogEntry {
|
||||
final DateTime timestamp;
|
||||
@@ -51,7 +47,11 @@ class AppDebugLogService extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void log(String message, {String tag = 'App', AppDebugLogLevel level = AppDebugLogLevel.info}) {
|
||||
void log(
|
||||
String message, {
|
||||
String tag = 'App',
|
||||
AppDebugLogLevel level = AppDebugLogLevel.info,
|
||||
}) {
|
||||
if (!_enabled) return;
|
||||
|
||||
_entries.add(
|
||||
|
||||
@@ -82,10 +82,7 @@ class AppSettingsService extends ChangeNotifier {
|
||||
final safeMin = minZoom <= maxZoom ? minZoom : maxZoom;
|
||||
final safeMax = minZoom <= maxZoom ? maxZoom : minZoom;
|
||||
await updateSettings(
|
||||
_settings.copyWith(
|
||||
mapCacheMinZoom: safeMin,
|
||||
mapCacheMaxZoom: safeMax,
|
||||
),
|
||||
_settings.copyWith(mapCacheMinZoom: safeMin, mapCacheMaxZoom: safeMax),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -113,15 +110,26 @@ class AppSettingsService extends ChangeNotifier {
|
||||
await updateSettings(_settings.copyWith(themeMode: value));
|
||||
}
|
||||
|
||||
Future<void> setLanguageOverride(String? value) async {
|
||||
await updateSettings(_settings.copyWith(languageOverride: value));
|
||||
}
|
||||
|
||||
Future<void> setAppDebugLogEnabled(bool value) async {
|
||||
await updateSettings(_settings.copyWith(appDebugLogEnabled: value));
|
||||
// Update the global logger
|
||||
appLogger.setEnabled(value);
|
||||
}
|
||||
|
||||
Future<void> setBatteryChemistryForDevice(String deviceId, String chemistry) async {
|
||||
final updated = Map<String, String>.from(_settings.batteryChemistryByDeviceId);
|
||||
Future<void> setBatteryChemistryForDevice(
|
||||
String deviceId,
|
||||
String chemistry,
|
||||
) async {
|
||||
final updated = Map<String, String>.from(
|
||||
_settings.batteryChemistryByDeviceId,
|
||||
);
|
||||
updated[deviceId] = chemistry;
|
||||
await updateSettings(_settings.copyWith(batteryChemistryByDeviceId: updated));
|
||||
await updateSettings(
|
||||
_settings.copyWith(batteryChemistryByDeviceId: updated),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import 'dart:isolate';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_foreground_task/flutter_foreground_task.dart';
|
||||
@@ -15,20 +14,14 @@ class BackgroundService {
|
||||
channelDescription: 'Keeps MeshCore running in the background.',
|
||||
channelImportance: NotificationChannelImportance.LOW,
|
||||
priority: NotificationPriority.LOW,
|
||||
iconData: const NotificationIconData(
|
||||
resType: ResourceType.mipmap,
|
||||
resPrefix: ResourcePrefix.ic,
|
||||
name: 'launcher',
|
||||
),
|
||||
),
|
||||
iosNotificationOptions: const IOSNotificationOptions(
|
||||
showNotification: false,
|
||||
playSound: false,
|
||||
),
|
||||
foregroundTaskOptions: const ForegroundTaskOptions(
|
||||
interval: 5000,
|
||||
foregroundTaskOptions: ForegroundTaskOptions(
|
||||
eventAction: ForegroundTaskEventAction.repeat(5000),
|
||||
autoRunOnBoot: false,
|
||||
allowWakeLock: true,
|
||||
allowWifiLock: false,
|
||||
),
|
||||
);
|
||||
@@ -64,13 +57,13 @@ void startCallback() {
|
||||
|
||||
class _MeshCoreTaskHandler extends TaskHandler {
|
||||
@override
|
||||
void onStart(DateTime timestamp, SendPort? sendPort) {}
|
||||
Future<void> onStart(DateTime timestamp, TaskStarter starter) async {}
|
||||
|
||||
@override
|
||||
void onRepeatEvent(DateTime timestamp, SendPort? sendPort) {}
|
||||
void onRepeatEvent(DateTime timestamp) {}
|
||||
|
||||
@override
|
||||
void onDestroy(DateTime timestamp, SendPort? sendPort) {}
|
||||
Future<void> onDestroy(DateTime timestamp, bool isTimeout) async {}
|
||||
|
||||
@override
|
||||
void onNotificationButtonPressed(String id) {}
|
||||
|
||||
@@ -16,7 +16,9 @@ class BleDebugLogEntry {
|
||||
|
||||
String get hexPreview {
|
||||
const maxBytes = 64;
|
||||
final bytes = payload.length > maxBytes ? payload.sublist(0, maxBytes) : payload;
|
||||
final bytes = payload.length > maxBytes
|
||||
? payload.sublist(0, maxBytes)
|
||||
: payload;
|
||||
final hex = bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ');
|
||||
return payload.length > maxBytes ? '$hex …' : hex;
|
||||
}
|
||||
@@ -26,14 +28,13 @@ class BleRawLogRxEntry {
|
||||
final DateTime timestamp;
|
||||
final Uint8List payload;
|
||||
|
||||
BleRawLogRxEntry({
|
||||
required this.timestamp,
|
||||
required this.payload,
|
||||
});
|
||||
BleRawLogRxEntry({required this.timestamp, required this.payload});
|
||||
|
||||
String get hexPreview {
|
||||
const maxBytes = 64;
|
||||
final bytes = payload.length > maxBytes ? payload.sublist(0, maxBytes) : payload;
|
||||
final bytes = payload.length > maxBytes
|
||||
? payload.sublist(0, maxBytes)
|
||||
: payload;
|
||||
final hex = bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ');
|
||||
return payload.length > maxBytes ? '$hex …' : hex;
|
||||
}
|
||||
@@ -45,7 +46,8 @@ class BleDebugLogService extends ChangeNotifier {
|
||||
final List<BleRawLogRxEntry> _rawLogRxEntries = [];
|
||||
|
||||
List<BleDebugLogEntry> get entries => List.unmodifiable(_entries);
|
||||
List<BleRawLogRxEntry> get rawLogRxEntries => List.unmodifiable(_rawLogRxEntries);
|
||||
List<BleRawLogRxEntry> get rawLogRxEntries =>
|
||||
List.unmodifiable(_rawLogRxEntries);
|
||||
|
||||
void logFrame(Uint8List frame, {required bool outgoing, String? note}) {
|
||||
if (frame.isEmpty) return;
|
||||
@@ -85,15 +87,32 @@ class BleDebugLogService extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
String _describeFrame(int code, Uint8List frame, bool outgoing, String? note) {
|
||||
final label = _codeLabel(code);
|
||||
String _describeFrame(
|
||||
int code,
|
||||
Uint8List frame,
|
||||
bool outgoing,
|
||||
String? note,
|
||||
) {
|
||||
final label = _codeLabel(code, outgoing: outgoing);
|
||||
final prefix = outgoing ? 'TX' : 'RX';
|
||||
final extra = _frameDetail(code, frame);
|
||||
final noteText = note != null ? ' • $note' : '';
|
||||
return '$prefix $label$extra$noteText';
|
||||
}
|
||||
|
||||
String _codeLabel(int code) {
|
||||
String _codeLabel(int code, {required bool outgoing}) {
|
||||
if (outgoing) {
|
||||
return _commandLabel(code) ?? 'CODE_$code';
|
||||
}
|
||||
|
||||
final pushLabel = _pushLabel(code);
|
||||
if (pushLabel != null) return pushLabel;
|
||||
final responseLabel = _responseLabel(code);
|
||||
if (responseLabel != null) return responseLabel;
|
||||
return 'CODE_$code';
|
||||
}
|
||||
|
||||
String? _commandLabel(int code) {
|
||||
switch (code) {
|
||||
case cmdAppStart:
|
||||
return 'CMD_APP_START';
|
||||
@@ -135,6 +154,17 @@ class BleDebugLogService extends ChangeNotifier {
|
||||
return 'CMD_SET_CHANNEL';
|
||||
case cmdGetRadioSettings:
|
||||
return 'CMD_GET_RADIO_SETTINGS';
|
||||
case cmdSetCustomVar:
|
||||
return 'CMD_SET_CUSTOM_VAR';
|
||||
case cmdSendTracePath:
|
||||
return 'CMD_SEND_TRACE_PATH';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
String? _responseLabel(int code) {
|
||||
switch (code) {
|
||||
case respCodeOk:
|
||||
return 'RESP_CODE_OK';
|
||||
case respCodeErr:
|
||||
@@ -167,6 +197,15 @@ class BleDebugLogService extends ChangeNotifier {
|
||||
return 'RESP_CODE_CHANNEL_INFO';
|
||||
case respCodeRadioSettings:
|
||||
return 'RESP_CODE_RADIO_SETTINGS';
|
||||
case pushCodeTraceData:
|
||||
return 'PUSH_CODE_TRACE_DATA';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
String? _pushLabel(int code) {
|
||||
switch (code) {
|
||||
case pushCodeAdvert:
|
||||
return 'PUSH_CODE_ADVERT';
|
||||
case pushCodePathUpdated:
|
||||
@@ -184,7 +223,7 @@ class BleDebugLogService extends ChangeNotifier {
|
||||
case pushCodeNewAdvert:
|
||||
return 'PUSH_CODE_NEW_ADVERT';
|
||||
default:
|
||||
return 'CODE_$code';
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -42,20 +42,21 @@ class MapTileCacheService {
|
||||
late final TileProvider tileProvider;
|
||||
|
||||
MapTileCacheService({BaseCacheManager? cacheManager})
|
||||
: cacheManager = cacheManager ??
|
||||
CacheManager(
|
||||
Config(
|
||||
cacheKey,
|
||||
stalePeriod: const Duration(days: 365),
|
||||
maxNrOfCacheObjects: 200000,
|
||||
),
|
||||
) {
|
||||
: cacheManager =
|
||||
cacheManager ??
|
||||
CacheManager(
|
||||
Config(
|
||||
cacheKey,
|
||||
stalePeriod: const Duration(days: 365),
|
||||
maxNrOfCacheObjects: 200000,
|
||||
),
|
||||
) {
|
||||
tileProvider = CachedNetworkTileProvider(cacheManager: this.cacheManager);
|
||||
}
|
||||
|
||||
Map<String, String> get defaultHeaders => {
|
||||
'User-Agent': 'flutter_map ($userAgentPackageName)',
|
||||
};
|
||||
'User-Agent': 'flutter_map ($userAgentPackageName)',
|
||||
};
|
||||
|
||||
Future<void> clearCache() async {
|
||||
await cacheManager.emptyCache();
|
||||
@@ -96,17 +97,21 @@ class MapTileCacheService {
|
||||
final future = cacheManager
|
||||
.downloadFile(url, key: url, authHeaders: authHeaders)
|
||||
.then((_) {
|
||||
completed += 1;
|
||||
}).catchError((_) {
|
||||
completed += 1;
|
||||
failed += 1;
|
||||
}).whenComplete(() {
|
||||
onProgress?.call(MapTileCacheProgress(
|
||||
completed: completed,
|
||||
total: total,
|
||||
failed: failed,
|
||||
));
|
||||
});
|
||||
completed += 1;
|
||||
})
|
||||
.catchError((_) {
|
||||
completed += 1;
|
||||
failed += 1;
|
||||
})
|
||||
.whenComplete(() {
|
||||
onProgress?.call(
|
||||
MapTileCacheProgress(
|
||||
completed: completed,
|
||||
total: total,
|
||||
failed: failed,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
pending.add(future);
|
||||
if (pending.length >= safeConcurrency) {
|
||||
@@ -189,11 +194,9 @@ class MapTileCacheService {
|
||||
int _latToTileY(double lat, int zoom, int maxIndex) {
|
||||
final n = 1 << zoom;
|
||||
final rad = lat * math.pi / 180.0;
|
||||
final value = ((1 -
|
||||
math.log(math.tan(rad) + 1 / math.cos(rad)) / math.pi) /
|
||||
2 *
|
||||
n)
|
||||
.floor();
|
||||
final value =
|
||||
((1 - math.log(math.tan(rad) + 1 / math.cos(rad)) / math.pi) / 2 * n)
|
||||
.floor();
|
||||
return value.clamp(0, maxIndex);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import 'package:crypto/crypto.dart';
|
||||
import '../models/contact.dart';
|
||||
import '../models/message.dart';
|
||||
import '../models/path_selection.dart';
|
||||
import 'storage_service.dart';
|
||||
import 'app_settings_service.dart';
|
||||
import 'app_debug_log_service.dart';
|
||||
|
||||
@@ -26,26 +25,27 @@ class _AckHashMapping {
|
||||
final String messageId;
|
||||
final DateTime timestamp;
|
||||
|
||||
_AckHashMapping({
|
||||
required this.messageId,
|
||||
required this.timestamp,
|
||||
});
|
||||
_AckHashMapping({required this.messageId, required this.timestamp});
|
||||
}
|
||||
|
||||
class MessageRetryService extends ChangeNotifier {
|
||||
static const int maxRetries = 5;
|
||||
static const int maxAckHistorySize = 100;
|
||||
|
||||
final StorageService _storage;
|
||||
final Map<String, Timer> _timeoutTimers = {};
|
||||
final Map<String, Message> _pendingMessages = {};
|
||||
final Map<String, Contact> _pendingContacts = {};
|
||||
final Map<String, PathSelection> _pendingPathSelections = {};
|
||||
final Map<String, _AckHashMapping> _ackHashToMessageId = {}; // ackHashHex → messageId + timestamp for O(1) lookup
|
||||
final Map<String, List<Uint8List>> _expectedAckHashes = {}; // Track all expected ACKs for retries (for history)
|
||||
final List<_AckHistoryEntry> _ackHistory = []; // Rolling buffer of recent ACK hashes
|
||||
final Map<String, List<String>> _pendingMessageQueuePerContact = {}; // contactPubKeyHex → FIFO queue of messageIds (DEPRECATED - will be removed)
|
||||
final Map<String, String> _expectedHashToMessageId = {}; // expectedAckHashHex → messageId (for matching RESP_CODE_SENT by hash)
|
||||
final Map<String, _AckHashMapping> _ackHashToMessageId =
|
||||
{}; // ackHashHex → messageId + timestamp for O(1) lookup
|
||||
final Map<String, List<Uint8List>> _expectedAckHashes =
|
||||
{}; // Track all expected ACKs for retries (for history)
|
||||
final List<_AckHistoryEntry> _ackHistory =
|
||||
[]; // Rolling buffer of recent ACK hashes
|
||||
final Map<String, List<String>> _pendingMessageQueuePerContact =
|
||||
{}; // contactPubKeyHex → FIFO queue of messageIds (DEPRECATED - will be removed)
|
||||
final Map<String, String> _expectedHashToMessageId =
|
||||
{}; // expectedAckHashHex → messageId (for matching RESP_CODE_SENT by hash)
|
||||
|
||||
Function(Contact, String, int, int)? _sendMessageCallback;
|
||||
Function(String, Message)? _addMessageCallback;
|
||||
@@ -59,7 +59,7 @@ class MessageRetryService extends ChangeNotifier {
|
||||
AppDebugLogService? _debugLogService;
|
||||
Function(String, PathSelection, bool, int?)? _recordPathResultCallback;
|
||||
|
||||
MessageRetryService(this._storage);
|
||||
MessageRetryService();
|
||||
|
||||
void initialize({
|
||||
required Function(Contact, String, int, int) sendMessageCallback,
|
||||
@@ -132,7 +132,8 @@ class MessageRetryService extends ChangeNotifier {
|
||||
final messagePathBytes =
|
||||
pathBytes ?? _resolveMessagePathBytes(contact, useFlood, pathSelection);
|
||||
final messagePathLength =
|
||||
pathLength ?? _resolveMessagePathLength(contact, useFlood, pathSelection);
|
||||
pathLength ??
|
||||
_resolveMessagePathLength(contact, useFlood, pathSelection);
|
||||
final message = Message(
|
||||
senderKey: contact.publicKey,
|
||||
text: text,
|
||||
@@ -169,15 +170,25 @@ class MessageRetryService extends ChangeNotifier {
|
||||
if (_setContactPathCallback != null && _clearContactPathCallback != null) {
|
||||
if (message.pathLength != null && message.pathLength! < 0) {
|
||||
// Flood mode - clear the path
|
||||
debugPrint('Setting flood mode for retry attempt ${message.retryCount}');
|
||||
debugPrint(
|
||||
'Setting flood mode for retry attempt ${message.retryCount}',
|
||||
);
|
||||
_clearContactPathCallback!(contact);
|
||||
} else if (message.pathLength != null && message.pathLength! >= 0) {
|
||||
// Specific path (including direct neighbor with pathLength=0)
|
||||
final pathStr = message.pathBytes.isEmpty
|
||||
? 'direct'
|
||||
: message.pathBytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(',');
|
||||
debugPrint('Setting path [$pathStr] (${message.pathLength} hops) for retry attempt ${message.retryCount}');
|
||||
await _setContactPathCallback!(contact, message.pathBytes, message.pathLength!);
|
||||
: message.pathBytes
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join(',');
|
||||
debugPrint(
|
||||
'Setting path [$pathStr] (${message.pathLength} hops) for retry attempt ${message.retryCount}',
|
||||
);
|
||||
await _setContactPathCallback!(
|
||||
contact,
|
||||
message.pathBytes,
|
||||
message.pathLength!,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,22 +199,30 @@ class MessageRetryService extends ChangeNotifier {
|
||||
// IMPORTANT: Use the transformed text (with SMAZ encoding if enabled) to match device's hash
|
||||
final selfPubKey = _getSelfPublicKeyCallback?.call();
|
||||
if (selfPubKey != null) {
|
||||
final outboundText = _prepareContactOutboundTextCallback?.call(contact, message.text) ?? message.text;
|
||||
final outboundText =
|
||||
_prepareContactOutboundTextCallback?.call(contact, message.text) ??
|
||||
message.text;
|
||||
final expectedHash = MessageRetryService.computeExpectedAckHash(
|
||||
timestampSeconds,
|
||||
attempt,
|
||||
outboundText,
|
||||
selfPubKey,
|
||||
);
|
||||
final expectedHashHex = expectedHash.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
||||
final expectedHashHex = expectedHash
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join();
|
||||
_expectedHashToMessageId[expectedHashHex] = messageId;
|
||||
|
||||
final shortText = message.text.length > 20 ? '${message.text.substring(0, 20)}...' : message.text;
|
||||
final shortText = message.text.length > 20
|
||||
? '${message.text.substring(0, 20)}...'
|
||||
: message.text;
|
||||
_debugLogService?.info(
|
||||
'Sent "$shortText" to ${contact.name} → expect ACK hash $expectedHashHex (attempt $attempt)',
|
||||
tag: 'AckHash',
|
||||
);
|
||||
debugPrint('Computed expected ACK hash $expectedHashHex for message $messageId');
|
||||
debugPrint(
|
||||
'Computed expected ACK hash $expectedHashHex for message $messageId',
|
||||
);
|
||||
}
|
||||
|
||||
// DEPRECATED: Old queue-based matching (kept for fallback)
|
||||
@@ -211,17 +230,14 @@ class MessageRetryService extends ChangeNotifier {
|
||||
_pendingMessageQueuePerContact[contact.publicKeyHex]!.add(messageId);
|
||||
|
||||
if (_sendMessageCallback != null) {
|
||||
_sendMessageCallback!(
|
||||
contact,
|
||||
message.text,
|
||||
attempt,
|
||||
timestampSeconds,
|
||||
);
|
||||
_sendMessageCallback!(contact, message.text, attempt, timestampSeconds);
|
||||
}
|
||||
}
|
||||
|
||||
void updateMessageFromSent(Uint8List ackHash, int timeoutMs) {
|
||||
final ackHashHex = ackHash.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
||||
final ackHashHex = ackHash
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join();
|
||||
|
||||
// NEW: Try hash-based matching first (fixes LoRa message drops causing mismatches)
|
||||
String? messageId = _expectedHashToMessageId.remove(ackHashHex);
|
||||
@@ -232,16 +248,21 @@ class MessageRetryService extends ChangeNotifier {
|
||||
final message = _pendingMessages[messageId];
|
||||
|
||||
if (contact != null && message != null) {
|
||||
final shortText = message.text.length > 20 ? '${message.text.substring(0, 20)}...' : message.text;
|
||||
final shortText = message.text.length > 20
|
||||
? '${message.text.substring(0, 20)}...'
|
||||
: message.text;
|
||||
_debugLogService?.info(
|
||||
'RESP_CODE_SENT received: ACK hash $ackHashHex ✓ matched "$shortText" to ${contact.name}',
|
||||
tag: 'AckHash',
|
||||
);
|
||||
debugPrint('Hash-based match: ACK hash $ackHashHex → message $messageId ✓');
|
||||
debugPrint(
|
||||
'Hash-based match: ACK hash $ackHashHex → message $messageId ✓',
|
||||
);
|
||||
|
||||
// Remove from old queue since we matched
|
||||
_pendingMessageQueuePerContact[contact.publicKeyHex]?.remove(messageId);
|
||||
if (_pendingMessageQueuePerContact[contact.publicKeyHex]?.isEmpty ?? false) {
|
||||
if (_pendingMessageQueuePerContact[contact.publicKeyHex]?.isEmpty ??
|
||||
false) {
|
||||
_pendingMessageQueuePerContact.remove(contact.publicKeyHex);
|
||||
}
|
||||
} else {
|
||||
@@ -261,7 +282,9 @@ class MessageRetryService extends ChangeNotifier {
|
||||
'RESP_CODE_SENT: ACK hash $ackHashHex not found in hash table, falling back to queue',
|
||||
tag: 'AckHash',
|
||||
);
|
||||
debugPrint('Hash-based match failed for $ackHashHex, falling back to queue-based matching');
|
||||
debugPrint(
|
||||
'Hash-based match failed for $ackHashHex, falling back to queue-based matching',
|
||||
);
|
||||
|
||||
for (var entry in _pendingMessageQueuePerContact.entries) {
|
||||
final contactKey = entry.key;
|
||||
@@ -273,7 +296,9 @@ class MessageRetryService extends ChangeNotifier {
|
||||
if (_pendingMessages.containsKey(candidateMessageId)) {
|
||||
messageId = candidateMessageId;
|
||||
contact = _pendingContacts[candidateMessageId];
|
||||
debugPrint('Queue-based match (fallback): $ackHashHex → message $messageId for $contactKey');
|
||||
debugPrint(
|
||||
'Queue-based match (fallback): $ackHashHex → message $messageId for $contactKey',
|
||||
);
|
||||
break;
|
||||
} else {
|
||||
debugPrint('Dequeued stale message $candidateMessageId - skipping');
|
||||
@@ -282,7 +307,9 @@ class MessageRetryService extends ChangeNotifier {
|
||||
if (_pendingMessages.containsKey(nextMessageId)) {
|
||||
messageId = nextMessageId;
|
||||
contact = _pendingContacts[nextMessageId];
|
||||
debugPrint('Queue-based match (fallback): $ackHashHex → message $messageId');
|
||||
debugPrint(
|
||||
'Queue-based match (fallback): $ackHashHex → message $messageId',
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -308,16 +335,22 @@ class MessageRetryService extends ChangeNotifier {
|
||||
final selection = _pendingPathSelections[messageId];
|
||||
|
||||
if (message == null) {
|
||||
debugPrint('Message $messageId no longer pending for ACK hash: $ackHashHex');
|
||||
debugPrint(
|
||||
'Message $messageId no longer pending for ACK hash: $ackHashHex',
|
||||
);
|
||||
_ackHashToMessageId.remove(ackHashHex);
|
||||
return;
|
||||
}
|
||||
|
||||
// Add this ACK hash to the list of expected ACKs for this message (for history)
|
||||
_expectedAckHashes[messageId] ??= [];
|
||||
if (!_expectedAckHashes[messageId]!.any((hash) => listEquals(hash, ackHash))) {
|
||||
if (!_expectedAckHashes[messageId]!.any(
|
||||
(hash) => listEquals(hash, ackHash),
|
||||
)) {
|
||||
_expectedAckHashes[messageId]!.add(Uint8List.fromList(ackHash));
|
||||
debugPrint('Added ACK hash $ackHashHex to message $messageId (total: ${_expectedAckHashes[messageId]!.length})');
|
||||
debugPrint(
|
||||
'Added ACK hash $ackHashHex to message $messageId (total: ${_expectedAckHashes[messageId]!.length})',
|
||||
);
|
||||
}
|
||||
|
||||
// Use device-provided timeout, or calculate from radio settings if timeout is 0 or invalid
|
||||
@@ -332,8 +365,13 @@ class MessageRetryService extends ChangeNotifier {
|
||||
} else {
|
||||
pathLengthValue = contact.pathLength;
|
||||
}
|
||||
actualTimeout = _calculateTimeoutCallback!(pathLengthValue, message.text.length);
|
||||
debugPrint('Using calculated timeout: ${actualTimeout}ms for path length $pathLengthValue');
|
||||
actualTimeout = _calculateTimeoutCallback!(
|
||||
pathLengthValue,
|
||||
message.text.length,
|
||||
);
|
||||
debugPrint(
|
||||
'Using calculated timeout: ${actualTimeout}ms for path length $pathLengthValue',
|
||||
);
|
||||
}
|
||||
|
||||
final updatedMessage = message.copyWith(
|
||||
@@ -366,16 +404,22 @@ class MessageRetryService extends ChangeNotifier {
|
||||
final selection = _pendingPathSelections[messageId];
|
||||
|
||||
if (message == null || contact == null) {
|
||||
debugPrint('Timeout fired but message $messageId no longer pending (likely already delivered)');
|
||||
debugPrint(
|
||||
'Timeout fired but message $messageId no longer pending (likely already delivered)',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final shortText = message.text.length > 20 ? '${message.text.substring(0, 20)}...' : message.text;
|
||||
final shortText = message.text.length > 20
|
||||
? '${message.text.substring(0, 20)}...'
|
||||
: message.text;
|
||||
_debugLogService?.warn(
|
||||
'Timeout: No ACK received for "$shortText" to ${contact.name} (attempt ${message.retryCount}) → retrying',
|
||||
tag: 'AckHash',
|
||||
);
|
||||
debugPrint('Timeout for message $messageId (retry ${message.retryCount}/${maxRetries - 1})');
|
||||
debugPrint(
|
||||
'Timeout for message $messageId (retry ${message.retryCount}/${maxRetries - 1})',
|
||||
);
|
||||
|
||||
if (message.retryCount < maxRetries - 1) {
|
||||
final backoffMs = 1000 * (1 << message.retryCount);
|
||||
@@ -404,7 +448,9 @@ class MessageRetryService extends ChangeNotifier {
|
||||
if (_pendingMessages.containsKey(messageId)) {
|
||||
_attemptSend(messageId);
|
||||
} else {
|
||||
debugPrint('Retry cancelled: message $messageId was delivered while waiting');
|
||||
debugPrint(
|
||||
'Retry cancelled: message $messageId was delivered while waiting',
|
||||
);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
@@ -422,7 +468,8 @@ class MessageRetryService extends ChangeNotifier {
|
||||
|
||||
// Clean up the queue entry for this contact
|
||||
_pendingMessageQueuePerContact[contact.publicKeyHex]?.remove(messageId);
|
||||
if (_pendingMessageQueuePerContact[contact.publicKeyHex]?.isEmpty ?? false) {
|
||||
if (_pendingMessageQueuePerContact[contact.publicKeyHex]?.isEmpty ??
|
||||
false) {
|
||||
_pendingMessageQueuePerContact.remove(contact.publicKeyHex);
|
||||
}
|
||||
|
||||
@@ -432,7 +479,13 @@ class MessageRetryService extends ChangeNotifier {
|
||||
_clearContactPathCallback!(contact);
|
||||
}
|
||||
|
||||
_recordPathResultFromMessage(contact.publicKeyHex, message, selection, false, null);
|
||||
_recordPathResultFromMessage(
|
||||
contact.publicKeyHex,
|
||||
message,
|
||||
selection,
|
||||
false,
|
||||
null,
|
||||
);
|
||||
|
||||
if (_updateMessageCallback != null) {
|
||||
_updateMessageCallback!(failedMessage);
|
||||
@@ -445,18 +498,22 @@ class MessageRetryService extends ChangeNotifier {
|
||||
void _moveAckHashesToHistory(String messageId) {
|
||||
final ackHashes = _expectedAckHashes.remove(messageId);
|
||||
if (ackHashes != null && ackHashes.isNotEmpty) {
|
||||
_ackHistory.add(_AckHistoryEntry(
|
||||
messageId: messageId,
|
||||
ackHashes: ackHashes,
|
||||
timestamp: DateTime.now(),
|
||||
));
|
||||
_ackHistory.add(
|
||||
_AckHistoryEntry(
|
||||
messageId: messageId,
|
||||
ackHashes: ackHashes,
|
||||
timestamp: DateTime.now(),
|
||||
),
|
||||
);
|
||||
|
||||
// Trim history to max size (rolling buffer)
|
||||
while (_ackHistory.length > maxAckHistorySize) {
|
||||
_ackHistory.removeAt(0);
|
||||
}
|
||||
|
||||
debugPrint('Moved ${ackHashes.length} ACK hashes to history for message $messageId (history size: ${_ackHistory.length})');
|
||||
debugPrint(
|
||||
'Moved ${ackHashes.length} ACK hashes to history for message $messageId (history size: ${_ackHistory.length})',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -464,7 +521,9 @@ class MessageRetryService extends ChangeNotifier {
|
||||
for (final entry in _ackHistory) {
|
||||
for (final expectedHash in entry.ackHashes) {
|
||||
if (listEquals(expectedHash, ackHash)) {
|
||||
debugPrint('Found ACK match in history: messageId=${entry.messageId}, age=${DateTime.now().difference(entry.timestamp).inSeconds}s');
|
||||
debugPrint(
|
||||
'Found ACK match in history: messageId=${entry.messageId}, age=${DateTime.now().difference(entry.timestamp).inSeconds}s',
|
||||
);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -474,7 +533,9 @@ class MessageRetryService extends ChangeNotifier {
|
||||
|
||||
void handleAckReceived(Uint8List ackHash, int tripTimeMs) {
|
||||
String? matchedMessageId;
|
||||
final ackHashHex = ackHash.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
||||
final ackHashHex = ackHash
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join();
|
||||
|
||||
debugPrint('ACK received: $ackHashHex, trip time: ${tripTimeMs}ms');
|
||||
|
||||
@@ -504,7 +565,9 @@ class MessageRetryService extends ChangeNotifier {
|
||||
tag: 'AckHash',
|
||||
);
|
||||
// Fallback: Check against ALL expected ACK hashes (from all retry attempts)
|
||||
debugPrint('ACK not in mapping, checking _expectedAckHashes (${_expectedAckHashes.length} messages)');
|
||||
debugPrint(
|
||||
'ACK not in mapping, checking _expectedAckHashes (${_expectedAckHashes.length} messages)',
|
||||
);
|
||||
for (var entry in _expectedAckHashes.entries) {
|
||||
final messageId = entry.key;
|
||||
final expectedHashes = entry.value;
|
||||
@@ -512,7 +575,9 @@ class MessageRetryService extends ChangeNotifier {
|
||||
for (final expectedHash in expectedHashes) {
|
||||
if (listEquals(expectedHash, ackHash)) {
|
||||
matchedMessageId = messageId;
|
||||
debugPrint('Matched ACK to message via fallback: $matchedMessageId (attempt ${expectedHashes.indexOf(expectedHash)})');
|
||||
debugPrint(
|
||||
'Matched ACK to message via fallback: $matchedMessageId (attempt ${expectedHashes.indexOf(expectedHash)})',
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -526,7 +591,9 @@ class MessageRetryService extends ChangeNotifier {
|
||||
final contact = _pendingContacts[matchedMessageId];
|
||||
final selection = _pendingPathSelections[matchedMessageId];
|
||||
|
||||
final shortText = message.text.length > 20 ? '${message.text.substring(0, 20)}...' : message.text;
|
||||
final shortText = message.text.length > 20
|
||||
? '${message.text.substring(0, 20)}...'
|
||||
: message.text;
|
||||
_debugLogService?.info(
|
||||
'PUSH_CODE_SEND_CONFIRMED: ACK hash $ackHashHex ✓ "$shortText" delivered to ${contact?.name ?? "unknown"} in ${tripTimeMs}ms',
|
||||
tag: 'AckHash',
|
||||
@@ -551,8 +618,11 @@ class MessageRetryService extends ChangeNotifier {
|
||||
|
||||
// Clean up the queue entry for this contact (remove any remaining references to this message)
|
||||
if (contact != null) {
|
||||
_pendingMessageQueuePerContact[contact.publicKeyHex]?.remove(matchedMessageId);
|
||||
if (_pendingMessageQueuePerContact[contact.publicKeyHex]?.isEmpty ?? false) {
|
||||
_pendingMessageQueuePerContact[contact.publicKeyHex]?.remove(
|
||||
matchedMessageId,
|
||||
);
|
||||
if (_pendingMessageQueuePerContact[contact.publicKeyHex]?.isEmpty ??
|
||||
false) {
|
||||
_pendingMessageQueuePerContact.remove(contact.publicKeyHex);
|
||||
}
|
||||
}
|
||||
@@ -562,7 +632,13 @@ class MessageRetryService extends ChangeNotifier {
|
||||
}
|
||||
|
||||
if (contact != null) {
|
||||
_recordPathResultFromMessage(contact.publicKeyHex, message, selection, true, tripTimeMs);
|
||||
_recordPathResultFromMessage(
|
||||
contact.publicKeyHex,
|
||||
message,
|
||||
selection,
|
||||
true,
|
||||
tripTimeMs,
|
||||
);
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
@@ -665,7 +741,12 @@ class MessageRetryService extends ChangeNotifier {
|
||||
if (_recordPathResultCallback == null) return;
|
||||
final recordSelection = selection ?? _selectionFromMessage(message);
|
||||
if (recordSelection == null) return;
|
||||
_recordPathResultCallback!(contactKey, recordSelection, success, tripTimeMs);
|
||||
_recordPathResultCallback!(
|
||||
contactKey,
|
||||
recordSelection,
|
||||
success,
|
||||
tripTimeMs,
|
||||
);
|
||||
}
|
||||
|
||||
PathSelection? _selectionFromMessage(Message message) {
|
||||
|
||||
@@ -1,32 +1,73 @@
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../l10n/app_localizations.dart';
|
||||
|
||||
class NotificationService {
|
||||
static final NotificationService _instance = NotificationService._internal();
|
||||
factory NotificationService() => _instance;
|
||||
NotificationService._internal();
|
||||
|
||||
final FlutterLocalNotificationsPlugin _notifications = FlutterLocalNotificationsPlugin();
|
||||
final FlutterLocalNotificationsPlugin _notifications =
|
||||
FlutterLocalNotificationsPlugin();
|
||||
bool _isInitialized = false;
|
||||
|
||||
// Locale for localized notification strings
|
||||
Locale _locale = const Locale('en');
|
||||
|
||||
/// Set the locale for notification strings (call when app locale changes)
|
||||
void setLocale(Locale locale) {
|
||||
_locale = locale;
|
||||
}
|
||||
|
||||
AppLocalizations get _l10n => lookupAppLocalizations(_locale);
|
||||
|
||||
// Rate limiting to prevent notification storms
|
||||
// (Added after getting notification-flooded while evaluating RF flood management. The irony.)
|
||||
static const _minNotificationInterval = Duration(seconds: 3);
|
||||
static const _batchWindow = Duration(seconds: 5);
|
||||
|
||||
DateTime? _lastNotificationTime;
|
||||
final List<_PendingNotification> _pendingNotifications = [];
|
||||
bool _isBatchingActive = false;
|
||||
bool _suppressNotifications = false;
|
||||
|
||||
/// Temporarily suppress all notifications (e.g., during sync)
|
||||
void suppressNotifications(bool suppress) {
|
||||
_suppressNotifications = suppress;
|
||||
if (suppress) {
|
||||
_pendingNotifications.clear();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> initialize() async {
|
||||
if (_isInitialized) return;
|
||||
|
||||
const androidSettings = AndroidInitializationSettings('@mipmap/ic_launcher');
|
||||
const androidSettings = AndroidInitializationSettings(
|
||||
'@mipmap/ic_launcher',
|
||||
);
|
||||
const iosSettings = DarwinInitializationSettings(
|
||||
requestAlertPermission: true,
|
||||
requestBadgePermission: true,
|
||||
requestSoundPermission: true,
|
||||
);
|
||||
const macSettings = DarwinInitializationSettings(
|
||||
requestAlertPermission: true,
|
||||
requestBadgePermission: true,
|
||||
requestSoundPermission: true,
|
||||
);
|
||||
|
||||
const initSettings = InitializationSettings(
|
||||
android: androidSettings,
|
||||
iOS: iosSettings,
|
||||
macOS: macSettings,
|
||||
);
|
||||
|
||||
try {
|
||||
await _notifications.initialize(
|
||||
initSettings,
|
||||
settings: initSettings,
|
||||
onDidReceiveNotificationResponse: _onNotificationTapped,
|
||||
);
|
||||
_isInitialized = true;
|
||||
@@ -41,16 +82,20 @@ class NotificationService {
|
||||
}
|
||||
|
||||
// Request Android 13+ notification permission
|
||||
final androidPlugin = _notifications.resolvePlatformSpecificImplementation<
|
||||
AndroidFlutterLocalNotificationsPlugin>();
|
||||
final androidPlugin = _notifications
|
||||
.resolvePlatformSpecificImplementation<
|
||||
AndroidFlutterLocalNotificationsPlugin
|
||||
>();
|
||||
if (androidPlugin != null) {
|
||||
final granted = await androidPlugin.requestNotificationsPermission();
|
||||
return granted ?? false;
|
||||
}
|
||||
|
||||
// iOS permissions are requested during initialization
|
||||
final iosPlugin = _notifications.resolvePlatformSpecificImplementation<
|
||||
IOSFlutterLocalNotificationsPlugin>();
|
||||
final iosPlugin = _notifications
|
||||
.resolvePlatformSpecificImplementation<
|
||||
IOSFlutterLocalNotificationsPlugin
|
||||
>();
|
||||
if (iosPlugin != null) {
|
||||
final granted = await iosPlugin.requestPermissions(
|
||||
alert: true,
|
||||
@@ -63,7 +108,7 @@ class NotificationService {
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<void> showMessageNotification({
|
||||
Future<void> _showMessageNotificationImpl({
|
||||
required String contactName,
|
||||
required String message,
|
||||
String? contactId,
|
||||
@@ -90,21 +135,29 @@ class NotificationService {
|
||||
badgeNumber: badgeCount,
|
||||
);
|
||||
|
||||
final macDetails = DarwinNotificationDetails(
|
||||
presentAlert: true,
|
||||
presentBadge: true,
|
||||
presentSound: true,
|
||||
badgeNumber: badgeCount,
|
||||
);
|
||||
|
||||
final notificationDetails = NotificationDetails(
|
||||
android: androidDetails,
|
||||
iOS: iosDetails,
|
||||
macOS: macDetails,
|
||||
);
|
||||
|
||||
await _notifications.show(
|
||||
contactId?.hashCode ?? 0,
|
||||
'New message from $contactName',
|
||||
message.length > 100 ? '${message.substring(0, 100)}...' : message,
|
||||
notificationDetails,
|
||||
id: contactId?.hashCode ?? 0,
|
||||
title: contactName,
|
||||
body: message,
|
||||
notificationDetails: notificationDetails,
|
||||
payload: 'message:$contactId',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> showAdvertNotification({
|
||||
Future<void> _showAdvertNotificationImpl({
|
||||
required String contactName,
|
||||
required String contactType,
|
||||
String? contactId,
|
||||
@@ -128,21 +181,28 @@ class NotificationService {
|
||||
presentSound: true,
|
||||
);
|
||||
|
||||
const macDetails = DarwinNotificationDetails(
|
||||
presentAlert: true,
|
||||
presentBadge: true,
|
||||
presentSound: true,
|
||||
);
|
||||
|
||||
const notificationDetails = NotificationDetails(
|
||||
android: androidDetails,
|
||||
iOS: iosDetails,
|
||||
macOS: macDetails,
|
||||
);
|
||||
|
||||
await _notifications.show(
|
||||
contactId?.hashCode ?? DateTime.now().millisecondsSinceEpoch,
|
||||
'New $contactType discovered',
|
||||
contactName,
|
||||
notificationDetails,
|
||||
id: contactId?.hashCode ?? DateTime.now().millisecondsSinceEpoch,
|
||||
title: _l10n.notification_newTypeDiscovered(contactType),
|
||||
body: contactName,
|
||||
notificationDetails: notificationDetails,
|
||||
payload: 'advert:$contactId',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> showChannelMessageNotification({
|
||||
Future<void> _showChannelMessageNotificationImpl({
|
||||
required String channelName,
|
||||
required String message,
|
||||
int? channelIndex,
|
||||
@@ -169,29 +229,46 @@ class NotificationService {
|
||||
badgeNumber: badgeCount,
|
||||
);
|
||||
|
||||
final macDetails = DarwinNotificationDetails(
|
||||
presentAlert: true,
|
||||
presentBadge: true,
|
||||
presentSound: true,
|
||||
badgeNumber: badgeCount,
|
||||
);
|
||||
|
||||
final notificationDetails = NotificationDetails(
|
||||
android: androidDetails,
|
||||
iOS: iosDetails,
|
||||
macOS: macDetails,
|
||||
);
|
||||
|
||||
final preview = _truncateMessage(message, 30);
|
||||
final preview = message.trim();
|
||||
final body = preview.isEmpty
|
||||
? 'Received new message'
|
||||
: 'Received new message: $preview';
|
||||
? _l10n.notification_receivedNewMessage
|
||||
: preview;
|
||||
|
||||
await _notifications.show(
|
||||
channelIndex?.hashCode ?? DateTime.now().millisecondsSinceEpoch,
|
||||
channelName,
|
||||
body,
|
||||
notificationDetails,
|
||||
id: channelIndex?.hashCode ?? DateTime.now().millisecondsSinceEpoch,
|
||||
title: channelName,
|
||||
body: body,
|
||||
notificationDetails: notificationDetails,
|
||||
payload: 'channel:$channelIndex',
|
||||
);
|
||||
}
|
||||
|
||||
String _truncateMessage(String message, int maxLength) {
|
||||
final trimmed = message.trim();
|
||||
if (trimmed.length <= maxLength) return trimmed;
|
||||
return '${trimmed.substring(0, maxLength)}...';
|
||||
/// Returns a privacy-safe identifier for debug logging.
|
||||
/// - advert: shows device name (body contains contactName)
|
||||
/// - message: shows "from: sender" (avoids logging message content)
|
||||
/// - channelMessage: shows "in: channel" (avoids logging message content)
|
||||
String _getNotificationIdentifier(_PendingNotification n) {
|
||||
switch (n.type) {
|
||||
case _NotificationType.advert:
|
||||
return n.body;
|
||||
case _NotificationType.message:
|
||||
return 'from: ${n.title}';
|
||||
case _NotificationType.channelMessage:
|
||||
return 'in: ${n.title}';
|
||||
}
|
||||
}
|
||||
|
||||
void _onNotificationTapped(NotificationResponse response) {
|
||||
@@ -208,6 +285,214 @@ class NotificationService {
|
||||
}
|
||||
|
||||
Future<void> cancel(int id) async {
|
||||
await _notifications.cancel(id);
|
||||
await _notifications.cancel(id: id);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// Public notification methods (rate limiting is enforced automatically)
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
|
||||
Future<void> showMessageNotification({
|
||||
required String contactName,
|
||||
required String message,
|
||||
String? contactId,
|
||||
int? badgeCount,
|
||||
}) async {
|
||||
if (_suppressNotifications) return;
|
||||
|
||||
_queueNotification(
|
||||
_PendingNotification(
|
||||
type: _NotificationType.message,
|
||||
title: contactName,
|
||||
body: message,
|
||||
id: contactId,
|
||||
badgeCount: badgeCount,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> showAdvertNotification({
|
||||
required String contactName,
|
||||
required String contactType,
|
||||
String? contactId,
|
||||
}) async {
|
||||
if (_suppressNotifications) return;
|
||||
|
||||
_queueNotification(
|
||||
_PendingNotification(
|
||||
type: _NotificationType.advert,
|
||||
title: contactType,
|
||||
body: contactName,
|
||||
id: contactId,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> showChannelMessageNotification({
|
||||
required String channelName,
|
||||
required String message,
|
||||
int? channelIndex,
|
||||
int? badgeCount,
|
||||
}) async {
|
||||
if (_suppressNotifications) return;
|
||||
|
||||
_queueNotification(
|
||||
_PendingNotification(
|
||||
type: _NotificationType.channelMessage,
|
||||
title: channelName,
|
||||
body: message,
|
||||
id: channelIndex?.toString(),
|
||||
badgeCount: badgeCount,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _queueNotification(_PendingNotification notification) {
|
||||
final now = DateTime.now();
|
||||
|
||||
// If we recently showed a notification, start batching
|
||||
if (_lastNotificationTime != null &&
|
||||
now.difference(_lastNotificationTime!) < _minNotificationInterval) {
|
||||
_pendingNotifications.add(notification);
|
||||
debugPrint(
|
||||
'[Notification] queued: ${notification.type.name} (${_getNotificationIdentifier(notification)})',
|
||||
);
|
||||
|
||||
// Start batch timer if not already running
|
||||
if (!_isBatchingActive) {
|
||||
_isBatchingActive = true;
|
||||
Future.delayed(_batchWindow, _processBatch);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Show immediately if enough time has passed
|
||||
debugPrint(
|
||||
'[Notification] sent immediately: ${notification.type.name} (${_getNotificationIdentifier(notification)})',
|
||||
);
|
||||
_showNotificationImmediately(notification);
|
||||
_lastNotificationTime = now;
|
||||
}
|
||||
|
||||
Future<void> _processBatch() async {
|
||||
_isBatchingActive = false;
|
||||
|
||||
if (_pendingNotifications.isEmpty) return;
|
||||
|
||||
final batch = List<_PendingNotification>.from(_pendingNotifications);
|
||||
_pendingNotifications.clear();
|
||||
|
||||
if (batch.length == 1) {
|
||||
// Single notification, show normally
|
||||
_showNotificationImmediately(batch.first);
|
||||
} else {
|
||||
// Multiple notifications, show summary
|
||||
await _showBatchSummary(batch);
|
||||
}
|
||||
|
||||
_lastNotificationTime = DateTime.now();
|
||||
}
|
||||
|
||||
Future<void> _showNotificationImmediately(
|
||||
_PendingNotification notification,
|
||||
) async {
|
||||
switch (notification.type) {
|
||||
case _NotificationType.message:
|
||||
await _showMessageNotificationImpl(
|
||||
contactName: notification.title,
|
||||
message: notification.body,
|
||||
contactId: notification.id,
|
||||
badgeCount: notification.badgeCount,
|
||||
);
|
||||
break;
|
||||
case _NotificationType.advert:
|
||||
await _showAdvertNotificationImpl(
|
||||
contactName: notification.body,
|
||||
contactType: notification.title,
|
||||
contactId: notification.id,
|
||||
);
|
||||
break;
|
||||
case _NotificationType.channelMessage:
|
||||
await _showChannelMessageNotificationImpl(
|
||||
channelName: notification.title,
|
||||
message: notification.body,
|
||||
channelIndex: int.tryParse(notification.id ?? ''),
|
||||
badgeCount: notification.badgeCount,
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _showBatchSummary(List<_PendingNotification> batch) async {
|
||||
if (!_isInitialized) await initialize();
|
||||
|
||||
// Group by type
|
||||
final messages = batch
|
||||
.where((n) => n.type == _NotificationType.message)
|
||||
.toList();
|
||||
final adverts = batch
|
||||
.where((n) => n.type == _NotificationType.advert)
|
||||
.toList();
|
||||
final channelMsgs = batch
|
||||
.where((n) => n.type == _NotificationType.channelMessage)
|
||||
.toList();
|
||||
|
||||
// Build summary text using localized plurals
|
||||
final parts = <String>[];
|
||||
if (messages.isNotEmpty) {
|
||||
parts.add(_l10n.notification_messagesCount(messages.length));
|
||||
}
|
||||
if (channelMsgs.isNotEmpty) {
|
||||
parts.add(_l10n.notification_channelMessagesCount(channelMsgs.length));
|
||||
}
|
||||
if (adverts.isNotEmpty) {
|
||||
parts.add(_l10n.notification_newNodesCount(adverts.length));
|
||||
}
|
||||
|
||||
if (parts.isEmpty) return;
|
||||
|
||||
// Show first few device names in batch summary for debugging (only if adverts exist)
|
||||
final deviceInfo = adverts.isNotEmpty
|
||||
? ' (${adverts.take(5).map((n) => n.body).join(', ')}${adverts.length > 5 ? ', ...' : ''})'
|
||||
: '';
|
||||
debugPrint('[Notification] batch summary: ${parts.join(", ")}$deviceInfo');
|
||||
|
||||
const androidDetails = AndroidNotificationDetails(
|
||||
'batch_summary',
|
||||
'Activity Summary',
|
||||
channelDescription: 'Batched notification summaries',
|
||||
importance: Importance.defaultImportance,
|
||||
priority: Priority.defaultPriority,
|
||||
icon: '@mipmap/ic_launcher',
|
||||
);
|
||||
|
||||
const notificationDetails = NotificationDetails(android: androidDetails);
|
||||
|
||||
await _notifications.show(
|
||||
id: 'batch_summary'.hashCode,
|
||||
title: _l10n.notification_activityTitle,
|
||||
body: parts.join(', '),
|
||||
notificationDetails: notificationDetails,
|
||||
payload: 'batch',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper class for pending notifications
|
||||
enum _NotificationType { message, advert, channelMessage }
|
||||
|
||||
class _PendingNotification {
|
||||
final _NotificationType type;
|
||||
final String title;
|
||||
final String body;
|
||||
final String? id;
|
||||
final int? badgeCount;
|
||||
|
||||
_PendingNotification({
|
||||
required this.type,
|
||||
required this.title,
|
||||
required this.body,
|
||||
this.id,
|
||||
this.badgeCount,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -61,7 +61,10 @@ class PathHistoryService extends ChangeNotifier {
|
||||
int? tripTimeMs,
|
||||
}) {
|
||||
if (selection.useFlood) {
|
||||
final stats = _floodStats.putIfAbsent(contactPubKeyHex, () => _FloodStats());
|
||||
final stats = _floodStats.putIfAbsent(
|
||||
contactPubKeyHex,
|
||||
() => _FloodStats(),
|
||||
);
|
||||
if (success) {
|
||||
stats.successCount += 1;
|
||||
if (tripTimeMs != null) stats.lastTripTimeMs = tripTimeMs;
|
||||
@@ -88,23 +91,28 @@ class PathHistoryService extends ChangeNotifier {
|
||||
}
|
||||
|
||||
PathSelection getNextAutoPathSelection(String contactPubKeyHex) {
|
||||
final ranked = _getRankedPaths(contactPubKeyHex)
|
||||
.take(_autoRotationTopCount)
|
||||
.toList();
|
||||
final ranked = _getRankedPaths(
|
||||
contactPubKeyHex,
|
||||
).take(_autoRotationTopCount).toList();
|
||||
if (ranked.isEmpty) {
|
||||
return const PathSelection(pathBytes: [], hopCount: -1, useFlood: true);
|
||||
}
|
||||
|
||||
_trackAccess(contactPubKeyHex);
|
||||
|
||||
final selections = ranked
|
||||
.map((path) => PathSelection(
|
||||
pathBytes: path.pathBytes,
|
||||
hopCount: path.hopCount,
|
||||
useFlood: false,
|
||||
))
|
||||
.toList()
|
||||
..add(const PathSelection(pathBytes: [], hopCount: -1, useFlood: true));
|
||||
final selections =
|
||||
ranked
|
||||
.map(
|
||||
(path) => PathSelection(
|
||||
pathBytes: path.pathBytes,
|
||||
hopCount: path.hopCount,
|
||||
useFlood: false,
|
||||
),
|
||||
)
|
||||
.toList()
|
||||
..add(
|
||||
const PathSelection(pathBytes: [], hopCount: -1, useFlood: true),
|
||||
);
|
||||
|
||||
final currentIndex = _autoRotationIndex[contactPubKeyHex] ?? 0;
|
||||
final selection = selections[currentIndex % selections.length];
|
||||
@@ -241,7 +249,8 @@ class PathHistoryService extends ChangeNotifier {
|
||||
}
|
||||
|
||||
Future<ContactPathHistory?> _loadHistoryFromStorage(
|
||||
String contactPubKeyHex) async {
|
||||
String contactPubKeyHex,
|
||||
) async {
|
||||
return await _storage.loadPathHistory(contactPubKeyHex);
|
||||
}
|
||||
|
||||
@@ -308,8 +317,10 @@ class PathHistoryService extends ChangeNotifier {
|
||||
..removeWhere((p) => p.pathBytes.isEmpty);
|
||||
|
||||
ranked.sort((a, b) {
|
||||
final aRate = (a.successCount + 1) / (a.successCount + a.failureCount + 2);
|
||||
final bRate = (b.successCount + 1) / (b.successCount + b.failureCount + 2);
|
||||
final aRate =
|
||||
(a.successCount + 1) / (a.successCount + a.failureCount + 2);
|
||||
final bRate =
|
||||
(b.successCount + 1) / (b.successCount + b.failureCount + 2);
|
||||
if (aRate != bRate) return bRate.compareTo(aRate);
|
||||
if (a.successCount != b.successCount) {
|
||||
return b.successCount.compareTo(a.successCount);
|
||||
@@ -329,7 +340,10 @@ class PathHistoryService extends ChangeNotifier {
|
||||
}
|
||||
|
||||
void _updateFloodStats(String contactPubKeyHex) {
|
||||
final stats = _floodStats.putIfAbsent(contactPubKeyHex, () => _FloodStats());
|
||||
final stats = _floodStats.putIfAbsent(
|
||||
contactPubKeyHex,
|
||||
() => _FloodStats(),
|
||||
);
|
||||
stats.lastUsed = DateTime.now();
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user