Compare commits

...

116 Commits

Author SHA1 Message Date
Zach 8b0bdd9a46 fix: update PRODUCT_BUNDLE_IDENTIFIER to com.monitormx.meshcoreopen 2026-01-24 01:37:19 -07:00
zjs81 45d914de57 chore: update version to 5.0.0+5 in pubspec.yaml 2026-01-24 01:26:23 -07:00
Zach 2c49534955 feat: add url_launcher_ios dependency and update project configuration 2026-01-24 01:24:56 -07:00
Zach c56cf9c3ed feat: add CocoaPods support for macOS and iOS, including necessary configurations and dependencies 2026-01-24 01:07:18 -07:00
zjs81 fee4cd13be chore: update version to 0.4.5+4 in pubspec.yaml 2026-01-24 00:52:15 -07:00
zjs81 a53d5ccfb6 Merge pull request #69 from spfmoby/better-french-translations2
More french translation updates
2026-01-24 00:50:11 -07:00
zjs81 e5d06b1c7e Merge pull request #102 from zjs81/pr-94
Pr 94
2026-01-24 00:46:48 -07:00
zjs81 e95a55e4f0 feat: add Ukrainian localization support and improve string formatting 2026-01-24 00:45:01 -07:00
zjs81 422ca941c2 Merge remote-tracking branch 'origin/main' into pr-94 2026-01-24 00:42:29 -07:00
zjs81 3098d860e9 Merge pull request #101 from zjs81/anupoh/main
Anupoh/main
2026-01-24 00:32:29 -07:00
zjs81 f0d34f7503 Update Russian localization for improved pluralization and add new chat link handling messages
- Enhanced pluralization rules for "hops" in various contexts to better reflect Russian grammar.
- Added new localization strings for chat link handling, including error messages and confirmation prompts.
- Ensured consistency in the use of plural forms across the application.
2026-01-24 00:27:45 -07:00
zjs81 daa0c3f9c3 Merge branch 'main' into anupoh/main 2026-01-24 00:22:28 -07:00
zjs81 09e1cd2b8d fix: improve BLE scanning reliability and filter out own node from contacts list improve text scaling 2026-01-24 00:17:18 -07:00
zjs81 fa514533eb feat: add ChatScrollController and JumpToBottomButton for improved chat scrolling experience
- Implemented ChatScrollController to manage scroll behavior and visibility of jump-to-bottom button.
- Added functionality to automatically scroll to the bottom when the keyboard opens.
- Created JumpToBottomButton widget that appears when the user scrolls up, allowing quick navigation back to the bottom of the chat.
2026-01-23 17:56:06 -07:00
zjs81 75b8b8af70 Merge pull request #60 from 446564/missing-tooltips
update tooltips
2026-01-23 16:47:31 -07:00
spfmoby 115667a27c More french translation updates6 2026-01-23 17:39:59 +01:00
spfmoby cfb51d96ff More french translation updates6 2026-01-23 17:39:49 +01:00
anupoh 75356fe20d Russian translation for the app
I've prepared the Russian localization files for the app. It would be great if localization were included in the app. Thanx a lot!
2026-01-23 16:58:16 +07:00
megadimich c43df67fac Ukrainian localization files 2026-01-22 15:08:42 +00:00
spfmoby e2b9b58d7d More french translation updates5 2026-01-22 10:25:42 +01:00
spfmoby d6794bc8d7 More french translation updates4 2026-01-22 08:45:54 +01:00
spfmoby 72216e2cf7 More french translation updates3 2026-01-22 08:21:09 +01:00
spfmoby 2a2275ec31 More french translation updates2 2026-01-22 08:16:58 +01:00
spfmoby dff037535d More french translation updates 2026-01-21 18:13:24 +01:00
zjs81 297e609b3e fix: replace RadioListTile with RadioGroup for better state management in community selection 2026-01-20 22:40:42 -07:00
zjs81 20171c491f fix: update iOS platform version and enable sentence capitalization in chat input fields 2026-01-20 22:28:37 -07:00
zjs81 cc43f4d198 Merge pull request #65 from zjs81/fix/message-length-safety-margin
fix: add safety margin to text message overhead calculations
2026-01-20 21:51:53 -07:00
zjs81 537384ea5b fix: add safety margin to text message overhead calculations 2026-01-20 21:50:35 -07:00
zjs81 a0be63b2e7 feat: integrate link handling in chat screen with linkify support
- Added flutter_linkify package to auto-detect and linkify URLs in chat messages.
- Implemented LinkHandler class to manage link tap confirmations and URL launching.
- Updated chat_screen.dart to use Linkify for displaying message text with links.
- Registered url_launcher plugin for handling URL launches across platforms.
- Updated pubspec.yaml and pubspec.lock to include new dependencies.
- Cleaned up untranslated.json by removing unused translations.
2026-01-20 21:42:54 -07:00
zjs81 1cc887e5bb Merge pull request #61 from 446564/remove-rcvd
remove msg notify prefix when preview avail
2026-01-20 21:11:08 -07:00
446564 26d9029538 remove msg notify prefix when preview avail
this removes the 'Received new message: ' prefix from notications
when there is a message preview available
2026-01-20 17:35:14 -08:00
446564 30bcbedf5e update tooltips
add missing tooltip:
- channels, add channel button
- map, filter nodes button
2026-01-20 17:21:44 -08:00
zjs81 3fdd8f5eaf chore: Update version to 0.4.0+4 in pubspec.yaml 2026-01-19 20:58:11 -07:00
zjs81 f4ec732de8 feat: Add community management features with QR code scanning
- Implement Community model for managing community data, including secret handling and PSK derivation.
- Create CommunityQrScannerScreen for scanning and joining communities via QR codes.
- Develop CommunityStore for persisting community data using SharedPreferences.
- Introduce QrCodeDisplay widget for displaying QR codes with customizable options.
- Add QrScannerWidget for reusable QR code scanning functionality with validation and controls.
2026-01-19 20:56:07 -07:00
zjs81 f790604d23 Merge pull request #42 from wel97459/dev-neighbours
Added Neighbors to the repeater hub and a screen to display the Neighbors
2026-01-19 19:17:00 -07:00
zjs81 8e3b563aba revert translate.py 2026-01-19 19:14:48 -07:00
zjs81 ee3b0a3126 Add untranslated messages file and update localization keys
- Added `untranslated.json` to track untranslated messages.
- Updated localization keys in various language files to use camelCase format for consistency.
- Modified `neighbours_screen.dart` to reference updated localization keys.
2026-01-19 19:13:22 -07:00
zjs81 31d633ee0b Merge main into dev-neighbours 2026-01-19 19:09:03 -07:00
zjs81 c269365d81 Merge pull request #48 from wel97459/dev-gps
Added GPS enable and GPS interval settings.
2026-01-19 19:02:13 -07:00
zjs81 9a9f59e53f localization: update GPS settings messages for clarity and consistency across multiple languages 2026-01-19 19:00:30 -07:00
zjs81 9cb667fad0 localization: fix punctuation in GPS interval settings for Spanish and Portuguese 2026-01-19 19:00:24 -07:00
zjs81 3fef594fe5 localization: update GPS settings messages and improve handling of custom variables 2026-01-19 18:56:06 -07:00
zjs81 8387304d2a Merge main into dev-gps
- Resolved localization conflicts by keeping both GPS settings and room management strings
- Merged room management features from main
- Merged map and contacts screen updates from main
2026-01-19 18:51:02 -07:00
zjs81 2acba9eb84 Merge pull request #51 from wel97459/dev-roomManagement
Added room server management
2026-01-19 18:34:51 -07:00
zjs81 30ba1799e1 localization: update room management strings in multiple languages and refactor room login handling 2026-01-19 18:29:53 -07:00
zjs81 13f9c5058a Merge branch 'main' into dev-roomManagement 2026-01-19 18:25:00 -07:00
Winston Lowe 98fc2d6e0a Updated gps setting to follow state of companion. 2026-01-19 16:57:46 -08:00
Winston Lowe 2becbb342c Added buildGetCustomVarsFrame
And added update to refreshDeviceInfo and _requestDeviceInfo.
Added parsing of Custom Vars
2026-01-19 16:55:39 -08:00
zjs81 5b2d5a494c Merge pull request #47 from ericszimmermann/main
Disable Map rotation
2026-01-19 09:26:29 -07:00
Winston Lowe 153736d36e added roomserver management 2026-01-18 21:21:33 -08:00
Winston Lowe 6c8a149e1b fix a few translations and used _neighbourCount 2026-01-18 12:01:57 -08:00
Winston Lowe b41ccee4f9 Merge branch 'main' into dev-neighbours 2026-01-18 11:27:19 -08:00
Winston Lowe 04a713bb76 Added a basic neighbours screen for repeaters 2026-01-18 11:17:47 -08:00
Winston Lowe 714aecd7e6 Added GPS enable and interval settings 2026-01-18 01:05:46 -08:00
Winston Lowe 2e1a5e0fbf added CMD_SET_CUSTOM_VAR to BLE debug 2026-01-18 01:03:45 -08:00
Winston Lowe 1f0b7d8d7b added buildSetCustomVarFrame and setCustomVar 2026-01-18 01:02:48 -08:00
ericszimmermann dffea23ce2 Merge branch 'zjs81:main' into main 2026-01-17 20:47:56 +01:00
zjs81 e0a8fb7ec0 Merge pull request #44 from mtlynch/gh-build
Add a Github Action to build code in CI
2026-01-17 11:39:37 -07:00
zjs81 06fc08c41f Merge pull request #45 from mtlynch/flutter-analyze
Fix issues flagged by flutter analyze
2026-01-17 11:38:08 -07:00
ericz c22bfed680 Merge branch 'disable_map_rotation'
Disable Map Rotation.
2026-01-17 19:30:52 +01:00
zjs81 316c76e5b4 Merge pull request #46 from ericszimmermann/main
German translation V2
2026-01-17 11:20:54 -07:00
ericz 4b215ad574 Disable Map rotation 2026-01-17 17:14:39 +01:00
ericz 09e60cebd9 German translation V2 2026-01-17 17:03:39 +01:00
Michael Lynch 6782347cf4 Fix issues flagged by flutter analyze
This fixes code quality issues that flutter analyze catches and adds a CI step to Github Actions to flag on any future issues.
2026-01-17 11:00:34 -05:00
Michael Lynch 1726119c3e Add a Github Action to build code in CI
This adds a CI workflow in Github Actions to verify that the flutter builds compile for all supported platforms.

