mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-06-21 09:55:27 +10:00
Compare commits
116 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8b0bdd9a46 | |||
| 45d914de57 | |||
| 2c49534955 | |||
| c56cf9c3ed | |||
| fee4cd13be | |||
| a53d5ccfb6 | |||
| e5d06b1c7e | |||
| e95a55e4f0 | |||
| 422ca941c2 | |||
| 3098d860e9 | |||
| f0d34f7503 | |||
| daa0c3f9c3 | |||
| 09e1cd2b8d | |||
| fa514533eb | |||
| 75b8b8af70 | |||
| 115667a27c | |||
| cfb51d96ff | |||
| 75356fe20d | |||
| c43df67fac | |||
| e2b9b58d7d | |||
| d6794bc8d7 | |||
| 72216e2cf7 | |||
| 2a2275ec31 | |||
| dff037535d | |||
| 297e609b3e | |||
| 20171c491f | |||
| cc43f4d198 | |||
| 537384ea5b | |||
| a0be63b2e7 | |||
| 1cc887e5bb | |||
| 26d9029538 | |||
| 30bcbedf5e | |||
| 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 | |||
| cd9f14dd09 | |||
| ad911a1d80 | |||
| 361dfb7808 | |||
| ad187962c9 | |||
| b7eec5627f |
@@ -0,0 +1,76 @@
|
||||
name: Build
|
||||
|
||||
on:
|
||||
push:
|
||||
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,23 @@
|
||||
name: Flutter Analyze
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
|
||||
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
|
||||
run: flutter analyze --fatal-infos --fatal-warnings
|
||||
@@ -70,6 +70,7 @@ secrets.dart
|
||||
**/android/local.properties
|
||||
**/android/.externalNativeBuild/
|
||||
*.jks
|
||||
key.properties
|
||||
keystore.properties
|
||||
|
||||
# Generated files
|
||||
|
||||
@@ -6,6 +6,18 @@ 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.
|
||||
|
||||
## Screenshots
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td><img src="docs/screenshots/contacts.jpg" width="200"/><br/><p align="center"><b>Contacts</b></p></td>
|
||||
<td><img src="docs/screenshots/chat1.jpg" width="200"/><br/><p align="center"><b>Chat</b></p></td>
|
||||
<td><img src="docs/screenshots/chat2.jpg" width="200"/><br/><p align="center"><b>Reactions</b></p></td>
|
||||
<td><img src="docs/screenshots/map.jpg" width="200"/><br/><p align="center"><b>Map</b></p></td>
|
||||
<td><img src="docs/screenshots/channels.jpg" width="200"/><br/><p align="center"><b>Channels</b></p></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## Features
|
||||
|
||||
### Core Functionality
|
||||
@@ -199,4 +211,3 @@ Your support helps maintain and improve this open-source project!
|
||||
|
||||
- Built with [Flutter](https://flutter.dev/)
|
||||
- Map tiles from [OpenStreetMap](https://www.openstreetmap.org/)
|
||||
- Voice codec support via [Codec2](https://github.com/drowe67/codec2)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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: 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.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 364 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 661 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 500 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 556 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
@@ -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
-3
@@ -1,4 +1,4 @@
|
||||
platform :ios, '12.0'
|
||||
platform :ios, '15.5'
|
||||
|
||||
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
|
||||
|
||||
@@ -26,8 +26,6 @@ require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelpe
|
||||
flutter_ios_podfile_setup
|
||||
|
||||
target 'Runner' do
|
||||
pod 'codec2', :path => '../third_party/codec2'
|
||||
|
||||
flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
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
|
||||
- path_provider_foundation (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- 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`)
|
||||
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
|
||||
- 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"
|
||||
path_provider_foundation:
|
||||
:path: ".symlinks/plugins/path_provider_foundation/darwin"
|
||||
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
|
||||
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
|
||||
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,105 @@
|
||||
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;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// Command codes (to device)
|
||||
const int cmdAppStart = 1;
|
||||
const int cmdSendTxtMsg = 2;
|
||||
@@ -29,6 +128,10 @@ const int cmdGetContactByKey = 30;
|
||||
const int cmdGetChannel = 31;
|
||||
const int cmdSetChannel = 32;
|
||||
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;
|
||||
@@ -62,6 +165,7 @@ 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;
|
||||
@@ -73,6 +177,8 @@ const int pushCodeLoginFail = 0x86;
|
||||
const int pushCodeStatusResponse = 0x87;
|
||||
const int pushCodeLogRxData = 0x88;
|
||||
const int pushCodeNewAdvert = 0x8A;
|
||||
const int pushCodeTelemetryResponse = 0x8B;
|
||||
const int pushCodeBinaryResponse = 0x8C;
|
||||
|
||||
// Contact/advertisement types
|
||||
const int advTypeChat = 1;
|
||||
@@ -89,8 +195,8 @@ 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 +246,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 +275,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 +313,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 +343,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 +379,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 +420,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 +446,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 +462,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,21 +508,17 @@ 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
|
||||
@@ -437,13 +528,13 @@ Uint8List buildSetChannelFrame(int channelIndex, String name, Uint8List psk) {
|
||||
// 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;
|
||||
final writer = BufferWriter();
|
||||
writer.writeByte(cmdSetRadioParams);
|
||||
writer.writeUInt32LE(freqHz);
|
||||
writer.writeUInt32LE(bwHz);
|
||||
writer.writeByte(sf);
|
||||
writer.writeByte(cr);
|
||||
return writer.toBytes();
|
||||
}
|
||||
|
||||
// Build CMD_SET_RADIO_TX_POWER frame
|
||||
@@ -455,10 +546,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 +562,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 +605,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 +633,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;
|
||||
@@ -592,23 +682,29 @@ Uint8List buildSendCliCommandFrame(
|
||||
Uint8List repeaterPubKey,
|
||||
String command, {
|
||||
int attempt = 0,
|
||||
int? timestampSeconds,
|
||||
}) {
|
||||
final textBytes = utf8.encode(command);
|
||||
final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||
const prefixSize = 6;
|
||||
final frame = Uint8List(1 + 1 + 1 + 4 + prefixSize + textBytes.length + 1);
|
||||
int offset = 0;
|
||||
|
||||
frame[offset++] = cmdSendTxtMsg;
|
||||
frame[offset++] = txtTypeCliData;
|
||||
frame[offset++] = attempt & 0xFF;
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
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,76 @@
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+1537
File diff suppressed because it is too large
Load Diff
+1537
File diff suppressed because it is too large
Load Diff
+1312
File diff suppressed because it is too large
Load Diff
+1537
File diff suppressed because it is too large
Load Diff
+1537
File diff suppressed because it is too large
Load Diff
+1537
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
+1537
File diff suppressed because it is too large
Load Diff
+1537
File diff suppressed because it is too large
Load Diff
+1537
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,778 @@
|
||||
{
|
||||
"@@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_preset915Mhz": "915 МГц",
|
||||
"settings_preset868Mhz": "868 МГц",
|
||||
"settings_preset433Mhz": "433 МГц",
|
||||
"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_longRange": "Дальний радиус",
|
||||
"settings_fastSpeed": "Высокая скорость",
|
||||
"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": "Неправильный формат ссылки"
|
||||
}
|
||||
+1537
File diff suppressed because it is too large
Load Diff
+1537
File diff suppressed because it is too large
Load Diff
+1537
File diff suppressed because it is too large
Load Diff
+1538
File diff suppressed because it is too large
Load Diff
+1537
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);
|
||||
}
|
||||
+30
-1
@@ -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';
|
||||
@@ -9,9 +11,11 @@ import 'services/path_history_service.dart';
|
||||
import 'services/app_settings_service.dart';
|
||||
import 'services/notification_service.dart';
|
||||
import 'services/ble_debug_log_service.dart';
|
||||
import 'services/app_debug_log_service.dart';
|
||||
import 'services/background_service.dart';
|
||||
import 'services/map_tile_cache_service.dart';
|
||||
import 'storage/prefs_manager.dart';
|
||||
import 'utils/app_logger.dart';
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
@@ -23,15 +27,22 @@ 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();
|
||||
final backgroundService = BackgroundService();
|
||||
final mapTileCacheService = MapTileCacheService();
|
||||
|
||||
// Load settings
|
||||
await appSettingsService.loadSettings();
|
||||
|
||||
// Initialize app logger
|
||||
appLogger.initialize(
|
||||
appDebugLogService,
|
||||
enabled: appSettingsService.settings.appDebugLogEnabled,
|
||||
);
|
||||
|
||||
// Initialize notification service
|
||||
final notificationService = NotificationService();
|
||||
await notificationService.initialize();
|
||||
@@ -43,6 +54,7 @@ void main() async {
|
||||
pathHistoryService: pathHistoryService,
|
||||
appSettingsService: appSettingsService,
|
||||
bleDebugLogService: bleDebugLogService,
|
||||
appDebugLogService: appDebugLogService,
|
||||
backgroundService: backgroundService,
|
||||
);
|
||||
|
||||
@@ -60,6 +72,7 @@ void main() async {
|
||||
storage: storage,
|
||||
appSettingsService: appSettingsService,
|
||||
bleDebugLogService: bleDebugLogService,
|
||||
appDebugLogService: appDebugLogService,
|
||||
mapTileCacheService: mapTileCacheService,
|
||||
));
|
||||
}
|
||||
@@ -71,6 +84,7 @@ class MeshCoreApp extends StatelessWidget {
|
||||
final StorageService storage;
|
||||
final AppSettingsService appSettingsService;
|
||||
final BleDebugLogService bleDebugLogService;
|
||||
final AppDebugLogService appDebugLogService;
|
||||
final MapTileCacheService mapTileCacheService;
|
||||
|
||||
const MeshCoreApp({
|
||||
@@ -81,6 +95,7 @@ class MeshCoreApp extends StatelessWidget {
|
||||
required this.storage,
|
||||
required this.appSettingsService,
|
||||
required this.bleDebugLogService,
|
||||
required this.appDebugLogService,
|
||||
required this.mapTileCacheService,
|
||||
});
|
||||
|
||||
@@ -93,6 +108,7 @@ class MeshCoreApp extends StatelessWidget {
|
||||
ChangeNotifierProvider.value(value: pathHistoryService),
|
||||
ChangeNotifierProvider.value(value: appSettingsService),
|
||||
ChangeNotifierProvider.value(value: bleDebugLogService),
|
||||
ChangeNotifierProvider.value(value: appDebugLogService),
|
||||
Provider.value(value: storage),
|
||||
Provider.value(value: mapTileCacheService),
|
||||
],
|
||||
@@ -101,6 +117,14 @@ 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,
|
||||
@@ -130,4 +154,9 @@ class MeshCoreApp extends StatelessWidget {
|
||||
return ThemeMode.system;
|
||||
}
|
||||
}
|
||||
|
||||
Locale? _localeFromSetting(String? languageCode) {
|
||||
if (languageCode == null) return null;
|
||||
return Locale(languageCode);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ 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;
|
||||
|
||||
AppSettings({
|
||||
@@ -38,6 +40,8 @@ class AppSettings {
|
||||
this.notifyOnNewAdvert = true,
|
||||
this.autoRouteRotationEnabled = false,
|
||||
this.themeMode = 'system',
|
||||
this.languageOverride,
|
||||
this.appDebugLogEnabled = false,
|
||||
Map<String, String>? batteryChemistryByDeviceId,
|
||||
}) : batteryChemistryByDeviceId = batteryChemistryByDeviceId ?? {};
|
||||
|
||||
@@ -60,6 +64,8 @@ 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,
|
||||
};
|
||||
}
|
||||
@@ -86,6 +92,8 @@ class AppSettings {
|
||||
notifyOnNewAdvert: json['notify_on_new_advert'] as bool? ?? true,
|
||||
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(
|
||||
(key, value) => MapEntry(key.toString(), value.toString()),
|
||||
) ??
|
||||
@@ -111,6 +119,8 @@ class AppSettings {
|
||||
bool? notifyOnNewAdvert,
|
||||
bool? autoRouteRotationEnabled,
|
||||
String? themeMode,
|
||||
Object? languageOverride = _unset,
|
||||
bool? appDebugLogEnabled,
|
||||
Map<String, String>? batteryChemistryByDeviceId,
|
||||
}) {
|
||||
return AppSettings(
|
||||
@@ -133,6 +143,9 @@ class AppSettings {
|
||||
notifyOnNewAdvert: notifyOnNewAdvert ?? this.notifyOnNewAdvert,
|
||||
autoRouteRotationEnabled: autoRouteRotationEnabled ?? this.autoRouteRotationEnabled,
|
||||
themeMode: themeMode ?? this.themeMode,
|
||||
languageOverride:
|
||||
languageOverride == _unset ? this.languageOverride : languageOverride as String?,
|
||||
appDebugLogEnabled: appDebugLogEnabled ?? this.appDebugLogEnabled,
|
||||
batteryChemistryByDeviceId: batteryChemistryByDeviceId ?? this.batteryChemistryByDeviceId,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:crypto/crypto.dart' as crypto;
|
||||
|
||||
import '../connector/meshcore_protocol.dart';
|
||||
|
||||
class Channel {
|
||||
@@ -61,6 +64,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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
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;
|
||||
}
|
||||
+18
-4
@@ -46,6 +46,11 @@ class Contact {
|
||||
}
|
||||
|
||||
String get pathLabel {
|
||||
if (pathOverride != null) {
|
||||
if (pathOverride! < 0) return 'Flood (forced)';
|
||||
if (pathOverride == 0) return 'Direct (forced)';
|
||||
return '$pathOverride hops (forced)';
|
||||
}
|
||||
if (pathLength < 0) return 'Flood';
|
||||
if (pathLength == 0) return 'Direct';
|
||||
return '$pathLength hops';
|
||||
@@ -83,12 +88,13 @@ class Contact {
|
||||
}
|
||||
|
||||
String get pathIdList {
|
||||
if (path.isEmpty) return '';
|
||||
final pathBytes = _pathBytesForDisplay;
|
||||
if (pathBytes.isEmpty) return '';
|
||||
final parts = <String>[];
|
||||
final groupSize = pathHashSize;
|
||||
for (int i = 0; i < path.length; i += groupSize) {
|
||||
final end = (i + groupSize) <= path.length ? (i + groupSize) : path.length;
|
||||
final chunk = path.sublist(i, end);
|
||||
for (int i = 0; i < pathBytes.length; i += groupSize) {
|
||||
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(),
|
||||
);
|
||||
@@ -96,6 +102,14 @@ class Contact {
|
||||
return parts.join(',');
|
||||
}
|
||||
|
||||
Uint8List get _pathBytesForDisplay {
|
||||
if (pathOverride != null) {
|
||||
if (pathOverride! < 0) return Uint8List(0);
|
||||
return pathOverrideBytes ?? Uint8List(0);
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
static Contact? fromFrame(Uint8List data) {
|
||||
if (data.length < contactFrameSize) return null;
|
||||
if (data[0] != respCodeContact) return null;
|
||||
|
||||
@@ -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,8 +41,10 @@ class Message {
|
||||
this.tripTimeMs,
|
||||
this.pathLength,
|
||||
Uint8List? pathBytes,
|
||||
Uint8List? fourByteRoomContactKey,
|
||||
Map<String, int>? 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,7 @@ class Message {
|
||||
pathLength: pathLength ?? this.pathLength,
|
||||
pathBytes: pathBytes ?? this.pathBytes,
|
||||
reactions: reactions ?? this.reactions,
|
||||
fourByteRoomContactKey: fourByteRoomContactKey ?? this.fourByteRoomContactKey,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
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 {
|
||||
const AppDebugLogScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Consumer<AppDebugLogService>(
|
||||
builder: (context, logService, _) {
|
||||
final entries = logService.entries.reversed.toList();
|
||||
final hasEntries = entries.isNotEmpty;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(context.l10n.debugLog_appTitle),
|
||||
centerTitle: true,
|
||||
actions: [
|
||||
IconButton(
|
||||
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}')
|
||||
.join('\n');
|
||||
await Clipboard.setData(ClipboardData(text: text));
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.debugLog_copied)),
|
||||
);
|
||||
}
|
||||
: null,
|
||||
),
|
||||
IconButton(
|
||||
tooltip: context.l10n.debugLog_clearLog,
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
onPressed: hasEntries
|
||||
? () {
|
||||
logService.clear();
|
||||
}
|
||||
: null,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: SafeArea(
|
||||
top: false,
|
||||
child: hasEntries
|
||||
? ListView.separated(
|
||||
itemCount: entries.length,
|
||||
separatorBuilder: (_, __) => const Divider(height: 1),
|
||||
itemBuilder: (context, index) {
|
||||
final entry = entries[index];
|
||||
return ListTile(
|
||||
dense: true,
|
||||
leading: _buildLevelIcon(entry.level),
|
||||
title: Text(
|
||||
'[${entry.tag}] ${entry.message}',
|
||||
style: const TextStyle(fontSize: 12, fontFamily: 'monospace'),
|
||||
),
|
||||
subtitle: Text(
|
||||
entry.formattedTime,
|
||||
style: TextStyle(fontSize: 10, color: Colors.grey[600]),
|
||||
),
|
||||
);
|
||||
},
|
||||
)
|
||||
: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.bug_report_outlined, size: 64, color: Colors.grey[400]),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
context.l10n.debugLog_noEntries,
|
||||
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
context.l10n.debugLog_enableInSettings,
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey[500]),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLevelIcon(AppDebugLogLevel level) {
|
||||
switch (level) {
|
||||
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);
|
||||
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(
|
||||
@@ -32,6 +33,8 @@ class AppSettingsScreen extends StatelessWidget {
|
||||
_buildBatteryCard(context, settingsService, connector),
|
||||
const SizedBox(height: 16),
|
||||
_buildMapSettingsCard(context, settingsService),
|
||||
const SizedBox(height: 16),
|
||||
_buildDebugCard(context, settingsService),
|
||||
],
|
||||
);
|
||||
},
|
||||
@@ -45,20 +48,28 @@ class AppSettingsScreen extends StatelessWidget {
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -69,17 +80,17 @@ class AppSettingsScreen extends StatelessWidget {
|
||||
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) {
|
||||
@@ -88,9 +99,9 @@ class AppSettingsScreen extends StatelessWidget {
|
||||
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),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -103,8 +114,8 @@ class AppSettingsScreen extends StatelessWidget {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(value
|
||||
? 'Notifications enabled'
|
||||
: 'Notifications disabled'),
|
||||
? context.l10n.appSettings_notificationsEnabled
|
||||
: context.l10n.appSettings_notificationsDisabled),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
@@ -118,13 +129,13 @@ class AppSettingsScreen extends StatelessWidget {
|
||||
color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
|
||||
),
|
||||
title: Text(
|
||||
'Message Notifications',
|
||||
context.l10n.appSettings_messageNotifications,
|
||||
style: TextStyle(
|
||||
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,
|
||||
),
|
||||
@@ -143,13 +154,13 @@ class AppSettingsScreen extends StatelessWidget {
|
||||
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,
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
'Show notification when receiving channel messages',
|
||||
context.l10n.appSettings_channelMessageNotificationsSubtitle,
|
||||
style: TextStyle(
|
||||
color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
|
||||
),
|
||||
@@ -168,13 +179,13 @@ class AppSettingsScreen extends StatelessWidget {
|
||||
color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
|
||||
),
|
||||
title: Text(
|
||||
'Advertisement Notifications',
|
||||
context.l10n.appSettings_advertisementNotifications,
|
||||
style: TextStyle(
|
||||
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,
|
||||
),
|
||||
@@ -196,25 +207,25 @@ class AppSettingsScreen extends StatelessWidget {
|
||||
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'),
|
||||
? context.l10n.appSettings_pathsWillBeCleared
|
||||
: context.l10n.appSettings_pathsWillNotBeCleared),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
@@ -223,16 +234,16 @@ 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'),
|
||||
? context.l10n.appSettings_autoRouteRotationEnabled
|
||||
: context.l10n.appSettings_autoRouteRotationDisabled),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
@@ -248,17 +259,17 @@ class AppSettingsScreen extends StatelessWidget {
|
||||
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);
|
||||
@@ -267,8 +278,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);
|
||||
@@ -277,8 +288,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);
|
||||
@@ -287,11 +298,11 @@ 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),
|
||||
@@ -299,12 +310,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: () {
|
||||
@@ -333,20 +346,20 @@ class AppSettingsScreen extends StatelessWidget {
|
||||
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(
|
||||
'Battery',
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
context.l10n.appSettings_battery,
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
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,
|
||||
@@ -357,18 +370,18 @@ class AppSettingsScreen extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
: 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),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -382,147 +395,262 @@ class AppSettingsScreen extends StatelessWidget {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Theme'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
RadioListTile<String>(
|
||||
title: const Text('System default'),
|
||||
value: 'system',
|
||||
groupValue: settingsService.settings.themeMode,
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
settingsService.setThemeMode(value);
|
||||
Navigator.pop(context);
|
||||
}
|
||||
},
|
||||
),
|
||||
RadioListTile<String>(
|
||||
title: const Text('Light'),
|
||||
value: 'light',
|
||||
groupValue: settingsService.settings.themeMode,
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
settingsService.setThemeMode(value);
|
||||
Navigator.pop(context);
|
||||
}
|
||||
},
|
||||
),
|
||||
RadioListTile<String>(
|
||||
title: const Text('Dark'),
|
||||
value: 'dark',
|
||||
groupValue: settingsService.settings.themeMode,
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
settingsService.setThemeMode(value);
|
||||
Navigator.pop(context);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
title: Text(context.l10n.appSettings_theme),
|
||||
content: RadioGroup<String>(
|
||||
groupValue: settingsService.settings.themeMode,
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
settingsService.setThemeMode(value);
|
||||
Navigator.pop(context);
|
||||
}
|
||||
},
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
RadioListTile<String>(
|
||||
title: Text(context.l10n.appSettings_themeSystem),
|
||||
value: 'system',
|
||||
),
|
||||
RadioListTile<String>(
|
||||
title: Text(context.l10n.appSettings_themeLight),
|
||||
value: 'light',
|
||||
),
|
||||
RadioListTile<String>(
|
||||
title: Text(context.l10n.appSettings_themeDark),
|
||||
value: 'dark',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
default:
|
||||
return context.l10n.appSettings_languageSystem;
|
||||
}
|
||||
}
|
||||
|
||||
void _showLanguageDialog(BuildContext context, AppSettingsService settingsService) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
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',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
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: const Text('Map Time Filter'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text('Show nodes discovered within:'),
|
||||
const SizedBox(height: 16),
|
||||
ListTile(
|
||||
title: const Text('All time'),
|
||||
leading: Radio<double>(
|
||||
value: 0,
|
||||
groupValue: settingsService.settings.mapTimeFilterHours,
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
settingsService.setMapTimeFilterHours(value);
|
||||
Navigator.pop(context);
|
||||
}
|
||||
},
|
||||
title: Text(context.l10n.appSettings_mapTimeFilter),
|
||||
content: RadioGroup<double>(
|
||||
groupValue: settingsService.settings.mapTimeFilterHours,
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
settingsService.setMapTimeFilterHours(value);
|
||||
Navigator.pop(context);
|
||||
}
|
||||
},
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(context.l10n.appSettings_showNodesDiscoveredWithin),
|
||||
const SizedBox(height: 16),
|
||||
ListTile(
|
||||
title: Text(context.l10n.appSettings_allTime),
|
||||
leading: Radio<double>(
|
||||
value: 0,
|
||||
),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
title: const Text('Last hour'),
|
||||
leading: Radio<double>(
|
||||
value: 1,
|
||||
groupValue: settingsService.settings.mapTimeFilterHours,
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
settingsService.setMapTimeFilterHours(value);
|
||||
Navigator.pop(context);
|
||||
}
|
||||
},
|
||||
ListTile(
|
||||
title: Text(context.l10n.appSettings_lastHour),
|
||||
leading: Radio<double>(
|
||||
value: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
title: const Text('Last 6 hours'),
|
||||
leading: Radio<double>(
|
||||
value: 6,
|
||||
groupValue: settingsService.settings.mapTimeFilterHours,
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
settingsService.setMapTimeFilterHours(value);
|
||||
Navigator.pop(context);
|
||||
}
|
||||
},
|
||||
ListTile(
|
||||
title: Text(context.l10n.appSettings_last6Hours),
|
||||
leading: Radio<double>(
|
||||
value: 6,
|
||||
),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
title: const Text('Last 24 hours'),
|
||||
leading: Radio<double>(
|
||||
value: 24,
|
||||
groupValue: settingsService.settings.mapTimeFilterHours,
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
settingsService.setMapTimeFilterHours(value);
|
||||
Navigator.pop(context);
|
||||
}
|
||||
},
|
||||
ListTile(
|
||||
title: Text(context.l10n.appSettings_last24Hours),
|
||||
leading: Radio<double>(
|
||||
value: 24,
|
||||
),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
title: const Text('Last week'),
|
||||
leading: Radio<double>(
|
||||
value: 168,
|
||||
groupValue: settingsService.settings.mapTimeFilterHours,
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
settingsService.setMapTimeFilterHours(value);
|
||||
Navigator.pop(context);
|
||||
}
|
||||
},
|
||||
ListTile(
|
||||
title: Text(context.l10n.appSettings_lastWeek),
|
||||
leading: Radio<double>(
|
||||
value: 168,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Close'),
|
||||
child: Text(context.l10n.common_close),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDebugCard(BuildContext context, AppSettingsService settingsService) {
|
||||
return Card(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
child: Text(
|
||||
context.l10n.appSettings_debugCard,
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
SwitchListTile(
|
||||
secondary: const Icon(Icons.bug_report_outlined),
|
||||
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
|
||||
? 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';
|
||||
|
||||
@@ -26,10 +27,10 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
|
||||
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 {
|
||||
@@ -43,13 +44,13 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
|
||||
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 +67,9 @@ 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) {
|
||||
@@ -113,8 +114,8 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
|
||||
);
|
||||
},
|
||||
)
|
||||
: const Center(
|
||||
child: Text('No BLE activity yet'),
|
||||
: Center(
|
||||
child: Text(context.l10n.debugLog_noBleActivity),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -136,7 +137,7 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Close'),
|
||||
child: Text(context.l10n.common_close),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
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/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';
|
||||
@@ -31,42 +37,51 @@ 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;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_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
|
||||
if (_scrollController.hasClients) {
|
||||
_scrollController.jumpTo(_scrollController.position.maxScrollExtent);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
_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;
|
||||
@@ -83,9 +98,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;
|
||||
@@ -119,7 +134,7 @@ 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),
|
||||
),
|
||||
@@ -127,9 +142,9 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
builder: (context, connector, _) {
|
||||
final unreadCount =
|
||||
connector.getUnreadCountForChannelIndex(widget.channel.index);
|
||||
final privacy = widget.channel.isPublicChannel ? 'Public' : 'Private';
|
||||
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),
|
||||
);
|
||||
@@ -151,10 +166,6 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
builder: (context, connector, child) {
|
||||
final messages = connector.getChannelMessages(widget.channel);
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_scrollToBottom();
|
||||
});
|
||||
|
||||
if (messages.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
@@ -169,7 +180,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],
|
||||
@@ -177,7 +188,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],
|
||||
@@ -188,20 +199,51 @@ 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,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -239,7 +281,9 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
onTap: () => _showMessagePathInfo(message),
|
||||
onLongPress: () => _showMessageActions(message),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
padding: gifId != null
|
||||
? const EdgeInsets.all(4)
|
||||
: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: MediaQuery.of(context).size.width * 0.65,
|
||||
),
|
||||
@@ -253,15 +297,20 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (!isOutgoing) ...[
|
||||
Text(
|
||||
message.senderName,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
if (gifId == null) const SizedBox(height: 4),
|
||||
],
|
||||
if (message.replyToMessageId != null) ...[
|
||||
_buildReplyPreview(message),
|
||||
@@ -270,60 +319,84 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
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),
|
||||
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
|
||||
Text(
|
||||
message.text,
|
||||
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),
|
||||
Text(
|
||||
'via ${_formatPathPrefixes(displayPath)}',
|
||||
style: TextStyle(fontSize: 11, color: Colors.grey[600]),
|
||||
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),
|
||||
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),
|
||||
Padding(
|
||||
padding: gifId != null
|
||||
? const EdgeInsets.only(left: 8, right: 8, bottom: 4)
|
||||
: EdgeInsets.zero,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'${message.repeatCount}',
|
||||
style: TextStyle(fontSize: 11, color: Colors.grey[600]),
|
||||
_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 (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],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -362,8 +435,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) {
|
||||
@@ -371,7 +443,7 @@ 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 {
|
||||
@@ -405,7 +477,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Reply to ${message.replyToSenderName}',
|
||||
context.l10n.chat_replyTo(message.replyToSenderName ?? ''),
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.bold,
|
||||
@@ -514,7 +586,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'POI Shared',
|
||||
context.l10n.chat_poiShared,
|
||||
style: TextStyle(
|
||||
color: textColor,
|
||||
fontWeight: FontWeight.w600,
|
||||
@@ -622,7 +694,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,
|
||||
@@ -677,7 +749,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
IconButton(
|
||||
icon: const Icon(Icons.gif_box),
|
||||
onPressed: () => _showGifPicker(context),
|
||||
tooltip: 'Send GIF',
|
||||
tooltip: context.l10n.chat_sendGif,
|
||||
),
|
||||
Expanded(
|
||||
child: ValueListenableBuilder<TextEditingValue>(
|
||||
@@ -688,14 +760,16 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
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,
|
||||
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),
|
||||
@@ -709,11 +783,13 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
|
||||
return TextField(
|
||||
controller: _textController,
|
||||
focusNode: _textFieldFocusNode,
|
||||
inputFormatters: [
|
||||
Utf8LengthLimitingTextInputFormatter(maxBytes),
|
||||
],
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Type a message...',
|
||||
hintText: context.l10n.chat_typeMessage,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
@@ -756,7 +832,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;
|
||||
}
|
||||
@@ -795,7 +871,7 @@ 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);
|
||||
@@ -803,7 +879,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.add_reaction_outlined),
|
||||
title: const Text('Add Reaction'),
|
||||
title: Text(context.l10n.chat_addReaction),
|
||||
onTap: () {
|
||||
Navigator.pop(sheetContext);
|
||||
_showEmojiPicker(message);
|
||||
@@ -811,7 +887,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.copy),
|
||||
title: const Text('Copy'),
|
||||
title: Text(context.l10n.common_copy),
|
||||
onTap: () {
|
||||
Navigator.pop(sheetContext);
|
||||
_copyMessageText(message.text);
|
||||
@@ -819,7 +895,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);
|
||||
@@ -827,7 +903,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),
|
||||
),
|
||||
],
|
||||
@@ -859,7 +935,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
void _copyMessageText(String text) {
|
||||
Clipboard.setData(ClipboardData(text: text));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Message copied')),
|
||||
SnackBar(content: Text(context.l10n.chat_messageCopied)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -867,7 +943,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
await context.read<MeshCoreConnector>().deleteChannelMessage(message);
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Message deleted')),
|
||||
SnackBar(content: Text(context.l10n.chat_messageDeleted)),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ 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';
|
||||
|
||||
@@ -24,22 +26,24 @@ class ChannelMessagePathScreen extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return Consumer<MeshCoreConnector>(
|
||||
builder: (context, connector, _) {
|
||||
final l10n = context.l10n;
|
||||
final primaryPath = _selectPrimaryPath(message.pathBytes, message.pathVariants);
|
||||
final hops = _buildPathHops(primaryPath, connector.contacts);
|
||||
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.map_outlined),
|
||||
tooltip: 'View map',
|
||||
tooltip: l10n.channelPath_viewMap,
|
||||
onPressed: hasHopDetails
|
||||
? () {
|
||||
_openPathMap(context);
|
||||
@@ -57,7 +61,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 +69,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),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -88,6 +92,7 @@ class ChannelMessagePathScreen extends StatelessWidget {
|
||||
BuildContext context, {
|
||||
String? observedLabel,
|
||||
}) {
|
||||
final l10n = context.l10n;
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
@@ -95,16 +100,16 @@ 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),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -115,6 +120,7 @@ class ChannelMessagePathScreen extends StatelessWidget {
|
||||
BuildContext context,
|
||||
List<Uint8List> variants,
|
||||
) {
|
||||
final l10n = context.l10n;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -124,7 +130,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 +144,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(
|
||||
@@ -154,45 +164,52 @@ class ChannelMessagePathScreen extends StatelessWidget {
|
||||
hop.hasLocation
|
||||
? '${hop.position!.latitude.toStringAsFixed(5)}, '
|
||||
'${hop.position!.longitude.toStringAsFixed(5)}'
|
||||
: 'No location data',
|
||||
: 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) {
|
||||
@@ -274,7 +291,7 @@ class _ChannelMessagePathMapScreenState extends State<ChannelMessagePathMapScree
|
||||
primaryPath,
|
||||
);
|
||||
final selectedIndex = _indexForPath(selectedPath, observedPaths);
|
||||
final hops = _buildPathHops(selectedPath, connector.contacts);
|
||||
final hops = _buildPathHops(selectedPath, connector.contacts, context.l10n);
|
||||
final points = hops
|
||||
.where((hop) => hop.hasLocation)
|
||||
.map((hop) => hop.position!)
|
||||
@@ -297,7 +314,7 @@ class _ChannelMessagePathMapScreenState extends State<ChannelMessagePathMapScree
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Path Map'),
|
||||
title: Text(context.l10n.channelPath_mapTitle),
|
||||
),
|
||||
body: SafeArea(
|
||||
top: false,
|
||||
@@ -347,9 +364,9 @@ class _ChannelMessagePathMapScreenState extends State<ChannelMessagePathMapScree
|
||||
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 +385,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 +401,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 +415,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 +428,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),
|
||||
),
|
||||
],
|
||||
@@ -457,6 +478,7 @@ class _ChannelMessagePathMapScreenState extends State<ChannelMessagePathMapScree
|
||||
}
|
||||
|
||||
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,18 +493,18 @@ 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,
|
||||
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),
|
||||
@@ -504,7 +526,7 @@ class _ChannelMessagePathMapScreenState extends State<ChannelMessagePathMapScree
|
||||
hop.hasLocation
|
||||
? '${hop.position!.latitude.toStringAsFixed(5)}, '
|
||||
'${hop.position!.longitude.toStringAsFixed(5)}'
|
||||
: 'No location data',
|
||||
: l10n.channelPath_noLocationData,
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -523,19 +545,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)}';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -549,7 +573,11 @@ class _ObservedPath {
|
||||
});
|
||||
}
|
||||
|
||||
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 +588,7 @@ List<_PathHop> _buildPathHops(Uint8List pathBytes, List<Contact> contacts) {
|
||||
prefix: prefix,
|
||||
contact: contact,
|
||||
position: _resolvePosition(contact),
|
||||
l10n: l10n,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -612,15 +641,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;
|
||||
}
|
||||
|
||||
+1395
-238
File diff suppressed because it is too large
Load Diff
+363
-543
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;
|
||||
}
|
||||
}
|
||||
+330
-260
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../connector/meshcore_connector.dart';
|
||||
import '../l10n/l10n.dart';
|
||||
import '../connector/meshcore_protocol.dart';
|
||||
import '../models/contact.dart';
|
||||
import '../models/contact_group.dart';
|
||||
@@ -13,9 +14,12 @@ import '../utils/dialog_utils.dart';
|
||||
import '../utils/disconnect_navigation_mixin.dart';
|
||||
import '../utils/emoji_utils.dart';
|
||||
import '../utils/route_transitions.dart';
|
||||
import '../widgets/battery_indicator.dart';
|
||||
import '../widgets/list_filter_widget.dart';
|
||||
import '../widgets/empty_state.dart';
|
||||
import '../widgets/quick_switch_bar.dart';
|
||||
import '../widgets/repeater_login_dialog.dart';
|
||||
import '../widgets/room_login_dialog.dart';
|
||||
import '../widgets/unread_badge.dart';
|
||||
import 'channels_screen.dart';
|
||||
import 'chat_screen.dart';
|
||||
@@ -23,29 +27,15 @@ import 'map_screen.dart';
|
||||
import 'repeater_hub_screen.dart';
|
||||
import 'settings_screen.dart';
|
||||
|
||||
enum ContactSortOption {
|
||||
lastSeen,
|
||||
recentMessages,
|
||||
name,
|
||||
type,
|
||||
}
|
||||
|
||||
enum _ContactMenuAction {
|
||||
sortRecentMessages,
|
||||
sortName,
|
||||
sortType,
|
||||
toggleLastSeenFilter,
|
||||
toggleUnreadOnly,
|
||||
newGroup,
|
||||
enum RoomLoginDestination {
|
||||
chat,
|
||||
management,
|
||||
}
|
||||
|
||||
class ContactsScreen extends StatefulWidget {
|
||||
final bool hideBackButton;
|
||||
|
||||
const ContactsScreen({
|
||||
super.key,
|
||||
this.hideBackButton = false,
|
||||
});
|
||||
const ContactsScreen({super.key, this.hideBackButton = false});
|
||||
|
||||
@override
|
||||
State<ContactsScreen> createState() => _ContactsScreenState();
|
||||
@@ -56,8 +46,8 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
String _searchQuery = '';
|
||||
ContactSortOption _sortOption = ContactSortOption.lastSeen;
|
||||
bool _forceLastSeenSort = true;
|
||||
bool _showUnreadOnly = false;
|
||||
ContactTypeFilter _typeFilter = ContactTypeFilter.all;
|
||||
final ContactGroupStore _groupStore = ContactGroupStore();
|
||||
List<ContactGroup> _groups = [];
|
||||
Timer? _searchDebounce;
|
||||
@@ -97,141 +87,28 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
}
|
||||
|
||||
final allowBack = !connector.isConnected;
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return PopScope(
|
||||
canPop: allowBack,
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
titleSpacing: 16,
|
||||
centerTitle: false,
|
||||
automaticallyImplyLeading: !widget.hideBackButton && allowBack,
|
||||
title: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text('Contacts'),
|
||||
Text(
|
||||
'${connector.contacts.length} contacts',
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
leading: BatteryIndicator(connector: connector),
|
||||
title: Text(context.l10n.contacts_title),
|
||||
centerTitle: true,
|
||||
automaticallyImplyLeading: false,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: connector.isLoadingContacts
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.refresh),
|
||||
tooltip: 'Refresh',
|
||||
onPressed: connector.isLoadingContacts ? null : () => connector.getContacts(),
|
||||
),
|
||||
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(builder: (context) => const SettingsScreen()),
|
||||
),
|
||||
),
|
||||
PopupMenuButton<_ContactMenuAction>(
|
||||
tooltip: 'Contacts options',
|
||||
onSelected: (action) {
|
||||
switch (action) {
|
||||
case _ContactMenuAction.sortRecentMessages:
|
||||
setState(() {
|
||||
_sortOption = ContactSortOption.recentMessages;
|
||||
_forceLastSeenSort = false;
|
||||
});
|
||||
break;
|
||||
case _ContactMenuAction.sortName:
|
||||
setState(() {
|
||||
_sortOption = ContactSortOption.name;
|
||||
_forceLastSeenSort = false;
|
||||
});
|
||||
break;
|
||||
case _ContactMenuAction.sortType:
|
||||
setState(() {
|
||||
_sortOption = ContactSortOption.type;
|
||||
_forceLastSeenSort = false;
|
||||
});
|
||||
break;
|
||||
case _ContactMenuAction.toggleLastSeenFilter:
|
||||
setState(() {
|
||||
_forceLastSeenSort = !_forceLastSeenSort;
|
||||
if (_forceLastSeenSort) {
|
||||
_sortOption = ContactSortOption.lastSeen;
|
||||
}
|
||||
});
|
||||
break;
|
||||
case _ContactMenuAction.toggleUnreadOnly:
|
||||
setState(() {
|
||||
_showUnreadOnly = !_showUnreadOnly;
|
||||
});
|
||||
break;
|
||||
case _ContactMenuAction.newGroup:
|
||||
_showGroupEditor(context, connector.contacts);
|
||||
break;
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) {
|
||||
final labelStyle = theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w600,
|
||||
);
|
||||
return [
|
||||
PopupMenuItem<_ContactMenuAction>(
|
||||
enabled: false,
|
||||
child: Text('Sort by', style: labelStyle),
|
||||
),
|
||||
CheckedPopupMenuItem<_ContactMenuAction>(
|
||||
value: _ContactMenuAction.sortRecentMessages,
|
||||
checked: _sortOption == ContactSortOption.recentMessages,
|
||||
child: const Text('Recent messages'),
|
||||
),
|
||||
CheckedPopupMenuItem<_ContactMenuAction>(
|
||||
value: _ContactMenuAction.sortName,
|
||||
checked: _sortOption == ContactSortOption.name,
|
||||
child: const Text('Name'),
|
||||
),
|
||||
CheckedPopupMenuItem<_ContactMenuAction>(
|
||||
value: _ContactMenuAction.sortType,
|
||||
checked: _sortOption == ContactSortOption.type,
|
||||
child: const Text('Type'),
|
||||
),
|
||||
const PopupMenuDivider(),
|
||||
PopupMenuItem<_ContactMenuAction>(
|
||||
enabled: false,
|
||||
child: Text('Filters', style: labelStyle),
|
||||
),
|
||||
CheckedPopupMenuItem<_ContactMenuAction>(
|
||||
value: _ContactMenuAction.toggleLastSeenFilter,
|
||||
checked: _forceLastSeenSort,
|
||||
child: const Text('Last seen'),
|
||||
),
|
||||
CheckedPopupMenuItem<_ContactMenuAction>(
|
||||
value: _ContactMenuAction.toggleUnreadOnly,
|
||||
checked: _showUnreadOnly,
|
||||
child: const Text('Unread only'),
|
||||
),
|
||||
PopupMenuItem<_ContactMenuAction>(
|
||||
value: _ContactMenuAction.newGroup,
|
||||
child: const Text('New group'),
|
||||
),
|
||||
];
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: _buildContactsBody(context, connector),
|
||||
@@ -239,7 +116,8 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
top: false,
|
||||
child: QuickSwitchBar(
|
||||
selectedIndex: 0,
|
||||
onDestinationSelected: (index) => _handleQuickSwitch(index, context),
|
||||
onDestinationSelected: (index) =>
|
||||
_handleQuickSwitch(index, context),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -253,6 +131,30 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
await showDisconnectDialog(context, connector);
|
||||
}
|
||||
|
||||
Widget _buildFilterButton(BuildContext context, MeshCoreConnector connector) {
|
||||
return ContactsFilterMenu(
|
||||
sortOption: _sortOption,
|
||||
typeFilter: _typeFilter,
|
||||
showUnreadOnly: _showUnreadOnly,
|
||||
onSortChanged: (value) {
|
||||
setState(() {
|
||||
_sortOption = value;
|
||||
});
|
||||
},
|
||||
onTypeFilterChanged: (value) {
|
||||
setState(() {
|
||||
_typeFilter = value;
|
||||
});
|
||||
},
|
||||
onUnreadOnlyChanged: (value) {
|
||||
setState(() {
|
||||
_showUnreadOnly = value;
|
||||
});
|
||||
},
|
||||
onNewGroup: () => _showGroupEditor(context, connector.contacts),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContactsBody(BuildContext context, MeshCoreConnector connector) {
|
||||
final contacts = connector.contacts;
|
||||
|
||||
@@ -261,16 +163,17 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
}
|
||||
|
||||
if (contacts.isEmpty && _groups.isEmpty) {
|
||||
return const EmptyState(
|
||||
return EmptyState(
|
||||
icon: Icons.people_outline,
|
||||
title: 'No contacts yet',
|
||||
subtitle: 'Contacts will appear when devices advertise',
|
||||
title: context.l10n.contacts_noContacts,
|
||||
subtitle: context.l10n.contacts_contactsWillAppear,
|
||||
);
|
||||
}
|
||||
|
||||
final filteredAndSorted = _filterAndSortContacts(contacts, connector);
|
||||
final filteredGroups =
|
||||
_showUnreadOnly ? const <ContactGroup>[] : _filterAndSortGroups(_groups, contacts);
|
||||
final filteredGroups = _showUnreadOnly
|
||||
? const <ContactGroup>[]
|
||||
: _filterAndSortGroups(_groups, contacts);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
@@ -279,10 +182,13 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Search contacts...',
|
||||
hintText: context.l10n.contacts_searchContacts,
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
suffixIcon: _searchQuery.isNotEmpty
|
||||
? IconButton(
|
||||
suffixIcon: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (_searchQuery.isNotEmpty)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
_searchController.clear();
|
||||
@@ -290,12 +196,17 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
_searchQuery = '';
|
||||
});
|
||||
},
|
||||
)
|
||||
: null,
|
||||
),
|
||||
_buildFilterButton(context, connector),
|
||||
],
|
||||
),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
),
|
||||
onChanged: (value) {
|
||||
_searchDebounce?.cancel();
|
||||
@@ -318,8 +229,8 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_showUnreadOnly
|
||||
? 'No unread contacts'
|
||||
: 'No contacts or groups found',
|
||||
? context.l10n.contacts_noUnreadContacts
|
||||
: context.l10n.contacts_noContactsFound,
|
||||
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
|
||||
),
|
||||
],
|
||||
@@ -334,14 +245,18 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
final group = filteredGroups[index];
|
||||
return _buildGroupTile(context, group, contacts);
|
||||
}
|
||||
final contact = filteredAndSorted[index - filteredGroups.length];
|
||||
final unreadCount = connector.getUnreadCountForContact(contact);
|
||||
final contact =
|
||||
filteredAndSorted[index - filteredGroups.length];
|
||||
final unreadCount = connector.getUnreadCountForContact(
|
||||
contact,
|
||||
);
|
||||
return _ContactTile(
|
||||
contact: contact,
|
||||
lastSeen: _resolveLastSeen(contact),
|
||||
unreadCount: unreadCount,
|
||||
onTap: () => _openChat(context, contact),
|
||||
onLongPress: () => _showContactOptions(context, connector, contact),
|
||||
onLongPress: () =>
|
||||
_showContactOptions(context, connector, contact),
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -351,68 +266,113 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
);
|
||||
}
|
||||
|
||||
List<ContactGroup> _filterAndSortGroups(List<ContactGroup> groups, List<Contact> contacts) {
|
||||
List<ContactGroup> _filterAndSortGroups(
|
||||
List<ContactGroup> groups,
|
||||
List<Contact> contacts,
|
||||
) {
|
||||
final query = _searchQuery.trim().toLowerCase();
|
||||
final contactsByKey = <String, Contact>{};
|
||||
for (final contact in contacts) {
|
||||
contactsByKey[contact.publicKeyHex] = contact;
|
||||
}
|
||||
|
||||
final filtered = groups.where((group) {
|
||||
if (query.isEmpty) return true;
|
||||
if (group.name.toLowerCase().contains(query)) return true;
|
||||
for (final key in group.memberKeys) {
|
||||
final contact = contactsByKey[key];
|
||||
if (contact != null && matchesContactQuery(contact, query)) return true;
|
||||
}
|
||||
return false;
|
||||
}).toList();
|
||||
final filtered = groups
|
||||
.where((group) {
|
||||
if (query.isEmpty) return true;
|
||||
if (group.name.toLowerCase().contains(query)) return true;
|
||||
for (final key in group.memberKeys) {
|
||||
final contact = contactsByKey[key];
|
||||
if (contact != null && matchesContactQuery(contact, query)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
})
|
||||
.where((group) {
|
||||
if (_typeFilter == ContactTypeFilter.all) return true;
|
||||
for (final key in group.memberKeys) {
|
||||
final contact = contactsByKey[key];
|
||||
if (contact != null && _matchesTypeFilter(contact)) return true;
|
||||
}
|
||||
return false;
|
||||
})
|
||||
.toList();
|
||||
|
||||
filtered.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
|
||||
filtered.sort(
|
||||
(a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()),
|
||||
);
|
||||
return filtered;
|
||||
}
|
||||
|
||||
List<Contact> _filterAndSortContacts(List<Contact> contacts, MeshCoreConnector connector) {
|
||||
List<Contact> _filterAndSortContacts(
|
||||
List<Contact> contacts,
|
||||
MeshCoreConnector connector,
|
||||
) {
|
||||
var filtered = contacts.where((contact) {
|
||||
if (_searchQuery.isEmpty) return true;
|
||||
return matchesContactQuery(contact, _searchQuery);
|
||||
}).toList();
|
||||
|
||||
// Filter out own node from the list
|
||||
if (connector.selfPublicKey != null) {
|
||||
final selfPubKeyHex = pubKeyToHex(connector.selfPublicKey!);
|
||||
filtered = filtered.where((contact) {
|
||||
return contact.publicKeyHex != selfPubKeyHex;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
if (_typeFilter != ContactTypeFilter.all) {
|
||||
filtered = filtered.where(_matchesTypeFilter).toList();
|
||||
}
|
||||
|
||||
if (_showUnreadOnly) {
|
||||
filtered = filtered.where((contact) {
|
||||
return connector.getUnreadCountForContact(contact) > 0;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
final sortOption = _forceLastSeenSort ? ContactSortOption.lastSeen : _sortOption;
|
||||
switch (sortOption) {
|
||||
switch (_sortOption) {
|
||||
case ContactSortOption.lastSeen:
|
||||
filtered.sort((a, b) => _resolveLastSeen(b).compareTo(_resolveLastSeen(a)));
|
||||
filtered.sort(
|
||||
(a, b) => _resolveLastSeen(b).compareTo(_resolveLastSeen(a)),
|
||||
);
|
||||
break;
|
||||
case ContactSortOption.recentMessages:
|
||||
filtered.sort((a, b) {
|
||||
final aMessages = connector.getMessages(a);
|
||||
final bMessages = connector.getMessages(b);
|
||||
final aLastMsg = aMessages.isEmpty ? DateTime(1970) : aMessages.last.timestamp;
|
||||
final bLastMsg = bMessages.isEmpty ? DateTime(1970) : bMessages.last.timestamp;
|
||||
final aLastMsg = aMessages.isEmpty
|
||||
? DateTime(1970)
|
||||
: aMessages.last.timestamp;
|
||||
final bLastMsg = bMessages.isEmpty
|
||||
? DateTime(1970)
|
||||
: bMessages.last.timestamp;
|
||||
return bLastMsg.compareTo(aLastMsg);
|
||||
});
|
||||
break;
|
||||
case ContactSortOption.name:
|
||||
filtered.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
|
||||
break;
|
||||
case ContactSortOption.type:
|
||||
filtered.sort((a, b) {
|
||||
final typeCompare = a.type.compareTo(b.type);
|
||||
if (typeCompare != 0) return typeCompare;
|
||||
return a.name.toLowerCase().compareTo(b.name.toLowerCase());
|
||||
});
|
||||
filtered.sort(
|
||||
(a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()),
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}
|
||||
|
||||
bool _matchesTypeFilter(Contact contact) {
|
||||
switch (_typeFilter) {
|
||||
case ContactTypeFilter.all:
|
||||
return true;
|
||||
case ContactTypeFilter.users:
|
||||
return contact.type == advTypeChat;
|
||||
case ContactTypeFilter.repeaters:
|
||||
return contact.type == advTypeRepeater;
|
||||
case ContactTypeFilter.rooms:
|
||||
return contact.type == advTypeRoom;
|
||||
}
|
||||
}
|
||||
|
||||
DateTime _resolveLastSeen(Contact contact) {
|
||||
if (contact.type != advTypeChat) return contact.lastSeen;
|
||||
return contact.lastMessageAt.isAfter(contact.lastSeen)
|
||||
@@ -420,9 +380,13 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
: contact.lastSeen;
|
||||
}
|
||||
|
||||
Widget _buildGroupTile(BuildContext context, ContactGroup group, List<Contact> contacts) {
|
||||
Widget _buildGroupTile(
|
||||
BuildContext context,
|
||||
ContactGroup group,
|
||||
List<Contact> contacts,
|
||||
) {
|
||||
final memberContacts = _resolveGroupContacts(group, contacts);
|
||||
final subtitle = _formatGroupMembers(memberContacts);
|
||||
final subtitle = _formatGroupMembers(context, memberContacts);
|
||||
return ListTile(
|
||||
leading: const CircleAvatar(
|
||||
backgroundColor: Colors.teal,
|
||||
@@ -439,7 +403,10 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
);
|
||||
}
|
||||
|
||||
List<Contact> _resolveGroupContacts(ContactGroup group, List<Contact> contacts) {
|
||||
List<Contact> _resolveGroupContacts(
|
||||
ContactGroup group,
|
||||
List<Contact> contacts,
|
||||
) {
|
||||
final byKey = <String, Contact>{};
|
||||
for (final contact in contacts) {
|
||||
byKey[contact.publicKeyHex] = contact;
|
||||
@@ -451,12 +418,14 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
resolved.add(contact);
|
||||
}
|
||||
}
|
||||
resolved.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
|
||||
resolved.sort(
|
||||
(a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()),
|
||||
);
|
||||
return resolved;
|
||||
}
|
||||
|
||||
String _formatGroupMembers(List<Contact> members) {
|
||||
if (members.isEmpty) return 'No members';
|
||||
String _formatGroupMembers(BuildContext context, List<Contact> members) {
|
||||
if (members.isEmpty) return context.l10n.contacts_noMembers;
|
||||
final names = members.map((c) => c.name).toList();
|
||||
if (names.length <= 2) return names.join(', ');
|
||||
return '${names.take(2).join(', ')} +${names.length - 2}';
|
||||
@@ -466,6 +435,8 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
// Check if this is a repeater
|
||||
if (contact.type == advTypeRepeater) {
|
||||
_showRepeaterLogin(context, contact);
|
||||
} else if (contact.type == advTypeRoom) {
|
||||
_showRoomLogin(context, contact, RoomLoginDestination.chat);
|
||||
} else {
|
||||
context.read<MeshCoreConnector>().markContactRead(contact.publicKeyHex);
|
||||
Navigator.push(
|
||||
@@ -481,17 +452,13 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
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;
|
||||
}
|
||||
@@ -507,10 +474,8 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => RepeaterHubScreen(
|
||||
repeater: repeater,
|
||||
password: password,
|
||||
),
|
||||
builder: (context) =>
|
||||
RepeaterHubScreen(repeater: repeater, password: password),
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -518,7 +483,35 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
);
|
||||
}
|
||||
|
||||
void _showGroupOptions(BuildContext context, ContactGroup group, List<Contact> contacts) {
|
||||
void _showRoomLogin(
|
||||
BuildContext context,
|
||||
Contact room,
|
||||
RoomLoginDestination destination,
|
||||
) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => RoomLoginDialog(
|
||||
room: room,
|
||||
onLogin: (password) {
|
||||
context.read<MeshCoreConnector>().markContactRead(room.publicKeyHex);
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => destination == RoomLoginDestination.management
|
||||
? RepeaterHubScreen(repeater: room, password: password)
|
||||
: ChatScreen(contact: room),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showGroupOptions(
|
||||
BuildContext context,
|
||||
ContactGroup group,
|
||||
List<Contact> contacts,
|
||||
) {
|
||||
final members = _resolveGroupContacts(group, contacts);
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
@@ -529,7 +522,7 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.edit),
|
||||
title: const Text('Edit Group'),
|
||||
title: Text(context.l10n.contacts_editGroup),
|
||||
onTap: () {
|
||||
Navigator.pop(sheetContext);
|
||||
_showGroupEditor(context, contacts, group: group);
|
||||
@@ -537,7 +530,10 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.delete, color: Colors.red),
|
||||
title: const Text('Delete Group', style: TextStyle(color: Colors.red)),
|
||||
title: Text(
|
||||
context.l10n.contacts_deleteGroup,
|
||||
style: const TextStyle(color: Colors.red),
|
||||
),
|
||||
onTap: () {
|
||||
Navigator.pop(sheetContext);
|
||||
_confirmDeleteGroup(context, group);
|
||||
@@ -566,12 +562,12 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: const Text('Delete Group'),
|
||||
content: Text('Remove "${group.name}"?'),
|
||||
title: Text(context.l10n.contacts_deleteGroup),
|
||||
content: Text(context.l10n.contacts_deleteGroupConfirm(group.name)),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogContext),
|
||||
child: const Text('Cancel'),
|
||||
child: Text(context.l10n.common_cancel),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
@@ -581,7 +577,10 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
});
|
||||
await _saveGroups();
|
||||
},
|
||||
child: const Text('Delete', style: TextStyle(color: Colors.red)),
|
||||
child: Text(
|
||||
context.l10n.common_delete,
|
||||
style: const TextStyle(color: Colors.red),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -607,10 +606,16 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
final filteredContacts = filterQuery.isEmpty
|
||||
? sortedContacts
|
||||
: sortedContacts
|
||||
.where((contact) => matchesContactQuery(contact, filterQuery))
|
||||
.toList();
|
||||
.where(
|
||||
(contact) => matchesContactQuery(contact, filterQuery),
|
||||
)
|
||||
.toList();
|
||||
return AlertDialog(
|
||||
title: Text(isEditing ? 'Edit Group' : 'New Group'),
|
||||
title: Text(
|
||||
isEditing
|
||||
? context.l10n.contacts_editGroup
|
||||
: context.l10n.contacts_newGroup,
|
||||
),
|
||||
content: SizedBox(
|
||||
width: double.maxFinite,
|
||||
child: Column(
|
||||
@@ -618,17 +623,17 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
children: [
|
||||
TextField(
|
||||
controller: nameController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Group name',
|
||||
border: OutlineInputBorder(),
|
||||
decoration: InputDecoration(
|
||||
labelText: context.l10n.contacts_groupName,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Filter contacts...',
|
||||
prefixIcon: Icon(Icons.search),
|
||||
border: OutlineInputBorder(),
|
||||
decoration: InputDecoration(
|
||||
hintText: context.l10n.contacts_filterContacts,
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
border: const OutlineInputBorder(),
|
||||
isDense: true,
|
||||
),
|
||||
onChanged: (value) {
|
||||
@@ -641,12 +646,18 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
SizedBox(
|
||||
height: 240,
|
||||
child: filteredContacts.isEmpty
|
||||
? const Center(child: Text('No contacts match your filter'))
|
||||
? Center(
|
||||
child: Text(
|
||||
context.l10n.contacts_noContactsMatchFilter,
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
itemCount: filteredContacts.length,
|
||||
itemBuilder: (context, index) {
|
||||
final contact = filteredContacts[index];
|
||||
final isSelected = selectedKeys.contains(contact.publicKeyHex);
|
||||
final isSelected = selectedKeys.contains(
|
||||
contact.publicKeyHex,
|
||||
);
|
||||
return CheckboxListTile(
|
||||
value: isSelected,
|
||||
title: Text(contact.name),
|
||||
@@ -670,14 +681,16 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogContext),
|
||||
child: const Text('Cancel'),
|
||||
child: Text(context.l10n.common_cancel),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
final name = nameController.text.trim();
|
||||
if (name.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Group name is required')),
|
||||
SnackBar(
|
||||
content: Text(context.l10n.contacts_groupNameRequired),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -687,13 +700,19 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
});
|
||||
if (exists) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Group "$name" already exists')),
|
||||
SnackBar(
|
||||
content: Text(
|
||||
context.l10n.contacts_groupAlreadyExists(name),
|
||||
),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
if (isEditing) {
|
||||
final index = _groups.indexWhere((g) => g.name == group.name);
|
||||
final index = _groups.indexWhere(
|
||||
(g) => g.name == group.name,
|
||||
);
|
||||
if (index != -1) {
|
||||
_groups[index] = ContactGroup(
|
||||
name: name,
|
||||
@@ -701,7 +720,12 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
);
|
||||
}
|
||||
} else {
|
||||
_groups.add(ContactGroup(name: name, memberKeys: selectedKeys.toList()));
|
||||
_groups.add(
|
||||
ContactGroup(
|
||||
name: name,
|
||||
memberKeys: selectedKeys.toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
await _saveGroups();
|
||||
@@ -709,7 +733,11 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
Navigator.pop(dialogContext);
|
||||
}
|
||||
},
|
||||
child: Text(isEditing ? 'Save' : 'Create'),
|
||||
child: Text(
|
||||
isEditing
|
||||
? context.l10n.common_save
|
||||
: context.l10n.common_create,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -724,36 +752,57 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
Contact contact,
|
||||
) {
|
||||
final isRepeater = contact.type == advTypeRepeater;
|
||||
final isRoom = contact.type == advTypeRoom;
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (context) => SafeArea(
|
||||
builder: (sheetContext) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (isRepeater)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.cell_tower, color: Colors.orange),
|
||||
title: const Text('Manage Repeater'),
|
||||
title: Text(context.l10n.contacts_manageRepeater),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
Navigator.pop(sheetContext);
|
||||
_showRepeaterLogin(context, contact);
|
||||
},
|
||||
)
|
||||
else
|
||||
else if (isRoom) ...[
|
||||
ListTile(
|
||||
leading: const Icon(Icons.room, color: Colors.blue),
|
||||
title: Text(context.l10n.contacts_roomLogin),
|
||||
onTap: () {
|
||||
Navigator.pop(sheetContext);
|
||||
_showRoomLogin(context, contact, RoomLoginDestination.chat);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.room_preferences, color: Colors.orange),
|
||||
title: Text(context.l10n.room_management),
|
||||
onTap: () {
|
||||
Navigator.pop(sheetContext);
|
||||
_showRoomLogin(context, contact, RoomLoginDestination.management);
|
||||
},
|
||||
),
|
||||
] else
|
||||
ListTile(
|
||||
leading: const Icon(Icons.chat),
|
||||
title: const Text('Open Chat'),
|
||||
title: Text(context.l10n.contacts_openChat),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
Navigator.pop(sheetContext);
|
||||
_openChat(context, contact);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.delete, color: Colors.red),
|
||||
title: const Text('Delete Contact', style: TextStyle(color: Colors.red)),
|
||||
title: Text(
|
||||
context.l10n.contacts_deleteContact,
|
||||
style: const TextStyle(color: Colors.red),
|
||||
),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
Navigator.pop(sheetContext);
|
||||
_confirmDelete(context, connector, contact);
|
||||
},
|
||||
),
|
||||
@@ -770,20 +819,23 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Delete Contact'),
|
||||
content: Text('Remove ${contact.name} from contacts?'),
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: Text(context.l10n.contacts_deleteContact),
|
||||
content: Text(context.l10n.contacts_removeConfirm(contact.name)),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
onPressed: () => Navigator.pop(dialogContext),
|
||||
child: Text(context.l10n.common_cancel),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
Navigator.pop(dialogContext);
|
||||
connector.removeContact(contact);
|
||||
},
|
||||
child: const Text('Delete', style: TextStyle(color: Colors.red)),
|
||||
child: Text(
|
||||
context.l10n.common_delete,
|
||||
style: const TextStyle(color: Colors.red),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -808,28 +860,41 @@ class _ContactTile extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final shotPublicKey =
|
||||
"<${contact.publicKeyHex.substring(0, 8)}...${contact.publicKeyHex.substring(contact.publicKeyHex.length - 8)}>";
|
||||
return ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: _getTypeColor(contact.type),
|
||||
child: _buildContactAvatar(contact),
|
||||
),
|
||||
title: Text(contact.name),
|
||||
subtitle: Text('${contact.typeLabel} • ${contact.pathLabel}'),
|
||||
trailing: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
if (unreadCount > 0) ...[
|
||||
UnreadBadge(count: unreadCount),
|
||||
const SizedBox(height: 4),
|
||||
],
|
||||
Text(
|
||||
_formatLastSeen(lastSeen),
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
|
||||
subtitle: Text(
|
||||
'${contact.typeLabel} • ${contact.pathLabel} $shotPublicKey',
|
||||
),
|
||||
// Clamp text scaling in trailing section to prevent overflow while
|
||||
// maintaining accessibility. Primary content (title/subtitle) scales normally.
|
||||
trailing: MediaQuery(
|
||||
data: MediaQuery.of(context).copyWith(
|
||||
textScaler: TextScaler.linear(
|
||||
MediaQuery.textScalerOf(context).scale(1.0).clamp(1.0, 1.3),
|
||||
),
|
||||
if (contact.hasLocation)
|
||||
Icon(Icons.location_on, size: 14, color: Colors.grey[400]),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
if (unreadCount > 0) ...[
|
||||
UnreadBadge(count: unreadCount),
|
||||
const SizedBox(height: 4),
|
||||
],
|
||||
Text(
|
||||
_formatLastSeen(context, lastSeen),
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
|
||||
),
|
||||
if (contact.hasLocation)
|
||||
Icon(Icons.location_on, size: 14, color: Colors.grey[400]),
|
||||
],
|
||||
),
|
||||
),
|
||||
onTap: onTap,
|
||||
onLongPress: onLongPress,
|
||||
@@ -839,10 +904,7 @@ class _ContactTile extends StatelessWidget {
|
||||
Widget _buildContactAvatar(Contact contact) {
|
||||
final emoji = firstEmoji(contact.name);
|
||||
if (emoji != null) {
|
||||
return Text(
|
||||
emoji,
|
||||
style: const TextStyle(fontSize: 18),
|
||||
);
|
||||
return Text(emoji, style: const TextStyle(fontSize: 18));
|
||||
}
|
||||
return Icon(_getTypeIcon(contact.type), color: Colors.white, size: 20);
|
||||
}
|
||||
@@ -877,17 +939,25 @@ class _ContactTile extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
String _formatLastSeen(DateTime lastSeen) {
|
||||
String _formatLastSeen(BuildContext context, DateTime lastSeen) {
|
||||
final now = DateTime.now();
|
||||
final diff = now.difference(lastSeen);
|
||||
|
||||
if (diff.isNegative || diff.inMinutes < 5) return 'Last seen now';
|
||||
if (diff.inMinutes < 60) return 'Last seen ${diff.inMinutes} mins ago';
|
||||
if (diff.isNegative || diff.inMinutes < 5) {
|
||||
return context.l10n.contacts_lastSeenNow;
|
||||
}
|
||||
if (diff.inMinutes < 60) {
|
||||
return context.l10n.contacts_lastSeenMinsAgo(diff.inMinutes);
|
||||
}
|
||||
if (diff.inHours < 24) {
|
||||
final hours = diff.inHours;
|
||||
return hours == 1 ? 'Last seen 1 hour ago' : 'Last seen $hours hours ago';
|
||||
return hours == 1
|
||||
? context.l10n.contacts_lastSeenHourAgo
|
||||
: context.l10n.contacts_lastSeenHoursAgo(hours);
|
||||
}
|
||||
final days = diff.inDays;
|
||||
return days == 1 ? 'Last seen 1 day ago' : 'Last seen $days days ago';
|
||||
return days == 1
|
||||
? context.l10n.contacts_lastSeenDayAgo
|
||||
: context.l10n.contacts_lastSeenDaysAgo(days);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
@@ -39,13 +40,20 @@ class _DeviceScreenState extends State<DeviceScreen>
|
||||
canPop: false,
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: _buildBatteryIndicator(connector, context),
|
||||
titleSpacing: 16,
|
||||
centerTitle: false,
|
||||
title: _buildAppBarTitle(connector, theme),
|
||||
automaticallyImplyLeading: false,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.bluetooth_disabled),
|
||||
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(
|
||||
@@ -53,11 +61,6 @@ class _DeviceScreenState extends State<DeviceScreen>
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.bluetooth_disabled),
|
||||
tooltip: 'Disconnect',
|
||||
onPressed: () => _disconnect(context, connector),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: SafeArea(
|
||||
@@ -66,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),
|
||||
],
|
||||
@@ -85,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,
|
||||
@@ -178,7 +181,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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,8 +176,11 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
|
||||
});
|
||||
|
||||
final message = result.failed > 0
|
||||
? 'Cached ${result.downloaded} tiles (${result.failed} failed)'
|
||||
: 'Cached ${result.downloaded} tiles';
|
||||
? context.l10n.mapCache_cachedTilesWithFailed(
|
||||
result.downloaded,
|
||||
result.failed,
|
||||
)
|
||||
: context.l10n.mapCache_cachedTiles(result.downloaded);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(message)),
|
||||
);
|
||||
@@ -185,16 +190,16 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
|
||||
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,13 +218,14 @@ 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'),
|
||||
title: Text(l10n.mapCache_title),
|
||||
centerTitle: true,
|
||||
),
|
||||
body: Column(
|
||||
@@ -264,8 +270,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 +288,9 @@ 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,7 +298,7 @@ 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,
|
||||
),
|
||||
),
|
||||
@@ -300,14 +306,14 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
|
||||
TextButton(
|
||||
onPressed:
|
||||
_isDownloading || selectedBounds == null ? null : _clearBounds,
|
||||
child: const Text('Clear'),
|
||||
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:
|
||||
@@ -330,12 +336,15 @@ 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 +352,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 +361,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 +369,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 +391,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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+425
-203
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,13 @@ 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';
|
||||
import '../widgets/debug_frame_viewer.dart';
|
||||
import '../services/repeater_command_service.dart';
|
||||
import '../widgets/path_management_dialog.dart';
|
||||
|
||||
class RepeaterCliScreen extends StatefulWidget {
|
||||
final Contact repeater;
|
||||
@@ -32,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
|
||||
@@ -75,6 +77,13 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
|
||||
});
|
||||
}
|
||||
|
||||
Contact _resolveRepeater(MeshCoreConnector connector) {
|
||||
return connector.contacts.firstWhere(
|
||||
(c) => c.publicKeyHex == widget.repeater.publicKeyHex,
|
||||
orElse: () => widget.repeater,
|
||||
);
|
||||
}
|
||||
|
||||
void _handleTextMessageResponse(Uint8List frame) {
|
||||
final parsed = parseContactMessageText(frame);
|
||||
if (parsed == null) return;
|
||||
@@ -111,15 +120,18 @@ 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');
|
||||
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 repeater = _resolveRepeater(connector);
|
||||
final response = await _commandService!.sendCommand(
|
||||
widget.repeater,
|
||||
repeater,
|
||||
command,
|
||||
retries: 1,
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
@@ -137,7 +149,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(),
|
||||
});
|
||||
});
|
||||
@@ -204,43 +216,96 @@ 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;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text('Repeater CLI'),
|
||||
Text(l10n.repeater_cliTitle),
|
||||
Text(
|
||||
widget.repeater.name,
|
||||
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: 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,
|
||||
),
|
||||
],
|
||||
@@ -268,10 +333,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),
|
||||
),
|
||||
@@ -282,7 +348,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,
|
||||
@@ -290,12 +379,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]),
|
||||
),
|
||||
],
|
||||
@@ -359,6 +448,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,
|
||||
@@ -367,12 +457,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),
|
||||
@@ -380,10 +470,10 @@ 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'),
|
||||
@@ -416,312 +506,284 @@ 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(
|
||||
_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}',
|
||||
description: l10n.repeater_cliHelpGps,
|
||||
),
|
||||
const _CommandHelpEntry(
|
||||
_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),
|
||||
_buildHelpSection(context, l10n.repeater_general, generalCommands),
|
||||
const SizedBox(height: 16),
|
||||
_buildHelpSection(context, 'Settings', settingsCommands),
|
||||
_buildHelpSection(context, l10n.repeater_settingsCategory, settingsCommands),
|
||||
const SizedBox(height: 16),
|
||||
_buildHelpSection(context, 'Bridge', bridgeCommands),
|
||||
_buildHelpSection(context, l10n.repeater_bridge, bridgeCommands),
|
||||
const SizedBox(height: 16),
|
||||
_buildHelpSection(context, 'Logging', loggingCommands),
|
||||
_buildHelpSection(context, l10n.repeater_logging, loggingCommands),
|
||||
const SizedBox(height: 16),
|
||||
_buildHelpSection(
|
||||
context,
|
||||
'Neighbors (Repeater only)',
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -729,7 +791,7 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Close'),
|
||||
child: Text(l10n.common_close),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -33,117 +45,171 @@ class RepeaterHubScreen extends StatelessWidget {
|
||||
),
|
||||
body: SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
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),
|
||||
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,
|
||||
),
|
||||
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.publicKeyHex.substring(0, 8)}...${repeater.publicKeyHex.substring(repeater.publicKeyHex.length - 8)}>',
|
||||
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,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -189,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,10 +3,13 @@ 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';
|
||||
import '../connector/meshcore_protocol.dart';
|
||||
import '../services/repeater_command_service.dart';
|
||||
import '../widgets/path_management_dialog.dart';
|
||||
|
||||
class RepeaterStatusScreen extends StatefulWidget {
|
||||
final Contact repeater;
|
||||
@@ -23,6 +26,10 @@ 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;
|
||||
|
||||
bool _isLoading = false;
|
||||
StreamSubscription<Uint8List>? _frameSubscription;
|
||||
RepeaterCommandService? _commandService;
|
||||
@@ -45,6 +52,7 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
|
||||
int? _directRx;
|
||||
int? _dupFlood;
|
||||
int? _dupDirect;
|
||||
PathSelection? _pendingStatusSelection;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -80,6 +88,13 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
|
||||
});
|
||||
}
|
||||
|
||||
Contact _resolveRepeater(MeshCoreConnector connector) {
|
||||
return connector.contacts.firstWhere(
|
||||
(c) => c.publicKeyHex == widget.repeater.publicKeyHex,
|
||||
orElse: () => widget.repeater,
|
||||
);
|
||||
}
|
||||
|
||||
void _handleTextMessageResponse(Uint8List frame) {
|
||||
final parsed = parseContactMessageText(frame);
|
||||
if (parsed == null) return;
|
||||
@@ -90,6 +105,7 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
|
||||
|
||||
// Parse status responses
|
||||
_parseStatusResponse(parsed.text);
|
||||
_recordStatusResult(true);
|
||||
}
|
||||
|
||||
void _handleStatusResponse(Uint8List frame) {
|
||||
@@ -97,11 +113,13 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
|
||||
final prefix = frame.sublist(2, 8);
|
||||
if (!_matchesRepeaterPrefix(prefix)) return;
|
||||
|
||||
const payloadOffset = 8;
|
||||
const statsSize = 52;
|
||||
if (frame.length < payloadOffset + statsSize) return;
|
||||
if (frame.length < _statusResponseBytes) return;
|
||||
|
||||
final data = ByteData.sublistView(frame, payloadOffset, payloadOffset + statsSize);
|
||||
final data = ByteData.sublistView(
|
||||
frame,
|
||||
_statusPayloadOffset,
|
||||
_statusResponseBytes,
|
||||
);
|
||||
int offset = 0;
|
||||
|
||||
final batteryMv = data.getUint16(offset, Endian.little);
|
||||
@@ -160,6 +178,7 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
|
||||
_dupDirect = directDups;
|
||||
_dupFlood = floodDups;
|
||||
});
|
||||
_recordStatusResult(true);
|
||||
}
|
||||
|
||||
bool _matchesRepeaterPrefix(Uint8List prefix) {
|
||||
@@ -213,6 +232,7 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_statusRequestedAt = DateTime.now();
|
||||
_pendingStatusSelection = null;
|
||||
_batteryMv = null;
|
||||
_uptimeSecs = null;
|
||||
_queueLen = null;
|
||||
@@ -234,21 +254,36 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
|
||||
|
||||
try {
|
||||
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||
final frame = buildSendStatusRequestFrame(widget.repeater.publicKey);
|
||||
final repeater = _resolveRepeater(connector);
|
||||
final selection = await connector.preparePathForContactSend(repeater);
|
||||
_pendingStatusSelection = selection;
|
||||
final frame = buildSendStatusRequestFrame(repeater.publicKey);
|
||||
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(const Duration(seconds: 12), () {
|
||||
_statusTimeout = Timer(Duration(milliseconds: timeoutMs), () {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Status request timed out.'),
|
||||
SnackBar(
|
||||
content: Text(context.l10n.repeater_statusRequestTimeout),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
_recordStatusResult(false);
|
||||
});
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
@@ -258,31 +293,94 @@ 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
_recordStatusResult(false);
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
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: [
|
||||
const Text('Repeater Status'),
|
||||
Text(l10n.repeater_statusTitle),
|
||||
Text(
|
||||
widget.repeater.name,
|
||||
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(
|
||||
@@ -292,7 +390,7 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
|
||||
)
|
||||
: const Icon(Icons.refresh),
|
||||
onPressed: _isLoading ? null : _loadStatus,
|
||||
tooltip: 'Refresh',
|
||||
tooltip: l10n.repeater_refresh,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -316,6 +414,7 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
|
||||
}
|
||||
|
||||
Widget _buildSystemInfoCard() {
|
||||
final l10n = context.l10n;
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
@@ -324,20 +423,20 @@ 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)),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -345,6 +444,7 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
|
||||
}
|
||||
|
||||
Widget _buildRadioStatsCard() {
|
||||
final l10n = context.l10n;
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
@@ -353,20 +453,20 @@ 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)),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -374,6 +474,7 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
|
||||
}
|
||||
|
||||
Widget _buildPacketStatsCard() {
|
||||
final l10n = context.l10n;
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
@@ -382,18 +483,18 @@ 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()),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -466,37 +567,41 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
|
||||
|
||||
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 '—';
|
||||
final dupTotal = _packetsRecv! - _floodRx! - _directRx!;
|
||||
if (dupTotal < 0) return '—';
|
||||
return 'Total: $dupTotal';
|
||||
return l10n.repeater_duplicatesTotal(dupTotal);
|
||||
}
|
||||
|
||||
String _formatValue(num? value, {String? suffix}) {
|
||||
|
||||
@@ -3,6 +3,7 @@ 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';
|
||||
|
||||
@@ -14,7 +15,7 @@ class ScannerScreen extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('MeshCore Open'),
|
||||
title: Text(context.l10n.scanner_title),
|
||||
centerTitle: true,
|
||||
automaticallyImplyLeading: false,
|
||||
),
|
||||
@@ -58,7 +59,7 @@ 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 +70,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;
|
||||
}
|
||||
@@ -123,8 +125,8 @@ class ScannerScreen extends StatelessWidget {
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
connector.state == MeshCoreConnectionState.scanning
|
||||
? 'Searching for MeshCore devices...'
|
||||
: 'Tap Scan to find MeshCore devices',
|
||||
? context.l10n.scanner_searchingDevices
|
||||
: context.l10n.scanner_tapToScan,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: Colors.grey[600],
|
||||
@@ -172,7 +174,7 @@ class ScannerScreen extends StatelessWidget {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Connection failed: $e'),
|
||||
content: Text(context.l10n.scanner_connectionFailed(e.toString())),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
|
||||
+471
-220
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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
enum AppDebugLogLevel {
|
||||
info,
|
||||
warning,
|
||||
error,
|
||||
}
|
||||
|
||||
class AppDebugLogEntry {
|
||||
final DateTime timestamp;
|
||||
final AppDebugLogLevel level;
|
||||
final String tag;
|
||||
final String message;
|
||||
|
||||
AppDebugLogEntry({
|
||||
required this.timestamp,
|
||||
required this.level,
|
||||
required this.tag,
|
||||
required this.message,
|
||||
});
|
||||
|
||||
String get levelLabel {
|
||||
switch (level) {
|
||||
case AppDebugLogLevel.info:
|
||||
return 'INFO';
|
||||
case AppDebugLogLevel.warning:
|
||||
return 'WARN';
|
||||
case AppDebugLogLevel.error:
|
||||
return 'ERROR';
|
||||
}
|
||||
}
|
||||
|
||||
String get formattedTime {
|
||||
return '${timestamp.hour.toString().padLeft(2, '0')}:'
|
||||
'${timestamp.minute.toString().padLeft(2, '0')}:'
|
||||
'${timestamp.second.toString().padLeft(2, '0')}.'
|
||||
'${timestamp.millisecond.toString().padLeft(3, '0')}';
|
||||
}
|
||||
}
|
||||
|
||||
class AppDebugLogService extends ChangeNotifier {
|
||||
static const int maxEntries = 1000;
|
||||
final List<AppDebugLogEntry> _entries = [];
|
||||
bool _enabled = false;
|
||||
|
||||
List<AppDebugLogEntry> get entries => List.unmodifiable(_entries);
|
||||
bool get enabled => _enabled;
|
||||
|
||||
void setEnabled(bool value) {
|
||||
_enabled = value;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void log(String message, {String tag = 'App', AppDebugLogLevel level = AppDebugLogLevel.info}) {
|
||||
if (!_enabled) return;
|
||||
|
||||
_entries.add(
|
||||
AppDebugLogEntry(
|
||||
timestamp: DateTime.now(),
|
||||
level: level,
|
||||
tag: tag,
|
||||
message: message,
|
||||
),
|
||||
);
|
||||
|
||||
if (_entries.length > maxEntries) {
|
||||
_entries.removeRange(0, _entries.length - maxEntries);
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
|
||||
// Also print to console for development
|
||||
debugPrint('[$tag] $message');
|
||||
}
|
||||
|
||||
void info(String message, {String tag = 'App'}) {
|
||||
log(message, tag: tag, level: AppDebugLogLevel.info);
|
||||
}
|
||||
|
||||
void warn(String message, {String tag = 'App'}) {
|
||||
log(message, tag: tag, level: AppDebugLogLevel.warning);
|
||||
}
|
||||
|
||||
void error(String message, {String tag = 'App'}) {
|
||||
log(message, tag: tag, level: AppDebugLogLevel.error);
|
||||
}
|
||||
|
||||
void clear() {
|
||||
_entries.clear();
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../models/app_settings.dart';
|
||||
import '../storage/prefs_manager.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
|
||||
class AppSettingsService extends ChangeNotifier {
|
||||
static const String _settingsKey = 'app_settings';
|
||||
@@ -112,6 +113,16 @@ 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);
|
||||
updated[deviceId] = chemistry;
|
||||
|
||||
@@ -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,15 @@ class BleDebugLogService extends ChangeNotifier {
|
||||
return 'CMD_SET_CHANNEL';
|
||||
case cmdGetRadioSettings:
|
||||
return 'CMD_GET_RADIO_SETTINGS';
|
||||
case cmdSetCustomVar:
|
||||
return 'CMD_SET_CUSTOM_VAR';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
String? _responseLabel(int code) {
|
||||
switch (code) {
|
||||
case respCodeOk:
|
||||
return 'RESP_CODE_OK';
|
||||
case respCodeErr:
|
||||
@@ -167,6 +195,13 @@ class BleDebugLogService extends ChangeNotifier {
|
||||
return 'RESP_CODE_CHANNEL_INFO';
|
||||
case respCodeRadioSettings:
|
||||
return 'RESP_CODE_RADIO_SETTINGS';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
String? _pushLabel(int code) {
|
||||
switch (code) {
|
||||
case pushCodeAdvert:
|
||||
return 'PUSH_CODE_ADVERT';
|
||||
case pushCodePathUpdated:
|
||||
@@ -184,7 +219,7 @@ class BleDebugLogService extends ChangeNotifier {
|
||||
case pushCodeNewAdvert:
|
||||
return 'PUSH_CODE_NEW_ADVERT';
|
||||
default:
|
||||
return 'CODE_$code';
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
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';
|
||||
|
||||
class _AckHistoryEntry {
|
||||
final String messageId;
|
||||
@@ -33,7 +35,6 @@ 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 = {};
|
||||
@@ -41,7 +42,8 @@ class MessageRetryService extends ChangeNotifier {
|
||||
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
|
||||
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;
|
||||
@@ -49,10 +51,13 @@ class MessageRetryService extends ChangeNotifier {
|
||||
Function(Contact)? _clearContactPathCallback;
|
||||
Function(Contact, Uint8List, int)? _setContactPathCallback;
|
||||
Function(int, int)? _calculateTimeoutCallback;
|
||||
Uint8List? Function()? _getSelfPublicKeyCallback;
|
||||
String Function(Contact, String)? _prepareContactOutboundTextCallback;
|
||||
AppSettingsService? _appSettingsService;
|
||||
AppDebugLogService? _debugLogService;
|
||||
Function(String, PathSelection, bool, int?)? _recordPathResultCallback;
|
||||
|
||||
MessageRetryService(this._storage);
|
||||
MessageRetryService();
|
||||
|
||||
void initialize({
|
||||
required Function(Contact, String, int, int) sendMessageCallback,
|
||||
@@ -61,7 +66,10 @@ class MessageRetryService extends ChangeNotifier {
|
||||
Function(Contact)? clearContactPathCallback,
|
||||
Function(Contact, Uint8List, int)? setContactPathCallback,
|
||||
Function(int pathLength, int messageBytes)? calculateTimeoutCallback,
|
||||
Uint8List? Function()? getSelfPublicKeyCallback,
|
||||
String Function(Contact, String)? prepareContactOutboundTextCallback,
|
||||
AppSettingsService? appSettingsService,
|
||||
AppDebugLogService? debugLogService,
|
||||
Function(String, PathSelection, bool, int?)? recordPathResultCallback,
|
||||
}) {
|
||||
_sendMessageCallback = sendMessageCallback;
|
||||
@@ -70,10 +78,46 @@ class MessageRetryService extends ChangeNotifier {
|
||||
_clearContactPathCallback = clearContactPathCallback;
|
||||
_setContactPathCallback = setContactPathCallback;
|
||||
_calculateTimeoutCallback = calculateTimeoutCallback;
|
||||
_getSelfPublicKeyCallback = getSelfPublicKeyCallback;
|
||||
_prepareContactOutboundTextCallback = prepareContactOutboundTextCallback;
|
||||
_appSettingsService = appSettingsService;
|
||||
_debugLogService = debugLogService;
|
||||
_recordPathResultCallback = recordPathResultCallback;
|
||||
}
|
||||
|
||||
/// Compute expected ACK hash using same algorithm as firmware:
|
||||
/// SHA256([timestamp(4)][attempt(1)][text][sender_pubkey(32)]) -> first 4 bytes
|
||||
static Uint8List computeExpectedAckHash(
|
||||
int timestampSeconds,
|
||||
int attempt,
|
||||
String text,
|
||||
Uint8List senderPubKey,
|
||||
) {
|
||||
final textBytes = utf8.encode(text);
|
||||
final buffer = Uint8List(4 + 1 + textBytes.length + senderPubKey.length);
|
||||
int offset = 0;
|
||||
|
||||
// timestamp (4 bytes, little-endian)
|
||||
buffer[offset++] = timestampSeconds & 0xFF;
|
||||
buffer[offset++] = (timestampSeconds >> 8) & 0xFF;
|
||||
buffer[offset++] = (timestampSeconds >> 16) & 0xFF;
|
||||
buffer[offset++] = (timestampSeconds >> 24) & 0xFF;
|
||||
|
||||
// attempt (1 byte)
|
||||
buffer[offset++] = attempt & 0x03;
|
||||
|
||||
// text
|
||||
buffer.setRange(offset, offset + textBytes.length, textBytes);
|
||||
offset += textBytes.length;
|
||||
|
||||
// sender public key (32 bytes)
|
||||
buffer.setRange(offset, offset + senderPubKey.length, senderPubKey);
|
||||
|
||||
// Compute SHA256 and return first 4 bytes
|
||||
final hash = sha256.convert(buffer);
|
||||
return Uint8List.fromList(hash.bytes.sublist(0, 4));
|
||||
}
|
||||
|
||||
Future<void> sendMessageWithRetry({
|
||||
required Contact contact,
|
||||
required String text,
|
||||
@@ -136,14 +180,35 @@ class MessageRetryService extends ChangeNotifier {
|
||||
}
|
||||
|
||||
final attempt = message.retryCount.clamp(0, 3);
|
||||
final timestampSeconds = message.timestamp.millisecondsSinceEpoch ~/ 1000;
|
||||
|
||||
// Enqueue this message to track send order for ACK hash mapping (FIFO)
|
||||
// Compute expected ACK hash that device will return in RESP_CODE_SENT
|
||||
// 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 expectedHash = MessageRetryService.computeExpectedAckHash(
|
||||
timestampSeconds,
|
||||
attempt,
|
||||
outboundText,
|
||||
selfPubKey,
|
||||
);
|
||||
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;
|
||||
_debugLogService?.info(
|
||||
'Sent "$shortText" to ${contact.name} → expect ACK hash $expectedHashHex (attempt $attempt)',
|
||||
tag: 'AckHash',
|
||||
);
|
||||
debugPrint('Computed expected ACK hash $expectedHashHex for message $messageId');
|
||||
}
|
||||
|
||||
// DEPRECATED: Old queue-based matching (kept for fallback)
|
||||
_pendingMessageQueuePerContact[contact.publicKeyHex] ??= [];
|
||||
_pendingMessageQueuePerContact[contact.publicKeyHex]!.add(messageId);
|
||||
debugPrint('Enqueued message $messageId for ${contact.name} (queue size: ${_pendingMessageQueuePerContact[contact.publicKeyHex]!.length})');
|
||||
|
||||
if (_sendMessageCallback != null) {
|
||||
final timestampSeconds = message.timestamp.millisecondsSinceEpoch ~/ 1000;
|
||||
_sendMessageCallback!(
|
||||
contact,
|
||||
message.text,
|
||||
@@ -156,35 +221,68 @@ class MessageRetryService extends ChangeNotifier {
|
||||
void updateMessageFromSent(Uint8List ackHash, int timeoutMs) {
|
||||
final ackHashHex = ackHash.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
||||
|
||||
// Dequeue the next message from the FIFO queue to match with this RESP_CODE_SENT
|
||||
// We iterate through contacts to find which one has a pending message in their queue
|
||||
String? messageId;
|
||||
// NEW: Try hash-based matching first (fixes LoRa message drops causing mismatches)
|
||||
String? messageId = _expectedHashToMessageId.remove(ackHashHex);
|
||||
Contact? contact;
|
||||
|
||||
for (var entry in _pendingMessageQueuePerContact.entries) {
|
||||
final contactKey = entry.key;
|
||||
final queue = entry.value;
|
||||
if (messageId != null) {
|
||||
contact = _pendingContacts[messageId];
|
||||
final message = _pendingMessages[messageId];
|
||||
|
||||
if (queue.isNotEmpty) {
|
||||
// Dequeue the first (oldest) message from this contact's queue
|
||||
final candidateMessageId = queue.removeAt(0);
|
||||
if (contact != null && message != null) {
|
||||
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 ✓');
|
||||
|
||||
// Verify this message is still pending
|
||||
if (_pendingMessages.containsKey(candidateMessageId)) {
|
||||
messageId = candidateMessageId;
|
||||
contact = _pendingContacts[candidateMessageId];
|
||||
debugPrint('Dequeued message $messageId for $contactKey (remaining in queue: ${queue.length})');
|
||||
break;
|
||||
} else {
|
||||
debugPrint('Dequeued stale message $candidateMessageId - skipping');
|
||||
// Continue to next message in queue
|
||||
if (queue.isNotEmpty) {
|
||||
final nextMessageId = queue.removeAt(0);
|
||||
if (_pendingMessages.containsKey(nextMessageId)) {
|
||||
messageId = nextMessageId;
|
||||
contact = _pendingContacts[nextMessageId];
|
||||
debugPrint('Dequeued next message $messageId for $contactKey (remaining: ${queue.length})');
|
||||
break;
|
||||
// Remove from old queue since we matched
|
||||
_pendingMessageQueuePerContact[contact.publicKeyHex]?.remove(messageId);
|
||||
if (_pendingMessageQueuePerContact[contact.publicKeyHex]?.isEmpty ?? false) {
|
||||
_pendingMessageQueuePerContact.remove(contact.publicKeyHex);
|
||||
}
|
||||
} else {
|
||||
_debugLogService?.warn(
|
||||
'RESP_CODE_SENT: ACK hash $ackHashHex matched but message no longer pending',
|
||||
tag: 'AckHash',
|
||||
);
|
||||
debugPrint('Hash matched $messageId but message no longer pending');
|
||||
messageId = null;
|
||||
contact = null;
|
||||
}
|
||||
}
|
||||
|
||||
// FALLBACK: Old queue-based matching (for messages sent before hash computation was added)
|
||||
if (messageId == null) {
|
||||
_debugLogService?.warn(
|
||||
'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');
|
||||
|
||||
for (var entry in _pendingMessageQueuePerContact.entries) {
|
||||
final contactKey = entry.key;
|
||||
final queue = entry.value;
|
||||
|
||||
if (queue.isNotEmpty) {
|
||||
final candidateMessageId = queue.removeAt(0);
|
||||
|
||||
if (_pendingMessages.containsKey(candidateMessageId)) {
|
||||
messageId = candidateMessageId;
|
||||
contact = _pendingContacts[candidateMessageId];
|
||||
debugPrint('Queue-based match (fallback): $ackHashHex → message $messageId for $contactKey');
|
||||
break;
|
||||
} else {
|
||||
debugPrint('Dequeued stale message $candidateMessageId - skipping');
|
||||
if (queue.isNotEmpty) {
|
||||
final nextMessageId = queue.removeAt(0);
|
||||
if (_pendingMessages.containsKey(nextMessageId)) {
|
||||
messageId = nextMessageId;
|
||||
contact = _pendingContacts[nextMessageId];
|
||||
debugPrint('Queue-based match (fallback): $ackHashHex → message $messageId');
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -192,7 +290,7 @@ class MessageRetryService extends ChangeNotifier {
|
||||
}
|
||||
|
||||
if (messageId == null || contact == null) {
|
||||
debugPrint('No pending message found for ACK hash: $ackHashHex (all queues empty or stale)');
|
||||
debugPrint('No pending message found for ACK hash: $ackHashHex');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -270,6 +368,11 @@ class MessageRetryService extends ChangeNotifier {
|
||||
return;
|
||||
}
|
||||
|
||||
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})');
|
||||
|
||||
if (message.retryCount < maxRetries - 1) {
|
||||
@@ -287,8 +390,14 @@ class MessageRetryService extends ChangeNotifier {
|
||||
_updateMessageCallback!(updatedMessage);
|
||||
}
|
||||
|
||||
_debugLogService?.info(
|
||||
'Scheduling retry for "$shortText" to ${contact.name} after ${backoffMs}ms backoff',
|
||||
tag: 'AckHash',
|
||||
);
|
||||
debugPrint('Scheduling retry after ${backoffMs}ms');
|
||||
Timer(Duration(milliseconds: backoffMs), () {
|
||||
|
||||
// Store the backoff timer so it can be canceled if new RESP_CODE_SENT arrives
|
||||
_timeoutTimers[messageId] = Timer(Duration(milliseconds: backoffMs), () {
|
||||
// Double-check message is still pending before retry
|
||||
if (_pendingMessages.containsKey(messageId)) {
|
||||
_attemptSend(messageId);
|
||||
@@ -388,6 +497,10 @@ class MessageRetryService extends ChangeNotifier {
|
||||
matchedMessageId = mapping.messageId;
|
||||
debugPrint('Matched ACK to message via direct lookup: $matchedMessageId');
|
||||
} else {
|
||||
_debugLogService?.warn(
|
||||
'PUSH_CODE_SEND_CONFIRMED: ACK hash $ackHashHex not found in direct mapping, trying fallback',
|
||||
tag: 'AckHash',
|
||||
);
|
||||
// Fallback: Check against ALL expected ACK hashes (from all retry attempts)
|
||||
debugPrint('ACK not in mapping, checking _expectedAckHashes (${_expectedAckHashes.length} messages)');
|
||||
for (var entry in _expectedAckHashes.entries) {
|
||||
@@ -411,6 +524,12 @@ 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;
|
||||
_debugLogService?.info(
|
||||
'PUSH_CODE_SEND_CONFIRMED: ACK hash $ackHashHex ✓ "$shortText" delivered to ${contact?.name ?? "unknown"} in ${tripTimeMs}ms',
|
||||
tag: 'AckHash',
|
||||
);
|
||||
|
||||
// Cancel any pending timeout or retry
|
||||
_timeoutTimers[matchedMessageId]?.cancel();
|
||||
_timeoutTimers.remove(matchedMessageId);
|
||||
@@ -448,8 +567,16 @@ class MessageRetryService extends ChangeNotifier {
|
||||
} else {
|
||||
// Check ACK history for recently completed messages
|
||||
if (_checkAckHistory(ackHash)) {
|
||||
_debugLogService?.info(
|
||||
'PUSH_CODE_SEND_CONFIRMED: ACK hash $ackHashHex matched a recently completed message (duplicate ACK)',
|
||||
tag: 'AckHash',
|
||||
);
|
||||
debugPrint('ACK matched a recently completed message from history');
|
||||
} else {
|
||||
_debugLogService?.error(
|
||||
'PUSH_CODE_SEND_CONFIRMED: ACK hash $ackHashHex has no matching message!',
|
||||
tag: 'AckHash',
|
||||
);
|
||||
debugPrint('No matching message found for ACK: $ackHashHex');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,10 +18,16 @@ class NotificationService {
|
||||
requestBadgePermission: true,
|
||||
requestSoundPermission: true,
|
||||
);
|
||||
const macSettings = DarwinInitializationSettings(
|
||||
requestAlertPermission: true,
|
||||
requestBadgePermission: true,
|
||||
requestSoundPermission: true,
|
||||
);
|
||||
|
||||
const initSettings = InitializationSettings(
|
||||
android: androidSettings,
|
||||
iOS: iosSettings,
|
||||
macOS: macSettings,
|
||||
);
|
||||
|
||||
try {
|
||||
@@ -90,9 +96,17 @@ 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(
|
||||
@@ -128,9 +142,16 @@ 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(
|
||||
@@ -169,15 +190,23 @@ 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 body = preview.isEmpty
|
||||
? 'Received new message'
|
||||
: 'Received new message: $preview';
|
||||
: preview;
|
||||
|
||||
await _notifications.show(
|
||||
channelIndex?.hashCode ?? DateTime.now().millisecondsSinceEpoch,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:async';
|
||||
import '../models/contact.dart';
|
||||
import '../models/path_selection.dart';
|
||||
import '../connector/meshcore_connector.dart';
|
||||
import '../connector/meshcore_protocol.dart';
|
||||
|
||||
@@ -11,7 +12,6 @@ class RepeaterCommandService {
|
||||
final Map<String, String> _pendingByPrefix = {};
|
||||
int _prefixCounter = 0;
|
||||
|
||||
static const int timeoutSeconds = 10; // Flood mode timeout
|
||||
static const int maxRetries = 5;
|
||||
|
||||
RepeaterCommandService(this._connector);
|
||||
@@ -23,6 +23,7 @@ class RepeaterCommandService {
|
||||
String command, {
|
||||
Function(String)? onResponse,
|
||||
Function(int)? onAttempt,
|
||||
int retries = maxRetries,
|
||||
}) async {
|
||||
final repeaterKey = repeater.publicKeyHex;
|
||||
final hasPending = _pendingCommands.keys.any((id) => id.startsWith(repeaterKey));
|
||||
@@ -30,43 +31,84 @@ class RepeaterCommandService {
|
||||
throw Exception('Another command is still awaiting a response.');
|
||||
}
|
||||
|
||||
// Create completer for this command
|
||||
final attemptCount = retries < 1 ? 1 : retries;
|
||||
final selection = await _connector.preparePathForContactSend(repeater);
|
||||
|
||||
for (int attempt = 0; attempt < attemptCount; attempt++) {
|
||||
onAttempt?.call(attempt + 1);
|
||||
try {
|
||||
final response = await _sendCommandAttempt(
|
||||
repeater,
|
||||
command,
|
||||
selection,
|
||||
attempt,
|
||||
);
|
||||
onResponse?.call(response);
|
||||
return response;
|
||||
} catch (e) {
|
||||
if (attempt == attemptCount - 1) rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
throw Exception('Command failed after $attemptCount attempts');
|
||||
}
|
||||
|
||||
Future<String> _sendCommandAttempt(
|
||||
Contact repeater,
|
||||
String command,
|
||||
PathSelection selection,
|
||||
int attempt,
|
||||
) async {
|
||||
final repeaterKey = repeater.publicKeyHex;
|
||||
final commandId = '${repeaterKey}_${DateTime.now().millisecondsSinceEpoch}';
|
||||
final completer = Completer<String>();
|
||||
_pendingCommands[commandId] = completer;
|
||||
|
||||
onAttempt?.call(0);
|
||||
|
||||
// Send frame once (no retries)
|
||||
try {
|
||||
final prefix = _nextPrefixToken();
|
||||
_commandPrefixes[commandId] = prefix;
|
||||
_pendingByPrefix[prefix] = commandId;
|
||||
final framedCommand = '$prefix$command';
|
||||
final frame = buildSendCliCommandFrame(repeater.publicKey, framedCommand, attempt: 0);
|
||||
final pathLengthValue = selection.useFlood ? -1 : selection.hopCount;
|
||||
final timestampSeconds = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||
_connector.trackRepeaterAck(
|
||||
contact: repeater,
|
||||
selection: selection,
|
||||
text: framedCommand,
|
||||
timestampSeconds: timestampSeconds,
|
||||
attempt: attempt,
|
||||
);
|
||||
final frame = buildSendCliCommandFrame(
|
||||
repeater.publicKey,
|
||||
framedCommand,
|
||||
attempt: attempt,
|
||||
timestampSeconds: timestampSeconds,
|
||||
);
|
||||
final responseBytes = frame.length > maxFrameSize ? frame.length : maxFrameSize;
|
||||
final timeoutMs = _connector.calculateTimeout(
|
||||
pathLength: pathLengthValue,
|
||||
messageBytes: responseBytes,
|
||||
);
|
||||
final timeoutSeconds = (timeoutMs / 1000).ceil();
|
||||
await _connector.sendFrame(frame);
|
||||
_commandTimeouts[commandId]?.cancel();
|
||||
_commandTimeouts[commandId] = Timer(
|
||||
Duration(milliseconds: timeoutMs),
|
||||
() {
|
||||
final completer = _pendingCommands[commandId];
|
||||
if (completer != null && !completer.isCompleted) {
|
||||
completer.completeError('Command timeout after $timeoutSeconds seconds');
|
||||
_cleanup(commandId);
|
||||
}
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
_cleanup(commandId);
|
||||
throw Exception('Failed to send command: $e');
|
||||
}
|
||||
|
||||
// Set timeout for this attempt
|
||||
_commandTimeouts[commandId]?.cancel();
|
||||
_commandTimeouts[commandId] = Timer(
|
||||
Duration(seconds: timeoutSeconds),
|
||||
() {
|
||||
final completer = _pendingCommands[commandId];
|
||||
if (completer != null && !completer.isCompleted) {
|
||||
completer.completeError('Command timeout after $timeoutSeconds seconds');
|
||||
_cleanup(commandId);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Wait for response or timeout
|
||||
try {
|
||||
final response = await completer.future;
|
||||
return response;
|
||||
return await completer.future;
|
||||
} finally {
|
||||
_cleanup(commandId);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import '../models/community.dart';
|
||||
import 'prefs_manager.dart';
|
||||
|
||||
/// Persists communities to local storage using SharedPreferences.
|
||||
///
|
||||
/// Communities are stored as a JSON array under a single key.
|
||||
/// Each community contains its secret K, so this data should
|
||||
/// be considered sensitive (though device encryption handles security).
|
||||
class CommunityStore {
|
||||
static const String _communitiesKey = 'communities_v1';
|
||||
|
||||
/// Load all communities from storage
|
||||
Future<List<Community>> loadCommunities() async {
|
||||
final prefs = PrefsManager.instance;
|
||||
final jsonString = prefs.getString(_communitiesKey);
|
||||
if (jsonString == null || jsonString.isEmpty) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
final jsonList = jsonDecode(jsonString) as List<dynamic>;
|
||||
return jsonList
|
||||
.map((json) => Community.fromJson(json as Map<String, dynamic>))
|
||||
.toList();
|
||||
} catch (e) {
|
||||
// If JSON is corrupted, return empty list
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Save all communities to storage
|
||||
Future<void> saveCommunities(List<Community> communities) async {
|
||||
final prefs = PrefsManager.instance;
|
||||
final jsonList = communities.map((c) => c.toJson()).toList();
|
||||
await prefs.setString(_communitiesKey, jsonEncode(jsonList));
|
||||
}
|
||||
|
||||
/// Add a new community
|
||||
Future<void> addCommunity(Community community) async {
|
||||
final communities = await loadCommunities();
|
||||
|
||||
// Check if community with same ID already exists
|
||||
final existingIndex = communities.indexWhere((c) => c.id == community.id);
|
||||
if (existingIndex >= 0) {
|
||||
// Replace existing
|
||||
communities[existingIndex] = community;
|
||||
} else {
|
||||
communities.add(community);
|
||||
}
|
||||
|
||||
await saveCommunities(communities);
|
||||
}
|
||||
|
||||
/// Update an existing community
|
||||
Future<void> updateCommunity(Community community) async {
|
||||
final communities = await loadCommunities();
|
||||
final index = communities.indexWhere((c) => c.id == community.id);
|
||||
if (index >= 0) {
|
||||
communities[index] = community;
|
||||
await saveCommunities(communities);
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a community by ID
|
||||
Future<void> removeCommunity(String communityId) async {
|
||||
final communities = await loadCommunities();
|
||||
communities.removeWhere((c) => c.id == communityId);
|
||||
await saveCommunities(communities);
|
||||
}
|
||||
|
||||
/// Get a community by ID
|
||||
Future<Community?> getCommunity(String communityId) async {
|
||||
final communities = await loadCommunities();
|
||||
try {
|
||||
return communities.firstWhere((c) => c.id == communityId);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a community with the same secret already exists
|
||||
/// (to prevent duplicate imports from QR scanning)
|
||||
Future<Community?> findByCommunityId(String cid) async {
|
||||
final communities = await loadCommunities();
|
||||
try {
|
||||
return communities.firstWhere((c) => c.communityId == cid);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a hashtag channel to a community
|
||||
Future<void> addHashtagChannel(
|
||||
String communityId,
|
||||
String hashtag,
|
||||
) async {
|
||||
final community = await getCommunity(communityId);
|
||||
if (community != null) {
|
||||
final updated = community.addHashtagChannel(hashtag);
|
||||
await updateCommunity(updated);
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a hashtag channel from a community
|
||||
Future<void> removeHashtagChannel(
|
||||
String communityId,
|
||||
String hashtag,
|
||||
) async {
|
||||
final community = await getCommunity(communityId);
|
||||
if (community != null) {
|
||||
final updated = community.removeHashtagChannel(hashtag);
|
||||
await updateCommunity(updated);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,7 @@ class MessageStore {
|
||||
'pathLength': msg.pathLength,
|
||||
'pathBytes': msg.pathBytes.isNotEmpty ? base64Encode(msg.pathBytes) : null,
|
||||
'reactions': msg.reactions,
|
||||
'fourByteRoomContactKey': base64Encode(msg.fourByteRoomContactKey),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -86,6 +87,9 @@ class MessageStore {
|
||||
reactions: (json['reactions'] as Map<String, dynamic>?)?.map(
|
||||
(key, value) => MapEntry(key, value as int),
|
||||
) ?? {},
|
||||
fourByteRoomContactKey: json['fourByteRoomContactKey'] != null
|
||||
? Uint8List.fromList(base64Decode(json['fourByteRoomContactKey'] as String))
|
||||
: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import '../services/app_debug_log_service.dart';
|
||||
|
||||
/// Global app logger instance
|
||||
/// Usage: appLogger.info('Message', tag: 'MyClass');
|
||||
class AppLogger {
|
||||
AppDebugLogService? _service;
|
||||
bool _enabled = false;
|
||||
|
||||
/// Initialize the logger with the debug log service
|
||||
void initialize(AppDebugLogService service, {bool enabled = false}) {
|
||||
_service = service;
|
||||
_enabled = enabled;
|
||||
_service?.setEnabled(enabled);
|
||||
}
|
||||
|
||||
/// Update whether logging is enabled
|
||||
void setEnabled(bool enabled) {
|
||||
_enabled = enabled;
|
||||
_service?.setEnabled(enabled);
|
||||
}
|
||||
|
||||
/// Check if logging is currently enabled
|
||||
bool get isEnabled => _enabled;
|
||||
|
||||
/// Log an info message
|
||||
void info(String message, {String tag = 'App'}) {
|
||||
if (_enabled && _service != null) {
|
||||
_service!.info(message, tag: tag);
|
||||
}
|
||||
}
|
||||
|
||||
/// Log a warning message
|
||||
void warn(String message, {String tag = 'App'}) {
|
||||
if (_enabled && _service != null) {
|
||||
_service!.warn(message, tag: tag);
|
||||
}
|
||||
}
|
||||
|
||||
/// Log an error message
|
||||
void error(String message, {String tag = 'App'}) {
|
||||
if (_enabled && _service != null) {
|
||||
_service!.error(message, tag: tag);
|
||||
}
|
||||
}
|
||||
|
||||
/// Log a message with custom level
|
||||
void log(String message, {String tag = 'App', AppDebugLogLevel level = AppDebugLogLevel.info}) {
|
||||
if (_enabled && _service != null) {
|
||||
_service!.log(message, tag: tag, level: level);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Global logger instance
|
||||
final appLogger = AppLogger();
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../connector/meshcore_connector.dart';
|
||||
import '../l10n/l10n.dart';
|
||||
|
||||
/// Shows a confirmation dialog before disconnecting from the device.
|
||||
/// Returns true if user confirmed and disconnect completed, false otherwise.
|
||||
@@ -7,20 +8,20 @@ Future<bool> showDisconnectDialog(
|
||||
BuildContext context,
|
||||
MeshCoreConnector connector,
|
||||
) async {
|
||||
final l10n = context.l10n;
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Disconnect'),
|
||||
content: const Text(
|
||||
'Are you sure you want to disconnect from this device?'),
|
||||
title: Text(l10n.dialog_disconnect),
|
||||
content: Text(l10n.dialog_disconnectConfirm),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('Cancel'),
|
||||
child: Text(l10n.common_cancel),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: const Text('Disconnect'),
|
||||
child: Text(l10n.common_disconnect),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../connector/meshcore_connector.dart';
|
||||
|
||||
class BatteryUi {
|
||||
final IconData icon;
|
||||
final Color? color;
|
||||
const BatteryUi(this.icon, this.color);
|
||||
}
|
||||
|
||||
BatteryUi batteryUiForPercent(int? percent) {
|
||||
if (percent == null) {
|
||||
return const BatteryUi(Icons.battery_unknown, Colors.grey);
|
||||
}
|
||||
|
||||
final p = percent.clamp(0, 100);
|
||||
|
||||
return switch (p) {
|
||||
<= 5 => const BatteryUi(Icons.battery_alert, Colors.redAccent),
|
||||
<= 15 => const BatteryUi(Icons.battery_0_bar, Colors.redAccent),
|
||||
<= 30 => const BatteryUi(Icons.battery_1_bar, Colors.orange),
|
||||
<= 45 => const BatteryUi(Icons.battery_2_bar, Colors.amber),
|
||||
<= 60 => const BatteryUi(Icons.battery_3_bar, Colors.lightGreen),
|
||||
<= 80 => const BatteryUi(Icons.battery_5_bar, Colors.green),
|
||||
_ => const BatteryUi(Icons.battery_full, Colors.green),
|
||||
};
|
||||
}
|
||||
|
||||
class BatteryIndicator extends StatefulWidget {
|
||||
final MeshCoreConnector connector;
|
||||
|
||||
const BatteryIndicator({
|
||||
super.key,
|
||||
required this.connector,
|
||||
});
|
||||
|
||||
@override
|
||||
State<BatteryIndicator> createState() => _BatteryIndicatorState();
|
||||
}
|
||||
|
||||
class _BatteryIndicatorState extends State<BatteryIndicator> {
|
||||
bool _showBatteryVoltage = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final percent = widget.connector.batteryPercent;
|
||||
final millivolts = widget.connector.batteryMillivolts;
|
||||
|
||||
if (millivolts == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final String displayText;
|
||||
if (_showBatteryVoltage) {
|
||||
displayText = '${(millivolts / 1000.0).toStringAsFixed(2)}V';
|
||||
} else {
|
||||
displayText = percent != null ? '$percent%' : '—';
|
||||
}
|
||||
|
||||
final batteryUi = batteryUiForPercent(percent);
|
||||
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_showBatteryVoltage = !_showBatteryVoltage;
|
||||
});
|
||||
},
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(batteryUi.icon, size: 18, color: batteryUi.color),
|
||||
const SizedBox(width: 2),
|
||||
Flexible(
|
||||
child: Text(
|
||||
displayText,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: batteryUi.color,
|
||||
),
|
||||
overflow: TextOverflow.visible,
|
||||
maxLines: 1,
|
||||
softWrap: false,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../l10n/l10n.dart';
|
||||
import '../connector/meshcore_protocol.dart';
|
||||
|
||||
/// Debug widget to show the hex dump of a frame
|
||||
@@ -10,23 +11,32 @@ class DebugFrameViewer {
|
||||
.join(' ');
|
||||
|
||||
final details = StringBuffer();
|
||||
details.writeln('Frame Length: ${frame.length} bytes\n');
|
||||
details.writeln('Command: 0x${frame[0].toRadixString(16).padLeft(2, '0')}');
|
||||
details.writeln(context.l10n.debugFrame_length(frame.length));
|
||||
details.writeln('');
|
||||
details.writeln(
|
||||
context.l10n.debugFrame_command(frame[0].toRadixString(16).padLeft(2, '0')),
|
||||
);
|
||||
|
||||
if (frame[0] == cmdSendTxtMsg && frame.length > 37) {
|
||||
details.writeln('\nText Message Frame:');
|
||||
details.writeln('- Destination PubKey: ${pubKeyToHex(frame.sublist(1, 33))}');
|
||||
details.writeln('- Timestamp: ${readUint32LE(frame, 33)}');
|
||||
details.writeln('- Flags: 0x${frame[37].toRadixString(16).padLeft(2, '0')}');
|
||||
details.writeln('');
|
||||
details.writeln(context.l10n.debugFrame_textMessageHeader);
|
||||
details.writeln(context.l10n.debugFrame_destinationPubKey(pubKeyToHex(frame.sublist(1, 33))));
|
||||
details.writeln(context.l10n.debugFrame_timestamp(readUint32LE(frame, 33)));
|
||||
details.writeln(
|
||||
context.l10n.debugFrame_flags(frame[37].toRadixString(16).padLeft(2, '0')),
|
||||
);
|
||||
final txtType = (frame[37] >> 2) & 0x03;
|
||||
details.writeln('- Text Type: $txtType ${txtType == txtTypeCliData ? "(CLI)" : "(Plain)"}');
|
||||
final typeLabel = txtType == txtTypeCliData
|
||||
? context.l10n.debugFrame_textTypeCli
|
||||
: context.l10n.debugFrame_textTypePlain;
|
||||
details.writeln(context.l10n.debugFrame_textType(txtType, typeLabel));
|
||||
if (frame.length > 38) {
|
||||
final textBytes = frame.sublist(38);
|
||||
final nullIdx = textBytes.indexOf(0);
|
||||
final text = String.fromCharCodes(
|
||||
nullIdx >= 0 ? textBytes.sublist(0, nullIdx) : textBytes
|
||||
);
|
||||
details.writeln('- Text: "$text"');
|
||||
details.writeln(context.l10n.debugFrame_text(text));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,9 +54,9 @@ class DebugFrameViewer {
|
||||
style: const TextStyle(fontFamily: 'monospace', fontSize: 12),
|
||||
),
|
||||
const Divider(),
|
||||
const Text(
|
||||
'Hex Dump:',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
Text(
|
||||
context.l10n.debugFrame_hexDump,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
@@ -59,7 +69,7 @@ class DebugFrameViewer {
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Close'),
|
||||
child: Text(context.l10n.common_close),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
|
||||
import '../l10n/l10n.dart';
|
||||
|
||||
/// A reusable tile widget for displaying a MeshCore device in a list
|
||||
class DeviceTile extends StatelessWidget {
|
||||
@@ -23,13 +24,13 @@ class DeviceTile extends StatelessWidget {
|
||||
return ListTile(
|
||||
leading: _buildSignalIcon(rssi),
|
||||
title: Text(
|
||||
name.isNotEmpty ? name : 'Unknown Device',
|
||||
name.isNotEmpty ? name : context.l10n.common_unknownDevice,
|
||||
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||
),
|
||||
subtitle: Text(device.remoteId.toString()),
|
||||
trailing: ElevatedButton(
|
||||
onPressed: onTap,
|
||||
child: const Text('Connect'),
|
||||
child: Text(context.l10n.common_connect),
|
||||
),
|
||||
onTap: onTap,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class FeatureToggleRow extends StatefulWidget {
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final bool value;
|
||||
final bool hasRefreshing;
|
||||
final bool isRefreshing;
|
||||
final ValueChanged<bool>? onChanged;
|
||||
final VoidCallback? onRefresh;
|
||||
final String? refreshTooltip;
|
||||
|
||||
const FeatureToggleRow({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.value,
|
||||
this.hasRefreshing = false,
|
||||
this.isRefreshing = false,
|
||||
this.onChanged,
|
||||
this.onRefresh,
|
||||
this.refreshTooltip,
|
||||
});
|
||||
|
||||
@override
|
||||
State<FeatureToggleRow> createState() => _FeatureToggleRow();
|
||||
}
|
||||
|
||||
class _FeatureToggleRow extends State<FeatureToggleRow> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SwitchListTile(
|
||||
title: Text(widget.title),
|
||||
subtitle: Text(widget.subtitle),
|
||||
value: widget.value,
|
||||
onChanged: widget.onChanged,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
if (widget.hasRefreshing)
|
||||
IconButton(
|
||||
icon: widget.isRefreshing
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.refresh, size: 20),
|
||||
onPressed: widget.isRefreshing ? null : widget.onRefresh,
|
||||
tooltip: widget.refreshTooltip,
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../l10n/l10n.dart';
|
||||
|
||||
class EmojiPicker extends StatelessWidget {
|
||||
final Function(String) onEmojiSelected;
|
||||
@@ -10,30 +12,39 @@ class EmojiPicker extends StatelessWidget {
|
||||
|
||||
static const List<String> quickEmojis = ['👍', '❤️', '😂', '🎉', '👏', '🔥'];
|
||||
|
||||
static const Map<String, List<String>> emojiCategories = {
|
||||
'Smileys': [
|
||||
static const List<String> _smileys = [
|
||||
'😀', '😃', '😄', '😁', '😅', '😂', '🤣', '😊', '😇', '🙂', '🙃', '😉', '😌', '😍', '🥰', '😘',
|
||||
'😗', '😙', '😚', '😋', '😛', '😝', '😜', '🤪', '🤨', '🧐', '🤓', '😎', '🥸', '🤩', '🥳', '😏',
|
||||
'😒', '😞', '😔', '😟', '😕', '🙁', '😣', '😖', '😫', '😩', '🥺', '😢', '😭', '😤', '😠', '😡',
|
||||
'🤬', '🤯', '😳', '🥵', '🥶', '😱', '😨', '😰', '😥', '😓', '🤗', '🤔', '🤭', '🤫', '🤥', '😶',
|
||||
],
|
||||
'Gestures': [
|
||||
];
|
||||
static const List<String> _gestures = [
|
||||
'👍', '👎', '👊', '✊', '🤛', '🤜', '🤞', '✌️', '🤟', '🤘', '👌', '🤌', '🤏', '👈', '👉', '👆',
|
||||
'👇', '☝️', '👋', '🤚', '🖐️', '✋', '🖖', '👏', '🙌', '👐', '🤲', '🤝', '🙏', '✍️', '💅', '🤳',
|
||||
],
|
||||
'Hearts': [
|
||||
];
|
||||
static const List<String> _hearts = [
|
||||
'❤️', '🧡', '💛', '💚', '💙', '💜', '🖤', '🤍', '🤎', '💔', '❤️🔥', '❤️🩹', '💕', '💞', '💓', '💗',
|
||||
'💖', '💘', '💝', '💟', '💌', '💢', '💥', '💫', '💦', '💨', '🕳️', '💬', '👁️🗨️', '🗨️', '🗯️', '💭',
|
||||
],
|
||||
'Objects': [
|
||||
];
|
||||
static const List<String> _objects = [
|
||||
'🎉', '🎊', '🎈', '🎁', '🎀', '🪅', '🪆', '🏆', '🥇', '🥈', '🥉', '⚽', '⚾', '🥎', '🏀', '🏐',
|
||||
'🏈', '🏉', '🎾', '🥏', '🎳', '🏏', '🏑', '🏒', '🥍', '🏓', '🏸', '🥊', '🥋', '🥅', '⛳', '🔥',
|
||||
'⭐', '🌟', '✨', '⚡', '💡', '🔦', '🏮', '🪔', '📱', '💻', '⌚', '📷', '📺', '📻', '🎵', '🎶',
|
||||
],
|
||||
};
|
||||
];
|
||||
|
||||
Map<String, List<String>> _emojiCategories(AppLocalizations l10n) {
|
||||
return {
|
||||
l10n.emojiCategorySmileys: _smileys,
|
||||
l10n.emojiCategoryGestures: _gestures,
|
||||
l10n.emojiCategoryHearts: _hearts,
|
||||
l10n.emojiCategoryObjects: _objects,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
final emojiCategories = _emojiCategories(l10n);
|
||||
return Container(
|
||||
height: MediaQuery.of(context).size.height * 0.5,
|
||||
decoration: BoxDecoration(
|
||||
@@ -47,9 +58,9 @@ class EmojiPicker extends StatelessWidget {
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
'Add Reaction',
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
Text(
|
||||
l10n.chat_addReaction,
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user