I tried adding Windows, but it currently fails, so I excluded it from this initial set.
2026-01-17 10:48:46 -05:00
zjs81 988806dccd Merge pull request #41 from mtlynch/show-error
Show repeater login error in login dialog
2026-01-16 19:10:15 -07:00
zjs81 14ff8250c0 Add support for private and hashtag channels in localization and channel management
- Updated Polish, Portuguese, Slovak, Slovenian, Swedish, and Chinese localization files to include new strings for creating and joining private channels, as well as joining hashtag channels.
- Enhanced the channel management UI to allow users to create and join private channels, join public channels, and join channels via hashtags.
- Implemented PSK derivation from hashtags using SHA256 in the Channel model.
- Improved the translation script to handle missing keys and translate all locales efficiently.
2026-01-16 19:06:39 -07:00
Michael Lynch 2a04ebb8b6 Show repeater login error in login dialog 2026-01-16 09:35:02 -05:00
zjs81 a14462978d Replace Column with SingleChildScrollView in RepeaterLoginDialog for better layout handling 2026-01-15 21:49:54 -07:00
zjs81 df7fb45683 Merge pull request #38 from wel97459/dev-contactsPubkey
Added public key in contacts list and in the repeater hub
2026-01-15 19:26:53 -07:00
zjs81 f01eff07ff Merge pull request #37 from wel97459/dev-map
Fix map centering
2026-01-15 19:20:20 -07:00
zjs81 7cc7183e0c Refactor map initialization and zoom calculation logic in MapScreen 2026-01-15 19:15:42 -07:00
zjs81 a6b2756d0d Ran flutter format on the file 2026-01-15 19:11:13 -07:00
zjs81 614f3d4601 Add signing configuration support in build.gradle.kts 2026-01-15 18:42:20 -07:00
zjs81 7c33647119 Add key.properties support for signing configuration in build.gradle.kts 2026-01-15 18:42:20 -07:00
zjs81 fde8b686f5 Merge pull request #28 from spfmoby/better-french-translations
Replace Publicité by Annonce in the french translations
2026-01-15 18:30:55 -07:00
zjs81 9bc3a27b53 Merge pull request #30 from dennis1248/main
Update Dutch translations
2026-01-15 18:30:02 -07:00
Winston Lowe a8f387b0da Fix map centering weirdly
When nodes or markers are outside of the main area of interest.
2026-01-14 19:38:01 -08:00
Winston Lowe dd1a73c247 Repeater hub now show public key at the top 2026-01-14 19:34:41 -08:00
Winston Lowe e36f6b7eb9 changed contects list to show public keys of contect 2026-01-14 19:33:07 -08:00
Dennis ten Hoove fcef82be63 Update Dutch translations
This solves many of the most obvious errors and inconsistensies in the initial translation.
2026-01-13 11:53:54 +01:00
spfmoby 6ddb8f1a3d more fr translations / .arb and .dart synced 2026-01-13 08:27:01 +01:00
spfmoby 7a22223756 Replace Publicité by Annonce in the french translations 2026-01-12 10:18:18 +01:00
zjs81 dba639abdc Bump version to 0.3.0+3 in pubspec.yaml 2026-01-11 19:06:54 -07:00
zjs81 1483fb7f1c Add battery polling functionality to MeshCoreConnector 2026-01-11 19:02:33 -07:00
zjs81 df04f315b4 Add Privacy Policy document outlining data collection practices and user rights 2026-01-11 18:12:31 -07:00
zjs81 c0f0c58518 Refactor radio settings to use nullable types and update command generation logic for improved safety 2026-01-11 18:08:44 -07:00
zjs81 01bd8243da Refactor timeout calculations for repeater and login frames to ensure minimum message size is respected; remove obsolete widget test file. 2026-01-11 17:40:19 -07:00
zjs81 b2ce82fe7e Add localization support and translation script
- Introduced a new extension for localization in Flutter with `LocalizationExtension` in `l10n.dart`.
- Added a Python script `translate.py` for translating ARB/JSON localization files using a local Ollama model, preserving keys and placeholders, and handling ICU format rules.
2026-01-11 17:13:50 -07:00
zjs81 2495cd840f Merge pull request #16 from wel97459/dev-telemetry
Added telemetry to repeater management
2026-01-11 13:47:44 -07:00
zjs81 bc6c1f1fab Consolidate BufferReader/Writer, add response validation for repeater settings
- Move BufferReader/BufferWriter into meshcore_protocol.dart
- Refactor build functions to use BufferWriter
- Add content-based validation for CLI responses over LoRa
- Add individual refresh buttons for TX power and feature toggles
- Hide unimplemented features (Privacy Mode, Encrypted Advert Interval)
2026-01-11 13:44:01 -07:00
zjs81 310818f9d3 Merge pull request #27 from zjs81/dev-roomserver-fixes
Dev roomserver fixes
2026-01-11 11:52:45 -07:00
zjs81 8c3ffa5472 Refactor code for improved readability and null safety in various files Also updated PR to allow login via map. 2026-01-11 11:51:40 -07:00
zjs81 be3b920b3f Merge branch 'main' into dev-roomserver 2026-01-11 11:36:14 -07:00
zjs81 7703aaafc6 Merge pull request #26 from zjs81/dev-MapManageRepeater
Dev map manage repeater
2026-01-11 11:24:15 -07:00
zjs81 1ba3f3ac49 Merge branch 'main' into dev-MapManageRepeater 2026-01-11 11:21:21 -07:00
zjs81 ffbfd1a40c Refactor Manage Repeater button to close dialog before opening login 2026-01-11 11:17:23 -07:00
Winston Lowe ab7cc84db5 moved roomserver chat into chat_screen 2026-01-09 23:44:42 -08:00
Winston Lowe f3aef42331 changed noification to support messages from room server. 2026-01-09 00:04:30 -08:00
Winston Lowe 367f89fb1b Added value to Message fourByteRoomContactKey which holds the first 4 bytes of the contacts pub key that posted the message to the room. 2026-01-09 00:03:50 -08:00
zjs81 fe57963a26 Merge pull request #17 from wel97459/dev-icon-color
Fixed icons not being visible in Dark mode
2026-01-08 14:48:04 -07:00
Winston Lowe fca810737d Working on Parsing room server messages. 2026-01-08 12:58:27 -08:00
Winston Lowe 35e866abfb Add login for room servers 2026-01-07 23:31:09 -08:00
Winston Lowe ffce582b3b Change debug messages that I left and forgot 2026-01-07 10:45:30 -08:00
Winston Lowe 8c73359125 Fixed icons not being visible in Dark mode 2026-01-07 01:16:12 -08:00
Winston Lowe 401a3842ca Added loading message 2026-01-07 01:00:34 -08:00
Winston Lowe 2993ec1f49 Add to CayenneLpp parseByChannel function, and got basic ui working. 2026-01-07 00:53:56 -08:00
Winston Lowe c306ad798c Added telemetry to repeater interface. 2026-01-07 00:50:20 -08:00
Winston Lowe f5be9b9691 Added Manage Repeater to contact dialog from map view. 2026-01-05 16:41:46 -08:00
zach e3d7607db9 fix overflowing widget and also add network perms for mac 2026-01-02 15:32:46 -07:00
zach c44f0d1ae2 add notification perms 2026-01-02 14:58:13 -07:00
zach cd9f14dd09 update version 2026-01-02 14:50:11 -07:00
zach ad911a1d80 Add advanced path management, debug logging, and fix channel sync
New features:
- In-app debug log viewer with copy/clear functionality
- Advanced path management UI with history and custom path builder
- Battery indicator widget with voltage/percentage toggle
- Contact/channel filtering and sorting improvements
- Repeater command ACK tracking with path history integration

Fixes:
- Switch channel sync from parallel to sequential to prevent timeouts
- Preserve path overrides when contacts refresh from device
- Fix ACK hash computation for SMAZ-encoded messages
- Proper cleanup of pending operations on disconnect
2026-01-02 14:22:39 -07:00
zach 361dfb7808 update readme 2025-12-31 23:19:12 -07:00
zach ad187962c9 add imgs 2025-12-31 23:17:34 -07:00
zjs81 b7eec5627f Remove duplicate acknowledgment 2025-12-31 22:48:33 -07:00
131 changed files with 80000 additions and 2976 deletions
+76
View File
@@ -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
+23
View File
@@ -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
+1
View File
@@ -70,6 +70,7 @@ secrets.dart
**/android/local.properties
**/android/.externalNativeBuild/
*.jks
key.properties
keystore.properties
# Generated files
+12 -1
View File
@@ -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)
+25 -3
View File
@@ -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")
}
}
}
+12
View File
@@ -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

+104
View File
@@ -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
View File
@@ -1 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
#include "Generated.xcconfig"
+1
View File
@@ -1 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
#include "Generated.xcconfig"
+1 -3
View File
@@ -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
+152
View File
@@ -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
+74 -6
View File
@@ -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
View File
@@ -4,4 +4,7 @@
<FileRef
location = "group:Runner.xcodeproj">
</FileRef>
<FileRef
location = "group:Pods/Pods.xcodeproj">
</FileRef>
</Workspace>
+7
View File
@@ -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>
+6
View File
@@ -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
+263 -167
View File
@@ -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();
}
+261
View File
@@ -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;
}
}
+68
View File
@@ -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();
}
}
+76
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+1537
View File
File diff suppressed because it is too large Load Diff
+1312
View File
File diff suppressed because it is too large Load Diff
+1537
View File
File diff suppressed because it is too large Load Diff
+1537
View File
File diff suppressed because it is too large Load Diff
+1537
View File
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
View File
File diff suppressed because it is too large Load Diff
+1537
View File
File diff suppressed because it is too large Load Diff
+1537
View File
File diff suppressed because it is too large Load Diff
+778
View File
@@ -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.04.2 В)",
"appSettings_batteryLifepo4": "LiFePO4 (2.63.65 В)",
"appSettings_batteryLipo": "LiPo (3.04.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": "3002500 МГц",
"repeater_txPower": "Мощность передачи",
"repeater_txPowerHelper": "130 дБм",
"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
View File
File diff suppressed because it is too large Load Diff
+1537
View File
File diff suppressed because it is too large Load Diff
+1537
View File
File diff suppressed because it is too large Load Diff
+1538
View File
File diff suppressed because it is too large Load Diff
+1537
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -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
View File
@@ -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);
}
}
+13
View File
@@ -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,
);
}
+41
View File
@@ -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);
}
+243
View File
@@ -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
View File
@@ -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;
+5
View File
@@ -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,
);
}
+107
View File
@@ -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);
}
}
}
+303 -175
View File
@@ -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),
),
);
},
),
],
),
+11 -10
View File
@@ -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),
),
],
),
+193 -117
View File
@@ -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(
'$privacyUnread: $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)),
);
}
+86 -57
View File
@@ -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;
}
File diff suppressed because it is too large Load Diff
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
View File
@@ -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);
}
}
+12 -9
View File
@@ -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,
+45 -34
View File
@@ -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),
);
}
}
File diff suppressed because it is too large Load Diff
+456
View File
@@ -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),
),
),
),
],
),
);
}
}
+239 -177
View File
@@ -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),
),
],
),
+170 -107
View File
@@ -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
+147 -42
View File
@@ -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}) {
+12 -10
View File
@@ -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,
),
);
File diff suppressed because it is too large Load Diff
+433
View File
@@ -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),
);
}
}
+92
View File
@@ -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();
}
}
+11
View File
@@ -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;
+46 -11
View File
@@ -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;
}
}
+161 -34
View File
@@ -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');
}
}
+30 -1
View File
@@ -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,
+64 -22
View File
@@ -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);
}
+117
View File
@@ -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);
}
}
}
+4
View File
@@ -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,
);
}
}
+55
View File
@@ -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();
+6 -5
View File
@@ -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),
),
],
),
+94
View File
@@ -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,
),
),
],
),
),
);
}
}
+22 -12
View File
@@ -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),
),
],
),
+3 -2
View File
@@ -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,
);
+59
View File
@@ -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,
),
],
);
}
}
+24 -13
View File
@@ -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