Compare commits

...

113 Commits

Author SHA1 Message Date
Zach 92d2b224e7 fix: address PR review issues
- Fix memory leak by adding dispose() to remove connection listener
- Fix typo: changedNavgation -> _changedNavigation
- Add mounted check before navigation to prevent errors
- Remove overly aggressive _handleDisconnection() call on battery request failure
- Only reset battery flag on error to allow retry without disconnecting
2026-01-28 21:29:18 -07:00
Winston Lowe 34a6b5d895 Added error catching to requestBatteryStatus
to call _handleDisconnection when it fails update.

Updated ScannerScreen to manage navigation state logic on connection.
2026-01-28 20:13:40 -08:00
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
122 changed files with 77380 additions and 2413 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/local.properties
**/android/.externalNativeBuild/ **/android/.externalNativeBuild/
*.jks *.jks
key.properties
keystore.properties keystore.properties
# Generated files # Generated files
+25 -3
View File
@@ -1,3 +1,5 @@
import java.util.Properties
plugins { plugins {
id("com.android.application") id("com.android.application")
id("kotlin-android") id("kotlin-android")
@@ -5,6 +7,12 @@ plugins {
id("dev.flutter.flutter-gradle-plugin") 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 { android {
namespace = "com.meshcore.meshcore_open" namespace = "com.meshcore.meshcore_open"
compileSdk = flutter.compileSdkVersion 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 { buildTypes {
release { release {
// TODO: Add your own signing config for the release build. signingConfig = if (keystorePropertiesFile.exists()) {
// Signing with the debug keys for now, so `flutter run --release` works. signingConfigs.getByName("release")
signingConfig = signingConfigs.getByName("debug") } 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"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE"/> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/> <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 <application
android:label="meshcore_open" android:label="meshcore_open"
@@ -64,5 +67,14 @@
<action android:name="android.intent.action.PROCESS_TEXT"/> <action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/> <data android:mimeType="text/plain"/>
</intent> </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> </queries>
</manifest> </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.
+1
View File
@@ -1 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
#include "Generated.xcconfig" #include "Generated.xcconfig"
+1
View File
@@ -1 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
#include "Generated.xcconfig" #include "Generated.xcconfig"
+1 -1
View File
@@ -1,4 +1,4 @@
platform :ios, '12.0' platform :ios, '15.5'
ENV['COCOAPODS_DISABLE_STATS'] = 'true' ENV['COCOAPODS_DISABLE_STATS'] = 'true'
+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 */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; 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 */ /* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */ /* Begin PBXContainerItemProxy section */
@@ -42,9 +43,13 @@
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; }; 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>"; }; 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>"; }; 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; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
@@ -62,6 +67,7 @@
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
9A698254711B63C3940A64CB /* libPods-Runner.a in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -94,6 +100,8 @@
97C146F01CF9000F007C117D /* Runner */, 97C146F01CF9000F007C117D /* Runner */,
97C146EF1CF9000F007C117D /* Products */, 97C146EF1CF9000F007C117D /* Products */,
331C8082294A63A400263BE5 /* RunnerTests */, 331C8082294A63A400263BE5 /* RunnerTests */,
DEE6F094D3B70E76087722E1 /* Pods */,
DAE613E34DF694C2E33B64C7 /* Frameworks */,
); );
sourceTree = "<group>"; sourceTree = "<group>";
}; };
@@ -121,6 +129,25 @@
path = Runner; path = Runner;
sourceTree = "<group>"; 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 */ /* End PBXGroup section */
/* Begin PBXNativeTarget section */ /* Begin PBXNativeTarget section */
@@ -145,12 +172,14 @@
isa = PBXNativeTarget; isa = PBXNativeTarget;
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
buildPhases = ( buildPhases = (
DE3B2E091393835C0B38492E /* [CP] Check Pods Manifest.lock */,
9740EEB61CF901F6004384FC /* Run Script */, 9740EEB61CF901F6004384FC /* Run Script */,
97C146EA1CF9000F007C117D /* Sources */, 97C146EA1CF9000F007C117D /* Sources */,
97C146EB1CF9000F007C117D /* Frameworks */, 97C146EB1CF9000F007C117D /* Frameworks */,
97C146EC1CF9000F007C117D /* Resources */, 97C146EC1CF9000F007C117D /* Resources */,
9705A1C41CF9048500538489 /* Embed Frameworks */, 9705A1C41CF9048500538489 /* Embed Frameworks */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */,
B788CEDB957A87EE8AC593BB /* [CP] Copy Pods Resources */,
); );
buildRules = ( buildRules = (
); );
@@ -253,6 +282,45 @@
shellPath = /bin/sh; shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; 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 */ /* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */
@@ -368,7 +436,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
PRODUCT_BUNDLE_IDENTIFIER = com.meshcore.meshcoreOpen; PRODUCT_BUNDLE_IDENTIFIER = com.monitormx.meshcoreopen;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
@@ -384,7 +452,7 @@
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.meshcore.meshcoreOpen.RunnerTests; PRODUCT_BUNDLE_IDENTIFIER = com.monitormx.meshcoreopen.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_OPTIMIZATION_LEVEL = "-Onone";
@@ -401,7 +469,7 @@
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.meshcore.meshcoreOpen.RunnerTests; PRODUCT_BUNDLE_IDENTIFIER = com.monitormx.meshcoreopen.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
@@ -416,7 +484,7 @@
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.meshcore.meshcoreOpen.RunnerTests; PRODUCT_BUNDLE_IDENTIFIER = com.monitormx.meshcoreopen.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
@@ -547,7 +615,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
PRODUCT_BUNDLE_IDENTIFIER = com.meshcore.meshcoreOpen; PRODUCT_BUNDLE_IDENTIFIER = com.monitormx.meshcoreopen;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_OPTIMIZATION_LEVEL = "-Onone";
@@ -569,7 +637,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
PRODUCT_BUNDLE_IDENTIFIER = com.meshcore.meshcoreOpen; PRODUCT_BUNDLE_IDENTIFIER = com.monitormx.meshcoreopen;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
+3
View File
@@ -4,4 +4,7 @@
<FileRef <FileRef
location = "group:Runner.xcodeproj"> location = "group:Runner.xcodeproj">
</FileRef> </FileRef>
<FileRef
location = "group:Pods/Pods.xcodeproj">
</FileRef>
</Workspace> </Workspace>
+7
View File
@@ -53,5 +53,12 @@
<string>This app uses Bluetooth to communicate with MeshCore devices.</string> <string>This app uses Bluetooth to communicate with MeshCore devices.</string>
<key>NSBluetoothPeripheralUsageDescription</key> <key>NSBluetoothPeripheralUsageDescription</key>
<string>This app uses Bluetooth to communicate with MeshCore devices.</string> <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> </dict>
</plist> </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
+262 -168
View File
@@ -1,6 +1,105 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:typed_data'; 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) // Command codes (to device)
const int cmdAppStart = 1; const int cmdAppStart = 1;
const int cmdSendTxtMsg = 2; const int cmdSendTxtMsg = 2;
@@ -29,6 +128,10 @@ const int cmdGetContactByKey = 30;
const int cmdGetChannel = 31; const int cmdGetChannel = 31;
const int cmdSetChannel = 32; const int cmdSetChannel = 32;
const int cmdGetRadioSettings = 57; const int cmdGetRadioSettings = 57;
const int cmdGetTelemetryReq = 39;
const int cmdGetCustomVar = 40;
const int cmdSetCustomVar = 41;
const int cmdSendBinaryReq = 50;
// Text message types // Text message types
const int txtTypePlain = 0; const int txtTypePlain = 0;
@@ -62,6 +165,7 @@ const int respCodeContactMsgRecvV3 = 16;
const int respCodeChannelMsgRecvV3 = 17; const int respCodeChannelMsgRecvV3 = 17;
const int respCodeChannelInfo = 18; const int respCodeChannelInfo = 18;
const int respCodeRadioSettings = 25; const int respCodeRadioSettings = 25;
const int respCodeCustomVars = 21;
// Push codes (async from device) // Push codes (async from device)
const int pushCodeAdvert = 0x80; const int pushCodeAdvert = 0x80;
@@ -73,6 +177,8 @@ const int pushCodeLoginFail = 0x86;
const int pushCodeStatusResponse = 0x87; const int pushCodeStatusResponse = 0x87;
const int pushCodeLogRxData = 0x88; const int pushCodeLogRxData = 0x88;
const int pushCodeNewAdvert = 0x8A; const int pushCodeNewAdvert = 0x8A;
const int pushCodeTelemetryResponse = 0x8B;
const int pushCodeBinaryResponse = 0x8C;
// Contact/advertisement types // Contact/advertisement types
const int advTypeChat = 1; const int advTypeChat = 1;
@@ -89,8 +195,8 @@ const int maxFrameSize = 172;
const int appProtocolVersion = 3; const int appProtocolVersion = 3;
// Matches firmware MAX_TEXT_LEN (10 * CIPHER_BLOCK_SIZE). // Matches firmware MAX_TEXT_LEN (10 * CIPHER_BLOCK_SIZE).
const int maxTextPayloadBytes = 160; const int maxTextPayloadBytes = 160;
const int _sendTextMsgOverheadBytes = 1 + 1 + 1 + 4 + 6 + 1; const int _sendTextMsgOverheadBytes = 1 + 1 + 1 + 4 + 6 + 1 + 2; // +2 safety margin
const int _sendChannelTextMsgOverheadBytes = 1 + 1 + 1 + 4 + 1; const int _sendChannelTextMsgOverheadBytes = 1 + 1 + 1 + 4 + 1 + 2; // +2 safety margin
int maxContactMessageBytes() { int maxContactMessageBytes() {
final byFrame = maxFrameSize - _sendTextMsgOverheadBytes; final byFrame = maxFrameSize - _sendTextMsgOverheadBytes;
@@ -140,10 +246,7 @@ class ParsedContactText {
final Uint8List senderPrefix; final Uint8List senderPrefix;
final String text; final String text;
const ParsedContactText({ const ParsedContactText({required this.senderPrefix, required this.text});
required this.senderPrefix,
required this.text,
});
} }
ParsedContactText? parseContactMessageText(Uint8List frame) { ParsedContactText? parseContactMessageText(Uint8List frame) {
@@ -172,10 +275,17 @@ ParsedContactText? parseContactMessageText(Uint8List frame) {
return null; 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) { if (text.isEmpty && frame.length > baseTextOffset + 4) {
text = text = readCString(
readCString(frame, baseTextOffset + 4, frame.length - (baseTextOffset + 4)).trim(); frame,
baseTextOffset + 4,
frame.length - (baseTextOffset + 4),
).trim();
} }
if (text.isEmpty) return null; if (text.isEmpty) return null;
@@ -203,19 +313,6 @@ int readInt32LE(Uint8List data, int offset) {
return val; 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 // Helper to read null-terminated UTF-8 string
String readCString(Uint8List data, int offset, int maxLen) { String readCString(Uint8List data, int offset, int maxLen) {
int end = offset; int end = offset;
@@ -246,34 +343,32 @@ Uint8List hexToPubKey(String hex) {
// Build CMD_GET_CONTACTS frame // Build CMD_GET_CONTACTS frame
Uint8List buildGetContactsFrame({int? since}) { Uint8List buildGetContactsFrame({int? since}) {
final writer = BufferWriter();
writer.writeByte(cmdGetContacts);
if (since != null) { if (since != null) {
final frame = Uint8List(5); writer.writeUInt32LE(since);
frame[0] = cmdGetContacts;
writeUint32LE(frame, 1, since);
return frame;
} }
return Uint8List.fromList([cmdGetContacts]); return writer.toBytes();
} }
// Build CMD_SEND_LOGIN frame // Build CMD_SEND_LOGIN frame
// Format: [cmd][pub_key x32][password...]\0 // Format: [cmd][pub_key x32][password...]\0
Uint8List buildSendLoginFrame(Uint8List recipientPubKey, String password) { Uint8List buildSendLoginFrame(Uint8List recipientPubKey, String password) {
final passwordBytes = utf8.encode(password); final writer = BufferWriter();
final frame = Uint8List(1 + pubKeySize + passwordBytes.length + 1); writer.writeByte(cmdSendLogin);
frame[0] = cmdSendLogin; writer.writeBytes(recipientPubKey);
frame.setRange(1, 1 + pubKeySize, recipientPubKey); writer.writeString(password);
frame.setRange(1 + pubKeySize, 1 + pubKeySize + passwordBytes.length, passwordBytes); writer.writeByte(0);
frame[frame.length - 1] = 0; return writer.toBytes();
return frame;
} }
// Build CMD_SEND_STATUS_REQ frame // Build CMD_SEND_STATUS_REQ frame
// Format: [cmd][pub_key x32] // Format: [cmd][pub_key x32]
Uint8List buildSendStatusRequestFrame(Uint8List recipientPubKey) { Uint8List buildSendStatusRequestFrame(Uint8List recipientPubKey) {
final frame = Uint8List(1 + pubKeySize); final writer = BufferWriter();
frame[0] = cmdSendStatusReq; writer.writeByte(cmdSendStatusReq);
frame.setRange(1, 1 + pubKeySize, recipientPubKey); writer.writeBytes(recipientPubKey);
return frame; return writer.toBytes();
} }
// Build CMD_SEND_TXT_MSG frame (companion_radio format) // Build CMD_SEND_TXT_MSG frame (companion_radio format)
@@ -284,48 +379,39 @@ Uint8List buildSendTextMsgFrame(
int attempt = 0, int attempt = 0,
int? timestampSeconds, int? timestampSeconds,
}) { }) {
final textBytes = utf8.encode(text); final timestamp =
final timestamp = timestampSeconds ?? (DateTime.now().millisecondsSinceEpoch ~/ 1000); timestampSeconds ?? (DateTime.now().millisecondsSinceEpoch ~/ 1000);
const prefixSize = 6; final writer = BufferWriter();
final safeAttempt = attempt.clamp(0, 3); writer.writeByte(cmdSendTxtMsg);
final frame = Uint8List(1 + 1 + 1 + 4 + prefixSize + textBytes.length + 1); writer.writeByte(txtTypePlain);
int offset = 0; writer.writeByte(attempt.clamp(0, 3));
writer.writeUInt32LE(timestamp);
frame[offset++] = cmdSendTxtMsg; writer.writeBytes(recipientPubKey.sublist(0, 6));
frame[offset++] = txtTypePlain; writer.writeString(text);
frame[offset++] = safeAttempt; writer.writeByte(0);
writeUint32LE(frame, offset, timestamp); return writer.toBytes();
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;
} }
// Build CMD_SEND_CHANNEL_TXT_MSG frame // Build CMD_SEND_CHANNEL_TXT_MSG frame
// Format: [cmd][txt_type][channel_idx][timestamp x4][text...] // Format: [cmd][txt_type][channel_idx][timestamp x4][text...]
Uint8List buildSendChannelTextMsgFrame(int channelIndex, String text) { Uint8List buildSendChannelTextMsgFrame(int channelIndex, String text) {
final textBytes = utf8.encode(text);
final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000; final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000;
final frame = Uint8List(1 + 1 + 1 + 4 + textBytes.length + 1); final writer = BufferWriter();
frame[0] = cmdSendChannelTxtMsg; writer.writeByte(cmdSendChannelTxtMsg);
frame[1] = 0; // TXT_TYPE_PLAIN writer.writeByte(txtTypePlain);
frame[2] = channelIndex; writer.writeByte(channelIndex);
writeUint32LE(frame, 3, timestamp); writer.writeUInt32LE(timestamp);
frame.setRange(7, 7 + textBytes.length, textBytes); writer.writeString(text);
frame[frame.length - 1] = 0; // null terminator writer.writeByte(0);
return frame; return writer.toBytes();
} }
// Build CMD_REMOVE_CONTACT frame // Build CMD_REMOVE_CONTACT frame
Uint8List buildRemoveContactFrame(Uint8List pubKey) { Uint8List buildRemoveContactFrame(Uint8List pubKey) {
final frame = Uint8List(1 + pubKeySize); final writer = BufferWriter();
frame[0] = cmdRemoveContact; writer.writeByte(cmdRemoveContact);
frame.setRange(1, 1 + pubKeySize, pubKey); writer.writeBytes(pubKey);
return frame; return writer.toBytes();
} }
// Build CMD_APP_START frame // Build CMD_APP_START frame
@@ -334,14 +420,13 @@ Uint8List buildAppStartFrame({
String appName = 'MeshCoreOpen', String appName = 'MeshCoreOpen',
int appVersion = 1, int appVersion = 1,
}) { }) {
final nameBytes = utf8.encode(appName); final writer = BufferWriter();
final frame = Uint8List(8 + nameBytes.length + 1); writer.writeByte(cmdAppStart);
frame[0] = cmdAppStart; writer.writeByte(appVersion);
frame[1] = appVersion; writer.writeBytes(Uint8List(6)); // reserved bytes
// bytes 2-7 are reserved (zeros) writer.writeString(appName);
frame.setRange(8, 8 + nameBytes.length, nameBytes); writer.writeByte(0);
frame[frame.length - 1] = 0; // null terminator return writer.toBytes();
return frame;
} }
// Build CMD_DEVICE_QUERY frame // Build CMD_DEVICE_QUERY frame
@@ -361,10 +446,10 @@ Uint8List buildGetBattAndStorageFrame() {
// Build CMD_SET_DEVICE_TIME frame // Build CMD_SET_DEVICE_TIME frame
Uint8List buildSetDeviceTimeFrame(int timestamp) { Uint8List buildSetDeviceTimeFrame(int timestamp) {
final frame = Uint8List(5); final writer = BufferWriter();
frame[0] = cmdSetDeviceTime; writer.writeByte(cmdSetDeviceTime);
writeUint32LE(frame, 1, timestamp); writer.writeUInt32LE(timestamp);
return frame; return writer.toBytes();
} }
// Build CMD_SEND_SELF_ADVERT frame // Build CMD_SEND_SELF_ADVERT frame
@@ -377,21 +462,31 @@ Uint8List buildSendSelfAdvertFrame({bool flood = false}) {
// Format: [cmd][name...] // Format: [cmd][name...]
Uint8List buildSetAdvertNameFrame(String name) { Uint8List buildSetAdvertNameFrame(String name) {
final nameBytes = utf8.encode(name); final nameBytes = utf8.encode(name);
final nameLen = nameBytes.length < maxNameSize ? nameBytes.length : maxNameSize - 1; final nameLen = nameBytes.length < maxNameSize
final frame = Uint8List(1 + nameLen); ? nameBytes.length
frame[0] = cmdSetAdvertName; : maxNameSize - 1;
frame.setRange(1, 1 + nameLen, nameBytes.sublist(0, nameLen)); final writer = BufferWriter();
return frame; writer.writeByte(cmdSetAdvertName);
writer.writeBytes(Uint8List.fromList(nameBytes.sublist(0, nameLen)));
return writer.toBytes();
} }
// Build CMD_SET_ADVERT_LATLON frame // Build CMD_SET_ADVERT_LATLON frame
// Format: [cmd][lat x4][lon x4] // Format: [cmd][lat x4][lon x4]
Uint8List buildSetAdvertLatLonFrame(double lat, double lon) { Uint8List buildSetAdvertLatLonFrame(double lat, double lon) {
final frame = Uint8List(9); final writer = BufferWriter();
frame[0] = cmdSetAdvertLatLon; writer.writeByte(cmdSetAdvertLatLon);
writeInt32LE(frame, 1, (lat * 1000000).round()); writer.writeInt32LE((lat * 1000000).round());
writeInt32LE(frame, 5, (lon * 1000000).round()); writer.writeInt32LE((lon * 1000000).round());
return frame; 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 // Build CMD_REBOOT frame
@@ -413,21 +508,17 @@ Uint8List buildGetChannelFrame(int channelIndex) {
// Build CMD_SET_CHANNEL frame // Build CMD_SET_CHANNEL frame
// Format: [cmd][idx][name x32][psk x16] // Format: [cmd][idx][name x32][psk x16]
Uint8List buildSetChannelFrame(int channelIndex, String name, Uint8List psk) { Uint8List buildSetChannelFrame(int channelIndex, String name, Uint8List psk) {
final frame = Uint8List(2 + 32 + 16); final writer = BufferWriter();
frame[0] = cmdSetChannel; writer.writeByte(cmdSetChannel);
frame[1] = channelIndex; writer.writeByte(channelIndex);
// Write name (max 32 bytes UTF-8, null-padded) writer.writeCString(name, 32);
final nameBytes = utf8.encode(name); // Write PSK (16 bytes, zero-padded)
final nameLen = nameBytes.length < 32 ? nameBytes.length : 31; // Reserve 1 byte for null final pskPadded = Uint8List(16);
for (int i = 0; i < nameLen; i++) {
frame[2 + i] = nameBytes[i];
}
// frame[2 + nameLen] is already 0 (null terminator)
// Write PSK (16 bytes)
for (int i = 0; i < 16 && i < psk.length; i++) { 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 // Build CMD_SET_RADIO_PARAMS frame
@@ -437,13 +528,13 @@ Uint8List buildSetChannelFrame(int channelIndex, String name, Uint8List psk) {
// sf: spreading factor (5-12) // sf: spreading factor (5-12)
// cr: coding rate (5-8) // cr: coding rate (5-8)
Uint8List buildSetRadioParamsFrame(int freqHz, int bwHz, int sf, int cr) { Uint8List buildSetRadioParamsFrame(int freqHz, int bwHz, int sf, int cr) {
final frame = Uint8List(11); final writer = BufferWriter();
frame[0] = cmdSetRadioParams; writer.writeByte(cmdSetRadioParams);
writeUint32LE(frame, 1, freqHz); writer.writeUInt32LE(freqHz);
writeUint32LE(frame, 5, bwHz); writer.writeUInt32LE(bwHz);
frame[9] = sf; writer.writeByte(sf);
frame[10] = cr; writer.writeByte(cr);
return frame; return writer.toBytes();
} }
// Build CMD_SET_RADIO_TX_POWER frame // Build CMD_SET_RADIO_TX_POWER frame
@@ -455,10 +546,10 @@ Uint8List buildSetRadioTxPowerFrame(int powerDbm) {
// Build CMD_RESET_PATH frame // Build CMD_RESET_PATH frame
// Format: [cmd][pub_key x32] // Format: [cmd][pub_key x32]
Uint8List buildResetPathFrame(Uint8List pubKey) { Uint8List buildResetPathFrame(Uint8List pubKey) {
final frame = Uint8List(1 + pubKeySize); final writer = BufferWriter();
frame[0] = cmdResetPath; writer.writeByte(cmdResetPath);
frame.setRange(1, 1 + pubKeySize, pubKey); writer.writeBytes(pubKey);
return frame; return writer.toBytes();
} }
// Build CMD_ADD_UPDATE_CONTACT frame to set custom path // Build CMD_ADD_UPDATE_CONTACT frame to set custom path
@@ -471,50 +562,42 @@ Uint8List buildUpdateContactPathFrame(
int flags = 0, int flags = 0,
String name = '', String name = '',
}) { }) {
// Frame size: 1 + 32 + 1 + 1 + 1 + 64 + 32 + 4 = 136 bytes minimum final writer = BufferWriter();
final frame = Uint8List(1 + pubKeySize + 1 + 1 + 1 + maxPathSize + maxNameSize + 4); writer.writeByte(cmdAddUpdateContact);
int offset = 0; writer.writeBytes(pubKey);
writer.writeByte(type);
writer.writeByte(flags);
writer.writeByte(pathLen);
frame[offset++] = cmdAddUpdateContact; // Path data (64 bytes, zero-padded)
final pathPadded = Uint8List(maxPathSize);
// 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;
if (customPath.isNotEmpty && pathLen > 0) { if (customPath.isNotEmpty && pathLen > 0) {
final copyLen = customPath.length < maxPathSize ? customPath.length : maxPathSize; final copyLen = customPath.length < maxPathSize
frame.setRange(offset, offset + copyLen, customPath.sublist(0, copyLen)); ? customPath.length
: maxPathSize;
for (int i = 0; i < copyLen; i++) {
pathPadded[i] = customPath[i];
}
} }
offset += maxPathSize; writer.writeBytes(pathPadded);
// Name (32 bytes, null-padded) // Name (32 bytes, null-padded)
if (name.isNotEmpty) { writer.writeCString(name, maxNameSize);
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;
// Timestamp (current time) // Timestamp
final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000; 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 // Build CMD_GET_CONTACT_BY_KEY frame
// Format: [cmd][pub_key x32] // Format: [cmd][pub_key x32]
Uint8List buildGetContactByKeyFrame(Uint8List pubKey) { Uint8List buildGetContactByKeyFrame(Uint8List pubKey) {
final frame = Uint8List(1 + pubKeySize); final writer = BufferWriter();
frame[0] = cmdGetContactByKey; writer.writeByte(cmdGetContactByKey);
frame.setRange(1, 1 + pubKeySize, pubKey); writer.writeBytes(pubKey);
return frame; return writer.toBytes();
} }
// Build CMD_GET_RADIO_SETTINGS frame // Build CMD_GET_RADIO_SETTINGS frame
@@ -522,6 +605,11 @@ Uint8List buildGetRadioSettingsFrame() {
return Uint8List.fromList([cmdGetRadioSettings]); return Uint8List.fromList([cmdGetRadioSettings]);
} }
//Build CMD_GET_CUSTOM_VARS frame
Uint8List buildGetCustomVarsFrame() {
return Uint8List.fromList([cmdGetCustomVar]);
}
// Calculate LoRa airtime for a packet // Calculate LoRa airtime for a packet
// Based on Semtech SX127x datasheet formula // Based on Semtech SX127x datasheet formula
// Returns airtime in milliseconds // Returns airtime in milliseconds
@@ -545,9 +633,11 @@ int calculateLoRaAirtime({
final crc = 1; // CRC enabled final crc = 1; // CRC enabled
final de = lowDataRateOptimize ? 1 : 0; 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); 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) { if (payloadSymbols < 0) {
payloadSymbols = 8; payloadSymbols = 8;
@@ -594,23 +684,27 @@ Uint8List buildSendCliCommandFrame(
int attempt = 0, int attempt = 0,
int? timestampSeconds, int? timestampSeconds,
}) { }) {
final textBytes = utf8.encode(command); final timestamp =
final timestamp = timestampSeconds ?? (DateTime.now().millisecondsSinceEpoch ~/ 1000); timestampSeconds ?? (DateTime.now().millisecondsSinceEpoch ~/ 1000);
const prefixSize = 6; final writer = BufferWriter();
final safeAttempt = attempt.clamp(0, 3); writer.writeByte(cmdSendTxtMsg);
final frame = Uint8List(1 + 1 + 1 + 4 + prefixSize + textBytes.length + 1); writer.writeByte(txtTypeCliData);
int offset = 0; writer.writeByte(attempt.clamp(0, 3));
writer.writeUInt32LE(timestamp);
frame[offset++] = cmdSendTxtMsg; writer.writeBytes(repeaterPubKey.sublist(0, 6));
frame[offset++] = txtTypeCliData; writer.writeString(command);
frame[offset++] = safeAttempt; writer.writeByte(0);
writeUint32LE(frame, offset, timestamp); return writer.toBytes();
offset += 4; }
frame.setRange(offset, offset + prefixSize, repeaterPubKey.sublist(0, prefixSize)); // Build a telemetry request frame
offset += prefixSize; // Format: [cmd][pub_key x32][payload]
Uint8List buildSendBinaryReq(Uint8List repeaterPubKey, {Uint8List? payload}) {
frame.setRange(offset, offset + textBytes.length, textBytes); final writer = BufferWriter();
frame[frame.length - 1] = 0; // null terminator writer.writeByte(cmdSendBinaryReq);
return frame; 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);
}
+16 -1
View File
@@ -1,4 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'l10n/app_localizations.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'connector/meshcore_connector.dart'; import 'connector/meshcore_connector.dart';
@@ -25,7 +27,7 @@ void main() async {
final storage = StorageService(); final storage = StorageService();
final connector = MeshCoreConnector(); final connector = MeshCoreConnector();
final pathHistoryService = PathHistoryService(storage); final pathHistoryService = PathHistoryService(storage);
final retryService = MessageRetryService(storage); final retryService = MessageRetryService();
final appSettingsService = AppSettingsService(); final appSettingsService = AppSettingsService();
final bleDebugLogService = BleDebugLogService(); final bleDebugLogService = BleDebugLogService();
final appDebugLogService = AppDebugLogService(); final appDebugLogService = AppDebugLogService();
@@ -115,6 +117,14 @@ class MeshCoreApp extends StatelessWidget {
return MaterialApp( return MaterialApp(
title: 'MeshCore Open', title: 'MeshCore Open',
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: AppLocalizations.supportedLocales,
locale: _localeFromSetting(settingsService.settings.languageOverride),
theme: ThemeData( theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue), colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
useMaterial3: true, useMaterial3: true,
@@ -144,4 +154,9 @@ class MeshCoreApp extends StatelessWidget {
return ThemeMode.system; return ThemeMode.system;
} }
} }
Locale? _localeFromSetting(String? languageCode) {
if (languageCode == null) return null;
return Locale(languageCode);
}
} }
+7
View File
@@ -18,6 +18,7 @@ class AppSettings {
final bool notifyOnNewAdvert; final bool notifyOnNewAdvert;
final bool autoRouteRotationEnabled; final bool autoRouteRotationEnabled;
final String themeMode; final String themeMode;
final String? languageOverride; // null = system default
final bool appDebugLogEnabled; final bool appDebugLogEnabled;
final Map<String, String> batteryChemistryByDeviceId; final Map<String, String> batteryChemistryByDeviceId;
@@ -39,6 +40,7 @@ class AppSettings {
this.notifyOnNewAdvert = true, this.notifyOnNewAdvert = true,
this.autoRouteRotationEnabled = false, this.autoRouteRotationEnabled = false,
this.themeMode = 'system', this.themeMode = 'system',
this.languageOverride,
this.appDebugLogEnabled = false, this.appDebugLogEnabled = false,
Map<String, String>? batteryChemistryByDeviceId, Map<String, String>? batteryChemistryByDeviceId,
}) : batteryChemistryByDeviceId = batteryChemistryByDeviceId ?? {}; }) : batteryChemistryByDeviceId = batteryChemistryByDeviceId ?? {};
@@ -62,6 +64,7 @@ class AppSettings {
'notify_on_new_advert': notifyOnNewAdvert, 'notify_on_new_advert': notifyOnNewAdvert,
'auto_route_rotation_enabled': autoRouteRotationEnabled, 'auto_route_rotation_enabled': autoRouteRotationEnabled,
'theme_mode': themeMode, 'theme_mode': themeMode,
'language_override': languageOverride,
'app_debug_log_enabled': appDebugLogEnabled, 'app_debug_log_enabled': appDebugLogEnabled,
'battery_chemistry_by_device_id': batteryChemistryByDeviceId, 'battery_chemistry_by_device_id': batteryChemistryByDeviceId,
}; };
@@ -89,6 +92,7 @@ class AppSettings {
notifyOnNewAdvert: json['notify_on_new_advert'] as bool? ?? true, notifyOnNewAdvert: json['notify_on_new_advert'] as bool? ?? true,
autoRouteRotationEnabled: json['auto_route_rotation_enabled'] as bool? ?? false, autoRouteRotationEnabled: json['auto_route_rotation_enabled'] as bool? ?? false,
themeMode: json['theme_mode'] as String? ?? 'system', themeMode: json['theme_mode'] as String? ?? 'system',
languageOverride: json['language_override'] as String?,
appDebugLogEnabled: json['app_debug_log_enabled'] as bool? ?? false, appDebugLogEnabled: json['app_debug_log_enabled'] as bool? ?? false,
batteryChemistryByDeviceId: (json['battery_chemistry_by_device_id'] as Map?)?.map( batteryChemistryByDeviceId: (json['battery_chemistry_by_device_id'] as Map?)?.map(
(key, value) => MapEntry(key.toString(), value.toString()), (key, value) => MapEntry(key.toString(), value.toString()),
@@ -115,6 +119,7 @@ class AppSettings {
bool? notifyOnNewAdvert, bool? notifyOnNewAdvert,
bool? autoRouteRotationEnabled, bool? autoRouteRotationEnabled,
String? themeMode, String? themeMode,
Object? languageOverride = _unset,
bool? appDebugLogEnabled, bool? appDebugLogEnabled,
Map<String, String>? batteryChemistryByDeviceId, Map<String, String>? batteryChemistryByDeviceId,
}) { }) {
@@ -138,6 +143,8 @@ class AppSettings {
notifyOnNewAdvert: notifyOnNewAdvert ?? this.notifyOnNewAdvert, notifyOnNewAdvert: notifyOnNewAdvert ?? this.notifyOnNewAdvert,
autoRouteRotationEnabled: autoRouteRotationEnabled ?? this.autoRouteRotationEnabled, autoRouteRotationEnabled: autoRouteRotationEnabled ?? this.autoRouteRotationEnabled,
themeMode: themeMode ?? this.themeMode, themeMode: themeMode ?? this.themeMode,
languageOverride:
languageOverride == _unset ? this.languageOverride : languageOverride as String?,
appDebugLogEnabled: appDebugLogEnabled ?? this.appDebugLogEnabled, appDebugLogEnabled: appDebugLogEnabled ?? this.appDebugLogEnabled,
batteryChemistryByDeviceId: batteryChemistryByDeviceId ?? this.batteryChemistryByDeviceId, batteryChemistryByDeviceId: batteryChemistryByDeviceId ?? this.batteryChemistryByDeviceId,
); );
+41
View File
@@ -1,5 +1,8 @@
import 'dart:convert';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:crypto/crypto.dart' as crypto;
import '../connector/meshcore_protocol.dart'; import '../connector/meshcore_protocol.dart';
class Channel { class Channel {
@@ -61,6 +64,44 @@ class Channel {
return bytes; 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) { static String formatPskHex(Uint8List psk) {
return _bytesToHex(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;
}
+5
View File
@@ -23,6 +23,7 @@ class Message {
final int? pathLength; final int? pathLength;
final Uint8List pathBytes; final Uint8List pathBytes;
final Map<String, int> reactions; final Map<String, int> reactions;
final Uint8List fourByteRoomContactKey;
Message({ Message({
required this.senderKey, required this.senderKey,
@@ -40,8 +41,10 @@ class Message {
this.tripTimeMs, this.tripTimeMs,
this.pathLength, this.pathLength,
Uint8List? pathBytes, Uint8List? pathBytes,
Uint8List? fourByteRoomContactKey,
Map<String, int>? reactions, Map<String, int>? reactions,
}) : pathBytes = pathBytes ?? Uint8List(0), }) : pathBytes = pathBytes ?? Uint8List(0),
fourByteRoomContactKey = fourByteRoomContactKey ?? Uint8List(0),
reactions = reactions ?? {}; reactions = reactions ?? {};
String get senderKeyHex => pubKeyToHex(senderKey); String get senderKeyHex => pubKeyToHex(senderKey);
@@ -58,6 +61,7 @@ class Message {
Uint8List? pathBytes, Uint8List? pathBytes,
bool? isCli, bool? isCli,
Map<String, int>? reactions, Map<String, int>? reactions,
Uint8List? fourByteRoomContactKey,
}) { }) {
return Message( return Message(
senderKey: senderKey, senderKey: senderKey,
@@ -76,6 +80,7 @@ class Message {
pathLength: pathLength ?? this.pathLength, pathLength: pathLength ?? this.pathLength,
pathBytes: pathBytes ?? this.pathBytes, pathBytes: pathBytes ?? this.pathBytes,
reactions: reactions ?? this.reactions, reactions: reactions ?? this.reactions,
fourByteRoomContactKey: fourByteRoomContactKey ?? this.fourByteRoomContactKey,
); );
} }
+7 -6
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../l10n/l10n.dart';
import '../services/app_debug_log_service.dart'; import '../services/app_debug_log_service.dart';
class AppDebugLogScreen extends StatelessWidget { class AppDebugLogScreen extends StatelessWidget {
@@ -16,11 +17,11 @@ class AppDebugLogScreen extends StatelessWidget {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text('App Debug Log'), title: Text(context.l10n.debugLog_appTitle),
centerTitle: true, centerTitle: true,
actions: [ actions: [
IconButton( IconButton(
tooltip: 'Copy log', tooltip: context.l10n.debugLog_copyLog,
icon: const Icon(Icons.copy), icon: const Icon(Icons.copy),
onPressed: hasEntries onPressed: hasEntries
? () async { ? () async {
@@ -31,13 +32,13 @@ class AppDebugLogScreen extends StatelessWidget {
await Clipboard.setData(ClipboardData(text: text)); await Clipboard.setData(ClipboardData(text: text));
if (!context.mounted) return; if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Debug log copied')), SnackBar(content: Text(context.l10n.debugLog_copied)),
); );
} }
: null, : null,
), ),
IconButton( IconButton(
tooltip: 'Clear log', tooltip: context.l10n.debugLog_clearLog,
icon: const Icon(Icons.delete_outline), icon: const Icon(Icons.delete_outline),
onPressed: hasEntries onPressed: hasEntries
? () { ? () {
@@ -76,12 +77,12 @@ class AppDebugLogScreen extends StatelessWidget {
Icon(Icons.bug_report_outlined, size: 64, color: Colors.grey[400]), Icon(Icons.bug_report_outlined, size: 64, color: Colors.grey[400]),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
'No debug logs yet', context.l10n.debugLog_noEntries,
style: TextStyle(fontSize: 16, color: Colors.grey[600]), style: TextStyle(fontSize: 16, color: Colors.grey[600]),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
'Enable app debug logging in settings', context.l10n.debugLog_enableInSettings,
style: TextStyle(fontSize: 12, color: Colors.grey[500]), style: TextStyle(fontSize: 12, color: Colors.grey[500]),
), ),
], ],
+218 -89
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart'; import '../connector/meshcore_connector.dart';
import '../l10n/l10n.dart';
import '../services/app_settings_service.dart'; import '../services/app_settings_service.dart';
import '../services/notification_service.dart'; import '../services/notification_service.dart';
import 'map_cache_screen.dart'; import 'map_cache_screen.dart';
@@ -13,7 +14,7 @@ class AppSettingsScreen extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text('App Settings'), title: Text(context.l10n.appSettings_title),
centerTitle: true, centerTitle: true,
), ),
body: SafeArea( body: SafeArea(
@@ -47,20 +48,28 @@ class AppSettingsScreen extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const Padding( Padding(
padding: EdgeInsets.fromLTRB(16, 16, 16, 8), padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text( child: Text(
'Appearance', context.l10n.appSettings_appearance,
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
), ),
), ),
ListTile( ListTile(
leading: const Icon(Icons.brightness_6_outlined), leading: const Icon(Icons.brightness_6_outlined),
title: const Text('Theme'), title: Text(context.l10n.appSettings_theme),
subtitle: Text(_themeModeLabel(settingsService.settings.themeMode)), subtitle: Text(_themeModeLabel(context, settingsService.settings.themeMode)),
trailing: const Icon(Icons.chevron_right), trailing: const Icon(Icons.chevron_right),
onTap: () => _showThemeModeDialog(context, settingsService), 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),
),
], ],
), ),
); );
@@ -71,17 +80,17 @@ class AppSettingsScreen extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const Padding( Padding(
padding: EdgeInsets.fromLTRB(16, 16, 16, 8), padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text( child: Text(
'Notifications', context.l10n.appSettings_notifications,
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
), ),
), ),
SwitchListTile( SwitchListTile(
secondary: const Icon(Icons.notifications_outlined), secondary: const Icon(Icons.notifications_outlined),
title: const Text('Enable Notifications'), title: Text(context.l10n.appSettings_enableNotifications),
subtitle: const Text('Receive notifications for messages and adverts'), subtitle: Text(context.l10n.appSettings_enableNotificationsSubtitle),
value: settingsService.settings.notificationsEnabled, value: settingsService.settings.notificationsEnabled,
onChanged: (value) async { onChanged: (value) async {
if (value) { if (value) {
@@ -90,9 +99,9 @@ class AppSettingsScreen extends StatelessWidget {
if (!granted) { if (!granted) {
if (context.mounted) { if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( SnackBar(
content: Text('Notification permission denied'), content: Text(context.l10n.appSettings_notificationPermissionDenied),
duration: Duration(seconds: 2), duration: const Duration(seconds: 2),
), ),
); );
} }
@@ -105,8 +114,8 @@ class AppSettingsScreen extends StatelessWidget {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text(value content: Text(value
? 'Notifications enabled' ? context.l10n.appSettings_notificationsEnabled
: 'Notifications disabled'), : context.l10n.appSettings_notificationsDisabled),
duration: const Duration(seconds: 2), duration: const Duration(seconds: 2),
), ),
); );
@@ -120,13 +129,13 @@ class AppSettingsScreen extends StatelessWidget {
color: settingsService.settings.notificationsEnabled ? null : Colors.grey, color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
), ),
title: Text( title: Text(
'Message Notifications', context.l10n.appSettings_messageNotifications,
style: TextStyle( style: TextStyle(
color: settingsService.settings.notificationsEnabled ? null : Colors.grey, color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
), ),
), ),
subtitle: Text( subtitle: Text(
'Show notification when receiving new messages', context.l10n.appSettings_messageNotificationsSubtitle,
style: TextStyle( style: TextStyle(
color: settingsService.settings.notificationsEnabled ? null : Colors.grey, color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
), ),
@@ -145,13 +154,13 @@ class AppSettingsScreen extends StatelessWidget {
color: settingsService.settings.notificationsEnabled ? null : Colors.grey, color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
), ),
title: Text( title: Text(
'Channel Message Notifications', context.l10n.appSettings_channelMessageNotifications,
style: TextStyle( style: TextStyle(
color: settingsService.settings.notificationsEnabled ? null : Colors.grey, color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
), ),
), ),
subtitle: Text( subtitle: Text(
'Show notification when receiving channel messages', context.l10n.appSettings_channelMessageNotificationsSubtitle,
style: TextStyle( style: TextStyle(
color: settingsService.settings.notificationsEnabled ? null : Colors.grey, color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
), ),
@@ -170,13 +179,13 @@ class AppSettingsScreen extends StatelessWidget {
color: settingsService.settings.notificationsEnabled ? null : Colors.grey, color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
), ),
title: Text( title: Text(
'Advertisement Notifications', context.l10n.appSettings_advertisementNotifications,
style: TextStyle( style: TextStyle(
color: settingsService.settings.notificationsEnabled ? null : Colors.grey, color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
), ),
), ),
subtitle: Text( subtitle: Text(
'Show notification when new nodes are discovered', context.l10n.appSettings_advertisementNotificationsSubtitle,
style: TextStyle( style: TextStyle(
color: settingsService.settings.notificationsEnabled ? null : Colors.grey, color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
), ),
@@ -198,25 +207,25 @@ class AppSettingsScreen extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const Padding( Padding(
padding: EdgeInsets.fromLTRB(16, 16, 16, 8), padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text( child: Text(
'Messaging', context.l10n.appSettings_messaging,
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
), ),
), ),
SwitchListTile( SwitchListTile(
secondary: const Icon(Icons.refresh_outlined), secondary: const Icon(Icons.refresh_outlined),
title: const Text('Clear Path on Max Retry'), title: Text(context.l10n.appSettings_clearPathOnMaxRetry),
subtitle: const Text('Reset contact path after 5 failed send attempts'), subtitle: Text(context.l10n.appSettings_clearPathOnMaxRetrySubtitle),
value: settingsService.settings.clearPathOnMaxRetry, value: settingsService.settings.clearPathOnMaxRetry,
onChanged: (value) { onChanged: (value) {
settingsService.setClearPathOnMaxRetry(value); settingsService.setClearPathOnMaxRetry(value);
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text(value content: Text(value
? 'Paths will be cleared after 5 failed retries' ? context.l10n.appSettings_pathsWillBeCleared
: 'Paths will not be auto-cleared'), : context.l10n.appSettings_pathsWillNotBeCleared),
duration: const Duration(seconds: 2), duration: const Duration(seconds: 2),
), ),
); );
@@ -225,16 +234,16 @@ class AppSettingsScreen extends StatelessWidget {
const Divider(height: 1), const Divider(height: 1),
SwitchListTile( SwitchListTile(
secondary: const Icon(Icons.alt_route), secondary: const Icon(Icons.alt_route),
title: const Text('Auto Route Rotation'), title: Text(context.l10n.appSettings_autoRouteRotation),
subtitle: const Text('Cycle between best paths and flood mode'), subtitle: Text(context.l10n.appSettings_autoRouteRotationSubtitle),
value: settingsService.settings.autoRouteRotationEnabled, value: settingsService.settings.autoRouteRotationEnabled,
onChanged: (value) { onChanged: (value) {
settingsService.setAutoRouteRotationEnabled(value); settingsService.setAutoRouteRotationEnabled(value);
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text(value content: Text(value
? 'Auto route rotation enabled' ? context.l10n.appSettings_autoRouteRotationEnabled
: 'Auto route rotation disabled'), : context.l10n.appSettings_autoRouteRotationDisabled),
duration: const Duration(seconds: 2), duration: const Duration(seconds: 2),
), ),
); );
@@ -250,17 +259,17 @@ class AppSettingsScreen extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const Padding( Padding(
padding: EdgeInsets.fromLTRB(16, 16, 16, 8), padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text( child: Text(
'Map Display', context.l10n.appSettings_mapDisplay,
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
), ),
), ),
SwitchListTile( SwitchListTile(
secondary: const Icon(Icons.router_outlined), secondary: const Icon(Icons.router_outlined),
title: const Text('Show Repeaters'), title: Text(context.l10n.appSettings_showRepeaters),
subtitle: const Text('Display repeater nodes on the map'), subtitle: Text(context.l10n.appSettings_showRepeatersSubtitle),
value: settingsService.settings.mapShowRepeaters, value: settingsService.settings.mapShowRepeaters,
onChanged: (value) { onChanged: (value) {
settingsService.setMapShowRepeaters(value); settingsService.setMapShowRepeaters(value);
@@ -269,8 +278,8 @@ class AppSettingsScreen extends StatelessWidget {
const Divider(height: 1), const Divider(height: 1),
SwitchListTile( SwitchListTile(
secondary: const Icon(Icons.chat_outlined), secondary: const Icon(Icons.chat_outlined),
title: const Text('Show Chat Nodes'), title: Text(context.l10n.appSettings_showChatNodes),
subtitle: const Text('Display chat nodes on the map'), subtitle: Text(context.l10n.appSettings_showChatNodesSubtitle),
value: settingsService.settings.mapShowChatNodes, value: settingsService.settings.mapShowChatNodes,
onChanged: (value) { onChanged: (value) {
settingsService.setMapShowChatNodes(value); settingsService.setMapShowChatNodes(value);
@@ -279,8 +288,8 @@ class AppSettingsScreen extends StatelessWidget {
const Divider(height: 1), const Divider(height: 1),
SwitchListTile( SwitchListTile(
secondary: const Icon(Icons.people_outline), secondary: const Icon(Icons.people_outline),
title: const Text('Show Other Nodes'), title: Text(context.l10n.appSettings_showOtherNodes),
subtitle: const Text('Display other node types on the map'), subtitle: Text(context.l10n.appSettings_showOtherNodesSubtitle),
value: settingsService.settings.mapShowOtherNodes, value: settingsService.settings.mapShowOtherNodes,
onChanged: (value) { onChanged: (value) {
settingsService.setMapShowOtherNodes(value); settingsService.setMapShowOtherNodes(value);
@@ -289,11 +298,11 @@ class AppSettingsScreen extends StatelessWidget {
const Divider(height: 1), const Divider(height: 1),
ListTile( ListTile(
leading: const Icon(Icons.timer_outlined), leading: const Icon(Icons.timer_outlined),
title: const Text('Time Filter'), title: Text(context.l10n.appSettings_timeFilter),
subtitle: Text( subtitle: Text(
settingsService.settings.mapTimeFilterHours == 0 settingsService.settings.mapTimeFilterHours == 0
? 'Show all nodes' ? context.l10n.appSettings_timeFilterShowAll
: 'Show nodes from last ${settingsService.settings.mapTimeFilterHours.toInt()} hours', : context.l10n.appSettings_timeFilterShowLast(settingsService.settings.mapTimeFilterHours.toInt()),
), ),
trailing: const Icon(Icons.chevron_right), trailing: const Icon(Icons.chevron_right),
onTap: () => _showTimeFilterDialog(context, settingsService), onTap: () => _showTimeFilterDialog(context, settingsService),
@@ -301,12 +310,14 @@ class AppSettingsScreen extends StatelessWidget {
const Divider(height: 1), const Divider(height: 1),
ListTile( ListTile(
leading: const Icon(Icons.download_outlined), leading: const Icon(Icons.download_outlined),
title: const Text('Offline Map Cache'), title: Text(context.l10n.appSettings_offlineMapCache),
subtitle: Text( subtitle: Text(
settingsService.settings.mapCacheBounds == null settingsService.settings.mapCacheBounds == null
? 'No area selected' ? context.l10n.appSettings_noAreaSelected
: 'Area selected (zoom ${settingsService.settings.mapCacheMinZoom}' : context.l10n.appSettings_areaSelectedZoom(
'-${settingsService.settings.mapCacheMaxZoom})', settingsService.settings.mapCacheMinZoom,
settingsService.settings.mapCacheMaxZoom,
),
), ),
trailing: const Icon(Icons.chevron_right), trailing: const Icon(Icons.chevron_right),
onTap: () { onTap: () {
@@ -335,20 +346,20 @@ class AppSettingsScreen extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const Padding( Padding(
padding: EdgeInsets.fromLTRB(16, 16, 16, 8), padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text( child: Text(
'Battery', context.l10n.appSettings_battery,
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
), ),
), ),
ListTile( ListTile(
leading: const Icon(Icons.battery_full), leading: const Icon(Icons.battery_full),
title: const Text('Battery Chemistry'), title: Text(context.l10n.appSettings_batteryChemistry),
subtitle: Text( subtitle: Text(
isConnected isConnected
? 'Set per device (${connector.deviceDisplayName})' ? context.l10n.appSettings_batteryChemistryPerDevice(connector.deviceDisplayName)
: 'Connect to a device to choose', : context.l10n.appSettings_batteryChemistryConnectFirst,
), ),
trailing: DropdownButton<String>( trailing: DropdownButton<String>(
value: selection, value: selection,
@@ -359,18 +370,18 @@ class AppSettingsScreen extends StatelessWidget {
} }
} }
: null, : null,
items: const [ items: [
DropdownMenuItem( DropdownMenuItem(
value: 'nmc', value: 'nmc',
child: Text('18650 NMC (3.0-4.2V)'), child: Text(context.l10n.appSettings_batteryNmc),
), ),
DropdownMenuItem( DropdownMenuItem(
value: 'lifepo4', value: 'lifepo4',
child: Text('LiFePO4 (2.6-3.65V)'), child: Text(context.l10n.appSettings_batteryLifepo4),
), ),
DropdownMenuItem( DropdownMenuItem(
value: 'lipo', value: 'lipo',
child: Text('LiPo (3.0-4.2V)'), child: Text(context.l10n.appSettings_batteryLipo),
), ),
], ],
), ),
@@ -384,7 +395,7 @@ class AppSettingsScreen extends StatelessWidget {
showDialog( showDialog(
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
title: const Text('Theme'), title: Text(context.l10n.appSettings_theme),
content: RadioGroup<String>( content: RadioGroup<String>(
groupValue: settingsService.settings.themeMode, groupValue: settingsService.settings.themeMode,
onChanged: (value) { onChanged: (value) {
@@ -397,15 +408,15 @@ class AppSettingsScreen extends StatelessWidget {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
RadioListTile<String>( RadioListTile<String>(
title: const Text('System default'), title: Text(context.l10n.appSettings_themeSystem),
value: 'system', value: 'system',
), ),
RadioListTile<String>( RadioListTile<String>(
title: const Text('Light'), title: Text(context.l10n.appSettings_themeLight),
value: 'light', value: 'light',
), ),
RadioListTile<String>( RadioListTile<String>(
title: const Text('Dark'), title: Text(context.l10n.appSettings_themeDark),
value: 'dark', value: 'dark',
), ),
], ],
@@ -414,29 +425,147 @@ class AppSettingsScreen extends StatelessWidget {
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(context), 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) { switch (value) {
case 'light': case 'light':
return 'Light'; return context.l10n.appSettings_themeLight;
case 'dark': case 'dark':
return 'Dark'; return context.l10n.appSettings_themeDark;
default: 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) { void _showTimeFilterDialog(BuildContext context, AppSettingsService settingsService) {
showDialog( showDialog(
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
title: const Text('Map Time Filter'), title: Text(context.l10n.appSettings_mapTimeFilter),
content: RadioGroup<double>( content: RadioGroup<double>(
groupValue: settingsService.settings.mapTimeFilterHours, groupValue: settingsService.settings.mapTimeFilterHours,
onChanged: (value) { onChanged: (value) {
@@ -448,34 +577,34 @@ class AppSettingsScreen extends StatelessWidget {
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
const Text('Show nodes discovered within:'), Text(context.l10n.appSettings_showNodesDiscoveredWithin),
const SizedBox(height: 16), const SizedBox(height: 16),
ListTile( ListTile(
title: const Text('All time'), title: Text(context.l10n.appSettings_allTime),
leading: Radio<double>( leading: Radio<double>(
value: 0, value: 0,
), ),
), ),
ListTile( ListTile(
title: const Text('Last hour'), title: Text(context.l10n.appSettings_lastHour),
leading: Radio<double>( leading: Radio<double>(
value: 1, value: 1,
), ),
), ),
ListTile( ListTile(
title: const Text('Last 6 hours'), title: Text(context.l10n.appSettings_last6Hours),
leading: Radio<double>( leading: Radio<double>(
value: 6, value: 6,
), ),
), ),
ListTile( ListTile(
title: const Text('Last 24 hours'), title: Text(context.l10n.appSettings_last24Hours),
leading: Radio<double>( leading: Radio<double>(
value: 24, value: 24,
), ),
), ),
ListTile( ListTile(
title: const Text('Last week'), title: Text(context.l10n.appSettings_lastWeek),
leading: Radio<double>( leading: Radio<double>(
value: 168, value: 168,
), ),
@@ -486,7 +615,7 @@ class AppSettingsScreen extends StatelessWidget {
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
child: const Text('Close'), child: Text(context.l10n.common_close),
), ),
], ],
), ),
@@ -498,17 +627,17 @@ class AppSettingsScreen extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const Padding( Padding(
padding: EdgeInsets.fromLTRB(16, 16, 16, 8), padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text( child: Text(
'Debug', context.l10n.appSettings_debugCard,
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
), ),
), ),
SwitchListTile( SwitchListTile(
secondary: const Icon(Icons.bug_report_outlined), secondary: const Icon(Icons.bug_report_outlined),
title: const Text('App Debug Logging'), title: Text(context.l10n.appSettings_appDebugLogging),
subtitle: const Text('Log app debug messages for troubleshooting'), subtitle: Text(context.l10n.appSettings_appDebugLoggingSubtitle),
value: settingsService.settings.appDebugLogEnabled, value: settingsService.settings.appDebugLogEnabled,
onChanged: (value) async { onChanged: (value) async {
await settingsService.setAppDebugLogEnabled(value); await settingsService.setAppDebugLogEnabled(value);
@@ -516,8 +645,8 @@ class AppSettingsScreen extends StatelessWidget {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text(value content: Text(value
? 'App debug logging enabled' ? context.l10n.appSettings_appDebugLoggingEnabled
: 'App debug logging disabled'), : context.l10n.appSettings_appDebugLoggingDisabled),
duration: const Duration(seconds: 2), duration: const Duration(seconds: 2),
), ),
); );
+11 -10
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import '../l10n/l10n.dart';
import '../services/ble_debug_log_service.dart'; import '../services/ble_debug_log_service.dart';
import '../connector/meshcore_protocol.dart'; import '../connector/meshcore_protocol.dart';
@@ -26,10 +27,10 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
final hasEntries = showingFrames ? entries.isNotEmpty : rawEntries.isNotEmpty; final hasEntries = showingFrames ? entries.isNotEmpty : rawEntries.isNotEmpty;
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text('BLE Debug Log'), title: Text(context.l10n.debugLog_bleTitle),
actions: [ actions: [
IconButton( IconButton(
tooltip: 'Copy log', tooltip: context.l10n.debugLog_copyLog,
icon: const Icon(Icons.copy), icon: const Icon(Icons.copy),
onPressed: hasEntries onPressed: hasEntries
? () async { ? () async {
@@ -43,13 +44,13 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
await Clipboard.setData(ClipboardData(text: text)); await Clipboard.setData(ClipboardData(text: text));
if (!context.mounted) return; if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('BLE log copied')), SnackBar(content: Text(context.l10n.debugLog_bleCopied)),
); );
} }
: null, : null,
), ),
IconButton( IconButton(
tooltip: 'Clear log', tooltip: context.l10n.debugLog_clearLog,
icon: const Icon(Icons.delete_outline), icon: const Icon(Icons.delete_outline),
onPressed: hasEntries onPressed: hasEntries
? () { ? () {
@@ -66,9 +67,9 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
Padding( Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
child: SegmentedButton<_BleLogView>( child: SegmentedButton<_BleLogView>(
segments: const [ segments: [
ButtonSegment(value: _BleLogView.frames, label: Text('Frames')), ButtonSegment(value: _BleLogView.frames, label: Text(context.l10n.debugLog_frames)),
ButtonSegment(value: _BleLogView.rawLogRx, label: Text('Raw Log-RX')), ButtonSegment(value: _BleLogView.rawLogRx, label: Text(context.l10n.debugLog_rawLogRx)),
], ],
selected: {_view}, selected: {_view},
onSelectionChanged: (selection) { onSelectionChanged: (selection) {
@@ -113,8 +114,8 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
); );
}, },
) )
: const Center( : Center(
child: Text('No BLE activity yet'), child: Text(context.l10n.debugLog_noBleActivity),
), ),
), ),
], ],
@@ -136,7 +137,7 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
child: const Text('Close'), child: Text(context.l10n.common_close),
), ),
], ],
), ),
+191 -116
View File
@@ -3,17 +3,22 @@ import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart'; import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_linkify/flutter_linkify.dart';
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart'; import '../connector/meshcore_connector.dart';
import '../helpers/chat_scroll_controller.dart';
import '../connector/meshcore_protocol.dart'; import '../connector/meshcore_protocol.dart';
import '../helpers/link_handler.dart';
import '../helpers/utf8_length_limiter.dart'; import '../helpers/utf8_length_limiter.dart';
import '../l10n/l10n.dart';
import '../models/channel.dart'; import '../models/channel.dart';
import '../models/channel_message.dart'; import '../models/channel_message.dart';
import '../utils/emoji_utils.dart'; import '../utils/emoji_utils.dart';
import '../widgets/emoji_picker.dart'; import '../widgets/emoji_picker.dart';
import '../widgets/gif_message.dart'; import '../widgets/gif_message.dart';
import '../widgets/jump_to_bottom_button.dart';
import '../widgets/gif_picker.dart'; import '../widgets/gif_picker.dart';
import 'channel_message_path_screen.dart'; import 'channel_message_path_screen.dart';
import 'map_screen.dart'; import 'map_screen.dart';
@@ -32,42 +37,51 @@ class ChannelChatScreen extends StatefulWidget {
class _ChannelChatScreenState extends State<ChannelChatScreen> { class _ChannelChatScreenState extends State<ChannelChatScreen> {
final TextEditingController _textController = TextEditingController(); final TextEditingController _textController = TextEditingController();
final ScrollController _scrollController = ScrollController(); final ChatScrollController _scrollController = ChatScrollController();
final FocusNode _textFieldFocusNode = FocusNode();
ChannelMessage? _replyingToMessage; ChannelMessage? _replyingToMessage;
final Map<String, GlobalKey> _messageKeys = {}; final Map<String, GlobalKey> _messageKeys = {};
bool _isLoadingOlder = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_textFieldFocusNode.addListener(_onTextFieldFocusChange);
_scrollController.onScrollNearTop = _loadOlderMessages;
SchedulerBinding.instance.addPostFrameCallback((_) { SchedulerBinding.instance.addPostFrameCallback((_) {
if (!mounted) return; if (!mounted) return;
context.read<MeshCoreConnector>().setActiveChannel(widget.channel.index); context.read<MeshCoreConnector>().setActiveChannel(widget.channel.index);
// Scroll to bottom when opening channel chat - use SchedulerBinding for next frame
if (_scrollController.hasClients) {
_scrollController.jumpTo(_scrollController.position.maxScrollExtent);
}
}); });
} }
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 @override
void dispose() { void dispose() {
context.read<MeshCoreConnector>().setActiveChannel(null); context.read<MeshCoreConnector>().setActiveChannel(null);
_textFieldFocusNode.removeListener(_onTextFieldFocusChange);
_textFieldFocusNode.dispose();
_textController.dispose(); _textController.dispose();
_scrollController.dispose(); _scrollController.dispose();
super.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) { void _setReplyingTo(ChannelMessage message) {
setState(() { setState(() {
_replyingToMessage = message; _replyingToMessage = message;
@@ -84,9 +98,9 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
final key = _messageKeys[messageId]; final key = _messageKeys[messageId];
if (key == null) { if (key == null) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( SnackBar(
content: Text('Original message not found'), content: Text(context.l10n.chat_originalMessageNotFound),
duration: Duration(seconds: 2), duration: const Duration(seconds: 2),
), ),
); );
return; return;
@@ -120,7 +134,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
children: [ children: [
Text( Text(
widget.channel.name.isEmpty widget.channel.name.isEmpty
? 'Channel ${widget.channel.index}' ? context.l10n.channels_channelIndex(widget.channel.index)
: widget.channel.name, : widget.channel.name,
style: const TextStyle(fontSize: 16), style: const TextStyle(fontSize: 16),
), ),
@@ -128,9 +142,9 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
builder: (context, connector, _) { builder: (context, connector, _) {
final unreadCount = final unreadCount =
connector.getUnreadCountForChannelIndex(widget.channel.index); 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( return Text(
'$privacyUnread: $unreadCount', '$privacy${context.l10n.chat_unread(unreadCount)}',
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 12), style: const TextStyle(fontSize: 12),
); );
@@ -152,10 +166,6 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
builder: (context, connector, child) { builder: (context, connector, child) {
final messages = connector.getChannelMessages(widget.channel); final messages = connector.getChannelMessages(widget.channel);
SchedulerBinding.instance.addPostFrameCallback((_) {
_scrollToBottom();
});
if (messages.isEmpty) { if (messages.isEmpty) {
return Center( return Center(
child: Column( child: Column(
@@ -170,7 +180,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
'No messages yet', context.l10n.chat_noMessages,
style: TextStyle( style: TextStyle(
fontSize: 16, fontSize: 16,
color: Colors.grey[600], color: Colors.grey[600],
@@ -178,7 +188,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
'Send a message to get started', context.l10n.chat_sendMessageToStart,
style: TextStyle( style: TextStyle(
fontSize: 14, fontSize: 14,
color: Colors.grey[500], color: Colors.grey[500],
@@ -189,20 +199,51 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
); );
} }
return ListView.builder( // Reverse messages so newest appear at bottom with reverse: true
controller: _scrollController, final reversedMessages = messages.reversed.toList();
padding: const EdgeInsets.all(8), final itemCount = reversedMessages.length + (_isLoadingOlder ? 1 : 0);
itemCount: messages.length,
itemBuilder: (context, index) { // Auto-scroll to bottom if user is already at bottom
final message = messages[index]; WidgetsBinding.instance.addPostFrameCallback((_) {
if (!_messageKeys.containsKey(message.messageId)) { _scrollController.scrollToBottomIfAtBottom();
_messageKeys[message.messageId] = GlobalKey(); });
}
return Container( return Stack(
key: _messageKeys[message.messageId]!, children: [
child: _buildMessageBubble(message), 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,
),
],
); );
}, },
), ),
@@ -240,7 +281,9 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
onTap: () => _showMessagePathInfo(message), onTap: () => _showMessagePathInfo(message),
onLongPress: () => _showMessageActions(message), onLongPress: () => _showMessageActions(message),
child: Container( 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( constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.65, maxWidth: MediaQuery.of(context).size.width * 0.65,
), ),
@@ -254,15 +297,20 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
if (!isOutgoing) ...[ if (!isOutgoing) ...[
Text( Padding(
message.senderName, padding: gifId != null
style: TextStyle( ? const EdgeInsets.only(left: 8, top: 4, bottom: 4)
fontSize: 12, : EdgeInsets.zero,
fontWeight: FontWeight.bold, child: Text(
color: Theme.of(context).colorScheme.primary, 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) ...[ if (message.replyToMessageId != null) ...[
_buildReplyPreview(message), _buildReplyPreview(message),
@@ -271,60 +319,84 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
if (poi != null) if (poi != null)
_buildPoiMessage(context, poi, isOutgoing) _buildPoiMessage(context, poi, isOutgoing)
else if (gifId != null) else if (gifId != null)
GifMessage( ClipRRect(
url: 'https://media.giphy.com/media/$gifId/giphy.gif', borderRadius: BorderRadius.circular(8),
backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, child: GifMessage(
fallbackTextColor: isOutgoing url: 'https://media.giphy.com/media/$gifId/giphy.gif',
? Theme.of(context).colorScheme.onPrimaryContainer.withValues(alpha: 0.7) backgroundColor: Colors.transparent,
: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6), fallbackTextColor: isOutgoing
? Theme.of(context).colorScheme.onPrimaryContainer.withValues(alpha: 0.7)
: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6),
),
) )
else else
Text( Linkify(
message.text, text: message.text,
style: const TextStyle(fontSize: 14), 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) ...[ if (displayPath.isNotEmpty) ...[
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Padding(
'via ${_formatPathPrefixes(displayPath)}', padding: gifId != null
style: TextStyle(fontSize: 11, color: Colors.grey[600]), ? const EdgeInsets.symmetric(horizontal: 8)
: EdgeInsets.zero,
child: Text(
'via ${_formatPathPrefixes(displayPath)}',
style: TextStyle(fontSize: 11, color: Colors.grey[600]),
),
), ),
], ],
const SizedBox(height: 4), const SizedBox(height: 4),
Row( Padding(
mainAxisSize: MainAxisSize.min, padding: gifId != null
children: [ ? const EdgeInsets.only(left: 8, right: 8, bottom: 4)
Text( : EdgeInsets.zero,
_formatTime(message.timestamp), child: Row(
style: TextStyle( mainAxisSize: MainAxisSize.min,
fontSize: 11, children: [
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( Text(
'${message.repeatCount}', _formatTime(message.timestamp),
style: TextStyle(fontSize: 11, color: Colors.grey[600]), 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],
),
],
],
), ),
], ],
), ),
@@ -363,8 +435,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
url: 'https://media.giphy.com/media/$gifId/giphy.gif', url: 'https://media.giphy.com/media/$gifId/giphy.gif',
backgroundColor: colorScheme.surfaceContainerHighest, backgroundColor: colorScheme.surfaceContainerHighest,
fallbackTextColor: previewTextColor, fallbackTextColor: previewTextColor,
width: 120, maxSize: 80,
height: 80,
), ),
); );
} else if (poi != null) { } else if (poi != null) {
@@ -372,7 +443,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
children: [ children: [
Icon(Icons.location_on_outlined, size: 14, color: previewTextColor), Icon(Icons.location_on_outlined, size: 14, color: previewTextColor),
const SizedBox(width: 4), const SizedBox(width: 4),
Text('Location', style: TextStyle(fontSize: 12, color: previewTextColor)), Text(context.l10n.chat_location, style: TextStyle(fontSize: 12, color: previewTextColor)),
], ],
); );
} else { } else {
@@ -406,7 +477,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
'Reply to ${message.replyToSenderName}', context.l10n.chat_replyTo(message.replyToSenderName ?? ''),
style: TextStyle( style: TextStyle(
fontSize: 11, fontSize: 11,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
@@ -515,7 +586,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
'POI Shared', context.l10n.chat_poiShared,
style: TextStyle( style: TextStyle(
color: textColor, color: textColor,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
@@ -623,7 +694,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
'Replying to ${message.senderName}', context.l10n.chat_replyingTo(message.senderName),
style: TextStyle( style: TextStyle(
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
@@ -678,7 +749,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
IconButton( IconButton(
icon: const Icon(Icons.gif_box), icon: const Icon(Icons.gif_box),
onPressed: () => _showGifPicker(context), onPressed: () => _showGifPicker(context),
tooltip: 'Send GIF', tooltip: context.l10n.chat_sendGif,
), ),
Expanded( Expanded(
child: ValueListenableBuilder<TextEditingValue>( child: ValueListenableBuilder<TextEditingValue>(
@@ -689,14 +760,16 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
return Row( return Row(
children: [ children: [
Expanded( Expanded(
child: GifMessage( child: ClipRRect(
url: 'https://media.giphy.com/media/$gifId/giphy.gif', borderRadius: BorderRadius.circular(12),
backgroundColor: child: GifMessage(
Theme.of(context).colorScheme.surfaceContainerHighest, url: 'https://media.giphy.com/media/$gifId/giphy.gif',
fallbackTextColor: backgroundColor:
Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6), Theme.of(context).colorScheme.surfaceContainerHighest,
width: 160, fallbackTextColor:
height: 110, Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6),
maxSize: 160,
),
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
@@ -710,11 +783,13 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
return TextField( return TextField(
controller: _textController, controller: _textController,
focusNode: _textFieldFocusNode,
inputFormatters: [ inputFormatters: [
Utf8LengthLimitingTextInputFormatter(maxBytes), Utf8LengthLimitingTextInputFormatter(maxBytes),
], ],
textCapitalization: TextCapitalization.sentences,
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'Type a message...', hintText: context.l10n.chat_typeMessage,
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: BorderRadius.circular(24), borderRadius: BorderRadius.circular(24),
), ),
@@ -757,7 +832,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
final maxBytes = maxChannelMessageBytes(connector.selfName); final maxBytes = maxChannelMessageBytes(connector.selfName);
if (utf8.encode(messageText).length > maxBytes) { if (utf8.encode(messageText).length > maxBytes) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Message too long (max $maxBytes bytes).')), SnackBar(content: Text(context.l10n.chat_messageTooLong(maxBytes))),
); );
return; return;
} }
@@ -796,7 +871,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
children: [ children: [
ListTile( ListTile(
leading: const Icon(Icons.reply), leading: const Icon(Icons.reply),
title: const Text('Reply'), title: Text(context.l10n.chat_reply),
onTap: () { onTap: () {
Navigator.pop(sheetContext); Navigator.pop(sheetContext);
_setReplyingTo(message); _setReplyingTo(message);
@@ -804,7 +879,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
), ),
ListTile( ListTile(
leading: const Icon(Icons.add_reaction_outlined), leading: const Icon(Icons.add_reaction_outlined),
title: const Text('Add Reaction'), title: Text(context.l10n.chat_addReaction),
onTap: () { onTap: () {
Navigator.pop(sheetContext); Navigator.pop(sheetContext);
_showEmojiPicker(message); _showEmojiPicker(message);
@@ -812,7 +887,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
), ),
ListTile( ListTile(
leading: const Icon(Icons.copy), leading: const Icon(Icons.copy),
title: const Text('Copy'), title: Text(context.l10n.common_copy),
onTap: () { onTap: () {
Navigator.pop(sheetContext); Navigator.pop(sheetContext);
_copyMessageText(message.text); _copyMessageText(message.text);
@@ -820,7 +895,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
), ),
ListTile( ListTile(
leading: const Icon(Icons.delete_outline), leading: const Icon(Icons.delete_outline),
title: const Text('Delete'), title: Text(context.l10n.common_delete),
onTap: () async { onTap: () async {
Navigator.pop(sheetContext); Navigator.pop(sheetContext);
await _deleteMessage(message); await _deleteMessage(message);
@@ -828,7 +903,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
), ),
ListTile( ListTile(
leading: const Icon(Icons.close), leading: const Icon(Icons.close),
title: const Text('Cancel'), title: Text(context.l10n.common_cancel),
onTap: () => Navigator.pop(sheetContext), onTap: () => Navigator.pop(sheetContext),
), ),
], ],
@@ -860,7 +935,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
void _copyMessageText(String text) { void _copyMessageText(String text) {
Clipboard.setData(ClipboardData(text: text)); Clipboard.setData(ClipboardData(text: text));
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Message copied')), SnackBar(content: Text(context.l10n.chat_messageCopied)),
); );
} }
@@ -868,7 +943,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
await context.read<MeshCoreConnector>().deleteChannelMessage(message); await context.read<MeshCoreConnector>().deleteChannelMessage(message);
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar( 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 '../connector/meshcore_connector.dart';
import '../services/map_tile_cache_service.dart'; import '../services/map_tile_cache_service.dart';
import '../connector/meshcore_protocol.dart'; import '../connector/meshcore_protocol.dart';
import '../l10n/app_localizations.dart';
import '../l10n/l10n.dart';
import '../models/channel_message.dart'; import '../models/channel_message.dart';
import '../models/contact.dart'; import '../models/contact.dart';
@@ -24,22 +26,24 @@ class ChannelMessagePathScreen extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Consumer<MeshCoreConnector>( return Consumer<MeshCoreConnector>(
builder: (context, connector, _) { builder: (context, connector, _) {
final l10n = context.l10n;
final primaryPath = _selectPrimaryPath(message.pathBytes, message.pathVariants); 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 hasHopDetails = primaryPath.isNotEmpty;
final observedLabel = _formatObservedHops( final observedLabel = _formatObservedHops(
primaryPath.length, primaryPath.length,
message.pathLength, message.pathLength,
l10n,
); );
final extraPaths = _otherPaths(primaryPath, message.pathVariants); final extraPaths = _otherPaths(primaryPath, message.pathVariants);
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text('Packet Path'), title: Text(l10n.channelPath_title),
actions: [ actions: [
IconButton( IconButton(
icon: const Icon(Icons.map_outlined), icon: const Icon(Icons.map_outlined),
tooltip: 'View map', tooltip: l10n.channelPath_viewMap,
onPressed: hasHopDetails onPressed: hasHopDetails
? () { ? () {
_openPathMap(context); _openPathMap(context);
@@ -57,7 +61,7 @@ class ChannelMessagePathScreen extends StatelessWidget {
const SizedBox(height: 16), const SizedBox(height: 16),
if (extraPaths.isNotEmpty) ...[ if (extraPaths.isNotEmpty) ...[
Text( Text(
'Other Observed Paths', l10n.channelPath_otherObservedPaths,
style: Theme.of(context).textTheme.titleSmall, style: Theme.of(context).textTheme.titleSmall,
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
@@ -65,17 +69,17 @@ class ChannelMessagePathScreen extends StatelessWidget {
const SizedBox(height: 16), const SizedBox(height: 16),
], ],
Text( Text(
'Repeater Hops', l10n.channelPath_repeaterHops,
style: Theme.of(context).textTheme.titleSmall, style: Theme.of(context).textTheme.titleSmall,
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
if (!hasHopDetails) if (!hasHopDetails)
const Text( Text(
'Hop details are not provided for this packet.', l10n.channelPath_noHopDetails,
style: TextStyle(color: Colors.grey), style: const TextStyle(color: Colors.grey),
) )
else else
..._buildHopTiles(hops), ..._buildHopTiles(context, hops),
], ],
), ),
), ),
@@ -88,6 +92,7 @@ class ChannelMessagePathScreen extends StatelessWidget {
BuildContext context, { BuildContext context, {
String? observedLabel, String? observedLabel,
}) { }) {
final l10n = context.l10n;
return Card( return Card(
child: Padding( child: Padding(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
@@ -95,16 +100,16 @@ class ChannelMessagePathScreen extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
'Message Details', l10n.channelPath_messageDetails,
style: Theme.of(context).textTheme.titleSmall, style: Theme.of(context).textTheme.titleSmall,
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
_buildDetailRow('Sender', message.senderName), _buildDetailRow(l10n.channelPath_senderLabel, message.senderName),
_buildDetailRow('Time', _formatTime(message.timestamp)), _buildDetailRow(l10n.channelPath_timeLabel, _formatTime(message.timestamp, l10n)),
if (message.repeatCount > 0) if (message.repeatCount > 0)
_buildDetailRow('Repeats', message.repeatCount.toString()), _buildDetailRow(l10n.channelPath_repeatsLabel, message.repeatCount.toString()),
_buildDetailRow('Path', _formatPathLabel(message.pathLength)), _buildDetailRow(l10n.channelPath_pathLabelTitle, _formatPathLabel(message.pathLength, l10n)),
if (observedLabel != null) _buildDetailRow('Observed', observedLabel), if (observedLabel != null) _buildDetailRow(l10n.channelPath_observedLabel, observedLabel),
], ],
), ),
), ),
@@ -115,6 +120,7 @@ class ChannelMessagePathScreen extends StatelessWidget {
BuildContext context, BuildContext context,
List<Uint8List> variants, List<Uint8List> variants,
) { ) {
final l10n = context.l10n;
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@@ -124,7 +130,10 @@ class ChannelMessagePathScreen extends StatelessWidget {
child: ListTile( child: ListTile(
dense: true, dense: true,
title: Text( 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])), subtitle: Text(_formatPathPrefixes(variants[i])),
trailing: const Icon(Icons.map_outlined, size: 20), 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 [ return [
for (final hop in hops) for (final hop in hops)
Card( Card(
@@ -154,45 +164,52 @@ class ChannelMessagePathScreen extends StatelessWidget {
hop.hasLocation hop.hasLocation
? '${hop.position!.latitude.toStringAsFixed(5)}, ' ? '${hop.position!.latitude.toStringAsFixed(5)}, '
'${hop.position!.longitude.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 now = DateTime.now();
final diff = now.difference(time); final diff = now.difference(time);
if (diff.inDays > 0) { if (diff.inDays > 0) {
return '${time.day}/${time.month} ' final timeLabel =
'${time.hour}:${time.minute.toString().padLeft(2, '0')}'; '${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) { String _formatPathLabel(int? pathLength, AppLocalizations l10n) {
if (pathLength == null) return 'Unknown'; if (pathLength == null) return l10n.channelPath_unknownPath;
if (pathLength < 0) return 'Flood'; if (pathLength < 0) return l10n.channelPath_floodPath;
if (pathLength == 0) return 'Direct'; if (pathLength == 0) return l10n.channelPath_directPath;
return '$pathLength hops'; 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)) { if (observedCount <= 0 && (pathLength == null || pathLength <= 0)) {
return null; return null;
} }
if (pathLength == null || pathLength < 0) { if (pathLength == null || pathLength < 0) {
return observedCount > 0 ? '$observedCount hops' : null; return observedCount > 0 ? l10n.chat_hopsCount(observedCount) : null;
} }
if (observedCount == 0) { if (observedCount == 0) {
return '0 of $pathLength hops'; return l10n.channelPath_observedZeroOf(pathLength);
} }
if (observedCount == 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) { Widget _buildDetailRow(String label, String value) {
@@ -274,7 +291,7 @@ class _ChannelMessagePathMapScreenState extends State<ChannelMessagePathMapScree
primaryPath, primaryPath,
); );
final selectedIndex = _indexForPath(selectedPath, observedPaths); final selectedIndex = _indexForPath(selectedPath, observedPaths);
final hops = _buildPathHops(selectedPath, connector.contacts); final hops = _buildPathHops(selectedPath, connector.contacts, context.l10n);
final points = hops final points = hops
.where((hop) => hop.hasLocation) .where((hop) => hop.hasLocation)
.map((hop) => hop.position!) .map((hop) => hop.position!)
@@ -297,7 +314,7 @@ class _ChannelMessagePathMapScreenState extends State<ChannelMessagePathMapScree
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text('Path Map'), title: Text(context.l10n.channelPath_mapTitle),
), ),
body: SafeArea( body: SafeArea(
top: false, top: false,
@@ -347,9 +364,9 @@ class _ChannelMessagePathMapScreenState extends State<ChannelMessagePathMapScree
Center( Center(
child: Card( child: Card(
color: Colors.white.withValues(alpha: 0.9), color: Colors.white.withValues(alpha: 0.9),
child: const Padding( child: Padding(
padding: EdgeInsets.all(12), 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, int selectedIndex,
ValueChanged<int> onSelected, ValueChanged<int> onSelected,
) { ) {
final l10n = context.l10n;
final selectedPath = paths[selectedIndex]; final selectedPath = paths[selectedIndex];
final label = selectedPath.isPrimary final label = selectedPath.isPrimary
? 'Path ${selectedIndex + 1} (Primary)' ? l10n.channelPath_primaryPath(selectedIndex + 1)
: 'Path ${selectedIndex + 1}'; : l10n.channelPath_pathLabel(selectedIndex + 1);
return Positioned( return Positioned(
left: 16, left: 16,
right: 16, right: 16,
@@ -383,9 +401,9 @@ class _ChannelMessagePathMapScreenState extends State<ChannelMessagePathMapScree
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const Text( Text(
'Observed Path', l10n.channelPath_observedPathHeader,
style: TextStyle(fontWeight: FontWeight.w600), style: const TextStyle(fontWeight: FontWeight.w600),
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
DropdownButtonHideUnderline( DropdownButtonHideUnderline(
@@ -397,8 +415,8 @@ class _ChannelMessagePathMapScreenState extends State<ChannelMessagePathMapScree
DropdownMenuItem( DropdownMenuItem(
value: i, value: i,
child: Text( child: Text(
'${paths[i].isPrimary ? 'Path ${i + 1} (Primary)' : 'Path ${i + 1}'}' '${paths[i].isPrimary ? l10n.channelPath_primaryPath(i + 1) : l10n.channelPath_pathLabel(i + 1)}'
'${_formatHopCount(paths[i].pathBytes.length)}', '${_formatHopCount(paths[i].pathBytes.length, l10n)}',
), ),
), ),
], ],
@@ -410,7 +428,10 @@ class _ChannelMessagePathMapScreenState extends State<ChannelMessagePathMapScree
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
'$label${_formatPathPrefixes(selectedPath.pathBytes)}', l10n.channelPath_selectedPathLabel(
label,
_formatPathPrefixes(selectedPath.pathBytes),
),
style: TextStyle(color: Colors.grey[700], fontSize: 12), style: TextStyle(color: Colors.grey[700], fontSize: 12),
), ),
], ],
@@ -457,6 +478,7 @@ class _ChannelMessagePathMapScreenState extends State<ChannelMessagePathMapScree
} }
Widget _buildLegendCard(BuildContext context, List<_PathHop> hops) { Widget _buildLegendCard(BuildContext context, List<_PathHop> hops) {
final l10n = context.l10n;
final maxHeight = MediaQuery.of(context).size.height * 0.35; final maxHeight = MediaQuery.of(context).size.height * 0.35;
final estimatedHeight = 72.0 + (hops.length * 56.0); final estimatedHeight = 72.0 + (hops.length * 56.0);
final cardHeight = max(96.0, min(maxHeight, estimatedHeight)); final cardHeight = max(96.0, min(maxHeight, estimatedHeight));
@@ -471,18 +493,18 @@ class _ChannelMessagePathMapScreenState extends State<ChannelMessagePathMapScree
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const Padding( Padding(
padding: EdgeInsets.all(12), padding: const EdgeInsets.all(12),
child: Text( child: Text(
'Repeater Hops', l10n.channelPath_repeaterHops,
style: TextStyle(fontWeight: FontWeight.w600), style: const TextStyle(fontWeight: FontWeight.w600),
), ),
), ),
const Divider(height: 1), const Divider(height: 1),
Expanded( Expanded(
child: hops.isEmpty child: hops.isEmpty
? const Center( ? Center(
child: Text('No hop details available for this packet.'), child: Text(l10n.channelPath_noHopDetailsAvailable),
) )
: ListView.separated( : ListView.separated(
padding: const EdgeInsets.symmetric(vertical: 4), padding: const EdgeInsets.symmetric(vertical: 4),
@@ -504,7 +526,7 @@ class _ChannelMessagePathMapScreenState extends State<ChannelMessagePathMapScree
hop.hasLocation hop.hasLocation
? '${hop.position!.latitude.toStringAsFixed(5)}, ' ? '${hop.position!.latitude.toStringAsFixed(5)}, '
'${hop.position!.longitude.toStringAsFixed(5)}' '${hop.position!.longitude.toStringAsFixed(5)}'
: 'No location data', : l10n.channelPath_noLocationData,
), ),
); );
}, },
@@ -523,19 +545,21 @@ class _PathHop {
final int prefix; final int prefix;
final Contact? contact; final Contact? contact;
final LatLng? position; final LatLng? position;
final AppLocalizations l10n;
const _PathHop({ const _PathHop({
required this.index, required this.index,
required this.prefix, required this.prefix,
required this.contact, required this.contact,
required this.position, required this.position,
required this.l10n,
}); });
bool get hasLocation => position != null; bool get hasLocation => position != null;
String get displayLabel { String get displayLabel {
final prefixLabel = _formatPrefix(prefix); 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>[]; final hops = <_PathHop>[];
for (var i = 0; i < pathBytes.length; i++) { for (var i = 0; i < pathBytes.length; i++) {
final prefix = pathBytes[i]; final prefix = pathBytes[i];
@@ -560,6 +588,7 @@ List<_PathHop> _buildPathHops(Uint8List pathBytes, List<Contact> contacts) {
prefix: prefix, prefix: prefix,
contact: contact, contact: contact,
position: _resolvePosition(contact), position: _resolvePosition(contact),
l10n: l10n,
), ),
); );
} }
@@ -612,15 +641,15 @@ String _formatPathPrefixes(Uint8List pathBytes) {
.join(','); .join(',');
} }
String _formatHopCount(int count) { String _formatHopCount(int count, AppLocalizations l10n) {
return '$count ${count == 1 ? 'hop' : 'hops'}'; return l10n.chat_hopsCount(count);
} }
String _resolveName(Contact? contact) { String _resolveName(Contact? contact, AppLocalizations l10n) {
if (contact == null) return 'Unknown Repeater'; if (contact == null) return l10n.channelPath_unknownRepeater;
final name = contact.name.trim(); final name = contact.name.trim();
if (name.isEmpty || name.toLowerCase() == 'unknown') { if (name.isEmpty || name.toLowerCase() == 'unknown') {
return 'Unknown Repeater'; return l10n.channelPath_unknownRepeater;
} }
return name; return name;
} }
File diff suppressed because it is too large Load Diff
+296 -166
View File
@@ -1,14 +1,18 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart'; import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_linkify/flutter_linkify.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
import '../connector/meshcore_connector.dart'; import '../connector/meshcore_connector.dart';
import '../connector/meshcore_protocol.dart'; import '../connector/meshcore_protocol.dart';
import '../helpers/chat_scroll_controller.dart';
import '../helpers/link_handler.dart';
import '../helpers/utf8_length_limiter.dart'; import '../helpers/utf8_length_limiter.dart';
import '../models/channel_message.dart'; import '../models/channel_message.dart';
import '../models/contact.dart'; import '../models/contact.dart';
@@ -19,9 +23,11 @@ import 'map_screen.dart';
import '../utils/emoji_utils.dart'; import '../utils/emoji_utils.dart';
import '../widgets/emoji_picker.dart'; import '../widgets/emoji_picker.dart';
import '../widgets/gif_message.dart'; import '../widgets/gif_message.dart';
import '../widgets/jump_to_bottom_button.dart';
import '../widgets/gif_picker.dart'; import '../widgets/gif_picker.dart';
import '../widgets/path_selection_dialog.dart'; import '../widgets/path_selection_dialog.dart';
import '../utils/app_logger.dart'; import '../utils/app_logger.dart';
import '../l10n/l10n.dart';
class ChatScreen extends StatefulWidget { class ChatScreen extends StatefulWidget {
final Contact contact; final Contact contact;
@@ -34,25 +40,44 @@ class ChatScreen extends StatefulWidget {
class _ChatScreenState extends State<ChatScreen> { class _ChatScreenState extends State<ChatScreen> {
final _textController = TextEditingController(); final _textController = TextEditingController();
final _scrollController = ScrollController(); final _scrollController = ChatScrollController();
final _textFieldFocusNode = FocusNode();
bool _isLoadingOlder = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_textFieldFocusNode.addListener(_onTextFieldFocusChange);
_scrollController.onScrollNearTop = _loadOlderMessages;
SchedulerBinding.instance.addPostFrameCallback((_) { SchedulerBinding.instance.addPostFrameCallback((_) {
if (!mounted) return; if (!mounted) return;
context.read<MeshCoreConnector>().setActiveContact(widget.contact.publicKeyHex); context.read<MeshCoreConnector>().setActiveContact(widget.contact.publicKeyHex);
// Scroll to bottom when opening chat use SchedulerBinding for next frame
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.loadOlderMessages(widget.contact.publicKeyHex);
if (mounted) {
setState(() => _isLoadingOlder = false);
}
}
@override @override
void dispose() { void dispose() {
context.read<MeshCoreConnector>().setActiveContact(null); context.read<MeshCoreConnector>().setActiveContact(null);
_textFieldFocusNode.removeListener(_onTextFieldFocusChange);
_textFieldFocusNode.dispose();
_textController.dispose(); _textController.dispose();
_scrollController.dispose(); _scrollController.dispose();
super.dispose(); super.dispose();
@@ -66,7 +91,7 @@ class _ChatScreenState extends State<ChatScreen> {
builder: (context, pathService, connector, _) { builder: (context, pathService, connector, _) {
final contact = _resolveContact(connector); final contact = _resolveContact(connector);
final unreadCount = connector.getUnreadCountForContactKey(widget.contact.publicKeyHex); final unreadCount = connector.getUnreadCountForContactKey(widget.contact.publicKeyHex);
final unreadLabel = 'Unread: $unreadCount'; final unreadLabel = context.l10n.chat_unread(unreadCount);
final pathLabel = _currentPathLabel(contact); final pathLabel = _currentPathLabel(contact);
// Show path details if we have path data (from device or override) // Show path details if we have path data (from device or override)
@@ -105,7 +130,7 @@ class _ChatScreenState extends State<ChatScreen> {
return PopupMenuButton<String>( return PopupMenuButton<String>(
icon: Icon(isFloodMode ? Icons.waves : Icons.route), icon: Icon(isFloodMode ? Icons.waves : Icons.route),
tooltip: 'Routing mode', tooltip: context.l10n.chat_routingMode,
onSelected: (mode) async { onSelected: (mode) async {
if (mode == 'flood') { if (mode == 'flood') {
await connector.setPathOverride(contact, pathLen: -1); await connector.setPathOverride(contact, pathLen: -1);
@@ -121,7 +146,7 @@ class _ChatScreenState extends State<ChatScreen> {
Icon(Icons.auto_mode, size: 20, color: !isFloodMode ? Theme.of(context).primaryColor : null), Icon(Icons.auto_mode, size: 20, color: !isFloodMode ? Theme.of(context).primaryColor : null),
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Text(
'Auto (use saved path)', context.l10n.chat_autoUseSavedPath,
style: TextStyle( style: TextStyle(
fontWeight: !isFloodMode ? FontWeight.bold : FontWeight.normal, fontWeight: !isFloodMode ? FontWeight.bold : FontWeight.normal,
), ),
@@ -136,7 +161,7 @@ class _ChatScreenState extends State<ChatScreen> {
Icon(Icons.waves, size: 20, color: isFloodMode ? Theme.of(context).primaryColor : null), Icon(Icons.waves, size: 20, color: isFloodMode ? Theme.of(context).primaryColor : null),
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Text(
'Force Flood Mode', context.l10n.chat_forceFloodMode,
style: TextStyle( style: TextStyle(
fontWeight: isFloodMode ? FontWeight.bold : FontWeight.normal, fontWeight: isFloodMode ? FontWeight.bold : FontWeight.normal,
), ),
@@ -150,7 +175,7 @@ class _ChatScreenState extends State<ChatScreen> {
), ),
IconButton( IconButton(
icon: const Icon(Icons.timeline), icon: const Icon(Icons.timeline),
tooltip: 'Path management', tooltip: context.l10n.chat_pathManagement,
onPressed: () => _showPathHistory(context), onPressed: () => _showPathHistory(context),
), ),
IconButton( IconButton(
@@ -162,13 +187,19 @@ class _ChatScreenState extends State<ChatScreen> {
body: Consumer<MeshCoreConnector>( body: Consumer<MeshCoreConnector>(
builder: (context, connector, child) { builder: (context, connector, child) {
final messages = connector.getMessages(widget.contact); final messages = connector.getMessages(widget.contact);
return Column( return Column(
children: [ children: [
Expanded( Expanded(
child: messages.isEmpty child: Stack(
? _buildEmptyState() children: [
: _buildMessageList(messages), messages.isEmpty
? _buildEmptyState()
: _buildMessageList(messages, connector),
JumpToBottomButton(
scrollController: _scrollController,
),
],
),
), ),
_buildInputBar(connector), _buildInputBar(connector),
], ],
@@ -186,12 +217,12 @@ class _ChatScreenState extends State<ChatScreen> {
Icon(Icons.chat_bubble_outline, size: 64, color: Colors.grey[400]), Icon(Icons.chat_bubble_outline, size: 64, color: Colors.grey[400]),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
'No messages yet', context.l10n.chat_noMessages,
style: TextStyle(fontSize: 16, color: Colors.grey[600]), style: TextStyle(fontSize: 16, color: Colors.grey[600]),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
'Send a message to ${widget.contact.name}', context.l10n.chat_sendMessageTo(widget.contact.name),
style: TextStyle(fontSize: 14, color: Colors.grey[500]), style: TextStyle(fontSize: 14, color: Colors.grey[500]),
), ),
], ],
@@ -199,18 +230,53 @@ class _ChatScreenState extends State<ChatScreen> {
); );
} }
Widget _buildMessageList(List<Message> messages) { Widget _buildMessageList(List<Message> messages, MeshCoreConnector connector) {
// 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 ListView.builder( return ListView.builder(
reverse: true, // List grows from bottom up
controller: _scrollController, controller: _scrollController,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 16), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 16),
itemCount: messages.length, itemCount: itemCount,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final message = messages[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;
Contact contact = widget.contact;
final message = reversedMessages[messageIndex];
String fourByteHex = '';
if (widget.contact.type == advTypeRoom) {
contact = _resolveContactFrom4Bytes(
connector,
message.fourByteRoomContactKey.isEmpty ? Uint8List.fromList([0, 0, 0, 0]) : message.fourByteRoomContactKey,
);
fourByteHex = message.fourByteRoomContactKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join().toUpperCase();
}
return _MessageBubble( return _MessageBubble(
message: message, message: message,
senderName: widget.contact.name, senderName: widget.contact.type == advTypeRoom ? "${contact.name} [$fourByteHex]" : contact.name,
onTap: () => _openMessagePath(message), isRoomServer: widget.contact.type == advTypeRoom,
onLongPress: () => _showMessageActions(message), onTap: () => _openMessagePath(message, contact),
onLongPress: () => _showMessageActions(message, contact),
); );
}, },
); );
@@ -233,7 +299,7 @@ class _ChatScreenState extends State<ChatScreen> {
IconButton( IconButton(
icon: const Icon(Icons.gif_box), icon: const Icon(Icons.gif_box),
onPressed: () => _showGifPicker(context), onPressed: () => _showGifPicker(context),
tooltip: 'Send GIF', tooltip: context.l10n.chat_sendGif,
), ),
Expanded( Expanded(
child: ValueListenableBuilder<TextEditingValue>( child: ValueListenableBuilder<TextEditingValue>(
@@ -244,13 +310,15 @@ class _ChatScreenState extends State<ChatScreen> {
return Row( return Row(
children: [ children: [
Expanded( Expanded(
child: GifMessage( child: ClipRRect(
url: 'https://media.giphy.com/media/$gifId/giphy.gif', borderRadius: BorderRadius.circular(12),
backgroundColor: colorScheme.surfaceContainerHighest, child: GifMessage(
fallbackTextColor: url: 'https://media.giphy.com/media/$gifId/giphy.gif',
colorScheme.onSurface.withValues(alpha: 0.6), backgroundColor: colorScheme.surfaceContainerHighest,
width: 160, fallbackTextColor:
height: 110, colorScheme.onSurface.withValues(alpha: 0.6),
maxSize: 160,
),
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
@@ -264,13 +332,15 @@ class _ChatScreenState extends State<ChatScreen> {
return TextField( return TextField(
controller: _textController, controller: _textController,
focusNode: _textFieldFocusNode,
inputFormatters: [ inputFormatters: [
Utf8LengthLimitingTextInputFormatter(maxBytes), Utf8LengthLimitingTextInputFormatter(maxBytes),
], ],
decoration: const InputDecoration( textCapitalization: TextCapitalization.sentences,
hintText: 'Type a message...', decoration: InputDecoration(
border: OutlineInputBorder(), hintText: context.l10n.chat_typeMessage,
contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 12), border: const OutlineInputBorder(),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
), ),
textInputAction: TextInputAction.send, textInputAction: TextInputAction.send,
onSubmitted: (_) => _sendMessage(connector), onSubmitted: (_) => _sendMessage(connector),
@@ -314,7 +384,7 @@ class _ChatScreenState extends State<ChatScreen> {
final maxBytes = maxContactMessageBytes(); final maxBytes = maxContactMessageBytes();
if (utf8.encode(text).length > maxBytes) { if (utf8.encode(text).length > maxBytes) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Message too long (max $maxBytes bytes).')), SnackBar(content: Text(context.l10n.chat_messageTooLong(maxBytes))),
); );
return; return;
} }
@@ -324,16 +394,6 @@ class _ChatScreenState extends State<ChatScreen> {
text, text,
); );
_textController.clear(); _textController.clear();
Future.delayed(const Duration(milliseconds: 100), () {
if (_scrollController.hasClients) {
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
);
}
});
} }
@@ -346,11 +406,11 @@ class _ChatScreenState extends State<ChatScreen> {
builder: (context, pathService, _) { builder: (context, pathService, _) {
final paths = pathService.getRecentPaths(widget.contact.publicKeyHex); final paths = pathService.getRecentPaths(widget.contact.publicKeyHex);
return AlertDialog( return AlertDialog(
title: const Row( title: Row(
children: [ children: [
Icon(Icons.timeline), const Icon(Icons.timeline),
SizedBox(width: 8), const SizedBox(width: 8),
Text('Path Management'), Text(context.l10n.chat_pathManagement),
], ],
), ),
content: SingleChildScrollView( content: SingleChildScrollView(
@@ -359,9 +419,9 @@ class _ChatScreenState extends State<ChatScreen> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
if (paths.isNotEmpty) ...[ if (paths.isNotEmpty) ...[
const Text( Text(
'Recent ACK Paths (tap to use):', context.l10n.chat_recentAckPaths,
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12), style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
), ),
if (paths.length >= 100) ...[ if (paths.length >= 100) ...[
const SizedBox(height: 8), const SizedBox(height: 8),
@@ -372,9 +432,9 @@ class _ChatScreenState extends State<ChatScreen> {
color: Colors.amber[100], color: Colors.amber[100],
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
), ),
child: const Text( child: Text(
'Path history is full. Remove entries to add new ones.', context.l10n.chat_pathHistoryFull,
style: TextStyle(fontSize: 12), style: const TextStyle(fontSize: 12),
), ),
), ),
], ],
@@ -393,11 +453,11 @@ class _ChatScreenState extends State<ChatScreen> {
), ),
), ),
title: Text( title: Text(
'${path.hopCount} ${path.hopCount == 1 ? 'hop' : 'hops'}', '${path.hopCount} ${path.hopCount == 1 ? context.l10n.chat_hopSingular : context.l10n.chat_hopPlural}',
style: const TextStyle(fontSize: 14), style: const TextStyle(fontSize: 14),
), ),
subtitle: Text( subtitle: Text(
'${(path.tripTimeMs / 1000).toStringAsFixed(2)}s • ${_formatRelativeTime(path.timestamp)}${path.successCount} successes', '${(path.tripTimeMs / 1000).toStringAsFixed(2)}s • ${_formatRelativeTime(path.timestamp)}${path.successCount} ${context.l10n.chat_successes}',
style: const TextStyle(fontSize: 11), style: const TextStyle(fontSize: 11),
), ),
trailing: Row( trailing: Row(
@@ -405,7 +465,7 @@ class _ChatScreenState extends State<ChatScreen> {
children: [ children: [
IconButton( IconButton(
icon: const Icon(Icons.close, size: 16), icon: const Icon(Icons.close, size: 16),
tooltip: 'Remove path', tooltip: context.l10n.chat_removePath,
onPressed: () async { onPressed: () async {
await pathService.removePathRecord( await pathService.removePathRecord(
widget.contact.publicKeyHex, widget.contact.publicKeyHex,
@@ -422,9 +482,9 @@ class _ChatScreenState extends State<ChatScreen> {
onTap: () async { onTap: () async {
if (path.pathBytes.isEmpty) { if (path.pathBytes.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( SnackBar(
content: Text('Path details not available yet. Try sending a message to refresh.'), content: Text(context.l10n.chat_pathDetailsNotAvailable),
duration: Duration(seconds: 2), duration: const Duration(seconds: 2),
), ),
); );
return; return;
@@ -454,13 +514,13 @@ class _ChatScreenState extends State<ChatScreen> {
}), }),
const Divider(), const Divider(),
] else ...[ ] else ...[
const Text('No path history yet.\nSend a message to discover paths.'), Text(context.l10n.chat_noPathHistoryYet),
const Divider(), const Divider(),
], ],
const SizedBox(height: 8), const SizedBox(height: 8),
const Text( Text(
'Path Actions:', context.l10n.chat_pathActions,
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12), style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
ListTile( ListTile(
@@ -470,8 +530,8 @@ class _ChatScreenState extends State<ChatScreen> {
backgroundColor: Colors.purple, backgroundColor: Colors.purple,
child: Icon(Icons.edit_road, size: 16), child: Icon(Icons.edit_road, size: 16),
), ),
title: const Text('Set Custom Path', style: TextStyle(fontSize: 14)), title: Text(context.l10n.chat_setCustomPath, style: const TextStyle(fontSize: 14)),
subtitle: const Text('Manually specify routing path', style: TextStyle(fontSize: 11)), subtitle: Text(context.l10n.chat_setCustomPathSubtitle, style: const TextStyle(fontSize: 11)),
onTap: () { onTap: () {
Navigator.pop(context); Navigator.pop(context);
_showCustomPathDialog(context); _showCustomPathDialog(context);
@@ -484,15 +544,15 @@ class _ChatScreenState extends State<ChatScreen> {
backgroundColor: Colors.orange, backgroundColor: Colors.orange,
child: Icon(Icons.clear_all, size: 16), child: Icon(Icons.clear_all, size: 16),
), ),
title: const Text('Clear Path', style: TextStyle(fontSize: 14)), title: Text(context.l10n.chat_clearPath, style: const TextStyle(fontSize: 14)),
subtitle: const Text('Force rediscovery on next send', style: TextStyle(fontSize: 11)), subtitle: Text(context.l10n.chat_clearPathSubtitle, style: const TextStyle(fontSize: 11)),
onTap: () async { onTap: () async {
await connector.clearContactPath(widget.contact); await connector.clearContactPath(widget.contact);
if (!context.mounted) return; if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( SnackBar(
content: Text('Path cleared. Next message will rediscover route.'), content: Text(context.l10n.chat_pathCleared),
duration: Duration(seconds: 2), duration: const Duration(seconds: 2),
), ),
); );
Navigator.pop(context); Navigator.pop(context);
@@ -505,15 +565,15 @@ class _ChatScreenState extends State<ChatScreen> {
backgroundColor: Colors.blue, backgroundColor: Colors.blue,
child: Icon(Icons.waves, size: 16), child: Icon(Icons.waves, size: 16),
), ),
title: const Text('Force Flood Mode', style: TextStyle(fontSize: 14)), title: Text(context.l10n.chat_forceFloodMode, style: const TextStyle(fontSize: 14)),
subtitle: const Text('Use routing toggle in app bar', style: TextStyle(fontSize: 11)), subtitle: Text(context.l10n.chat_floodModeSubtitle, style: const TextStyle(fontSize: 11)),
onTap: () async { onTap: () async {
await connector.setPathOverride(widget.contact, pathLen: -1); await connector.setPathOverride(widget.contact, pathLen: -1);
if (!context.mounted) return; if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( SnackBar(
content: Text('Flood mode enabled. Toggle back via routing icon in app bar.'), content: Text(context.l10n.chat_floodModeEnabled),
duration: Duration(seconds: 2), duration: const Duration(seconds: 2),
), ),
); );
Navigator.pop(context); Navigator.pop(context);
@@ -525,7 +585,7 @@ class _ChatScreenState extends State<ChatScreen> {
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
child: const Text('Close'), child: Text(context.l10n.common_close),
), ),
], ],
); );
@@ -536,18 +596,18 @@ class _ChatScreenState extends State<ChatScreen> {
String _formatRelativeTime(DateTime time) { String _formatRelativeTime(DateTime time) {
final diff = DateTime.now().difference(time); final diff = DateTime.now().difference(time);
if (diff.inSeconds < 60) return 'Just now'; if (diff.inSeconds < 60) return context.l10n.time_justNow;
if (diff.inMinutes < 60) return '${diff.inMinutes}m ago'; if (diff.inMinutes < 60) return context.l10n.time_minutesAgo(diff.inMinutes);
if (diff.inHours < 24) return '${diff.inHours}h ago'; if (diff.inHours < 24) return context.l10n.time_hoursAgo(diff.inHours);
return '${diff.inDays}d ago'; return context.l10n.time_daysAgo(diff.inDays);
} }
void _showFullPathDialog(BuildContext context, List<int> pathBytes) { void _showFullPathDialog(BuildContext context, List<int> pathBytes) {
if (pathBytes.isEmpty) { if (pathBytes.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( SnackBar(
content: Text('Path details not available yet. Try sending a message to refresh.'), content: Text(context.l10n.chat_pathDetailsNotAvailable),
duration: Duration(seconds: 2), duration: const Duration(seconds: 2),
), ),
); );
return; return;
@@ -560,12 +620,12 @@ class _ChatScreenState extends State<ChatScreen> {
showDialog( showDialog(
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
title: const Text('Full Path'), title: Text(context.l10n.chat_fullPath),
content: SelectableText(formattedPath), content: SelectableText(formattedPath),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
child: const Text('Close'), child: Text(context.l10n.common_close),
), ),
], ],
), ),
@@ -579,18 +639,25 @@ class _ChatScreenState extends State<ChatScreen> {
); );
} }
Contact _resolveContactFrom4Bytes(MeshCoreConnector connector, Uint8List key4Bytes) {
return connector.contacts.firstWhere(
(c) => listEquals(c.publicKey.sublist(0, 4), key4Bytes.sublist(0, 4)),
orElse: () => widget.contact,
);
}
String _currentPathLabel(Contact contact) { String _currentPathLabel(Contact contact) {
// Check if user has set a path override // Check if user has set a path override
if (contact.pathOverride != null) { if (contact.pathOverride != null) {
if (contact.pathOverride! < 0) return 'Flood (forced)'; if (contact.pathOverride! < 0) return context.l10n.chat_floodForced;
if (contact.pathOverride == 0) return 'Direct (forced)'; if (contact.pathOverride == 0) return context.l10n.chat_directForced;
return '${contact.pathOverride} hops (forced)'; return context.l10n.chat_hopsForced(contact.pathOverride!);
} }
// Use device's path // Use device's path
if (contact.pathLength < 0) return 'Flood (auto)'; if (contact.pathLength < 0) return context.l10n.chat_floodAuto;
if (contact.pathLength == 0) return 'Direct'; if (contact.pathLength == 0) return context.l10n.chat_direct;
return '${contact.pathLength} hops'; return context.l10n.chat_hopsCount(contact.pathLength);
} }
Future<void> _notifyPathSet( Future<void> _notifyPathSet(
@@ -605,12 +672,12 @@ class _ChatScreenState extends State<ChatScreen> {
if (!mounted) return; if (!mounted) return;
final status = !connector.isConnected final status = !connector.isConnected
? 'Saved locally. Connect to sync.' ? context.l10n.chat_pathSavedLocally
: (verified ? 'Device confirmed.' : 'Device not confirmed yet.'); : (verified ? context.l10n.chat_pathDeviceConfirmed : context.l10n.chat_pathDeviceNotConfirmed);
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text( content: Text(
'Path set: $hopCount ${hopCount == 1 ? 'hop' : 'hops'} - $status', context.l10n.chat_pathSetHops(hopCount, status),
), ),
duration: const Duration(seconds: 3), duration: const Duration(seconds: 3),
), ),
@@ -635,19 +702,19 @@ class _ChatScreenState extends State<ChatScreen> {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_buildInfoRow('Type', contact.typeLabel), _buildInfoRow(context.l10n.chat_type, contact.typeLabel),
_buildInfoRow('Path', contact.pathLabel), _buildInfoRow(context.l10n.chat_path, contact.pathLabel),
if (contact.hasLocation) if (contact.hasLocation)
_buildInfoRow( _buildInfoRow(
'Location', context.l10n.chat_location,
'${contact.latitude?.toStringAsFixed(4)}, ${contact.longitude?.toStringAsFixed(4)}', '${contact.latitude?.toStringAsFixed(4)}, ${contact.longitude?.toStringAsFixed(4)}',
), ),
_buildInfoRow('Public Key', '${contact.publicKeyHex.substring(0, 16)}...'), _buildInfoRow(context.l10n.chat_publicKey, '${contact.publicKeyHex.substring(0, 16)}...'),
const Divider(), const Divider(),
SwitchListTile( SwitchListTile(
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
title: const Text('SMAZ compression'), title: Text(context.l10n.channels_smazCompression),
subtitle: const Text('Compress outgoing messages'), subtitle: Text(context.l10n.chat_compressOutgoingMessages),
value: smazEnabled, value: smazEnabled,
onChanged: (value) { onChanged: (value) {
connector.setContactSmazEnabled(contact.publicKeyHex, value); connector.setContactSmazEnabled(contact.publicKeyHex, value);
@@ -659,7 +726,7 @@ class _ChatScreenState extends State<ChatScreen> {
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
child: const Text('Close'), child: Text(context.l10n.common_close),
), ),
], ],
); );
@@ -684,6 +751,15 @@ class _ChatScreenState extends State<ChatScreen> {
); );
} }
void _openChat(BuildContext context, Contact contact) {
// Check if this is a repeater
context.read<MeshCoreConnector>().markContactRead(contact.publicKeyHex);
Navigator.push(
context,
MaterialPageRoute(builder: (context) => ChatScreen(contact: contact)),
);
}
Future<void> _showCustomPathDialog(BuildContext context) async { Future<void> _showCustomPathDialog(BuildContext context) async {
final connector = Provider.of<MeshCoreConnector>(context, listen: false); final connector = Provider.of<MeshCoreConnector>(context, listen: false);
@@ -704,7 +780,7 @@ class _ChatScreenState extends State<ChatScreen> {
context, context,
availableContacts: availableContacts, availableContacts: availableContacts,
initialPath: pathForInput.isEmpty ? null : pathForInput, initialPath: pathForInput.isEmpty ? null : pathForInput,
title: 'Set Custom Path', title: context.l10n.chat_setCustomPath,
currentPathLabel: currentPathLabel, currentPathLabel: currentPathLabel,
onRefresh: connector.isConnected ? connector.getContacts : null, onRefresh: connector.isConnected ? connector.getContacts : null,
); );
@@ -734,10 +810,20 @@ class _ChatScreenState extends State<ChatScreen> {
} }
void _openMessagePath(Message message) { void _openMessagePath(Message message, Contact contact) {
final connector = context.read<MeshCoreConnector>(); final connector = context.read<MeshCoreConnector>();
final senderName = final fourByteHex = message.fourByteRoomContactKey
message.isOutgoing ? (connector.selfName ?? 'Me') : widget.contact.name; .map((b) => b.toRadixString(16).padLeft(2, '0'))
.join()
.toUpperCase();
final String senderName;
if (message.isOutgoing) {
senderName = connector.selfName ?? context.l10n.chat_me;
} else if (widget.contact.type == advTypeRoom) {
senderName = "${contact.name} [$fourByteHex]";
} else {
senderName = widget.contact.name;
}
final pathMessage = ChannelMessage( final pathMessage = ChannelMessage(
senderKey: null, senderKey: null,
senderName: senderName, senderName: senderName,
@@ -757,7 +843,7 @@ class _ChatScreenState extends State<ChatScreen> {
); );
} }
void _showMessageActions(Message message) { void _showMessageActions(Message message, Contact contact) {
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
builder: (sheetContext) => SafeArea( builder: (sheetContext) => SafeArea(
@@ -766,7 +852,7 @@ class _ChatScreenState extends State<ChatScreen> {
children: [ children: [
ListTile( ListTile(
leading: const Icon(Icons.add_reaction_outlined), leading: const Icon(Icons.add_reaction_outlined),
title: const Text('Add Reaction'), title: Text(context.l10n.chat_addReaction),
onTap: () { onTap: () {
Navigator.pop(sheetContext); Navigator.pop(sheetContext);
_showEmojiPicker(message); _showEmojiPicker(message);
@@ -774,7 +860,7 @@ class _ChatScreenState extends State<ChatScreen> {
), ),
ListTile( ListTile(
leading: const Icon(Icons.copy), leading: const Icon(Icons.copy),
title: const Text('Copy'), title: Text(context.l10n.common_copy),
onTap: () { onTap: () {
Navigator.pop(sheetContext); Navigator.pop(sheetContext);
_copyMessageText(message.text); _copyMessageText(message.text);
@@ -782,7 +868,7 @@ class _ChatScreenState extends State<ChatScreen> {
), ),
ListTile( ListTile(
leading: const Icon(Icons.delete_outline), leading: const Icon(Icons.delete_outline),
title: const Text('Delete'), title: Text(context.l10n.common_delete),
onTap: () async { onTap: () async {
Navigator.pop(sheetContext); Navigator.pop(sheetContext);
await _deleteMessage(message); await _deleteMessage(message);
@@ -792,15 +878,24 @@ class _ChatScreenState extends State<ChatScreen> {
message.status == MessageStatus.failed) message.status == MessageStatus.failed)
ListTile( ListTile(
leading: const Icon(Icons.refresh), leading: const Icon(Icons.refresh),
title: const Text('Retry'), title: Text(context.l10n.common_retry),
onTap: () { onTap: () {
Navigator.pop(sheetContext); Navigator.pop(sheetContext);
_retryMessage(message); _retryMessage(message);
}, },
), ),
if (widget.contact.type == advTypeRoom)
ListTile(
leading: const Icon(Icons.chat),
title: Text(context.l10n.contacts_openChat),
onTap: () {
Navigator.pop(sheetContext);
_openChat(context, contact);
},
),
ListTile( ListTile(
leading: const Icon(Icons.close), leading: const Icon(Icons.close),
title: const Text('Cancel'), title: Text(context.l10n.common_cancel),
onTap: () => Navigator.pop(sheetContext), onTap: () => Navigator.pop(sheetContext),
), ),
], ],
@@ -812,7 +907,7 @@ class _ChatScreenState extends State<ChatScreen> {
void _copyMessageText(String text) { void _copyMessageText(String text) {
Clipboard.setData(ClipboardData(text: text)); Clipboard.setData(ClipboardData(text: text));
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Message copied')), SnackBar(content: Text(context.l10n.chat_messageCopied)),
); );
} }
@@ -820,7 +915,7 @@ class _ChatScreenState extends State<ChatScreen> {
await context.read<MeshCoreConnector>().deleteMessage(message); await context.read<MeshCoreConnector>().deleteMessage(message);
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Message deleted')), SnackBar(content: Text(context.l10n.chat_messageDeleted)),
); );
} }
@@ -832,7 +927,7 @@ class _ChatScreenState extends State<ChatScreen> {
message.text, message.text,
); );
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Retrying message')), SnackBar(content: Text(context.l10n.chat_retryingMessage)),
); );
} }
@@ -862,12 +957,14 @@ class _ChatScreenState extends State<ChatScreen> {
class _MessageBubble extends StatelessWidget { class _MessageBubble extends StatelessWidget {
final Message message; final Message message;
final String senderName; final String senderName;
final bool isRoomServer;
final VoidCallback? onTap; final VoidCallback? onTap;
final VoidCallback? onLongPress; final VoidCallback? onLongPress;
const _MessageBubble({ const _MessageBubble({
required this.message, required this.message,
required this.senderName, required this.senderName,
required this.isRoomServer,
this.onTap, this.onTap,
this.onLongPress, this.onLongPress,
}); });
@@ -886,7 +983,10 @@ class _MessageBubble extends StatelessWidget {
? colorScheme.onErrorContainer ? colorScheme.onErrorContainer
: (isOutgoing ? colorScheme.onPrimary : colorScheme.onSurface); : (isOutgoing ? colorScheme.onPrimary : colorScheme.onSurface);
final metaColor = textColor.withValues(alpha: 0.7); final metaColor = textColor.withValues(alpha: 0.7);
String messageText = message.text;
if (isRoomServer && !isOutgoing) {
messageText = message.text.substring(4.clamp(0, message.text.length));
}
return Padding( return Padding(
padding: const EdgeInsets.symmetric(vertical: 4), padding: const EdgeInsets.symmetric(vertical: 4),
child: Column( child: Column(
@@ -905,7 +1005,9 @@ class _MessageBubble extends StatelessWidget {
], ],
Flexible( Flexible(
child: Container( 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( constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.65, maxWidth: MediaQuery.of(context).size.width * 0.65,
), ),
@@ -917,75 +1019,103 @@ class _MessageBubble extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
if (!isOutgoing) ...[ if (!isOutgoing) ...[
Text( Padding(
senderName, padding: gifId != null
style: TextStyle( ? const EdgeInsets.only(left: 8, top: 4, bottom: 4)
fontSize: 12, : EdgeInsets.zero,
fontWeight: FontWeight.bold, child: Text(
color: colorScheme.primary, senderName,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: colorScheme.primary,
),
), ),
), ),
const SizedBox(height: 4), if (gifId == null) const SizedBox(height: 4),
], ],
if (poi != null) if (poi != null)
_buildPoiMessage(context, poi, textColor, metaColor) _buildPoiMessage(context, poi, textColor, metaColor)
else if (gifId != null) else if (gifId != null)
GifMessage( ClipRRect(
url: 'https://media.giphy.com/media/$gifId/giphy.gif', borderRadius: BorderRadius.circular(12),
backgroundColor: bubbleColor, child: GifMessage(
fallbackTextColor: textColor.withValues(alpha: 0.7), url: 'https://media.giphy.com/media/$gifId/giphy.gif',
backgroundColor: Colors.transparent,
fallbackTextColor: textColor.withValues(alpha: 0.7),
),
) )
else else
Text( Linkify(
message.text, text: messageText,
style: TextStyle( style: TextStyle(
color: textColor, color: textColor,
), ),
linkStyle: const TextStyle(
color: Colors.green,
decoration: TextDecoration.underline,
),
options: const LinkifyOptions(
humanize: false,
defaultToHttps: false,
),
linkifiers: const [UrlLinkifier()],
onOpen: (link) => LinkHandler.handleLinkTap(context, link.url),
), ),
if (isOutgoing && message.retryCount > 0) ...[ if (isOutgoing && message.retryCount > 0) ...[
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Padding(
'Retry ${message.retryCount}/4', padding: gifId != null
style: TextStyle( ? const EdgeInsets.symmetric(horizontal: 8)
fontSize: 10, : EdgeInsets.zero,
color: metaColor, child: Text(
fontWeight: FontWeight.w500, context.l10n.chat_retryCount(message.retryCount, 4),
style: TextStyle(
fontSize: 10,
color: metaColor,
fontWeight: FontWeight.w500,
),
), ),
), ),
], ],
const SizedBox(height: 4), const SizedBox(height: 4),
Wrap( Padding(
spacing: 4, padding: gifId != null
crossAxisAlignment: WrapCrossAlignment.center, ? const EdgeInsets.only(left: 8, right: 8, bottom: 4)
children: [ : EdgeInsets.zero,
Text( child: Wrap(
_formatTime(message.timestamp), spacing: 4,
style: TextStyle( crossAxisAlignment: WrapCrossAlignment.center,
fontSize: 10, children: [
color: metaColor,
),
),
if (isOutgoing) ...[
const SizedBox(width: 4),
_buildStatusIcon(metaColor),
],
if (message.tripTimeMs != null &&
message.status == MessageStatus.delivered) ...[
const SizedBox(width: 4),
Icon(
Icons.speed,
size: 10,
color: isOutgoing ? metaColor : Colors.green[700],
),
Text( Text(
'${(message.tripTimeMs! / 1000).toStringAsFixed(1)}s', _formatTime(message.timestamp),
style: TextStyle( style: TextStyle(
fontSize: 9, fontSize: 10,
color: isOutgoing ? metaColor : Colors.green[700], color: metaColor,
), ),
), ),
if (isOutgoing) ...[
const SizedBox(width: 4),
_buildStatusIcon(metaColor),
],
if (message.tripTimeMs != null &&
message.status == MessageStatus.delivered) ...[
const SizedBox(width: 4),
Icon(
Icons.speed,
size: 10,
color: isOutgoing ? metaColor : Colors.green[700],
),
Text(
'${(message.tripTimeMs! / 1000).toStringAsFixed(1)}s',
style: TextStyle(
fontSize: 9,
color: isOutgoing ? metaColor : Colors.green[700],
),
),
],
], ],
], ),
), ),
], ],
), ),
@@ -1055,7 +1185,7 @@ class _MessageBubble extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
'POI Shared', context.l10n.chat_poiShared,
style: TextStyle( style: TextStyle(
color: textColor, color: textColor,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
@@ -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;
}
}
+275 -123
View File
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart'; import '../connector/meshcore_connector.dart';
import '../l10n/l10n.dart';
import '../connector/meshcore_protocol.dart'; import '../connector/meshcore_protocol.dart';
import '../models/contact.dart'; import '../models/contact.dart';
import '../models/contact_group.dart'; import '../models/contact_group.dart';
@@ -18,6 +19,7 @@ import '../widgets/list_filter_widget.dart';
import '../widgets/empty_state.dart'; import '../widgets/empty_state.dart';
import '../widgets/quick_switch_bar.dart'; import '../widgets/quick_switch_bar.dart';
import '../widgets/repeater_login_dialog.dart'; import '../widgets/repeater_login_dialog.dart';
import '../widgets/room_login_dialog.dart';
import '../widgets/unread_badge.dart'; import '../widgets/unread_badge.dart';
import 'channels_screen.dart'; import 'channels_screen.dart';
import 'chat_screen.dart'; import 'chat_screen.dart';
@@ -25,13 +27,15 @@ import 'map_screen.dart';
import 'repeater_hub_screen.dart'; import 'repeater_hub_screen.dart';
import 'settings_screen.dart'; import 'settings_screen.dart';
enum RoomLoginDestination {
chat,
management,
}
class ContactsScreen extends StatefulWidget { class ContactsScreen extends StatefulWidget {
final bool hideBackButton; final bool hideBackButton;
const ContactsScreen({ const ContactsScreen({super.key, this.hideBackButton = false});
super.key,
this.hideBackButton = false,
});
@override @override
State<ContactsScreen> createState() => _ContactsScreenState(); State<ContactsScreen> createState() => _ContactsScreenState();
@@ -88,18 +92,18 @@ class _ContactsScreenState extends State<ContactsScreen>
child: Scaffold( child: Scaffold(
appBar: AppBar( appBar: AppBar(
leading: BatteryIndicator(connector: connector), leading: BatteryIndicator(connector: connector),
title: const Text('Contacts'), title: Text(context.l10n.contacts_title),
centerTitle: true, centerTitle: true,
automaticallyImplyLeading: false, automaticallyImplyLeading: false,
actions: [ actions: [
IconButton( IconButton(
icon: const Icon(Icons.bluetooth_disabled), icon: const Icon(Icons.bluetooth_disabled),
tooltip: 'Disconnect', tooltip: context.l10n.common_disconnect,
onPressed: () => _disconnect(context, connector), onPressed: () => _disconnect(context, connector),
), ),
IconButton( IconButton(
icon: const Icon(Icons.tune), icon: const Icon(Icons.tune),
tooltip: 'Settings', tooltip: context.l10n.common_settings,
onPressed: () => Navigator.push( onPressed: () => Navigator.push(
context, context,
MaterialPageRoute(builder: (context) => const SettingsScreen()), MaterialPageRoute(builder: (context) => const SettingsScreen()),
@@ -112,7 +116,8 @@ class _ContactsScreenState extends State<ContactsScreen>
top: false, top: false,
child: QuickSwitchBar( child: QuickSwitchBar(
selectedIndex: 0, selectedIndex: 0,
onDestinationSelected: (index) => _handleQuickSwitch(index, context), onDestinationSelected: (index) =>
_handleQuickSwitch(index, context),
), ),
), ),
), ),
@@ -158,16 +163,17 @@ class _ContactsScreenState extends State<ContactsScreen>
} }
if (contacts.isEmpty && _groups.isEmpty) { if (contacts.isEmpty && _groups.isEmpty) {
return const EmptyState( return EmptyState(
icon: Icons.people_outline, icon: Icons.people_outline,
title: 'No contacts yet', title: context.l10n.contacts_noContacts,
subtitle: 'Contacts will appear when devices advertise', subtitle: context.l10n.contacts_contactsWillAppear,
); );
} }
final filteredAndSorted = _filterAndSortContacts(contacts, connector); final filteredAndSorted = _filterAndSortContacts(contacts, connector);
final filteredGroups = final filteredGroups = _showUnreadOnly
_showUnreadOnly ? const <ContactGroup>[] : _filterAndSortGroups(_groups, contacts); ? const <ContactGroup>[]
: _filterAndSortGroups(_groups, contacts);
return Column( return Column(
children: [ children: [
@@ -176,7 +182,7 @@ class _ContactsScreenState extends State<ContactsScreen>
child: TextField( child: TextField(
controller: _searchController, controller: _searchController,
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'Search contacts...', hintText: context.l10n.contacts_searchContacts,
prefixIcon: const Icon(Icons.search), prefixIcon: const Icon(Icons.search),
suffixIcon: Row( suffixIcon: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@@ -197,7 +203,10 @@ class _ContactsScreenState extends State<ContactsScreen>
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
), ),
onChanged: (value) { onChanged: (value) {
_searchDebounce?.cancel(); _searchDebounce?.cancel();
@@ -220,8 +229,8 @@ class _ContactsScreenState extends State<ContactsScreen>
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
_showUnreadOnly _showUnreadOnly
? 'No unread contacts' ? context.l10n.contacts_noUnreadContacts
: 'No contacts or groups found', : context.l10n.contacts_noContactsFound,
style: TextStyle(fontSize: 16, color: Colors.grey[600]), style: TextStyle(fontSize: 16, color: Colors.grey[600]),
), ),
], ],
@@ -236,14 +245,18 @@ class _ContactsScreenState extends State<ContactsScreen>
final group = filteredGroups[index]; final group = filteredGroups[index];
return _buildGroupTile(context, group, contacts); return _buildGroupTile(context, group, contacts);
} }
final contact = filteredAndSorted[index - filteredGroups.length]; final contact =
final unreadCount = connector.getUnreadCountForContact(contact); filteredAndSorted[index - filteredGroups.length];
final unreadCount = connector.getUnreadCountForContact(
contact,
);
return _ContactTile( return _ContactTile(
contact: contact, contact: contact,
lastSeen: _resolveLastSeen(contact), lastSeen: _resolveLastSeen(contact),
unreadCount: unreadCount, unreadCount: unreadCount,
onTap: () => _openChat(context, contact), onTap: () => _openChat(context, contact),
onLongPress: () => _showContactOptions(context, connector, contact), onLongPress: () =>
_showContactOptions(context, connector, contact),
); );
}, },
), ),
@@ -253,40 +266,61 @@ 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 query = _searchQuery.trim().toLowerCase();
final contactsByKey = <String, Contact>{}; final contactsByKey = <String, Contact>{};
for (final contact in contacts) { for (final contact in contacts) {
contactsByKey[contact.publicKeyHex] = contact; contactsByKey[contact.publicKeyHex] = contact;
} }
final filtered = groups.where((group) { final filtered = groups
if (query.isEmpty) return true; .where((group) {
if (group.name.toLowerCase().contains(query)) return true; if (query.isEmpty) return true;
for (final key in group.memberKeys) { if (group.name.toLowerCase().contains(query)) return true;
final contact = contactsByKey[key]; for (final key in group.memberKeys) {
if (contact != null && matchesContactQuery(contact, query)) return true; final contact = contactsByKey[key];
} if (contact != null && matchesContactQuery(contact, query)) {
return false; return true;
}).where((group) { }
if (_typeFilter == ContactTypeFilter.all) return true; }
for (final key in group.memberKeys) { return false;
final contact = contactsByKey[key]; })
if (contact != null && _matchesTypeFilter(contact)) return true; .where((group) {
} if (_typeFilter == ContactTypeFilter.all) return true;
return false; for (final key in group.memberKeys) {
}).toList(); 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; return filtered;
} }
List<Contact> _filterAndSortContacts(List<Contact> contacts, MeshCoreConnector connector) { List<Contact> _filterAndSortContacts(
List<Contact> contacts,
MeshCoreConnector connector,
) {
var filtered = contacts.where((contact) { var filtered = contacts.where((contact) {
if (_searchQuery.isEmpty) return true; if (_searchQuery.isEmpty) return true;
return matchesContactQuery(contact, _searchQuery); return matchesContactQuery(contact, _searchQuery);
}).toList(); }).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) { if (_typeFilter != ContactTypeFilter.all) {
filtered = filtered.where(_matchesTypeFilter).toList(); filtered = filtered.where(_matchesTypeFilter).toList();
} }
@@ -299,19 +333,27 @@ class _ContactsScreenState extends State<ContactsScreen>
switch (_sortOption) { switch (_sortOption) {
case ContactSortOption.lastSeen: case ContactSortOption.lastSeen:
filtered.sort((a, b) => _resolveLastSeen(b).compareTo(_resolveLastSeen(a))); filtered.sort(
(a, b) => _resolveLastSeen(b).compareTo(_resolveLastSeen(a)),
);
break; break;
case ContactSortOption.recentMessages: case ContactSortOption.recentMessages:
filtered.sort((a, b) { filtered.sort((a, b) {
final aMessages = connector.getMessages(a); final aMessages = connector.getMessages(a);
final bMessages = connector.getMessages(b); final bMessages = connector.getMessages(b);
final aLastMsg = aMessages.isEmpty ? DateTime(1970) : aMessages.last.timestamp; final aLastMsg = aMessages.isEmpty
final bLastMsg = bMessages.isEmpty ? DateTime(1970) : bMessages.last.timestamp; ? DateTime(1970)
: aMessages.last.timestamp;
final bLastMsg = bMessages.isEmpty
? DateTime(1970)
: bMessages.last.timestamp;
return bLastMsg.compareTo(aLastMsg); return bLastMsg.compareTo(aLastMsg);
}); });
break; break;
case ContactSortOption.name: case ContactSortOption.name:
filtered.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase())); filtered.sort(
(a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()),
);
break; break;
} }
@@ -338,9 +380,13 @@ class _ContactsScreenState extends State<ContactsScreen>
: contact.lastSeen; : 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 memberContacts = _resolveGroupContacts(group, contacts);
final subtitle = _formatGroupMembers(memberContacts); final subtitle = _formatGroupMembers(context, memberContacts);
return ListTile( return ListTile(
leading: const CircleAvatar( leading: const CircleAvatar(
backgroundColor: Colors.teal, backgroundColor: Colors.teal,
@@ -357,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>{}; final byKey = <String, Contact>{};
for (final contact in contacts) { for (final contact in contacts) {
byKey[contact.publicKeyHex] = contact; byKey[contact.publicKeyHex] = contact;
@@ -369,12 +418,14 @@ class _ContactsScreenState extends State<ContactsScreen>
resolved.add(contact); 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; return resolved;
} }
String _formatGroupMembers(List<Contact> members) { String _formatGroupMembers(BuildContext context, List<Contact> members) {
if (members.isEmpty) return 'No members'; if (members.isEmpty) return context.l10n.contacts_noMembers;
final names = members.map((c) => c.name).toList(); final names = members.map((c) => c.name).toList();
if (names.length <= 2) return names.join(', '); if (names.length <= 2) return names.join(', ');
return '${names.take(2).join(', ')} +${names.length - 2}'; return '${names.take(2).join(', ')} +${names.length - 2}';
@@ -384,6 +435,8 @@ class _ContactsScreenState extends State<ContactsScreen>
// Check if this is a repeater // Check if this is a repeater
if (contact.type == advTypeRepeater) { if (contact.type == advTypeRepeater) {
_showRepeaterLogin(context, contact); _showRepeaterLogin(context, contact);
} else if (contact.type == advTypeRoom) {
_showRoomLogin(context, contact, RoomLoginDestination.chat);
} else { } else {
context.read<MeshCoreConnector>().markContactRead(contact.publicKeyHex); context.read<MeshCoreConnector>().markContactRead(contact.publicKeyHex);
Navigator.push( Navigator.push(
@@ -399,17 +452,13 @@ class _ContactsScreenState extends State<ContactsScreen>
case 1: case 1:
Navigator.pushReplacement( Navigator.pushReplacement(
context, context,
buildQuickSwitchRoute( buildQuickSwitchRoute(const ChannelsScreen(hideBackButton: true)),
const ChannelsScreen(hideBackButton: true),
),
); );
break; break;
case 2: case 2:
Navigator.pushReplacement( Navigator.pushReplacement(
context, context,
buildQuickSwitchRoute( buildQuickSwitchRoute(const MapScreen(hideBackButton: true)),
const MapScreen(hideBackButton: true),
),
); );
break; break;
} }
@@ -425,10 +474,8 @@ class _ContactsScreenState extends State<ContactsScreen>
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
builder: (context) => RepeaterHubScreen( builder: (context) =>
repeater: repeater, RepeaterHubScreen(repeater: repeater, password: password),
password: password,
),
), ),
); );
}, },
@@ -436,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); final members = _resolveGroupContacts(group, contacts);
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
@@ -447,7 +522,7 @@ class _ContactsScreenState extends State<ContactsScreen>
children: [ children: [
ListTile( ListTile(
leading: const Icon(Icons.edit), leading: const Icon(Icons.edit),
title: const Text('Edit Group'), title: Text(context.l10n.contacts_editGroup),
onTap: () { onTap: () {
Navigator.pop(sheetContext); Navigator.pop(sheetContext);
_showGroupEditor(context, contacts, group: group); _showGroupEditor(context, contacts, group: group);
@@ -455,7 +530,10 @@ class _ContactsScreenState extends State<ContactsScreen>
), ),
ListTile( ListTile(
leading: const Icon(Icons.delete, color: Colors.red), 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: () { onTap: () {
Navigator.pop(sheetContext); Navigator.pop(sheetContext);
_confirmDeleteGroup(context, group); _confirmDeleteGroup(context, group);
@@ -484,12 +562,12 @@ class _ContactsScreenState extends State<ContactsScreen>
showDialog( showDialog(
context: context, context: context,
builder: (dialogContext) => AlertDialog( builder: (dialogContext) => AlertDialog(
title: const Text('Delete Group'), title: Text(context.l10n.contacts_deleteGroup),
content: Text('Remove "${group.name}"?'), content: Text(context.l10n.contacts_deleteGroupConfirm(group.name)),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(dialogContext), onPressed: () => Navigator.pop(dialogContext),
child: const Text('Cancel'), child: Text(context.l10n.common_cancel),
), ),
TextButton( TextButton(
onPressed: () async { onPressed: () async {
@@ -499,7 +577,10 @@ class _ContactsScreenState extends State<ContactsScreen>
}); });
await _saveGroups(); await _saveGroups();
}, },
child: const Text('Delete', style: TextStyle(color: Colors.red)), child: Text(
context.l10n.common_delete,
style: const TextStyle(color: Colors.red),
),
), ),
], ],
), ),
@@ -525,10 +606,16 @@ class _ContactsScreenState extends State<ContactsScreen>
final filteredContacts = filterQuery.isEmpty final filteredContacts = filterQuery.isEmpty
? sortedContacts ? sortedContacts
: sortedContacts : sortedContacts
.where((contact) => matchesContactQuery(contact, filterQuery)) .where(
.toList(); (contact) => matchesContactQuery(contact, filterQuery),
)
.toList();
return AlertDialog( return AlertDialog(
title: Text(isEditing ? 'Edit Group' : 'New Group'), title: Text(
isEditing
? context.l10n.contacts_editGroup
: context.l10n.contacts_newGroup,
),
content: SizedBox( content: SizedBox(
width: double.maxFinite, width: double.maxFinite,
child: Column( child: Column(
@@ -536,17 +623,17 @@ class _ContactsScreenState extends State<ContactsScreen>
children: [ children: [
TextField( TextField(
controller: nameController, controller: nameController,
decoration: const InputDecoration( decoration: InputDecoration(
labelText: 'Group name', labelText: context.l10n.contacts_groupName,
border: OutlineInputBorder(), border: const OutlineInputBorder(),
), ),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
TextField( TextField(
decoration: const InputDecoration( decoration: InputDecoration(
hintText: 'Filter contacts...', hintText: context.l10n.contacts_filterContacts,
prefixIcon: Icon(Icons.search), prefixIcon: const Icon(Icons.search),
border: OutlineInputBorder(), border: const OutlineInputBorder(),
isDense: true, isDense: true,
), ),
onChanged: (value) { onChanged: (value) {
@@ -559,12 +646,18 @@ class _ContactsScreenState extends State<ContactsScreen>
SizedBox( SizedBox(
height: 240, height: 240,
child: filteredContacts.isEmpty child: filteredContacts.isEmpty
? const Center(child: Text('No contacts match your filter')) ? Center(
child: Text(
context.l10n.contacts_noContactsMatchFilter,
),
)
: ListView.builder( : ListView.builder(
itemCount: filteredContacts.length, itemCount: filteredContacts.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final contact = filteredContacts[index]; final contact = filteredContacts[index];
final isSelected = selectedKeys.contains(contact.publicKeyHex); final isSelected = selectedKeys.contains(
contact.publicKeyHex,
);
return CheckboxListTile( return CheckboxListTile(
value: isSelected, value: isSelected,
title: Text(contact.name), title: Text(contact.name),
@@ -588,14 +681,16 @@ class _ContactsScreenState extends State<ContactsScreen>
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(dialogContext), onPressed: () => Navigator.pop(dialogContext),
child: const Text('Cancel'), child: Text(context.l10n.common_cancel),
), ),
TextButton( TextButton(
onPressed: () async { onPressed: () async {
final name = nameController.text.trim(); final name = nameController.text.trim();
if (name.isEmpty) { if (name.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Group name is required')), SnackBar(
content: Text(context.l10n.contacts_groupNameRequired),
),
); );
return; return;
} }
@@ -605,13 +700,19 @@ class _ContactsScreenState extends State<ContactsScreen>
}); });
if (exists) { if (exists) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Group "$name" already exists')), SnackBar(
content: Text(
context.l10n.contacts_groupAlreadyExists(name),
),
),
); );
return; return;
} }
setState(() { setState(() {
if (isEditing) { if (isEditing) {
final index = _groups.indexWhere((g) => g.name == group.name); final index = _groups.indexWhere(
(g) => g.name == group.name,
);
if (index != -1) { if (index != -1) {
_groups[index] = ContactGroup( _groups[index] = ContactGroup(
name: name, name: name,
@@ -619,7 +720,12 @@ class _ContactsScreenState extends State<ContactsScreen>
); );
} }
} else { } else {
_groups.add(ContactGroup(name: name, memberKeys: selectedKeys.toList())); _groups.add(
ContactGroup(
name: name,
memberKeys: selectedKeys.toList(),
),
);
} }
}); });
await _saveGroups(); await _saveGroups();
@@ -627,7 +733,11 @@ class _ContactsScreenState extends State<ContactsScreen>
Navigator.pop(dialogContext); Navigator.pop(dialogContext);
} }
}, },
child: Text(isEditing ? 'Save' : 'Create'), child: Text(
isEditing
? context.l10n.common_save
: context.l10n.common_create,
),
), ),
], ],
); );
@@ -642,36 +752,57 @@ class _ContactsScreenState extends State<ContactsScreen>
Contact contact, Contact contact,
) { ) {
final isRepeater = contact.type == advTypeRepeater; final isRepeater = contact.type == advTypeRepeater;
final isRoom = contact.type == advTypeRoom;
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
builder: (context) => SafeArea( builder: (sheetContext) => SafeArea(
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
if (isRepeater) if (isRepeater)
ListTile( ListTile(
leading: const Icon(Icons.cell_tower, color: Colors.orange), leading: const Icon(Icons.cell_tower, color: Colors.orange),
title: const Text('Manage Repeater'), title: Text(context.l10n.contacts_manageRepeater),
onTap: () { onTap: () {
Navigator.pop(context); Navigator.pop(sheetContext);
_showRepeaterLogin(context, contact); _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( ListTile(
leading: const Icon(Icons.chat), leading: const Icon(Icons.chat),
title: const Text('Open Chat'), title: Text(context.l10n.contacts_openChat),
onTap: () { onTap: () {
Navigator.pop(context); Navigator.pop(sheetContext);
_openChat(context, contact); _openChat(context, contact);
}, },
), ),
ListTile( ListTile(
leading: const Icon(Icons.delete, color: Colors.red), 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: () { onTap: () {
Navigator.pop(context); Navigator.pop(sheetContext);
_confirmDelete(context, connector, contact); _confirmDelete(context, connector, contact);
}, },
), ),
@@ -688,20 +819,23 @@ class _ContactsScreenState extends State<ContactsScreen>
) { ) {
showDialog( showDialog(
context: context, context: context,
builder: (context) => AlertDialog( builder: (dialogContext) => AlertDialog(
title: const Text('Delete Contact'), title: Text(context.l10n.contacts_deleteContact),
content: Text('Remove ${contact.name} from contacts?'), content: Text(context.l10n.contacts_removeConfirm(contact.name)),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(dialogContext),
child: const Text('Cancel'), child: Text(context.l10n.common_cancel),
), ),
TextButton( TextButton(
onPressed: () { onPressed: () {
Navigator.pop(context); Navigator.pop(dialogContext);
connector.removeContact(contact); connector.removeContact(contact);
}, },
child: const Text('Delete', style: TextStyle(color: Colors.red)), child: Text(
context.l10n.common_delete,
style: const TextStyle(color: Colors.red),
),
), ),
], ],
), ),
@@ -726,28 +860,41 @@ class _ContactTile extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final shotPublicKey =
"<${contact.publicKeyHex.substring(0, 8)}...${contact.publicKeyHex.substring(contact.publicKeyHex.length - 8)}>";
return ListTile( return ListTile(
leading: CircleAvatar( leading: CircleAvatar(
backgroundColor: _getTypeColor(contact.type), backgroundColor: _getTypeColor(contact.type),
child: _buildContactAvatar(contact), child: _buildContactAvatar(contact),
), ),
title: Text(contact.name), title: Text(contact.name),
subtitle: Text('${contact.typeLabel}${contact.pathLabel}'), subtitle: Text(
trailing: Column( '${contact.typeLabel}${contact.pathLabel} $shotPublicKey',
mainAxisAlignment: MainAxisAlignment.center, ),
crossAxisAlignment: CrossAxisAlignment.end, // Clamp text scaling in trailing section to prevent overflow while
children: [ // maintaining accessibility. Primary content (title/subtitle) scales normally.
if (unreadCount > 0) ...[ trailing: MediaQuery(
UnreadBadge(count: unreadCount), data: MediaQuery.of(context).copyWith(
const SizedBox(height: 4), textScaler: TextScaler.linear(
], MediaQuery.textScalerOf(context).scale(1.0).clamp(1.0, 1.3),
Text(
_formatLastSeen(lastSeen),
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
), ),
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, onTap: onTap,
onLongPress: onLongPress, onLongPress: onLongPress,
@@ -757,10 +904,7 @@ class _ContactTile extends StatelessWidget {
Widget _buildContactAvatar(Contact contact) { Widget _buildContactAvatar(Contact contact) {
final emoji = firstEmoji(contact.name); final emoji = firstEmoji(contact.name);
if (emoji != null) { if (emoji != null) {
return Text( return Text(emoji, style: const TextStyle(fontSize: 18));
emoji,
style: const TextStyle(fontSize: 18),
);
} }
return Icon(_getTypeIcon(contact.type), color: Colors.white, size: 20); return Icon(_getTypeIcon(contact.type), color: Colors.white, size: 20);
} }
@@ -795,17 +939,25 @@ class _ContactTile extends StatelessWidget {
} }
} }
String _formatLastSeen(DateTime lastSeen) { String _formatLastSeen(BuildContext context, DateTime lastSeen) {
final now = DateTime.now(); final now = DateTime.now();
final diff = now.difference(lastSeen); final diff = now.difference(lastSeen);
if (diff.isNegative || diff.inMinutes < 5) return 'Last seen now'; if (diff.isNegative || diff.inMinutes < 5) {
if (diff.inMinutes < 60) return 'Last seen ${diff.inMinutes} mins ago'; return context.l10n.contacts_lastSeenNow;
}
if (diff.inMinutes < 60) {
return context.l10n.contacts_lastSeenMinsAgo(diff.inMinutes);
}
if (diff.inHours < 24) { if (diff.inHours < 24) {
final hours = diff.inHours; 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; 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);
} }
} }
+6 -5
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart'; import '../connector/meshcore_connector.dart';
import '../l10n/l10n.dart';
import '../utils/dialog_utils.dart'; import '../utils/dialog_utils.dart';
import '../utils/disconnect_navigation_mixin.dart'; import '../utils/disconnect_navigation_mixin.dart';
import '../utils/route_transitions.dart'; import '../utils/route_transitions.dart';
@@ -47,12 +48,12 @@ class _DeviceScreenState extends State<DeviceScreen>
actions: [ actions: [
IconButton( IconButton(
icon: const Icon(Icons.bluetooth_disabled), icon: const Icon(Icons.bluetooth_disabled),
tooltip: 'Disconnect', tooltip: context.l10n.common_disconnect,
onPressed: () => _disconnect(context, connector), onPressed: () => _disconnect(context, connector),
), ),
IconButton( IconButton(
icon: const Icon(Icons.tune), icon: const Icon(Icons.tune),
tooltip: 'Settings', tooltip: context.l10n.common_settings,
onPressed: () => Navigator.push( onPressed: () => Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
@@ -68,7 +69,7 @@ class _DeviceScreenState extends State<DeviceScreen>
children: [ children: [
_buildConnectionCard(connector, context), _buildConnectionCard(connector, context),
const SizedBox(height: 16), const SizedBox(height: 16),
_buildSectionLabel(theme, 'Quick switch'), _buildSectionLabel(theme, context.l10n.device_quickSwitch),
const SizedBox(height: 12), const SizedBox(height: 12),
_buildQuickSwitchBar(context), _buildQuickSwitchBar(context),
], ],
@@ -87,7 +88,7 @@ class _DeviceScreenState extends State<DeviceScreen>
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Text( Text(
'MeshCore', context.l10n.device_meshcore,
style: theme.textTheme.labelSmall?.copyWith( style: theme.textTheme.labelSmall?.copyWith(
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
letterSpacing: 0.8, letterSpacing: 0.8,
@@ -180,7 +181,7 @@ class _DeviceScreenState extends State<DeviceScreen>
size: 18, size: 18,
color: colorScheme.onSecondaryContainer, color: colorScheme.onSecondaryContainer,
), ),
label: const Text('Connected'), label: Text(context.l10n.common_connected),
backgroundColor: colorScheme.secondaryContainer, backgroundColor: colorScheme.secondaryContainer,
labelStyle: theme.textTheme.labelMedium?.copyWith( labelStyle: theme.textTheme.labelMedium?.copyWith(
color: colorScheme.onSecondaryContainer, 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:latlong2/latlong.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../l10n/app_localizations.dart';
import '../l10n/l10n.dart';
import '../services/app_settings_service.dart'; import '../services/app_settings_service.dart';
import '../services/map_tile_cache_service.dart'; import '../services/map_tile_cache_service.dart';
@@ -110,14 +112,14 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
final bounds = _selectedBounds; final bounds = _selectedBounds;
if (bounds == null) { if (bounds == null) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Select an area to cache first')), SnackBar(content: Text(context.l10n.mapCache_selectAreaFirst)),
); );
return; return;
} }
if (_estimatedTiles == 0) { if (_estimatedTiles == 0) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('No tiles to download for this area')), SnackBar(content: Text(context.l10n.mapCache_noTilesToDownload)),
); );
return; return;
} }
@@ -125,18 +127,18 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
final confirmed = await showDialog<bool>( final confirmed = await showDialog<bool>(
context: context, context: context,
builder: (dialogContext) => AlertDialog( builder: (dialogContext) => AlertDialog(
title: const Text('Download tiles'), title: Text(context.l10n.mapCache_downloadTilesTitle),
content: Text( content: Text(
'Download $_estimatedTiles tiles for offline use?', context.l10n.mapCache_downloadTilesPrompt(_estimatedTiles),
), ),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(dialogContext, false), onPressed: () => Navigator.pop(dialogContext, false),
child: const Text('Cancel'), child: Text(context.l10n.common_cancel),
), ),
TextButton( TextButton(
onPressed: () => Navigator.pop(dialogContext, true), 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 final message = result.failed > 0
? 'Cached ${result.downloaded} tiles (${result.failed} failed)' ? context.l10n.mapCache_cachedTilesWithFailed(
: 'Cached ${result.downloaded} tiles'; result.downloaded,
result.failed,
)
: context.l10n.mapCache_cachedTiles(result.downloaded);
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message)), SnackBar(content: Text(message)),
); );
@@ -185,16 +190,16 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
final confirmed = await showDialog<bool>( final confirmed = await showDialog<bool>(
context: context, context: context,
builder: (dialogContext) => AlertDialog( builder: (dialogContext) => AlertDialog(
title: const Text('Clear offline cache'), title: Text(context.l10n.mapCache_clearOfflineCacheTitle),
content: const Text('Remove all cached map tiles?'), content: Text(context.l10n.mapCache_clearOfflineCachePrompt),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(dialogContext, false), onPressed: () => Navigator.pop(dialogContext, false),
child: const Text('Cancel'), child: Text(context.l10n.common_cancel),
), ),
TextButton( TextButton(
onPressed: () => Navigator.pop(dialogContext, true), 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(); await cacheService.clearCache();
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar( 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) { Widget build(BuildContext context) {
final tileCache = context.read<MapTileCacheService>(); final tileCache = context.read<MapTileCacheService>();
final selectedBounds = _selectedBounds; final selectedBounds = _selectedBounds;
final l10n = context.l10n;
final progressValue = _estimatedTiles == 0 final progressValue = _estimatedTiles == 0
? 0.0 ? 0.0
: (_completedTiles / _estimatedTiles).clamp(0.0, 1.0).toDouble(); : (_completedTiles / _estimatedTiles).clamp(0.0, 1.0).toDouble();
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text('Offline Map Cache'), title: Text(l10n.mapCache_title),
centerTitle: true, centerTitle: true,
), ),
body: Column( body: Column(
@@ -264,8 +270,8 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
child: Text( child: Text(
selectedBounds == null selectedBounds == null
? 'No area selected' ? l10n.mapCache_noAreaSelected
: _formatBounds(selectedBounds), : _formatBounds(selectedBounds, l10n),
style: const TextStyle(fontSize: 12), style: const TextStyle(fontSize: 12),
), ),
), ),
@@ -282,9 +288,9 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
const Text( Text(
'Cache Area', l10n.mapCache_cacheArea,
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16), style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Row( Row(
@@ -292,7 +298,7 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
Expanded( Expanded(
child: ElevatedButton.icon( child: ElevatedButton.icon(
icon: const Icon(Icons.crop_free), icon: const Icon(Icons.crop_free),
label: const Text('Use Current View'), label: Text(l10n.mapCache_useCurrentView),
onPressed: _isDownloading ? null : _setBoundsFromView, onPressed: _isDownloading ? null : _setBoundsFromView,
), ),
), ),
@@ -300,14 +306,14 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
TextButton( TextButton(
onPressed: onPressed:
_isDownloading || selectedBounds == null ? null : _clearBounds, _isDownloading || selectedBounds == null ? null : _clearBounds,
child: const Text('Clear'), child: Text(l10n.common_clear),
), ),
], ],
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
const Text( Text(
'Zoom Range', l10n.mapCache_zoomRange,
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16), style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
), ),
RangeSlider( RangeSlider(
values: values:
@@ -330,12 +336,15 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
_saveZoomRange(); _saveZoomRange();
}, },
), ),
Text('Estimated tiles: $_estimatedTiles'), Text(l10n.mapCache_estimatedTiles(_estimatedTiles)),
if (_isDownloading) ...[ if (_isDownloading) ...[
const SizedBox(height: 8), const SizedBox(height: 8),
LinearProgressIndicator(value: progressValue), LinearProgressIndicator(value: progressValue),
const SizedBox(height: 4), const SizedBox(height: 4),
Text('Downloaded $_completedTiles / $_estimatedTiles'), Text(l10n.mapCache_downloadedTiles(
_completedTiles,
_estimatedTiles,
)),
], ],
const SizedBox(height: 12), const SizedBox(height: 12),
Row( Row(
@@ -343,7 +352,7 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
Expanded( Expanded(
child: ElevatedButton.icon( child: ElevatedButton.icon(
icon: const Icon(Icons.download), icon: const Icon(Icons.download),
label: const Text('Download Tiles'), label: Text(l10n.mapCache_downloadTilesButton),
onPressed: _isDownloading || selectedBounds == null onPressed: _isDownloading || selectedBounds == null
? null ? null
: _startDownload, : _startDownload,
@@ -352,7 +361,7 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
const SizedBox(width: 12), const SizedBox(width: 12),
OutlinedButton( OutlinedButton(
onPressed: _isDownloading ? null : _clearCache, 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(
padding: const EdgeInsets.only(top: 8), padding: const EdgeInsets.only(top: 8),
child: Text( child: Text(
'Failed downloads: $_failedTiles', l10n.mapCache_failedDownloads(_failedTiles),
style: TextStyle(color: Colors.orange[700]), style: TextStyle(color: Colors.orange[700]),
), ),
), ),
@@ -382,10 +391,12 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
]; ];
} }
String _formatBounds(LatLngBounds bounds) { String _formatBounds(LatLngBounds bounds, AppLocalizations l10n) {
return 'N ${bounds.north.toStringAsFixed(4)}, ' return l10n.mapCache_boundsLabel(
'S ${bounds.south.toStringAsFixed(4)}, ' bounds.north.toStringAsFixed(4),
'E ${bounds.east.toStringAsFixed(4)}, ' bounds.south.toStringAsFixed(4),
'W ${bounds.west.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),
),
),
),
],
),
);
}
}
+178 -179
View File
@@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../l10n/l10n.dart';
import '../models/contact.dart'; import '../models/contact.dart';
import '../connector/meshcore_connector.dart'; import '../connector/meshcore_connector.dart';
import '../connector/meshcore_protocol.dart'; import '../connector/meshcore_protocol.dart';
@@ -33,14 +34,14 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
RepeaterCommandService? _commandService; RepeaterCommandService? _commandService;
// Common commands for quick access // Common commands for quick access
final List<Map<String, String>> _quickCommands = [ late final List<Map<String, String>> _quickCommands = [
{'label': 'Get Name', 'command': 'get name'}, {'labelKey': 'getName', 'command': 'get name'},
{'label': 'Get Radio', 'command': 'get radio'}, {'labelKey': 'getRadio', 'command': 'get radio'},
{'label': 'Get TX', 'command': 'get tx'}, {'labelKey': 'getTx', 'command': 'get tx'},
{'label': 'Neighbors', 'command': 'neighbors'}, {'labelKey': 'neighbors', 'command': 'neighbors'},
{'label': 'Version', 'command': 'ver'}, {'labelKey': 'version', 'command': 'ver'},
{'label': 'Advertise', 'command': 'advert'}, {'labelKey': 'advertise', 'command': 'advert'},
{'label': 'Clock', 'command': 'clock'}, {'labelKey': 'clock', 'command': 'clock'},
]; ];
@override @override
@@ -119,7 +120,7 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
// Show debug info if requested // Show debug info if requested
if (showDebug && mounted) { if (showDebug && mounted) {
final frame = buildSendCliCommandFrame(widget.repeater.publicKey, command); 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 // Send CLI command to repeater with retry
@@ -148,7 +149,7 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
setState(() { setState(() {
_commandHistory.add({ _commandHistory.add({
'type': 'response', 'type': 'response',
'text': 'Error: $e', 'text': context.l10n.repeater_cliCommandError(e.toString()),
'timestamp': DateTime.now().toString(), 'timestamp': DateTime.now().toString(),
}); });
}); });
@@ -215,6 +216,7 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = context.l10n;
final connector = context.watch<MeshCoreConnector>(); final connector = context.watch<MeshCoreConnector>();
final repeater = _resolveRepeater(connector); final repeater = _resolveRepeater(connector);
final isFloodMode = repeater.pathOverride == -1; final isFloodMode = repeater.pathOverride == -1;
@@ -225,7 +227,7 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
const Text('Repeater CLI'), Text(l10n.repeater_cliTitle),
Text( Text(
repeater.name, repeater.name,
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.normal), style: const TextStyle(fontSize: 14, fontWeight: FontWeight.normal),
@@ -236,7 +238,7 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
actions: [ actions: [
PopupMenuButton<String>( PopupMenuButton<String>(
icon: Icon(isFloodMode ? Icons.waves : Icons.route), icon: Icon(isFloodMode ? Icons.waves : Icons.route),
tooltip: 'Routing mode', tooltip: l10n.repeater_routingMode,
onSelected: (mode) async { onSelected: (mode) async {
if (mode == 'flood') { if (mode == 'flood') {
await connector.setPathOverride(repeater, pathLen: -1); await connector.setPathOverride(repeater, pathLen: -1);
@@ -252,7 +254,7 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
Icon(Icons.auto_mode, size: 20, color: !isFloodMode ? Theme.of(context).primaryColor : null), Icon(Icons.auto_mode, size: 20, color: !isFloodMode ? Theme.of(context).primaryColor : null),
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Text(
'Auto (use saved path)', l10n.repeater_autoUseSavedPath,
style: TextStyle( style: TextStyle(
fontWeight: !isFloodMode ? FontWeight.bold : FontWeight.normal, fontWeight: !isFloodMode ? FontWeight.bold : FontWeight.normal,
), ),
@@ -267,7 +269,7 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
Icon(Icons.waves, size: 20, color: isFloodMode ? Theme.of(context).primaryColor : null), Icon(Icons.waves, size: 20, color: isFloodMode ? Theme.of(context).primaryColor : null),
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Text(
'Force Flood Mode', l10n.repeater_forceFloodMode,
style: TextStyle( style: TextStyle(
fontWeight: isFloodMode ? FontWeight.bold : FontWeight.normal, fontWeight: isFloodMode ? FontWeight.bold : FontWeight.normal,
), ),
@@ -279,31 +281,31 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
), ),
IconButton( IconButton(
icon: const Icon(Icons.timeline), icon: const Icon(Icons.timeline),
tooltip: 'Path management', tooltip: l10n.repeater_pathManagement,
onPressed: () => PathManagementDialog.show(context, contact: repeater), onPressed: () => PathManagementDialog.show(context, contact: repeater),
), ),
IconButton( IconButton(
icon: const Icon(Icons.bug_report), icon: const Icon(Icons.bug_report),
tooltip: 'Debug Next Command', tooltip: l10n.repeater_debugNextCommand,
onPressed: () { onPressed: () {
// Set a flag or just send next command with debug // Set a flag or just send next command with debug
if (_commandController.text.trim().isNotEmpty) { if (_commandController.text.trim().isNotEmpty) {
_sendCommand(showDebug: true); _sendCommand(showDebug: true);
} else { } else {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Enter a command first')), SnackBar(content: Text(l10n.repeater_enterCommandFirst)),
); );
} }
}, },
), ),
IconButton( IconButton(
icon: const Icon(Icons.help_outline), icon: const Icon(Icons.help_outline),
tooltip: 'Command Help', tooltip: l10n.repeater_commandHelp,
onPressed: () => _showCommandHelp(context), onPressed: () => _showCommandHelp(context),
), ),
IconButton( IconButton(
icon: const Icon(Icons.clear_all), icon: const Icon(Icons.clear_all),
tooltip: 'Clear History', tooltip: l10n.repeater_clearHistory,
onPressed: _commandHistory.isEmpty ? null : _clearHistory, onPressed: _commandHistory.isEmpty ? null : _clearHistory,
), ),
], ],
@@ -331,10 +333,11 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
child: Row( child: Row(
children: _quickCommands.map((cmd) { children: _quickCommands.map((cmd) {
final label = _quickCommandLabel(cmd['labelKey']!);
return Padding( return Padding(
padding: const EdgeInsets.only(right: 8), padding: const EdgeInsets.only(right: 8),
child: ActionChip( child: ActionChip(
label: Text(cmd['label']!), label: Text(label),
onPressed: () => _useQuickCommand(cmd['command']!), onPressed: () => _useQuickCommand(cmd['command']!),
avatar: const Icon(Icons.play_arrow, size: 16), avatar: const Icon(Icons.play_arrow, size: 16),
), ),
@@ -345,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() { Widget _buildEmptyState() {
final l10n = context.l10n;
return Center( return Center(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
@@ -353,12 +379,12 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
Icon(Icons.terminal, size: 64, color: Colors.grey[400]), Icon(Icons.terminal, size: 64, color: Colors.grey[400]),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
'No commands sent yet', l10n.repeater_noCommandsSent,
style: TextStyle(fontSize: 16, color: Colors.grey[600]), style: TextStyle(fontSize: 16, color: Colors.grey[600]),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
'Type a command below or use quick commands', l10n.repeater_typeCommandOrUseQuick,
style: TextStyle(fontSize: 14, color: Colors.grey[500]), style: TextStyle(fontSize: 14, color: Colors.grey[500]),
), ),
], ],
@@ -422,6 +448,7 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
} }
Widget _buildCommandInput() { Widget _buildCommandInput() {
final l10n = context.l10n;
return Container( return Container(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
color: Theme.of(context).colorScheme.surface, color: Theme.of(context).colorScheme.surface,
@@ -430,12 +457,12 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
children: [ children: [
IconButton( IconButton(
icon: const Icon(Icons.arrow_upward, size: 20), icon: const Icon(Icons.arrow_upward, size: 20),
tooltip: 'Previous command', tooltip: l10n.repeater_previousCommand,
onPressed: () => _navigateHistory(true), onPressed: () => _navigateHistory(true),
), ),
IconButton( IconButton(
icon: const Icon(Icons.arrow_downward, size: 20), icon: const Icon(Icons.arrow_downward, size: 20),
tooltip: 'Next command', tooltip: l10n.repeater_nextCommand,
onPressed: () => _navigateHistory(false), onPressed: () => _navigateHistory(false),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
@@ -443,10 +470,10 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
child: TextField( child: TextField(
controller: _commandController, controller: _commandController,
focusNode: _commandFocusNode, focusNode: _commandFocusNode,
decoration: const InputDecoration( decoration: InputDecoration(
hintText: 'Enter command...', hintText: l10n.repeater_enterCommandHint,
border: OutlineInputBorder(), border: const OutlineInputBorder(),
contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 12), contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
prefixText: '> ', prefixText: '> ',
), ),
style: const TextStyle(fontFamily: 'monospace'), style: const TextStyle(fontFamily: 'monospace'),
@@ -479,312 +506,284 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
} }
void _showCommandHelp(BuildContext context) { void _showCommandHelp(BuildContext context) {
final l10n = context.l10n;
final generalCommands = [ final generalCommands = [
const _CommandHelpEntry( _CommandHelpEntry(
command: 'advert', command: 'advert',
description: 'Sends an advertisement packet', description: l10n.repeater_cliHelpAdvert,
), ),
const _CommandHelpEntry( _CommandHelpEntry(
command: 'reboot', command: 'reboot',
description: description: l10n.repeater_cliHelpReboot,
"Reboots the device. (note, you'll prob get 'Timeout' which is normal)",
), ),
const _CommandHelpEntry( _CommandHelpEntry(
command: 'clock', command: 'clock',
description: "Displays current time per device's clock.", description: l10n.repeater_cliHelpClock,
), ),
const _CommandHelpEntry( _CommandHelpEntry(
command: 'password {new-password}', command: 'password {new-password}',
description: 'Sets a new admin password for the device.', description: l10n.repeater_cliHelpPassword,
), ),
const _CommandHelpEntry( _CommandHelpEntry(
command: 'ver', command: 'ver',
description: 'Shows the device version and firmware build date.', description: l10n.repeater_cliHelpVersion,
), ),
const _CommandHelpEntry( _CommandHelpEntry(
command: 'clear stats', command: 'clear stats',
description: 'Resets various stats counters to zero.', description: l10n.repeater_cliHelpClearStats,
), ),
]; ];
final settingsCommands = [ final settingsCommands = [
const _CommandHelpEntry( _CommandHelpEntry(
command: 'set af {air-time-factor}', 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}', 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}', 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}', command: 'set allow.read.only {on|off}',
description: description: l10n.repeater_cliHelpSetAllowReadOnly,
"(Room server) If 'on', then login in blank password will be allowed, but cannot Post to room. (just read only)",
), ),
const _CommandHelpEntry( _CommandHelpEntry(
command: 'set flood.max {max-hops}', command: 'set flood.max {max-hops}',
description: description: l10n.repeater_cliHelpSetFloodMax,
'Sets the maximum number of hops of inbound flood packet (if >= max, packet is not forwarded)',
), ),
const _CommandHelpEntry( _CommandHelpEntry(
command: 'set int.thresh {db}', command: 'set int.thresh {db}',
description: description: l10n.repeater_cliHelpSetIntThresh,
'Sets the Interference Threshold (in DB). Default is 14. Set to 0 to disable channel interference detection.',
), ),
const _CommandHelpEntry( _CommandHelpEntry(
command: 'set agc.reset.interval {seconds}', command: 'set agc.reset.interval {seconds}',
description: description: l10n.repeater_cliHelpSetAgcResetInterval,
'Sets the interval to reset the Auto Gain Controller. Set to 0 to disable.',
), ),
const _CommandHelpEntry( _CommandHelpEntry(
command: 'set multi.acks {0|1}', 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}', command: 'set advert.interval {minutes}',
description: description: l10n.repeater_cliHelpSetAdvertInterval,
'Sets the timer interval in minutes to send a local (zero-hop) advertisement packet. Set to 0 to disable.',
), ),
const _CommandHelpEntry( _CommandHelpEntry(
command: 'set flood.advert.interval {hours}', command: 'set flood.advert.interval {hours}',
description: description: l10n.repeater_cliHelpSetFloodAdvertInterval,
'Sets the timer interval in hours to send a flood advertisement packet. Set to 0 to disable.',
), ),
const _CommandHelpEntry( _CommandHelpEntry(
command: 'set guest.password {guess-password}', command: 'set guest.password {guess-password}',
description: description: l10n.repeater_cliHelpSetGuestPassword,
'Sets/updates the guest password. (for repeaters, guest logins can send the "Get Stats" request)',
), ),
const _CommandHelpEntry( _CommandHelpEntry(
command: 'set name {name}', command: 'set name {name}',
description: 'Sets the advertisement name.', description: l10n.repeater_cliHelpSetName,
), ),
const _CommandHelpEntry( _CommandHelpEntry(
command: 'set lat {latitude}', command: 'set lat {latitude}',
description: 'Sets the advertisement map latitude. (decimal degrees)', description: l10n.repeater_cliHelpSetLat,
), ),
const _CommandHelpEntry( _CommandHelpEntry(
command: 'set lon {longitude}', 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}', command: 'set radio {freq},{bw},{sf},{cr}',
description: description: l10n.repeater_cliHelpSetRadio,
'Sets completely new radio params, and saves to preferences. Requires a "reboot" command to apply.',
), ),
const _CommandHelpEntry( _CommandHelpEntry(
command: 'set rxdelay {base}', command: 'set rxdelay {base}',
description: description: l10n.repeater_cliHelpSetRxDelay,
'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.',
), ),
const _CommandHelpEntry( _CommandHelpEntry(
command: 'set txdelay {factor}', command: 'set txdelay {factor}',
description: description: l10n.repeater_cliHelpSetTxDelay,
'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)',
), ),
const _CommandHelpEntry( _CommandHelpEntry(
command: 'set direct.txdelay {factor}', command: 'set direct.txdelay {factor}',
description: description: l10n.repeater_cliHelpSetDirectTxDelay,
'Same as txdelay, but for applying a random delay to the forwarding of direct-mode packets.',
), ),
const _CommandHelpEntry( _CommandHelpEntry(
command: 'set bridge.enabled {on|off}', command: 'set bridge.enabled {on|off}',
description: 'Enable/Disable bridge.', description: l10n.repeater_cliHelpSetBridgeEnabled,
), ),
const _CommandHelpEntry( _CommandHelpEntry(
command: 'set bridge.delay {0-10000}', 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}', command: 'set bridge.source {rx|tx}',
description: description: l10n.repeater_cliHelpSetBridgeSource,
'Choose wether the bridge will retransmit received packets or transmitted packets.',
), ),
const _CommandHelpEntry( _CommandHelpEntry(
command: 'set bridge.baud {speed}', 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}', 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}', command: 'set adc.multiplier {factor}',
description: description: l10n.repeater_cliHelpSetAdcMultiplier,
'Sets custom factor to adjust reported battery voltage (only supported on select boards).',
), ),
const _CommandHelpEntry( _CommandHelpEntry(
command: 'tempradio {freq},{bw},{sf},{cr},{minutes}', command: 'tempradio {freq},{bw},{sf},{cr},{minutes}',
description: description: l10n.repeater_cliHelpTempRadio,
'Sets temporary radio params for the given number of {minutes}, reverting to original radio params afterward. (does NOT save to preferences).',
), ),
const _CommandHelpEntry( _CommandHelpEntry(
command: 'setperm {pubkey-hex} {permissions}', command: 'setperm {pubkey-hex} {permissions}',
description: description: l10n.repeater_cliHelpSetPerm,
'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)',
), ),
]; ];
final bridgeCommands = [ final bridgeCommands = [
const _CommandHelpEntry( _CommandHelpEntry(
command: 'get bridge.type', command: 'get bridge.type',
description: 'Gets bridge type none, rs232, espnow', description: l10n.repeater_cliHelpGetBridgeType,
), ),
]; ];
final loggingCommands = [ final loggingCommands = [
const _CommandHelpEntry( _CommandHelpEntry(
command: 'log start', command: 'log start',
description: 'Starts packet logging to file system.', description: l10n.repeater_cliHelpLogStart,
), ),
const _CommandHelpEntry( _CommandHelpEntry(
command: 'log stop', command: 'log stop',
description: 'Stops packet logging to file system.', description: l10n.repeater_cliHelpLogStop,
), ),
const _CommandHelpEntry( _CommandHelpEntry(
command: 'log erase', command: 'log erase',
description: 'Erases the packet logs from file system.', description: l10n.repeater_cliHelpLogErase,
), ),
]; ];
final neighborCommands = [ final neighborCommands = [
const _CommandHelpEntry( _CommandHelpEntry(
command: 'neighbors', command: 'neighbors',
description: description: l10n.repeater_cliHelpNeighbors,
'Shows a list of other repeater nodes heard via zero-hop adverts. Each line is {id-prefix-hex}:{timestamp}:{snr-times-4}',
), ),
const _CommandHelpEntry( _CommandHelpEntry(
command: 'neighbor.remove {pubkey-prefix}', command: 'neighbor.remove {pubkey-prefix}',
description: description: l10n.repeater_cliHelpNeighborRemove,
'Removes first matching entry (by pubkey prefix (hex)), from neighbors list.',
), ),
]; ];
final regionCommands = [ final regionCommands = [
const _CommandHelpEntry( _CommandHelpEntry(
command: 'region', command: 'region',
description: description: l10n.repeater_cliHelpRegion,
'(serial only) Lists all defined regions and current flood permissions.',
), ),
const _CommandHelpEntry( _CommandHelpEntry(
command: 'region load', command: 'region load',
description: description: l10n.repeater_cliHelpRegionLoad,
'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.',
), ),
const _CommandHelpEntry( _CommandHelpEntry(
command: 'region get {* | name-prefix}', command: 'region get {* | name-prefix}',
description: description: l10n.repeater_cliHelpRegionGet,
'Searches for region with given name prefix (or "*" for the global scope). Replies with "-> {region-name} ({parent-name}) {\'F\'}"',
), ),
const _CommandHelpEntry( _CommandHelpEntry(
command: 'region put {name} {* | parent-name-prefix}', 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}', command: 'region remove {name}',
description: description: l10n.repeater_cliHelpRegionRemove,
'Removes a region definition with given name. (must match exactly, and have no child regions)',
), ),
const _CommandHelpEntry( _CommandHelpEntry(
command: 'region allowf {* | name-prefix}', command: 'region allowf {* | name-prefix}',
description: description: l10n.repeater_cliHelpRegionAllowf,
"Sets the 'F'lood permission for the given region. ('*' for the global/legacy scope)",
), ),
const _CommandHelpEntry( _CommandHelpEntry(
command: 'region denyf {* | name-prefix}', command: 'region denyf {* | name-prefix}',
description: description: l10n.repeater_cliHelpRegionDenyf,
"Removes the 'F'lood permission for the given region. (NOTE: at this stage NOT advised to use this on the global/legacy scope!!)",
), ),
const _CommandHelpEntry( _CommandHelpEntry(
command: 'region home', command: 'region home',
description: description: l10n.repeater_cliHelpRegionHome,
"Replies with the current 'home' region. (Note applied anywhere yet, reserved for future)",
), ),
const _CommandHelpEntry( _CommandHelpEntry(
command: 'region home {* | name-prefix}', command: 'region home {* | name-prefix}',
description: "Sets the 'home' region.", description: l10n.repeater_cliHelpRegionHomeSet,
), ),
const _CommandHelpEntry( _CommandHelpEntry(
command: 'region save', command: 'region save',
description: 'Persists the region list/map to storage.', description: l10n.repeater_cliHelpRegionSave,
), ),
]; ];
final gpsCommands = [ final gpsCommands = [
const _CommandHelpEntry( _CommandHelpEntry(
command: 'gps', command: 'gps',
description: description: l10n.repeater_cliHelpGps,
'Gives status of gps. When gps is off, it replies only off, if on it replies with on, {status}, {fix}, {sat count}',
), ),
const _CommandHelpEntry( _CommandHelpEntry(
command: 'gps {on|off}', command: 'gps {on|off}',
description: 'Toggles gps power state.', description: l10n.repeater_cliHelpGpsOnOff,
), ),
const _CommandHelpEntry( _CommandHelpEntry(
command: 'gps sync', command: 'gps sync',
description: 'Syncs node time with gps clock.', description: l10n.repeater_cliHelpGpsSync,
), ),
const _CommandHelpEntry( _CommandHelpEntry(
command: 'gps setloc', command: 'gps setloc',
description: "Sets node's position to gps coordinates and save preferences.", description: l10n.repeater_cliHelpGpsSetLoc,
), ),
const _CommandHelpEntry( _CommandHelpEntry(
command: 'gps advert', command: 'gps advert',
description: description: l10n.repeater_cliHelpGpsAdvert,
"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",
), ),
const _CommandHelpEntry( _CommandHelpEntry(
command: 'gps advert {none|share|prefs}', command: 'gps advert {none|share|prefs}',
description: 'Sets location advert configuration.', description: l10n.repeater_cliHelpGpsAdvertSet,
), ),
]; ];
showDialog( showDialog(
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
title: const Text('Commands List'), title: Text(l10n.repeater_commandsListTitle),
content: SingleChildScrollView( content: SingleChildScrollView(
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const Text( Text(
'NOTE: for the various "set ..." commands, there is also a "get ..." command.', l10n.repeater_commandsListNote,
style: TextStyle(fontSize: 13), style: const TextStyle(fontSize: 13),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
_buildHelpSection(context, 'General', generalCommands), _buildHelpSection(context, l10n.repeater_general, generalCommands),
const SizedBox(height: 16), const SizedBox(height: 16),
_buildHelpSection(context, 'Settings', settingsCommands), _buildHelpSection(context, l10n.repeater_settingsCategory, settingsCommands),
const SizedBox(height: 16), const SizedBox(height: 16),
_buildHelpSection(context, 'Bridge', bridgeCommands), _buildHelpSection(context, l10n.repeater_bridge, bridgeCommands),
const SizedBox(height: 16), const SizedBox(height: 16),
_buildHelpSection(context, 'Logging', loggingCommands), _buildHelpSection(context, l10n.repeater_logging, loggingCommands),
const SizedBox(height: 16), const SizedBox(height: 16),
_buildHelpSection( _buildHelpSection(
context, context,
'Neighbors (Repeater only)', l10n.repeater_neighborsRepeaterOnly,
neighborCommands, neighborCommands,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
_buildHelpSection( _buildHelpSection(
context, context,
'Region Management (Repeater only)', l10n.repeater_regionManagementRepeaterOnly,
regionCommands, regionCommands,
note: note: l10n.repeater_regionNote,
'Region commands have been introduced to manage region definitions and permissions.',
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
_buildHelpSection( _buildHelpSection(
context, context,
'GPS Management', l10n.repeater_gpsManagement,
gpsCommands, gpsCommands,
note: note: l10n.repeater_gpsNote,
'gps command has been introduced to manage location related topics.',
), ),
], ],
), ),
@@ -792,7 +791,7 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
child: const Text('Close'), child: Text(l10n.common_close),
), ),
], ],
), ),
+167 -101
View File
@@ -1,8 +1,12 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:meshcore_open/connector/meshcore_protocol.dart';
import '../l10n/l10n.dart';
import '../models/contact.dart'; import '../models/contact.dart';
import 'repeater_status_screen.dart'; import 'repeater_status_screen.dart';
import 'repeater_cli_screen.dart'; import 'repeater_cli_screen.dart';
import 'repeater_settings_screen.dart'; import 'repeater_settings_screen.dart';
import 'telemetry_screen.dart';
import 'neighbours_screen.dart';
class RepeaterHubScreen extends StatelessWidget { class RepeaterHubScreen extends StatelessWidget {
final Contact repeater; final Contact repeater;
@@ -16,16 +20,24 @@ class RepeaterHubScreen extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = context.l10n;
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Column( title: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
const Text('Repeater Management'), Text(
repeater.type == advTypeRepeater
? l10n.repeater_management
: l10n.room_management,
),
Text( Text(
repeater.name, repeater.name,
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.normal), style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.normal,
),
), ),
], ],
), ),
@@ -36,110 +48,167 @@ class RepeaterHubScreen extends StatelessWidget {
child: ListView( child: ListView(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
children: [ children: [
// Repeater info card // Repeater info card
Card( Card(
child: Padding( child: Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Column( child: Column(
children: [ children: [
CircleAvatar( CircleAvatar(
radius: 40, radius: 40,
backgroundColor: Colors.orange, backgroundColor: Colors.orange,
child: const Icon(Icons.cell_tower, size: 40, color: Colors.white), child: const Icon(
Icons.cell_tower,
size: 40,
color: Colors.white,
), ),
const SizedBox(height: 16), ),
Text( const SizedBox(height: 16),
repeater.name, Text(
style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold), repeater.name,
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
), ),
const SizedBox(height: 8), ),
Text( const SizedBox(height: 8),
repeater.pathLabel, Text(
style: TextStyle(fontSize: 14, color: Colors.grey[600]), '<${repeater.publicKeyHex.substring(0, 8)}...${repeater.publicKeyHex.substring(repeater.publicKeyHex.length - 8)}>',
), style: TextStyle(fontSize: 14, color: Colors.grey[600]),
if (repeater.hasLocation) ...[ ),
const SizedBox(height: 4), const SizedBox(height: 8),
Row( Text(
mainAxisAlignment: MainAxisAlignment.center, repeater.pathLabel,
children: [ style: TextStyle(fontSize: 14, color: Colors.grey[600]),
Icon(Icons.location_on, size: 14, color: Colors.grey[600]), ),
const SizedBox(width: 4), if (repeater.hasLocation) ...[
Text( const SizedBox(height: 4),
'${repeater.latitude?.toStringAsFixed(4)}, ${repeater.longitude?.toStringAsFixed(4)}', Row(
style: TextStyle(fontSize: 12, color: Colors.grey[600]), 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( const SizedBox(height: 24),
'Management Tools', Text(
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), l10n.repeater_managementTools,
), style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
const SizedBox(height: 16), ),
// Status button const SizedBox(height: 16),
_buildManagementCard( // Status button
context, _buildManagementCard(
icon: Icons.analytics, context,
title: 'Status', icon: Icons.analytics,
subtitle: 'View repeater status, stats, and neighbors', title: l10n.repeater_status,
color: Colors.blue, subtitle: l10n.repeater_statusSubtitle,
onTap: () { color: Colors.blue,
Navigator.push( onTap: () {
context, Navigator.push(
MaterialPageRoute( context,
builder: (context) => RepeaterStatusScreen( MaterialPageRoute(
repeater: repeater, builder: (context) => RepeaterStatusScreen(
password: password, repeater: repeater,
), password: password,
), ),
); ),
}, );
), },
const SizedBox(height: 12), ),
// CLI button const SizedBox(height: 16),
_buildManagementCard( // Telemetry button
context, _buildManagementCard(
icon: Icons.terminal, context,
title: 'CLI', icon: Icons.bar_chart_sharp,
subtitle: 'Send commands to the repeater', title: l10n.repeater_telemetry,
color: Colors.green, subtitle: l10n.repeater_telemetrySubtitle,
onTap: () { color: Colors.teal,
Navigator.push( onTap: () {
context, Navigator.push(
MaterialPageRoute( context,
builder: (context) => RepeaterCliScreen( MaterialPageRoute(
repeater: repeater, builder: (context) =>
password: password, 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 const SizedBox(height: 12),
_buildManagementCard( // Neighbors button
context, _buildManagementCard(
icon: Icons.settings, context,
title: 'Settings', icon: Icons.group,
subtitle: 'Configure repeater parameters', title: l10n.repeater_neighbours,
color: Colors.orange, subtitle: l10n.repeater_neighboursSubtitle,
onTap: () { color: Colors.orange,
Navigator.push( onTap: () {
context, Navigator.push(
MaterialPageRoute( context,
builder: (context) => RepeaterSettingsScreen( MaterialPageRoute(
repeater: repeater, builder: (context) => NeighboursScreen(
password: password, repeater: repeater,
), password: password,
), ),
); ),
}, );
), },
),
const SizedBox(height: 12),
// Settings button
_buildManagementCard(
context,
icon: Icons.settings,
title: l10n.repeater_settings,
subtitle: l10n.repeater_settingsSubtitle,
color: Colors.deepOrange,
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => RepeaterSettingsScreen(
repeater: repeater,
password: password,
),
),
);
},
),
], ],
), ),
), ),
@@ -186,10 +255,7 @@ class RepeaterHubScreen extends StatelessWidget {
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
subtitle, subtitle,
style: TextStyle( style: TextStyle(fontSize: 14, color: Colors.grey[600]),
fontSize: 14,
color: Colors.grey[600],
),
), ),
], ],
), ),
File diff suppressed because it is too large Load Diff
+52 -40
View File
@@ -3,6 +3,7 @@ import 'dart:convert';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../l10n/l10n.dart';
import '../models/contact.dart'; import '../models/contact.dart';
import '../models/path_selection.dart'; import '../models/path_selection.dart';
import '../connector/meshcore_connector.dart'; import '../connector/meshcore_connector.dart';
@@ -260,9 +261,12 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
await connector.sendFrame(frame); await connector.sendFrame(frame);
final pathLengthValue = selection.useFlood ? -1 : selection.hopCount; final pathLengthValue = selection.useFlood ? -1 : selection.hopCount;
final messageBytes = frame.length >= _statusResponseBytes var messageBytes = frame.length >= _statusResponseBytes
? frame.length ? frame.length
: _statusResponseBytes; : _statusResponseBytes;
if (messageBytes < maxFrameSize) {
messageBytes = maxFrameSize;
}
final timeoutMs = connector.calculateTimeout( final timeoutMs = connector.calculateTimeout(
pathLength: pathLengthValue, pathLength: pathLengthValue,
messageBytes: messageBytes, messageBytes: messageBytes,
@@ -274,8 +278,8 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
_isLoading = false; _isLoading = false;
}); });
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( SnackBar(
content: Text('Status request timed out.'), content: Text(context.l10n.repeater_statusRequestTimeout),
backgroundColor: Colors.red, backgroundColor: Colors.red,
), ),
); );
@@ -289,7 +293,7 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text('Error loading status: $e'), content: Text(context.l10n.repeater_errorLoadingStatus(e.toString())),
backgroundColor: Colors.red, backgroundColor: Colors.red,
), ),
); );
@@ -309,6 +313,7 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = context.l10n;
final connector = context.watch<MeshCoreConnector>(); final connector = context.watch<MeshCoreConnector>();
final repeater = _resolveRepeater(connector); final repeater = _resolveRepeater(connector);
final isFloodMode = repeater.pathOverride == -1; final isFloodMode = repeater.pathOverride == -1;
@@ -319,7 +324,7 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
const Text('Repeater Status'), Text(l10n.repeater_statusTitle),
Text( Text(
repeater.name, repeater.name,
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.normal), style: const TextStyle(fontSize: 14, fontWeight: FontWeight.normal),
@@ -330,7 +335,7 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
actions: [ actions: [
PopupMenuButton<String>( PopupMenuButton<String>(
icon: Icon(isFloodMode ? Icons.waves : Icons.route), icon: Icon(isFloodMode ? Icons.waves : Icons.route),
tooltip: 'Routing mode', tooltip: l10n.repeater_routingMode,
onSelected: (mode) async { onSelected: (mode) async {
if (mode == 'flood') { if (mode == 'flood') {
await connector.setPathOverride(repeater, pathLen: -1); await connector.setPathOverride(repeater, pathLen: -1);
@@ -346,7 +351,7 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
Icon(Icons.auto_mode, size: 20, color: !isFloodMode ? Theme.of(context).primaryColor : null), Icon(Icons.auto_mode, size: 20, color: !isFloodMode ? Theme.of(context).primaryColor : null),
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Text(
'Auto (use saved path)', l10n.repeater_autoUseSavedPath,
style: TextStyle( style: TextStyle(
fontWeight: !isFloodMode ? FontWeight.bold : FontWeight.normal, fontWeight: !isFloodMode ? FontWeight.bold : FontWeight.normal,
), ),
@@ -361,7 +366,7 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
Icon(Icons.waves, size: 20, color: isFloodMode ? Theme.of(context).primaryColor : null), Icon(Icons.waves, size: 20, color: isFloodMode ? Theme.of(context).primaryColor : null),
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Text(
'Force Flood Mode', l10n.repeater_forceFloodMode,
style: TextStyle( style: TextStyle(
fontWeight: isFloodMode ? FontWeight.bold : FontWeight.normal, fontWeight: isFloodMode ? FontWeight.bold : FontWeight.normal,
), ),
@@ -373,7 +378,7 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
), ),
IconButton( IconButton(
icon: const Icon(Icons.timeline), icon: const Icon(Icons.timeline),
tooltip: 'Path management', tooltip: l10n.repeater_pathManagement,
onPressed: () => PathManagementDialog.show(context, contact: repeater), onPressed: () => PathManagementDialog.show(context, contact: repeater),
), ),
IconButton( IconButton(
@@ -385,7 +390,7 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
) )
: const Icon(Icons.refresh), : const Icon(Icons.refresh),
onPressed: _isLoading ? null : _loadStatus, onPressed: _isLoading ? null : _loadStatus,
tooltip: 'Refresh', tooltip: l10n.repeater_refresh,
), ),
], ],
), ),
@@ -409,6 +414,7 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
} }
Widget _buildSystemInfoCard() { Widget _buildSystemInfoCard() {
final l10n = context.l10n;
return Card( return Card(
child: Padding( child: Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
@@ -417,20 +423,20 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
children: [ children: [
Row( Row(
children: [ 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 SizedBox(width: 8),
const Text( Text(
'System Information', l10n.repeater_systemInformation,
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
), ),
], ],
), ),
const Divider(), const Divider(),
_buildInfoRow('Battery', _batteryText()), _buildInfoRow(l10n.repeater_battery, _batteryText()),
_buildInfoRow('Clock (at login)', _clockText()), _buildInfoRow(l10n.repeater_clockAtLogin, _clockText()),
_buildInfoRow('Uptime', _formatDuration(_uptimeSecs)), _buildInfoRow(l10n.repeater_uptime, _formatDuration(_uptimeSecs)),
_buildInfoRow('Queue Length', _formatValue(_queueLen)), _buildInfoRow(l10n.repeater_queueLength, _formatValue(_queueLen)),
_buildInfoRow('Debug Flags', _formatValue(_debugFlags)), _buildInfoRow(l10n.repeater_debugFlags, _formatValue(_debugFlags)),
], ],
), ),
), ),
@@ -438,6 +444,7 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
} }
Widget _buildRadioStatsCard() { Widget _buildRadioStatsCard() {
final l10n = context.l10n;
return Card( return Card(
child: Padding( child: Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
@@ -446,20 +453,20 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
children: [ children: [
Row( Row(
children: [ children: [
Icon(Icons.radio, color: Theme.of(context).primaryColor), Icon(Icons.radio, color: Theme.of(context).textTheme.headlineSmall?.color),
const SizedBox(width: 8), const SizedBox(width: 8),
const Text( Text(
'Radio Statistics', l10n.repeater_radioStatistics,
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
), ),
], ],
), ),
const Divider(), const Divider(),
_buildInfoRow('Last RSSI', _formatValue(_lastRssi, suffix: ' dB')), _buildInfoRow(l10n.repeater_lastRssi, _formatValue(_lastRssi, suffix: ' dB')),
_buildInfoRow('Last SNR', _formatSnr(_lastSnr)), _buildInfoRow(l10n.repeater_lastSnr, _formatSnr(_lastSnr)),
_buildInfoRow('Noise Floor', _formatValue(_noiseFloor, suffix: ' dB')), _buildInfoRow(l10n.repeater_noiseFloor, _formatValue(_noiseFloor, suffix: ' dB')),
_buildInfoRow('TX Airtime', _formatDuration(_txAirSecs)), _buildInfoRow(l10n.repeater_txAirtime, _formatDuration(_txAirSecs)),
_buildInfoRow('RX Airtime', _formatDuration(_rxAirSecs)), _buildInfoRow(l10n.repeater_rxAirtime, _formatDuration(_rxAirSecs)),
], ],
), ),
), ),
@@ -467,6 +474,7 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
} }
Widget _buildPacketStatsCard() { Widget _buildPacketStatsCard() {
final l10n = context.l10n;
return Card( return Card(
child: Padding( child: Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
@@ -475,18 +483,18 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
children: [ children: [
Row( Row(
children: [ children: [
Icon(Icons.analytics, color: Theme.of(context).primaryColor), Icon(Icons.analytics, color: Theme.of(context).textTheme.headlineSmall?.color),
const SizedBox(width: 8), const SizedBox(width: 8),
const Text( Text(
'Packet Statistics', l10n.repeater_packetStatistics,
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
), ),
], ],
), ),
const Divider(), const Divider(),
_buildInfoRow('Sent', _packetTxText()), _buildInfoRow(l10n.repeater_sent, _packetTxText()),
_buildInfoRow('Received', _packetRxText()), _buildInfoRow(l10n.repeater_received, _packetRxText()),
_buildInfoRow('Duplicates', _duplicateText()), _buildInfoRow(l10n.repeater_duplicates, _duplicateText()),
], ],
), ),
), ),
@@ -559,37 +567,41 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
String _formatDuration(int? seconds) { String _formatDuration(int? seconds) {
if (seconds == null) return ''; if (seconds == null) return '';
final l10n = context.l10n;
final days = seconds ~/ 86400; final days = seconds ~/ 86400;
final hours = (seconds % 86400) ~/ 3600; final hours = (seconds % 86400) ~/ 3600;
final minutes = (seconds % 3600) ~/ 60; final minutes = (seconds % 3600) ~/ 60;
final secs = seconds % 60; final secs = seconds % 60;
return '$days days ${hours}h ${minutes}m ${secs}s'; return l10n.repeater_daysHoursMinsSecs(days, hours, minutes, secs);
} }
String _packetTxText() { String _packetTxText() {
if (_packetsSent == null) return ''; if (_packetsSent == null) return '';
final l10n = context.l10n;
final flood = _formatValue(_floodTx); final flood = _formatValue(_floodTx);
final direct = _formatValue(_directTx); final direct = _formatValue(_directTx);
return 'Total: $_packetsSent, Flood: $flood, Direct: $direct'; return l10n.repeater_packetTxTotal(_packetsSent!, flood, direct);
} }
String _packetRxText() { String _packetRxText() {
if (_packetsRecv == null) return ''; if (_packetsRecv == null) return '';
final l10n = context.l10n;
final flood = _formatValue(_floodRx); final flood = _formatValue(_floodRx);
final direct = _formatValue(_directRx); final direct = _formatValue(_directRx);
return 'Total: $_packetsRecv, Flood: $flood, Direct: $direct'; return l10n.repeater_packetRxTotal(_packetsRecv!, flood, direct);
} }
String _duplicateText() { String _duplicateText() {
final l10n = context.l10n;
if (_dupFlood != null || _dupDirect != null) { if (_dupFlood != null || _dupDirect != null) {
final flood = _formatValue(_dupFlood); final flood = _formatValue(_dupFlood);
final direct = _formatValue(_dupDirect); final direct = _formatValue(_dupDirect);
return 'Flood: $flood, Direct: $direct'; return l10n.repeater_duplicatesFloodDirect(flood, direct);
} }
if (_packetsRecv == null || _floodRx == null || _directRx == null) return ''; if (_packetsRecv == null || _floodRx == null || _directRx == null) return '';
final dupTotal = _packetsRecv! - _floodRx! - _directRx!; final dupTotal = _packetsRecv! - _floodRx! - _directRx!;
if (dupTotal < 0) return ''; if (dupTotal < 0) return '';
return 'Total: $dupTotal'; return l10n.repeater_duplicatesTotal(dupTotal);
} }
String _formatValue(num? value, {String? suffix}) { String _formatValue(num? value, {String? suffix}) {
+51 -20
View File
@@ -3,18 +3,57 @@ import 'package:flutter_blue_plus/flutter_blue_plus.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart'; import '../connector/meshcore_connector.dart';
import '../l10n/l10n.dart';
import '../widgets/device_tile.dart'; import '../widgets/device_tile.dart';
import 'contacts_screen.dart'; import 'contacts_screen.dart';
/// Screen for scanning and connecting to MeshCore devices /// Screen for scanning and connecting to MeshCore devices
class ScannerScreen extends StatelessWidget { class ScannerScreen extends StatefulWidget {
const ScannerScreen({super.key}); const ScannerScreen({super.key});
@override
State<ScannerScreen> createState() => _ScannerScreenState();
}
class _ScannerScreenState extends State<ScannerScreen> {
bool _changedNavigation = false;
late final VoidCallback _connectionListener;
@override
void initState() {
super.initState();
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
_connectionListener = () {
if (connector.state == MeshCoreConnectionState.disconnected) {
_changedNavigation = false;
} else if (connector.state == MeshCoreConnectionState.connected && !_changedNavigation) {
_changedNavigation = true;
if (mounted) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => const ContactsScreen(),
),
);
}
}
};
connector.addListener(_connectionListener);
}
@override
void dispose() {
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
connector.removeListener(_connectionListener);
super.dispose();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text('MeshCore Open'), title: Text(context.l10n.scanner_title),
centerTitle: true, centerTitle: true,
automaticallyImplyLeading: false, automaticallyImplyLeading: false,
), ),
@@ -58,7 +97,7 @@ class ScannerScreen extends StatelessWidget {
), ),
) )
: const Icon(Icons.bluetooth_searching), : const Icon(Icons.bluetooth_searching),
label: Text(isScanning ? 'Stop' : 'Scan'), label: Text(isScanning ? context.l10n.scanner_stop : context.l10n.scanner_scan),
); );
}, },
), ),
@@ -69,25 +108,26 @@ class ScannerScreen extends StatelessWidget {
String statusText; String statusText;
Color statusColor; Color statusColor;
final l10n = context.l10n;
switch (connector.state) { switch (connector.state) {
case MeshCoreConnectionState.scanning: case MeshCoreConnectionState.scanning:
statusText = 'Scanning for devices...'; statusText = l10n.scanner_scanning;
statusColor = Colors.blue; statusColor = Colors.blue;
break; break;
case MeshCoreConnectionState.connecting: case MeshCoreConnectionState.connecting:
statusText = 'Connecting...'; statusText = l10n.scanner_connecting;
statusColor = Colors.orange; statusColor = Colors.orange;
break; break;
case MeshCoreConnectionState.connected: case MeshCoreConnectionState.connected:
statusText = 'Connected to ${connector.deviceDisplayName}'; statusText = l10n.scanner_connectedTo(connector.deviceDisplayName);
statusColor = Colors.green; statusColor = Colors.green;
break; break;
case MeshCoreConnectionState.disconnecting: case MeshCoreConnectionState.disconnecting:
statusText = 'Disconnecting...'; statusText = l10n.scanner_disconnecting;
statusColor = Colors.orange; statusColor = Colors.orange;
break; break;
case MeshCoreConnectionState.disconnected: case MeshCoreConnectionState.disconnected:
statusText = 'Not connected'; statusText = l10n.scanner_notConnected;
statusColor = Colors.grey; statusColor = Colors.grey;
break; break;
} }
@@ -123,8 +163,8 @@ class ScannerScreen extends StatelessWidget {
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
connector.state == MeshCoreConnectionState.scanning connector.state == MeshCoreConnectionState.scanning
? 'Searching for MeshCore devices...' ? context.l10n.scanner_searchingDevices
: 'Tap Scan to find MeshCore devices', : context.l10n.scanner_tapToScan,
style: TextStyle( style: TextStyle(
fontSize: 16, fontSize: 16,
color: Colors.grey[600], color: Colors.grey[600],
@@ -159,20 +199,11 @@ class ScannerScreen extends StatelessWidget {
? result.device.platformName ? result.device.platformName
: result.advertisementData.advName; : result.advertisementData.advName;
await connector.connect(result.device, displayName: name); await connector.connect(result.device, displayName: name);
if (context.mounted && connector.isConnected) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const ContactsScreen(),
),
);
}
} catch (e) { } catch (e) {
if (context.mounted) { if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text('Connection failed: $e'), content: Text(context.l10n.scanner_connectionFailed(e.toString())),
backgroundColor: Colors.red, backgroundColor: Colors.red,
), ),
); );
+348 -219
View File
@@ -1,9 +1,11 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:meshcore_open/widgets/elements_ui.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:package_info_plus/package_info_plus.dart'; import 'package:package_info_plus/package_info_plus.dart';
import '../connector/meshcore_connector.dart'; import '../connector/meshcore_connector.dart';
import '../connector/meshcore_protocol.dart'; import '../connector/meshcore_protocol.dart';
import '../l10n/l10n.dart';
import '../models/radio_settings.dart'; import '../models/radio_settings.dart';
import 'app_settings_screen.dart'; import 'app_settings_screen.dart';
import 'app_debug_log_screen.dart'; import 'app_debug_log_screen.dart';
@@ -18,7 +20,7 @@ class SettingsScreen extends StatefulWidget {
class _SettingsScreenState extends State<SettingsScreen> { class _SettingsScreenState extends State<SettingsScreen> {
bool _showBatteryVoltage = false; bool _showBatteryVoltage = false;
String _appVersion = '...'; String _appVersion = '';
@override @override
void initState() { void initState() {
@@ -35,11 +37,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = context.l10n;
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(title: Text(l10n.settings_title), centerTitle: true),
title: const Text('Settings'),
centerTitle: true,
),
body: SafeArea( body: SafeArea(
top: false, top: false,
child: Consumer<MeshCoreConnector>( child: Consumer<MeshCoreConnector>(
@@ -47,7 +47,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
return ListView( return ListView(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
children: [ children: [
_buildDeviceInfoCard(connector), _buildDeviceInfoCard(context, connector),
const SizedBox(height: 16), const SizedBox(height: 16),
_buildAppSettingsCard(context), _buildAppSettingsCard(context),
const SizedBox(height: 16), const SizedBox(height: 16),
@@ -66,46 +66,72 @@ class _SettingsScreenState extends State<SettingsScreen> {
); );
} }
Widget _buildDeviceInfoCard(MeshCoreConnector connector) { Widget _buildDeviceInfoCard(
BuildContext context,
MeshCoreConnector connector,
) {
final l10n = context.l10n;
return Card( return Card(
child: Padding( child: Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const Text( Text(
'Device Info', l10n.settings_deviceInfo,
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
_buildInfoRow('Name', connector.deviceDisplayName), _buildInfoRow(l10n.settings_infoName, connector.deviceDisplayName),
_buildInfoRow('ID', connector.deviceIdLabel), _buildInfoRow(l10n.settings_infoId, connector.deviceIdLabel),
_buildInfoRow('Status', connector.isConnected ? 'Connected' : 'Disconnected'), _buildInfoRow(
_buildBatteryInfoRow(connector), l10n.settings_infoStatus,
connector.isConnected
? l10n.common_connected
: l10n.common_disconnected,
),
_buildBatteryInfoRow(context, connector),
if (connector.selfName != null) if (connector.selfName != null)
_buildInfoRow('Node Name', connector.selfName!), _buildInfoRow(l10n.settings_nodeName, connector.selfName!),
if (connector.selfPublicKey != null) if (connector.selfPublicKey != null)
_buildInfoRow('Public Key', '${pubKeyToHex(connector.selfPublicKey!).substring(0, 16)}...'), _buildInfoRow(
_buildInfoRow('Contacts Count', '${connector.contacts.length}'), l10n.settings_infoPublicKey,
_buildInfoRow('Channel Count', '${connector.channels.length}'), '${pubKeyToHex(connector.selfPublicKey!).substring(0, 16)}...',
),
_buildInfoRow(
l10n.settings_infoContactsCount,
'${connector.contacts.length}',
),
_buildInfoRow(
l10n.settings_infoChannelCount,
'${connector.channels.length}',
),
], ],
), ),
), ),
); );
} }
Widget _buildBatteryInfoRow(MeshCoreConnector connector) { Widget _buildBatteryInfoRow(
BuildContext context,
MeshCoreConnector connector,
) {
final l10n = context.l10n;
final percent = connector.batteryPercent; final percent = connector.batteryPercent;
final millivolts = connector.batteryMillivolts; final millivolts = connector.batteryMillivolts;
// figure out display value // figure out display value
final String displayValue; final String displayValue;
if (millivolts == null) { if (millivolts == null) {
displayValue = ''; displayValue = l10n.common_notAvailable;
} else if (_showBatteryVoltage) { } else if (_showBatteryVoltage) {
displayValue = '${(millivolts / 1000.0).toStringAsFixed(2)} V'; displayValue = l10n.common_voltageValue(
(millivolts / 1000.0).toStringAsFixed(2),
);
} else { } else {
displayValue = percent != null ? '$percent%' : ''; displayValue = percent != null
? l10n.common_percentValue(percent)
: l10n.common_notAvailable;
} }
final IconData icon; final IconData icon;
@@ -127,7 +153,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
} }
return _buildInfoRow( return _buildInfoRow(
'Battery', l10n.settings_infoBattery,
displayValue, displayValue,
leading: Icon(icon, size: 18, color: iconColor), leading: Icon(icon, size: 18, color: iconColor),
valueColor: valueColor, valueColor: valueColor,
@@ -142,11 +168,12 @@ class _SettingsScreenState extends State<SettingsScreen> {
} }
Widget _buildAppSettingsCard(BuildContext context) { Widget _buildAppSettingsCard(BuildContext context) {
final l10n = context.l10n;
return Card( return Card(
child: ListTile( child: ListTile(
leading: const Icon(Icons.settings_outlined), leading: const Icon(Icons.settings_outlined),
title: const Text('App Settings'), title: Text(l10n.settings_appSettings),
subtitle: const Text('Notifications, messaging, and map preferences'), subtitle: Text(l10n.settings_appSettingsSubtitle),
trailing: const Icon(Icons.chevron_right), trailing: const Icon(Icons.chevron_right),
onTap: () { onTap: () {
Navigator.push( Navigator.push(
@@ -158,46 +185,50 @@ class _SettingsScreenState extends State<SettingsScreen> {
); );
} }
Widget _buildNodeSettingsCard(BuildContext context, MeshCoreConnector connector) { Widget _buildNodeSettingsCard(
BuildContext context,
MeshCoreConnector connector,
) {
final l10n = context.l10n;
return Card( return Card(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const Padding( Padding(
padding: EdgeInsets.fromLTRB(16, 16, 16, 8), padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text( child: Text(
'Node Settings', l10n.settings_nodeSettings,
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
), ),
), ),
ListTile( ListTile(
leading: const Icon(Icons.person_outline), leading: const Icon(Icons.person_outline),
title: const Text('Node Name'), title: Text(l10n.settings_nodeName),
subtitle: Text(connector.selfName ?? 'Not set'), subtitle: Text(connector.selfName ?? l10n.settings_nodeNameNotSet),
trailing: const Icon(Icons.chevron_right), trailing: const Icon(Icons.chevron_right),
onTap: () => _editNodeName(context, connector), onTap: () => _editNodeName(context, connector),
), ),
const Divider(height: 1), const Divider(height: 1),
ListTile( ListTile(
leading: const Icon(Icons.radio), leading: const Icon(Icons.radio),
title: const Text('Radio Settings'), title: Text(l10n.settings_radioSettings),
subtitle: const Text('Frequency, power, spreading factor'), subtitle: Text(l10n.settings_radioSettingsSubtitle),
trailing: const Icon(Icons.chevron_right), trailing: const Icon(Icons.chevron_right),
onTap: () => _showRadioSettings(context, connector), onTap: () => _showRadioSettings(context, connector),
), ),
const Divider(height: 1), const Divider(height: 1),
ListTile( ListTile(
leading: const Icon(Icons.location_on_outlined), leading: const Icon(Icons.location_on_outlined),
title: const Text('Location'), title: Text(l10n.settings_location),
subtitle: const Text('GPS coordinates'), subtitle: Text(l10n.settings_locationSubtitle),
trailing: const Icon(Icons.chevron_right), trailing: const Icon(Icons.chevron_right),
onTap: () => _editLocation(context, connector), onTap: () => _editLocation(context, connector),
), ),
const Divider(height: 1), const Divider(height: 1),
ListTile( ListTile(
leading: const Icon(Icons.visibility_off_outlined), leading: const Icon(Icons.visibility_off_outlined),
title: const Text('Privacy Mode'), title: Text(l10n.settings_privacyMode),
subtitle: const Text('Hide name/location in advertisements'), subtitle: Text(l10n.settings_privacyModeSubtitle),
trailing: const Icon(Icons.chevron_right), trailing: const Icon(Icons.chevron_right),
onTap: () => _togglePrivacy(context, connector), onTap: () => _togglePrivacy(context, connector),
), ),
@@ -207,42 +238,43 @@ class _SettingsScreenState extends State<SettingsScreen> {
} }
Widget _buildActionsCard(BuildContext context, MeshCoreConnector connector) { Widget _buildActionsCard(BuildContext context, MeshCoreConnector connector) {
final l10n = context.l10n;
return Card( return Card(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const Padding( Padding(
padding: EdgeInsets.fromLTRB(16, 16, 16, 8), padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text( child: Text(
'Actions', l10n.settings_actions,
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
), ),
), ),
ListTile( ListTile(
leading: const Icon(Icons.cell_tower), leading: const Icon(Icons.cell_tower),
title: const Text('Send Advertisement'), title: Text(l10n.settings_sendAdvertisement),
subtitle: const Text('Broadcast presence now'), subtitle: Text(l10n.settings_sendAdvertisementSubtitle),
onTap: () => _sendAdvert(context, connector), onTap: () => _sendAdvert(context, connector),
), ),
const Divider(height: 1), const Divider(height: 1),
ListTile( ListTile(
leading: const Icon(Icons.sync), leading: const Icon(Icons.sync),
title: const Text('Sync Time'), title: Text(l10n.settings_syncTime),
subtitle: const Text('Set device clock to phone time'), subtitle: Text(l10n.settings_syncTimeSubtitle),
onTap: () => _syncTime(context, connector), onTap: () => _syncTime(context, connector),
), ),
const Divider(height: 1), const Divider(height: 1),
ListTile( ListTile(
leading: const Icon(Icons.refresh), leading: const Icon(Icons.refresh),
title: const Text('Refresh Contacts'), title: Text(l10n.settings_refreshContacts),
subtitle: const Text('Reload contact list from device'), subtitle: Text(l10n.settings_refreshContactsSubtitle),
onTap: () => connector.getContacts(), onTap: () => connector.getContacts(),
), ),
const Divider(height: 1), const Divider(height: 1),
ListTile( ListTile(
leading: const Icon(Icons.restart_alt, color: Colors.orange), leading: const Icon(Icons.restart_alt, color: Colors.orange),
title: const Text('Reboot Device'), title: Text(l10n.settings_rebootDevice),
subtitle: const Text('Restart the MeshCore device'), subtitle: Text(l10n.settings_rebootDeviceSubtitle),
onTap: () => _confirmReboot(context, connector), onTap: () => _confirmReboot(context, connector),
), ),
], ],
@@ -251,50 +283,60 @@ class _SettingsScreenState extends State<SettingsScreen> {
} }
Widget _buildAboutCard(BuildContext context) { Widget _buildAboutCard(BuildContext context) {
final l10n = context.l10n;
return Card( return Card(
child: ListTile( child: ListTile(
leading: const Icon(Icons.info_outline), leading: const Icon(Icons.info_outline),
title: const Text('About'), title: Text(l10n.settings_about),
subtitle: Text('MeshCore Open v$_appVersion'), subtitle: Text(
l10n.settings_aboutVersion(
_appVersion.isEmpty ? l10n.common_loading : _appVersion,
),
),
onTap: () => _showAbout(context), onTap: () => _showAbout(context),
), ),
); );
} }
Widget _buildDebugCard(BuildContext context) { Widget _buildDebugCard(BuildContext context) {
final l10n = context.l10n;
return Card( return Card(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const Padding( Padding(
padding: EdgeInsets.fromLTRB(16, 16, 16, 8), padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text( child: Text(
'Debug', l10n.settings_debug,
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
), ),
), ),
ListTile( ListTile(
leading: const Icon(Icons.bluetooth_outlined), leading: const Icon(Icons.bluetooth_outlined),
title: const Text('BLE Debug Log'), title: Text(l10n.settings_bleDebugLog),
subtitle: const Text('BLE commands, responses, and raw data'), subtitle: Text(l10n.settings_bleDebugLogSubtitle),
trailing: const Icon(Icons.chevron_right), trailing: const Icon(Icons.chevron_right),
onTap: () { onTap: () {
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute(builder: (context) => const BleDebugLogScreen()), MaterialPageRoute(
builder: (context) => const BleDebugLogScreen(),
),
); );
}, },
), ),
const Divider(height: 1), const Divider(height: 1),
ListTile( ListTile(
leading: const Icon(Icons.code_outlined), leading: const Icon(Icons.code_outlined),
title: const Text('App Debug Log'), title: Text(l10n.settings_appDebugLog),
subtitle: const Text('Application debug messages'), subtitle: Text(l10n.settings_appDebugLogSubtitle),
trailing: const Icon(Icons.chevron_right), trailing: const Icon(Icons.chevron_right),
onTap: () { onTap: () {
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute(builder: (context) => const AppDebugLogScreen()), MaterialPageRoute(
builder: (context) => const AppDebugLogScreen(),
),
); );
}, },
), ),
@@ -317,20 +359,14 @@ class _SettingsScreenState extends State<SettingsScreen> {
children: [ children: [
Row( Row(
children: [ children: [
if (leading != null) ...[ if (leading != null) ...[leading, const SizedBox(width: 8)],
leading,
const SizedBox(width: 8),
],
Text(label, style: TextStyle(color: Colors.grey[600])), Text(label, style: TextStyle(color: Colors.grey[600])),
], ],
), ),
Flexible( Flexible(
child: Text( child: Text(
value, value,
style: TextStyle( style: TextStyle(fontWeight: FontWeight.w500, color: valueColor),
fontWeight: FontWeight.w500,
color: valueColor,
),
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
), ),
@@ -349,23 +385,24 @@ class _SettingsScreenState extends State<SettingsScreen> {
} }
void _editNodeName(BuildContext context, MeshCoreConnector connector) { void _editNodeName(BuildContext context, MeshCoreConnector connector) {
final l10n = context.l10n;
final controller = TextEditingController(text: connector.selfName ?? ''); final controller = TextEditingController(text: connector.selfName ?? '');
showDialog( showDialog(
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
title: const Text('Node Name'), title: Text(l10n.settings_nodeName),
content: TextField( content: TextField(
controller: controller, controller: controller,
decoration: const InputDecoration( decoration: InputDecoration(
hintText: 'Enter node name', hintText: l10n.settings_nodeNameHint,
border: OutlineInputBorder(), border: const OutlineInputBorder(),
), ),
maxLength: 31, maxLength: 31,
), ),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
child: const Text('Cancel'), child: Text(l10n.common_cancel),
), ),
TextButton( TextButton(
onPressed: () async { onPressed: () async {
@@ -374,10 +411,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
await connector.refreshDeviceInfo(); await connector.refreshDeviceInfo();
if (!context.mounted) return; if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Name updated')), SnackBar(content: Text(l10n.settings_nodeNameUpdated)),
); );
}, },
child: const Text('Save'), child: Text(l10n.common_save),
), ),
], ],
), ),
@@ -392,91 +429,172 @@ class _SettingsScreenState extends State<SettingsScreen> {
} }
void _editLocation(BuildContext context, MeshCoreConnector connector) { void _editLocation(BuildContext context, MeshCoreConnector connector) {
final l10n = context.l10n;
final latController = TextEditingController(); final latController = TextEditingController();
final lonController = TextEditingController(); final lonController = TextEditingController();
final intervalController = TextEditingController();
latController.text = connector.selfLatitude?.toStringAsFixed(6) ?? '';
lonController.text = connector.selfLongitude?.toStringAsFixed(6) ?? '';
// Safe access to custom vars - may be null before device responds
final customVars = connector.currentCustomVars ?? {};
final bool hasGPS = customVars.containsKey("gps");
bool isGPSEnabled = customVars["gps"] == "1";
// Read current interval or default to 900 (15 minutes)
final currentInterval = int.tryParse(customVars["gps_interval"] ?? "") ?? 900;
intervalController.text = currentInterval.toString();
showDialog( showDialog(
context: context, context: context,
builder: (context) => AlertDialog( builder: (dialogContext) => StatefulBuilder(
title: const Text('Location'), builder: (context, setDialogState) => AlertDialog(
content: Column( title: Text(l10n.settings_location),
mainAxisSize: MainAxisSize.min, content: Column(
children: [ mainAxisSize: MainAxisSize.min,
TextField( children: [
controller: latController, TextField(
decoration: const InputDecoration( controller: latController,
labelText: 'Latitude', decoration: InputDecoration(
border: OutlineInputBorder(), labelText: l10n.settings_latitude,
border: const OutlineInputBorder(),
),
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
signed: true,
),
), ),
keyboardType: const TextInputType.numberWithOptions(decimal: true, signed: true), const SizedBox(height: 16),
TextField(
controller: lonController,
decoration: InputDecoration(
labelText: l10n.settings_longitude,
border: const OutlineInputBorder(),
),
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
signed: true,
),
),
if (hasGPS) ...[
const SizedBox(height: 16),
TextField(
controller: intervalController,
decoration: InputDecoration(
labelText: l10n.settings_locationIntervalSec,
border: const OutlineInputBorder(),
),
keyboardType: const TextInputType.numberWithOptions(
decimal: false,
signed: false,
),
),
const SizedBox(height: 16),
FeatureToggleRow(
title: l10n.settings_locationGPSEnable,
subtitle: l10n.settings_locationGPSEnableSubtitle,
value: isGPSEnabled,
onChanged: (value) async {
setDialogState(() => isGPSEnabled = value);
if (value) {
await connector.setCustomVar("gps:1");
} else {
await connector.setCustomVar("gps:0");
}
},
),
],
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(l10n.common_cancel),
), ),
const SizedBox(height: 16), TextButton(
TextField( onPressed: () async {
controller: lonController, Navigator.pop(context);
decoration: const InputDecoration(
labelText: 'Longitude', if (hasGPS) {
border: OutlineInputBorder(), final intervalText = intervalController.text.trim();
), if (intervalText.isEmpty) {
keyboardType: const TextInputType.numberWithOptions(decimal: true, signed: true), return;
}
final interval = int.tryParse(intervalText);
if (interval == null || interval < 60 || interval >= 86400) {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.settings_locationIntervalInvalid),
),
);
return;
}
await connector.setCustomVar("gps_interval:$interval");
await connector.refreshDeviceInfo();
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.settings_locationUpdated)),
);
}
final latText = latController.text.trim();
final lonText = lonController.text.trim();
if (latText.isEmpty && lonText.isEmpty) {
return;
}
final currentLat = connector.selfLatitude;
final currentLon = connector.selfLongitude;
final lat = latText.isNotEmpty
? double.tryParse(latText)
: currentLat;
final lon = lonText.isNotEmpty
? double.tryParse(lonText)
: currentLon;
if (lat == null || lon == null) {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.settings_locationBothRequired)),
);
return;
}
if (lat < -90 || lat > 90 || lon < -180 || lon > 180) {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.settings_locationInvalid)),
);
return;
}
await connector.setNodeLocation(lat: lat, lon: lon);
await connector.refreshDeviceInfo();
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.settings_locationUpdated)),
);
},
child: Text(l10n.common_save),
), ),
], ],
), ),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
TextButton(
onPressed: () async {
Navigator.pop(context);
final latText = latController.text.trim();
final lonText = lonController.text.trim();
if (latText.isEmpty && lonText.isEmpty) {
return;
}
final currentLat = connector.selfLatitude;
final currentLon = connector.selfLongitude;
final lat = latText.isNotEmpty ? double.tryParse(latText) : currentLat;
final lon = lonText.isNotEmpty ? double.tryParse(lonText) : currentLon;
if (lat == null || lon == null) {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Enter both latitude and longitude.')),
);
return;
}
if (lat < -90 || lat > 90 || lon < -180 || lon > 180) {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Invalid latitude or longitude.')),
);
return;
}
await connector.setNodeLocation(lat: lat, lon: lon);
await connector.refreshDeviceInfo();
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Location updated')),
);
},
child: const Text('Save'),
),
],
), ),
); );
} }
void _togglePrivacy(BuildContext context, MeshCoreConnector connector) { void _togglePrivacy(BuildContext context, MeshCoreConnector connector) {
final l10n = context.l10n;
showDialog( showDialog(
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
title: const Text('Privacy Mode'), title: Text(l10n.settings_privacyMode),
content: const Text('Toggle privacy mode to hide your name and location in advertisements.'), content: Text(l10n.settings_privacyModeToggle),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
child: const Text('Cancel'), child: Text(l10n.common_cancel),
), ),
TextButton( TextButton(
onPressed: () async { onPressed: () async {
@@ -485,10 +603,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
await connector.refreshDeviceInfo(); await connector.refreshDeviceInfo();
if (!context.mounted) return; if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Privacy mode enabled')), SnackBar(content: Text(l10n.settings_privacyModeEnabled)),
); );
}, },
child: const Text('Enable'), child: Text(l10n.common_enable),
), ),
TextButton( TextButton(
onPressed: () async { onPressed: () async {
@@ -497,10 +615,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
await connector.refreshDeviceInfo(); await connector.refreshDeviceInfo();
if (!context.mounted) return; if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Privacy mode disabled')), SnackBar(content: Text(l10n.settings_privacyModeDisabled)),
); );
}, },
child: const Text('Disable'), child: Text(l10n.common_disable),
), ),
], ],
), ),
@@ -508,36 +626,42 @@ class _SettingsScreenState extends State<SettingsScreen> {
} }
void _sendAdvert(BuildContext context, MeshCoreConnector connector) { void _sendAdvert(BuildContext context, MeshCoreConnector connector) {
final l10n = context.l10n;
connector.sendSelfAdvert(flood: true); connector.sendSelfAdvert(flood: true);
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(
const SnackBar(content: Text('Advertisement sent')), context,
); ).showSnackBar(SnackBar(content: Text(l10n.settings_advertisementSent)));
} }
void _syncTime(BuildContext context, MeshCoreConnector connector) { void _syncTime(BuildContext context, MeshCoreConnector connector) {
final l10n = context.l10n;
connector.syncTime(); connector.syncTime();
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(
const SnackBar(content: Text('Time synchronized')), context,
); ).showSnackBar(SnackBar(content: Text(l10n.settings_timeSynchronized)));
} }
void _confirmReboot(BuildContext context, MeshCoreConnector connector) { void _confirmReboot(BuildContext context, MeshCoreConnector connector) {
final l10n = context.l10n;
showDialog( showDialog(
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
title: const Text('Reboot Device'), title: Text(l10n.settings_rebootDevice),
content: const Text('Are you sure you want to reboot the device? You will be disconnected.'), content: Text(l10n.settings_rebootDeviceConfirm),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
child: const Text('Cancel'), child: Text(l10n.common_cancel),
), ),
TextButton( TextButton(
onPressed: () { onPressed: () {
Navigator.pop(context); Navigator.pop(context);
connector.rebootDevice(); connector.rebootDevice();
}, },
child: const Text('Reboot', style: TextStyle(color: Colors.orange)), child: Text(
l10n.common_reboot,
style: const TextStyle(color: Colors.orange),
),
), ),
], ],
), ),
@@ -545,16 +669,17 @@ class _SettingsScreenState extends State<SettingsScreen> {
} }
void _showAbout(BuildContext context) { void _showAbout(BuildContext context) {
final l10n = context.l10n;
showAboutDialog( showAboutDialog(
context: context, context: context,
applicationName: 'MeshCore Open', applicationName: l10n.appTitle,
applicationVersion: _appVersion, applicationVersion: _appVersion.isEmpty
applicationLegalese: '2024 MeshCore Open Source Project', ? l10n.common_loading
: _appVersion,
applicationLegalese: l10n.settings_aboutLegalese,
children: [ children: [
const SizedBox(height: 16), const SizedBox(height: 16),
const Text( Text(l10n.settings_aboutDescription),
'An open-source Flutter client for MeshCore LoRa mesh networking devices.',
),
], ],
); );
} }
@@ -582,7 +707,8 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> {
// Populate with current settings if available // Populate with current settings if available
if (widget.connector.currentFreqHz != null) { if (widget.connector.currentFreqHz != null) {
_frequencyController.text = (widget.connector.currentFreqHz! / 1000.0).toStringAsFixed(3); _frequencyController.text = (widget.connector.currentFreqHz! / 1000.0)
.toStringAsFixed(3);
} else { } else {
_frequencyController.text = '915.0'; _frequencyController.text = '915.0';
} }
@@ -643,42 +769,48 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> {
} }
Future<void> _saveSettings() async { Future<void> _saveSettings() async {
final l10n = context.l10n;
final freqMHz = double.tryParse(_frequencyController.text); final freqMHz = double.tryParse(_frequencyController.text);
final txPower = int.tryParse(_txPowerController.text); final txPower = int.tryParse(_txPowerController.text);
if (freqMHz == null || freqMHz < 300 || freqMHz > 2500) { if (freqMHz == null || freqMHz < 300 || freqMHz > 2500) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(
const SnackBar(content: Text('Invalid frequency (300-2500 MHz)')), context,
); ).showSnackBar(SnackBar(content: Text(l10n.settings_frequencyInvalid)));
return; return;
} }
if (txPower == null || txPower < 0 || txPower > 22) { if (txPower == null || txPower < 0 || txPower > 22) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(
const SnackBar(content: Text('Invalid TX power (0-22 dBm)')), context,
); ).showSnackBar(SnackBar(content: Text(l10n.settings_txPowerInvalid)));
return; return;
} }
final freqHz = (freqMHz * 1000).round(); final freqHz = (freqMHz * 1000).round();
final bwHz = _bandwidth.hz; final bwHz = _bandwidth.hz;
final sf = _spreadingFactor.value; final sf = _spreadingFactor.value;
final cr = _toDeviceCodingRate(_codingRate.value, widget.connector.currentCr); final cr = _toDeviceCodingRate(
_codingRate.value,
widget.connector.currentCr,
);
try { try {
await widget.connector.sendFrame(buildSetRadioParamsFrame(freqHz, bwHz, sf, cr)); await widget.connector.sendFrame(
buildSetRadioParamsFrame(freqHz, bwHz, sf, cr),
);
await widget.connector.sendFrame(buildSetRadioTxPowerFrame(txPower)); await widget.connector.sendFrame(buildSetRadioTxPowerFrame(txPower));
await widget.connector.refreshDeviceInfo(); await widget.connector.refreshDeviceInfo();
if (!mounted) return; if (!mounted) return;
Navigator.pop(context); Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Radio settings updated')), SnackBar(content: Text(l10n.settings_radioSettingsUpdated)),
); );
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error: $e')), SnackBar(content: Text(l10n.settings_error(e.toString()))),
); );
} }
} }
@@ -696,36 +828,40 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = context.l10n;
return AlertDialog( return AlertDialog(
title: const Text('Radio Settings'), title: Text(l10n.settings_radioSettings),
content: SingleChildScrollView( content: SingleChildScrollView(
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const Text('Presets', style: TextStyle(fontWeight: FontWeight.bold)), Text(
l10n.settings_presets,
style: const TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 8), const SizedBox(height: 8),
Wrap( Wrap(
spacing: 8, spacing: 8,
children: [ children: [
_PresetChip( _PresetChip(
label: '915 MHz', label: l10n.settings_preset915Mhz,
onTap: () => _applyPreset(RadioSettings.preset915MHz), onTap: () => _applyPreset(RadioSettings.preset915MHz),
), ),
_PresetChip( _PresetChip(
label: '868 MHz', label: l10n.settings_preset868Mhz,
onTap: () => _applyPreset(RadioSettings.preset868MHz), onTap: () => _applyPreset(RadioSettings.preset868MHz),
), ),
_PresetChip( _PresetChip(
label: '433 MHz', label: l10n.settings_preset433Mhz,
onTap: () => _applyPreset(RadioSettings.preset433MHz), onTap: () => _applyPreset(RadioSettings.preset433MHz),
), ),
_PresetChip( _PresetChip(
label: 'Long Range', label: l10n.settings_longRange,
onTap: () => _applyPreset(RadioSettings.presetLongRange), onTap: () => _applyPreset(RadioSettings.presetLongRange),
), ),
_PresetChip( _PresetChip(
label: 'Fast Speed', label: l10n.settings_fastSpeed,
onTap: () => _applyPreset(RadioSettings.presetFastSpeed), onTap: () => _applyPreset(RadioSettings.presetFastSpeed),
), ),
], ],
@@ -733,25 +869,26 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> {
const SizedBox(height: 24), const SizedBox(height: 24),
TextField( TextField(
controller: _frequencyController, controller: _frequencyController,
decoration: const InputDecoration( decoration: InputDecoration(
labelText: 'Frequency (MHz)', labelText: l10n.settings_frequency,
border: OutlineInputBorder(), border: const OutlineInputBorder(),
helperText: '300.0 - 2500.0', helperText: l10n.settings_frequencyHelper,
),
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
), ),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
DropdownButtonFormField<LoRaBandwidth>( DropdownButtonFormField<LoRaBandwidth>(
initialValue: _bandwidth, initialValue: _bandwidth,
decoration: const InputDecoration( decoration: InputDecoration(
labelText: 'Bandwidth', labelText: l10n.settings_bandwidth,
border: OutlineInputBorder(), border: const OutlineInputBorder(),
), ),
items: LoRaBandwidth.values items: LoRaBandwidth.values
.map((bw) => DropdownMenuItem( .map(
value: bw, (bw) => DropdownMenuItem(value: bw, child: Text(bw.label)),
child: Text(bw.label), )
))
.toList(), .toList(),
onChanged: (value) { onChanged: (value) {
if (value != null) setState(() => _bandwidth = value); if (value != null) setState(() => _bandwidth = value);
@@ -760,15 +897,14 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> {
const SizedBox(height: 16), const SizedBox(height: 16),
DropdownButtonFormField<LoRaSpreadingFactor>( DropdownButtonFormField<LoRaSpreadingFactor>(
initialValue: _spreadingFactor, initialValue: _spreadingFactor,
decoration: const InputDecoration( decoration: InputDecoration(
labelText: 'Spreading Factor', labelText: l10n.settings_spreadingFactor,
border: OutlineInputBorder(), border: const OutlineInputBorder(),
), ),
items: LoRaSpreadingFactor.values items: LoRaSpreadingFactor.values
.map((sf) => DropdownMenuItem( .map(
value: sf, (sf) => DropdownMenuItem(value: sf, child: Text(sf.label)),
child: Text(sf.label), )
))
.toList(), .toList(),
onChanged: (value) { onChanged: (value) {
if (value != null) setState(() => _spreadingFactor = value); if (value != null) setState(() => _spreadingFactor = value);
@@ -777,15 +913,14 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> {
const SizedBox(height: 16), const SizedBox(height: 16),
DropdownButtonFormField<LoRaCodingRate>( DropdownButtonFormField<LoRaCodingRate>(
initialValue: _codingRate, initialValue: _codingRate,
decoration: const InputDecoration( decoration: InputDecoration(
labelText: 'Coding Rate', labelText: l10n.settings_codingRate,
border: OutlineInputBorder(), border: const OutlineInputBorder(),
), ),
items: LoRaCodingRate.values items: LoRaCodingRate.values
.map((cr) => DropdownMenuItem( .map(
value: cr, (cr) => DropdownMenuItem(value: cr, child: Text(cr.label)),
child: Text(cr.label), )
))
.toList(), .toList(),
onChanged: (value) { onChanged: (value) {
if (value != null) setState(() => _codingRate = value); if (value != null) setState(() => _codingRate = value);
@@ -794,10 +929,10 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> {
const SizedBox(height: 16), const SizedBox(height: 16),
TextField( TextField(
controller: _txPowerController, controller: _txPowerController,
decoration: const InputDecoration( decoration: InputDecoration(
labelText: 'TX Power (dBm)', labelText: l10n.settings_txPower,
border: OutlineInputBorder(), border: const OutlineInputBorder(),
helperText: '0 - 22', helperText: l10n.settings_txPowerHelper,
), ),
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
), ),
@@ -807,12 +942,9 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> {
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
child: const Text('Cancel'), child: Text(l10n.common_cancel),
),
FilledButton(
onPressed: _saveSettings,
child: const Text('Save'),
), ),
FilledButton(onPressed: _saveSettings, child: Text(l10n.common_save)),
], ],
); );
} }
@@ -826,9 +958,6 @@ class _PresetChip extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ActionChip( return ActionChip(label: Text(label), onPressed: onTap);
label: Text(label),
onPressed: onTap,
);
} }
} }
+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),
);
}
}
+4
View File
@@ -113,6 +113,10 @@ class AppSettingsService extends ChangeNotifier {
await updateSettings(_settings.copyWith(themeMode: value)); await updateSettings(_settings.copyWith(themeMode: value));
} }
Future<void> setLanguageOverride(String? value) async {
await updateSettings(_settings.copyWith(languageOverride: value));
}
Future<void> setAppDebugLogEnabled(bool value) async { Future<void> setAppDebugLogEnabled(bool value) async {
await updateSettings(_settings.copyWith(appDebugLogEnabled: value)); await updateSettings(_settings.copyWith(appDebugLogEnabled: value));
// Update the global logger // Update the global logger
+46 -11
View File
@@ -16,7 +16,9 @@ class BleDebugLogEntry {
String get hexPreview { String get hexPreview {
const maxBytes = 64; 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(' '); final hex = bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ');
return payload.length > maxBytes ? '$hex' : hex; return payload.length > maxBytes ? '$hex' : hex;
} }
@@ -26,14 +28,13 @@ class BleRawLogRxEntry {
final DateTime timestamp; final DateTime timestamp;
final Uint8List payload; final Uint8List payload;
BleRawLogRxEntry({ BleRawLogRxEntry({required this.timestamp, required this.payload});
required this.timestamp,
required this.payload,
});
String get hexPreview { String get hexPreview {
const maxBytes = 64; 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(' '); final hex = bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ');
return payload.length > maxBytes ? '$hex' : hex; return payload.length > maxBytes ? '$hex' : hex;
} }
@@ -45,7 +46,8 @@ class BleDebugLogService extends ChangeNotifier {
final List<BleRawLogRxEntry> _rawLogRxEntries = []; final List<BleRawLogRxEntry> _rawLogRxEntries = [];
List<BleDebugLogEntry> get entries => List.unmodifiable(_entries); 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}) { void logFrame(Uint8List frame, {required bool outgoing, String? note}) {
if (frame.isEmpty) return; if (frame.isEmpty) return;
@@ -85,15 +87,32 @@ class BleDebugLogService extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
String _describeFrame(int code, Uint8List frame, bool outgoing, String? note) { String _describeFrame(
final label = _codeLabel(code); int code,
Uint8List frame,
bool outgoing,
String? note,
) {
final label = _codeLabel(code, outgoing: outgoing);
final prefix = outgoing ? 'TX' : 'RX'; final prefix = outgoing ? 'TX' : 'RX';
final extra = _frameDetail(code, frame); final extra = _frameDetail(code, frame);
final noteText = note != null ? '$note' : ''; final noteText = note != null ? '$note' : '';
return '$prefix $label$extra$noteText'; 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) { switch (code) {
case cmdAppStart: case cmdAppStart:
return 'CMD_APP_START'; return 'CMD_APP_START';
@@ -135,6 +154,15 @@ class BleDebugLogService extends ChangeNotifier {
return 'CMD_SET_CHANNEL'; return 'CMD_SET_CHANNEL';
case cmdGetRadioSettings: case cmdGetRadioSettings:
return 'CMD_GET_RADIO_SETTINGS'; return 'CMD_GET_RADIO_SETTINGS';
case cmdSetCustomVar:
return 'CMD_SET_CUSTOM_VAR';
default:
return null;
}
}
String? _responseLabel(int code) {
switch (code) {
case respCodeOk: case respCodeOk:
return 'RESP_CODE_OK'; return 'RESP_CODE_OK';
case respCodeErr: case respCodeErr:
@@ -167,6 +195,13 @@ class BleDebugLogService extends ChangeNotifier {
return 'RESP_CODE_CHANNEL_INFO'; return 'RESP_CODE_CHANNEL_INFO';
case respCodeRadioSettings: case respCodeRadioSettings:
return 'RESP_CODE_RADIO_SETTINGS'; return 'RESP_CODE_RADIO_SETTINGS';
default:
return null;
}
}
String? _pushLabel(int code) {
switch (code) {
case pushCodeAdvert: case pushCodeAdvert:
return 'PUSH_CODE_ADVERT'; return 'PUSH_CODE_ADVERT';
case pushCodePathUpdated: case pushCodePathUpdated:
@@ -184,7 +219,7 @@ class BleDebugLogService extends ChangeNotifier {
case pushCodeNewAdvert: case pushCodeNewAdvert:
return 'PUSH_CODE_NEW_ADVERT'; return 'PUSH_CODE_NEW_ADVERT';
default: default:
return 'CODE_$code'; return null;
} }
} }
+1 -3
View File
@@ -6,7 +6,6 @@ import 'package:crypto/crypto.dart';
import '../models/contact.dart'; import '../models/contact.dart';
import '../models/message.dart'; import '../models/message.dart';
import '../models/path_selection.dart'; import '../models/path_selection.dart';
import 'storage_service.dart';
import 'app_settings_service.dart'; import 'app_settings_service.dart';
import 'app_debug_log_service.dart'; import 'app_debug_log_service.dart';
@@ -36,7 +35,6 @@ class MessageRetryService extends ChangeNotifier {
static const int maxRetries = 5; static const int maxRetries = 5;
static const int maxAckHistorySize = 100; static const int maxAckHistorySize = 100;
final StorageService _storage;
final Map<String, Timer> _timeoutTimers = {}; final Map<String, Timer> _timeoutTimers = {};
final Map<String, Message> _pendingMessages = {}; final Map<String, Message> _pendingMessages = {};
final Map<String, Contact> _pendingContacts = {}; final Map<String, Contact> _pendingContacts = {};
@@ -59,7 +57,7 @@ class MessageRetryService extends ChangeNotifier {
AppDebugLogService? _debugLogService; AppDebugLogService? _debugLogService;
Function(String, PathSelection, bool, int?)? _recordPathResultCallback; Function(String, PathSelection, bool, int?)? _recordPathResultCallback;
MessageRetryService(this._storage); MessageRetryService();
void initialize({ void initialize({
required Function(Contact, String, int, int) sendMessageCallback, required Function(Contact, String, int, int) sendMessageCallback,
+30 -1
View File
@@ -18,10 +18,16 @@ class NotificationService {
requestBadgePermission: true, requestBadgePermission: true,
requestSoundPermission: true, requestSoundPermission: true,
); );
const macSettings = DarwinInitializationSettings(
requestAlertPermission: true,
requestBadgePermission: true,
requestSoundPermission: true,
);
const initSettings = InitializationSettings( const initSettings = InitializationSettings(
android: androidSettings, android: androidSettings,
iOS: iosSettings, iOS: iosSettings,
macOS: macSettings,
); );
try { try {
@@ -90,9 +96,17 @@ class NotificationService {
badgeNumber: badgeCount, badgeNumber: badgeCount,
); );
final macDetails = DarwinNotificationDetails(
presentAlert: true,
presentBadge: true,
presentSound: true,
badgeNumber: badgeCount,
);
final notificationDetails = NotificationDetails( final notificationDetails = NotificationDetails(
android: androidDetails, android: androidDetails,
iOS: iosDetails, iOS: iosDetails,
macOS: macDetails,
); );
await _notifications.show( await _notifications.show(
@@ -128,9 +142,16 @@ class NotificationService {
presentSound: true, presentSound: true,
); );
const macDetails = DarwinNotificationDetails(
presentAlert: true,
presentBadge: true,
presentSound: true,
);
const notificationDetails = NotificationDetails( const notificationDetails = NotificationDetails(
android: androidDetails, android: androidDetails,
iOS: iosDetails, iOS: iosDetails,
macOS: macDetails,
); );
await _notifications.show( await _notifications.show(
@@ -169,15 +190,23 @@ class NotificationService {
badgeNumber: badgeCount, badgeNumber: badgeCount,
); );
final macDetails = DarwinNotificationDetails(
presentAlert: true,
presentBadge: true,
presentSound: true,
badgeNumber: badgeCount,
);
final notificationDetails = NotificationDetails( final notificationDetails = NotificationDetails(
android: androidDetails, android: androidDetails,
iOS: iosDetails, iOS: iosDetails,
macOS: macDetails,
); );
final preview = _truncateMessage(message, 30); final preview = _truncateMessage(message, 30);
final body = preview.isEmpty final body = preview.isEmpty
? 'Received new message' ? 'Received new message'
: 'Received new message: $preview'; : preview;
await _notifications.show( await _notifications.show(
channelIndex?.hashCode ?? DateTime.now().millisecondsSinceEpoch, channelIndex?.hashCode ?? DateTime.now().millisecondsSinceEpoch,
+6 -5
View File
@@ -70,11 +70,6 @@ class RepeaterCommandService {
_pendingByPrefix[prefix] = commandId; _pendingByPrefix[prefix] = commandId;
final framedCommand = '$prefix$command'; final framedCommand = '$prefix$command';
final pathLengthValue = selection.useFlood ? -1 : selection.hopCount; final pathLengthValue = selection.useFlood ? -1 : selection.hopCount;
final timeoutMs = _connector.calculateTimeout(
pathLength: pathLengthValue,
messageBytes: framedCommand.length,
);
final timeoutSeconds = (timeoutMs / 1000).ceil();
final timestampSeconds = DateTime.now().millisecondsSinceEpoch ~/ 1000; final timestampSeconds = DateTime.now().millisecondsSinceEpoch ~/ 1000;
_connector.trackRepeaterAck( _connector.trackRepeaterAck(
contact: repeater, contact: repeater,
@@ -89,6 +84,12 @@ class RepeaterCommandService {
attempt: attempt, attempt: attempt,
timestampSeconds: timestampSeconds, 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); await _connector.sendFrame(frame);
_commandTimeouts[commandId]?.cancel(); _commandTimeouts[commandId]?.cancel();
_commandTimeouts[commandId] = Timer( _commandTimeouts[commandId] = Timer(
+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, 'pathLength': msg.pathLength,
'pathBytes': msg.pathBytes.isNotEmpty ? base64Encode(msg.pathBytes) : null, 'pathBytes': msg.pathBytes.isNotEmpty ? base64Encode(msg.pathBytes) : null,
'reactions': msg.reactions, 'reactions': msg.reactions,
'fourByteRoomContactKey': base64Encode(msg.fourByteRoomContactKey),
}; };
} }
@@ -86,6 +87,9 @@ class MessageStore {
reactions: (json['reactions'] as Map<String, dynamic>?)?.map( reactions: (json['reactions'] as Map<String, dynamic>?)?.map(
(key, value) => MapEntry(key, value as int), (key, value) => MapEntry(key, value as int),
) ?? {}, ) ?? {},
fourByteRoomContactKey: json['fourByteRoomContactKey'] != null
? Uint8List.fromList(base64Decode(json['fourByteRoomContactKey'] as String))
: null,
); );
} }
} }
+6 -5
View File
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../connector/meshcore_connector.dart'; import '../connector/meshcore_connector.dart';
import '../l10n/l10n.dart';
/// Shows a confirmation dialog before disconnecting from the device. /// Shows a confirmation dialog before disconnecting from the device.
/// Returns true if user confirmed and disconnect completed, false otherwise. /// Returns true if user confirmed and disconnect completed, false otherwise.
@@ -7,20 +8,20 @@ Future<bool> showDisconnectDialog(
BuildContext context, BuildContext context,
MeshCoreConnector connector, MeshCoreConnector connector,
) async { ) async {
final l10n = context.l10n;
final confirmed = await showDialog<bool>( final confirmed = await showDialog<bool>(
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
title: const Text('Disconnect'), title: Text(l10n.dialog_disconnect),
content: const Text( content: Text(l10n.dialog_disconnectConfirm),
'Are you sure you want to disconnect from this device?'),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(context, false), onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'), child: Text(l10n.common_cancel),
), ),
TextButton( TextButton(
onPressed: () => Navigator.pop(context, true), onPressed: () => Navigator.pop(context, true),
child: const Text('Disconnect'), child: Text(l10n.common_disconnect),
), ),
], ],
), ),
+14 -9
View File
@@ -67,18 +67,23 @@ class _BatteryIndicatorState extends State<BatteryIndicator> {
}, },
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon(batteryUi.icon, size: 20, color: batteryUi.color), Icon(batteryUi.icon, size: 18, color: batteryUi.color),
const SizedBox(width: 4), const SizedBox(width: 2),
Text( Flexible(
displayText, child: Text(
style: TextStyle( displayText,
fontSize: 14, style: TextStyle(
fontWeight: FontWeight.w500, fontSize: 12,
color: batteryUi.color, 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 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../l10n/l10n.dart';
import '../connector/meshcore_protocol.dart'; import '../connector/meshcore_protocol.dart';
/// Debug widget to show the hex dump of a frame /// Debug widget to show the hex dump of a frame
@@ -10,23 +11,32 @@ class DebugFrameViewer {
.join(' '); .join(' ');
final details = StringBuffer(); final details = StringBuffer();
details.writeln('Frame Length: ${frame.length} bytes\n'); details.writeln(context.l10n.debugFrame_length(frame.length));
details.writeln('Command: 0x${frame[0].toRadixString(16).padLeft(2, '0')}'); details.writeln('');
details.writeln(
context.l10n.debugFrame_command(frame[0].toRadixString(16).padLeft(2, '0')),
);
if (frame[0] == cmdSendTxtMsg && frame.length > 37) { if (frame[0] == cmdSendTxtMsg && frame.length > 37) {
details.writeln('\nText Message Frame:'); details.writeln('');
details.writeln('- Destination PubKey: ${pubKeyToHex(frame.sublist(1, 33))}'); details.writeln(context.l10n.debugFrame_textMessageHeader);
details.writeln('- Timestamp: ${readUint32LE(frame, 33)}'); details.writeln(context.l10n.debugFrame_destinationPubKey(pubKeyToHex(frame.sublist(1, 33))));
details.writeln('- Flags: 0x${frame[37].toRadixString(16).padLeft(2, '0')}'); 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; 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) { if (frame.length > 38) {
final textBytes = frame.sublist(38); final textBytes = frame.sublist(38);
final nullIdx = textBytes.indexOf(0); final nullIdx = textBytes.indexOf(0);
final text = String.fromCharCodes( final text = String.fromCharCodes(
nullIdx >= 0 ? textBytes.sublist(0, nullIdx) : textBytes 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), style: const TextStyle(fontFamily: 'monospace', fontSize: 12),
), ),
const Divider(), const Divider(),
const Text( Text(
'Hex Dump:', context.l10n.debugFrame_hexDump,
style: TextStyle(fontWeight: FontWeight.bold), style: const TextStyle(fontWeight: FontWeight.bold),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
@@ -59,7 +69,7 @@ class DebugFrameViewer {
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(context), 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/material.dart';
import 'package:flutter_blue_plus/flutter_blue_plus.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 /// A reusable tile widget for displaying a MeshCore device in a list
class DeviceTile extends StatelessWidget { class DeviceTile extends StatelessWidget {
@@ -23,13 +24,13 @@ class DeviceTile extends StatelessWidget {
return ListTile( return ListTile(
leading: _buildSignalIcon(rssi), leading: _buildSignalIcon(rssi),
title: Text( title: Text(
name.isNotEmpty ? name : 'Unknown Device', name.isNotEmpty ? name : context.l10n.common_unknownDevice,
style: const TextStyle(fontWeight: FontWeight.w500), style: const TextStyle(fontWeight: FontWeight.w500),
), ),
subtitle: Text(device.remoteId.toString()), subtitle: Text(device.remoteId.toString()),
trailing: ElevatedButton( trailing: ElevatedButton(
onPressed: onTap, onPressed: onTap,
child: const Text('Connect'), child: Text(context.l10n.common_connect),
), ),
onTap: onTap, 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 'package:flutter/material.dart';
import '../l10n/app_localizations.dart';
import '../l10n/l10n.dart';
class EmojiPicker extends StatelessWidget { class EmojiPicker extends StatelessWidget {
final Function(String) onEmojiSelected; final Function(String) onEmojiSelected;
@@ -10,30 +12,39 @@ class EmojiPicker extends StatelessWidget {
static const List<String> quickEmojis = ['👍', '❤️', '😂', '🎉', '👏', '🔥']; static const List<String> quickEmojis = ['👍', '❤️', '😂', '🎉', '👏', '🔥'];
static const Map<String, List<String>> emojiCategories = { static const List<String> _smileys = [
'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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = context.l10n;
final emojiCategories = _emojiCategories(l10n);
return Container( return Container(
height: MediaQuery.of(context).size.height * 0.5, height: MediaQuery.of(context).size.height * 0.5,
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -47,9 +58,9 @@ class EmojiPicker extends StatelessWidget {
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
const Text( Text(
'Add Reaction', l10n.chat_addReaction,
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
), ),
IconButton( IconButton(
icon: const Icon(Icons.close), icon: const Icon(Icons.close),
+42 -25
View File
@@ -6,16 +6,14 @@ class GifMessage extends StatefulWidget {
final String url; final String url;
final Color backgroundColor; final Color backgroundColor;
final Color fallbackTextColor; final Color fallbackTextColor;
final double width; final double maxSize;
final double height;
const GifMessage({ const GifMessage({
super.key, super.key,
required this.url, required this.url,
required this.backgroundColor, required this.backgroundColor,
required this.fallbackTextColor, required this.fallbackTextColor,
this.width = 200, this.maxSize = 200,
this.height = 140,
}); });
@override @override
@@ -122,6 +120,28 @@ class _GifMessageState extends State<GifMessage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// Calculate display size based on image aspect ratio
// Use 4:3 placeholder aspect ratio during loading to minimize layout shifts
double displayWidth = widget.maxSize;
double displayHeight = widget.maxSize * 0.75;
if (_image != null) {
final imageWidth = _image!.width.toDouble();
final imageHeight = _image!.height.toDouble();
final aspectRatio = imageWidth / imageHeight;
// Fit within maxSize, calculating dimensions from aspect ratio
if (aspectRatio >= 1) {
// Wider than tall: constrain by width
displayWidth = widget.maxSize;
displayHeight = displayWidth / aspectRatio;
} else {
// Taller than wide: constrain by height
displayHeight = widget.maxSize;
displayWidth = displayHeight * aspectRatio;
}
}
Widget content; Widget content;
if (_error != null) { if (_error != null) {
@@ -151,33 +171,30 @@ class _GifMessageState extends State<GifMessage> {
} else { } else {
content = RawImage( content = RawImage(
image: _image, image: _image,
fit: BoxFit.cover, fit: BoxFit.contain,
width: widget.width, width: displayWidth,
height: widget.height, height: displayHeight,
); );
} }
return GestureDetector( return GestureDetector(
onTap: _togglePause, onTap: _togglePause,
child: ClipRRect( child: Container(
borderRadius: BorderRadius.circular(10), color: widget.backgroundColor,
child: Container( width: displayWidth,
color: widget.backgroundColor, height: displayHeight,
width: widget.width, child: Stack(
height: widget.height, fit: StackFit.expand,
child: Stack( children: [
fit: StackFit.expand, content,
children: [ if (_isPaused && _image != null)
content, Container(
if (_isPaused && _image != null) color: Colors.black.withValues(alpha: 0.2),
Container( child: const Center(
color: Colors.black.withValues(alpha: 0.2), child: Icon(Icons.pause, color: Colors.white70, size: 28),
child: const Center(
child: Icon(Icons.pause, color: Colors.white70, size: 28),
),
), ),
], ),
), ],
), ),
), ),
); );
+16 -11
View File
@@ -2,6 +2,7 @@ import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import '../l10n/l10n.dart';
class GifPicker extends StatefulWidget { class GifPicker extends StatefulWidget {
final Function(String gifId) onGifSelected; final Function(String gifId) onGifSelected;
@@ -57,14 +58,16 @@ class _GifPickerState extends State<GifPicker> {
_isLoading = false; _isLoading = false;
}); });
} else { } else {
if (!mounted) return;
setState(() { setState(() {
_error = 'Failed to load GIFs'; _error = context.l10n.gifPicker_failedLoad;
_isLoading = false; _isLoading = false;
}); });
} }
} catch (e) { } catch (e) {
if (!mounted) return;
setState(() { setState(() {
_error = 'No internet connection'; _error = context.l10n.gifPicker_noInternet;
_isLoading = false; _isLoading = false;
}); });
} }
@@ -95,14 +98,16 @@ class _GifPickerState extends State<GifPicker> {
_isLoading = false; _isLoading = false;
}); });
} else { } else {
if (!mounted) return;
setState(() { setState(() {
_error = 'Failed to search GIFs'; _error = context.l10n.gifPicker_failedSearch;
_isLoading = false; _isLoading = false;
}); });
} }
} catch (e) { } catch (e) {
if (!mounted) return;
setState(() { setState(() {
_error = 'No internet connection'; _error = context.l10n.gifPicker_noInternet;
_isLoading = false; _isLoading = false;
}); });
} }
@@ -120,9 +125,9 @@ class _GifPickerState extends State<GifPicker> {
children: [ children: [
const Icon(Icons.gif_box, size: 28), const Icon(Icons.gif_box, size: 28),
const SizedBox(width: 8), const SizedBox(width: 8),
const Text( Text(
'Choose a GIF', context.l10n.gifPicker_title,
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
), ),
const Spacer(), const Spacer(),
IconButton( IconButton(
@@ -137,7 +142,7 @@ class _GifPickerState extends State<GifPicker> {
TextField( TextField(
controller: _searchController, controller: _searchController,
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'Search GIFs...', hintText: context.l10n.gifPicker_searchHint,
prefixIcon: const Icon(Icons.search), prefixIcon: const Icon(Icons.search),
suffixIcon: _searchController.text.isNotEmpty suffixIcon: _searchController.text.isNotEmpty
? IconButton( ? IconButton(
@@ -172,7 +177,7 @@ class _GifPickerState extends State<GifPicker> {
// Powered by Giphy attribution // Powered by Giphy attribution
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
'Powered by GIPHY', context.l10n.gifPicker_poweredBy,
style: TextStyle( style: TextStyle(
fontSize: 11, fontSize: 11,
color: Colors.grey[600], color: Colors.grey[600],
@@ -205,7 +210,7 @@ class _GifPickerState extends State<GifPicker> {
ElevatedButton.icon( ElevatedButton.icon(
onPressed: _loadTrendingGifs, onPressed: _loadTrendingGifs,
icon: const Icon(Icons.refresh), icon: const Icon(Icons.refresh),
label: const Text('Retry'), label: Text(context.l10n.common_retry),
), ),
], ],
), ),
@@ -220,7 +225,7 @@ class _GifPickerState extends State<GifPicker> {
Icon(Icons.search_off, size: 64, color: Colors.grey[400]), Icon(Icons.search_off, size: 64, color: Colors.grey[400]),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
'No GIFs found', context.l10n.gifPicker_noGifsFound,
style: TextStyle(fontSize: 16, color: Colors.grey[600]), style: TextStyle(fontSize: 16, color: Colors.grey[600]),
), ),
], ],
+29
View File
@@ -0,0 +1,29 @@
import 'package:flutter/material.dart';
import '../helpers/chat_scroll_controller.dart';
class JumpToBottomButton extends StatelessWidget {
final ChatScrollController scrollController;
const JumpToBottomButton({
super.key,
required this.scrollController,
});
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<bool>(
valueListenable: scrollController.showJumpToBottom,
builder: (context, show, _) {
if (!show) return const SizedBox.shrink();
return Positioned(
right: 16,
bottom: 16,
child: FloatingActionButton.small(
onPressed: scrollController.jumpToBottom,
child: const Icon(Icons.keyboard_arrow_down),
),
);
},
);
}
}
+16 -13
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../l10n/l10n.dart';
enum ContactSortOption { enum ContactSortOption {
lastSeen, lastSeen,
@@ -45,7 +46,7 @@ class SortFilterMenu extends StatelessWidget {
super.key, super.key,
required this.sections, required this.sections,
required this.onSelected, required this.onSelected,
this.tooltip = 'Filter and sort', required this.tooltip,
this.icon = const Icon(Icons.filter_list_outlined), this.icon = const Icon(Icons.filter_list_outlined),
}); });
@@ -131,59 +132,61 @@ class ContactsFilterMenu extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = context.l10n;
return SortFilterMenu( return SortFilterMenu(
tooltip: l10n.listFilter_tooltip,
sections: [ sections: [
SortFilterMenuSection( SortFilterMenuSection(
title: 'Sort by', title: l10n.listFilter_sortBy,
options: [ options: [
SortFilterMenuOption( SortFilterMenuOption(
value: _actionSortRecentMessages, value: _actionSortRecentMessages,
label: 'Latest messages', label: l10n.listFilter_latestMessages,
checked: sortOption == ContactSortOption.recentMessages, checked: sortOption == ContactSortOption.recentMessages,
), ),
SortFilterMenuOption( SortFilterMenuOption(
value: _actionSortLastSeen, value: _actionSortLastSeen,
label: 'Heard recently', label: l10n.listFilter_heardRecently,
checked: sortOption == ContactSortOption.lastSeen, checked: sortOption == ContactSortOption.lastSeen,
), ),
SortFilterMenuOption( SortFilterMenuOption(
value: _actionSortName, value: _actionSortName,
label: 'A-Z', label: l10n.listFilter_az,
checked: sortOption == ContactSortOption.name, checked: sortOption == ContactSortOption.name,
), ),
], ],
), ),
SortFilterMenuSection( SortFilterMenuSection(
title: 'Filters', title: l10n.listFilter_filters,
options: [ options: [
SortFilterMenuOption( SortFilterMenuOption(
value: _actionFilterAll, value: _actionFilterAll,
label: 'All', label: l10n.listFilter_all,
checked: typeFilter == ContactTypeFilter.all, checked: typeFilter == ContactTypeFilter.all,
), ),
SortFilterMenuOption( SortFilterMenuOption(
value: _actionFilterUsers, value: _actionFilterUsers,
label: 'Users', label: l10n.listFilter_users,
checked: typeFilter == ContactTypeFilter.users, checked: typeFilter == ContactTypeFilter.users,
), ),
SortFilterMenuOption( SortFilterMenuOption(
value: _actionFilterRepeaters, value: _actionFilterRepeaters,
label: 'Repeaters', label: l10n.listFilter_repeaters,
checked: typeFilter == ContactTypeFilter.repeaters, checked: typeFilter == ContactTypeFilter.repeaters,
), ),
SortFilterMenuOption( SortFilterMenuOption(
value: _actionFilterRooms, value: _actionFilterRooms,
label: 'Room servers', label: l10n.listFilter_roomServers,
checked: typeFilter == ContactTypeFilter.rooms, checked: typeFilter == ContactTypeFilter.rooms,
), ),
SortFilterMenuOption( SortFilterMenuOption(
value: _actionToggleUnreadOnly, value: _actionToggleUnreadOnly,
label: 'Unread only', label: l10n.listFilter_unreadOnly,
checked: showUnreadOnly, checked: showUnreadOnly,
), ),
const SortFilterMenuOption( SortFilterMenuOption(
value: _actionNewGroup, value: _actionNewGroup,
label: 'New group', label: l10n.listFilter_newGroup,
), ),
], ],
), ),
+48 -48
View File
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart'; import '../connector/meshcore_connector.dart';
import '../l10n/l10n.dart';
import '../models/contact.dart'; import '../models/contact.dart';
import '../services/path_history_service.dart'; import '../services/path_history_service.dart';
import 'path_selection_dialog.dart'; import 'path_selection_dialog.dart';
@@ -12,13 +13,11 @@ class PathManagementDialog {
static Future<void> show( static Future<void> show(
BuildContext context, { BuildContext context, {
required Contact contact, required Contact contact,
String title = 'Path Management',
}) { }) {
return showDialog<void>( return showDialog<void>(
context: context, context: context,
builder: (context) => _PathManagementDialog( builder: (context) => _PathManagementDialog(
contact: contact, contact: contact,
title: title,
), ),
); );
} }
@@ -26,11 +25,9 @@ class PathManagementDialog {
class _PathManagementDialog extends StatelessWidget { class _PathManagementDialog extends StatelessWidget {
final Contact contact; final Contact contact;
final String title;
const _PathManagementDialog({ const _PathManagementDialog({
required this.contact, required this.contact,
required this.title,
}); });
Contact _resolveContact(MeshCoreConnector connector) { Contact _resolveContact(MeshCoreConnector connector) {
@@ -40,20 +37,22 @@ class _PathManagementDialog extends StatelessWidget {
); );
} }
String _formatRelativeTime(DateTime time) { String _formatRelativeTime(BuildContext context, DateTime time) {
final l10n = context.l10n;
final diff = DateTime.now().difference(time); final diff = DateTime.now().difference(time);
if (diff.inSeconds < 60) return 'Just now'; if (diff.inSeconds < 60) return l10n.time_justNow;
if (diff.inMinutes < 60) return '${diff.inMinutes}m ago'; if (diff.inMinutes < 60) return l10n.time_minutesAgo(diff.inMinutes);
if (diff.inHours < 24) return '${diff.inHours}h ago'; if (diff.inHours < 24) return l10n.time_hoursAgo(diff.inHours);
return '${diff.inDays}d ago'; return l10n.time_daysAgo(diff.inDays);
} }
void _showFullPathDialog(BuildContext context, List<int> pathBytes) { void _showFullPathDialog(BuildContext context, List<int> pathBytes) {
final l10n = context.l10n;
if (pathBytes.isEmpty) { if (pathBytes.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( SnackBar(
content: Text('Path details not available yet. Try sending a message to refresh.'), content: Text(l10n.chat_pathDetailsNotAvailable),
duration: Duration(seconds: 2), duration: const Duration(seconds: 2),
), ),
); );
return; return;
@@ -66,12 +65,12 @@ class _PathManagementDialog extends StatelessWidget {
showDialog( showDialog(
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
title: const Text('Full Path'), title: Text(l10n.chat_fullPath),
content: SelectableText(formattedPath), content: SelectableText(formattedPath),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
child: const Text('Close'), child: Text(l10n.common_close),
), ),
], ],
), ),
@@ -83,6 +82,7 @@ class _PathManagementDialog extends StatelessWidget {
MeshCoreConnector connector, MeshCoreConnector connector,
Contact currentContact, Contact currentContact,
) async { ) async {
final l10n = context.l10n;
if (currentContact.pathLength > 0 && currentContact.path.isEmpty && connector.isConnected) { if (currentContact.pathLength > 0 && currentContact.path.isEmpty && connector.isConnected) {
connector.getContacts(); connector.getContacts();
} }
@@ -96,7 +96,6 @@ class _PathManagementDialog extends StatelessWidget {
context, context,
availableContacts: availableContacts, availableContacts: availableContacts,
initialPath: pathForInput.isEmpty ? null : pathForInput, initialPath: pathForInput.isEmpty ? null : pathForInput,
title: 'Set Custom Path',
currentPathLabel: currentContact.pathLabel, currentPathLabel: currentContact.pathLabel,
onRefresh: connector.isConnected ? connector.getContacts : null, onRefresh: connector.isConnected ? connector.getContacts : null,
); );
@@ -111,7 +110,7 @@ class _PathManagementDialog extends StatelessWidget {
if (!context.mounted) return; if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text('Path set: ${result.length} ${result.length == 1 ? "hop" : "hops"}'), content: Text(l10n.chat_hopsCount(result.length)),
duration: const Duration(seconds: 2), duration: const Duration(seconds: 2),
), ),
); );
@@ -120,27 +119,28 @@ class _PathManagementDialog extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = context.l10n;
return Consumer2<MeshCoreConnector, PathHistoryService>( return Consumer2<MeshCoreConnector, PathHistoryService>(
builder: (context, connector, pathService, _) { builder: (context, connector, pathService, _) {
final currentContact = _resolveContact(connector); final currentContact = _resolveContact(connector);
final paths = pathService.getRecentPaths(currentContact.publicKeyHex); final paths = pathService.getRecentPaths(currentContact.publicKeyHex);
return AlertDialog( return AlertDialog(
title: Text(title), title: Text(l10n.chat_pathManagement),
content: SingleChildScrollView( content: SingleChildScrollView(
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
'Current path: ${currentContact.pathLabel}', l10n.path_currentPath(currentContact.pathLabel),
style: const TextStyle(fontSize: 12, color: Colors.grey), style: const TextStyle(fontSize: 12, color: Colors.grey),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
if (paths.isNotEmpty) ...[ if (paths.isNotEmpty) ...[
const Text( Text(
'Recent ACK Paths (tap to use):', l10n.chat_recentAckPaths,
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12), style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
), ),
if (paths.length >= 100) ...[ if (paths.length >= 100) ...[
const SizedBox(height: 8), const SizedBox(height: 8),
@@ -151,9 +151,9 @@ class _PathManagementDialog extends StatelessWidget {
color: Colors.amberAccent, color: Colors.amberAccent,
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
), ),
child: const Text( child: Text(
'Path history is full. Remove entries to add new ones.', l10n.chat_pathHistoryFull,
style: TextStyle(fontSize: 12), style: const TextStyle(fontSize: 12),
), ),
), ),
], ],
@@ -172,11 +172,11 @@ class _PathManagementDialog extends StatelessWidget {
), ),
), ),
title: Text( title: Text(
'${path.hopCount} ${path.hopCount == 1 ? 'hop' : 'hops'}', l10n.chat_hopsCount(path.hopCount),
style: const TextStyle(fontSize: 14), style: const TextStyle(fontSize: 14),
), ),
subtitle: Text( subtitle: Text(
'${(path.tripTimeMs / 1000).toStringAsFixed(2)}s • ${_formatRelativeTime(path.timestamp)}${path.successCount} successes', '${(path.tripTimeMs / 1000).toStringAsFixed(2)}s • ${_formatRelativeTime(context, path.timestamp)}${path.successCount} ${l10n.chat_successes}',
style: const TextStyle(fontSize: 11), style: const TextStyle(fontSize: 11),
), ),
trailing: Row( trailing: Row(
@@ -184,7 +184,7 @@ class _PathManagementDialog extends StatelessWidget {
children: [ children: [
IconButton( IconButton(
icon: const Icon(Icons.close, size: 16), icon: const Icon(Icons.close, size: 16),
tooltip: 'Remove path', tooltip: l10n.chat_removePath,
onPressed: () async { onPressed: () async {
await pathService.removePathRecord( await pathService.removePathRecord(
currentContact.publicKeyHex, currentContact.publicKeyHex,
@@ -201,9 +201,9 @@ class _PathManagementDialog extends StatelessWidget {
onTap: () async { onTap: () async {
if (path.pathBytes.isEmpty) { if (path.pathBytes.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( SnackBar(
content: Text('Path details not available yet. Try sending a message to refresh.'), content: Text(l10n.chat_pathDetailsNotAvailable),
duration: Duration(seconds: 2), duration: const Duration(seconds: 2),
), ),
); );
return; return;
@@ -222,7 +222,7 @@ class _PathManagementDialog extends StatelessWidget {
Navigator.pop(context); Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text('Using ${path.hopCount} ${path.hopCount == 1 ? 'hop' : 'hops'} path'), content: Text(l10n.path_usingHopsPath(path.hopCount)),
duration: const Duration(seconds: 2), duration: const Duration(seconds: 2),
), ),
); );
@@ -232,13 +232,13 @@ class _PathManagementDialog extends StatelessWidget {
}), }),
const Divider(), const Divider(),
] else ...[ ] else ...[
const Text('No path history yet.\nSend a message to discover paths.'), Text(l10n.chat_noPathHistoryYet),
const Divider(), const Divider(),
], ],
const SizedBox(height: 8), const SizedBox(height: 8),
const Text( Text(
'Path Actions:', l10n.chat_pathActions,
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12), style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
ListTile( ListTile(
@@ -248,8 +248,8 @@ class _PathManagementDialog extends StatelessWidget {
backgroundColor: Colors.purple, backgroundColor: Colors.purple,
child: Icon(Icons.edit_road, size: 16), child: Icon(Icons.edit_road, size: 16),
), ),
title: const Text('Set Custom Path', style: TextStyle(fontSize: 14)), title: Text(l10n.chat_setCustomPath, style: const TextStyle(fontSize: 14)),
subtitle: const Text('Manually specify routing path', style: TextStyle(fontSize: 11)), subtitle: Text(l10n.chat_setCustomPathSubtitle, style: const TextStyle(fontSize: 11)),
onTap: () async { onTap: () async {
await _setCustomPath(context, connector, currentContact); await _setCustomPath(context, connector, currentContact);
}, },
@@ -261,15 +261,15 @@ class _PathManagementDialog extends StatelessWidget {
backgroundColor: Colors.orange, backgroundColor: Colors.orange,
child: Icon(Icons.clear_all, size: 16), child: Icon(Icons.clear_all, size: 16),
), ),
title: const Text('Clear Path', style: TextStyle(fontSize: 14)), title: Text(l10n.chat_clearPath, style: const TextStyle(fontSize: 14)),
subtitle: const Text('Force rediscovery on next send', style: TextStyle(fontSize: 11)), subtitle: Text(l10n.chat_clearPathSubtitle, style: const TextStyle(fontSize: 11)),
onTap: () async { onTap: () async {
await connector.clearContactPath(currentContact); await connector.clearContactPath(currentContact);
if (!context.mounted) return; if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( SnackBar(
content: Text('Path cleared. Next message will rediscover route.'), content: Text(l10n.chat_pathCleared),
duration: Duration(seconds: 2), duration: const Duration(seconds: 2),
), ),
); );
Navigator.pop(context); Navigator.pop(context);
@@ -282,15 +282,15 @@ class _PathManagementDialog extends StatelessWidget {
backgroundColor: Colors.blue, backgroundColor: Colors.blue,
child: Icon(Icons.waves, size: 16), child: Icon(Icons.waves, size: 16),
), ),
title: const Text('Force Flood Mode', style: TextStyle(fontSize: 14)), title: Text(l10n.chat_forceFloodMode, style: const TextStyle(fontSize: 14)),
subtitle: const Text('Use routing toggle in app bar', style: TextStyle(fontSize: 11)), subtitle: Text(l10n.chat_floodModeSubtitle, style: const TextStyle(fontSize: 11)),
onTap: () async { onTap: () async {
await connector.setPathOverride(currentContact, pathLen: -1); await connector.setPathOverride(currentContact, pathLen: -1);
if (!context.mounted) return; if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( SnackBar(
content: Text('Flood mode enabled. Toggle back via routing icon in app bar.'), content: Text(l10n.chat_floodModeEnabled),
duration: Duration(seconds: 2), duration: const Duration(seconds: 2),
), ),
); );
Navigator.pop(context); Navigator.pop(context);
@@ -302,7 +302,7 @@ class _PathManagementDialog extends StatelessWidget {
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
child: const Text('Close'), child: Text(l10n.common_close),
), ),
], ],
); );
+41 -38
View File
@@ -1,19 +1,20 @@
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../l10n/l10n.dart';
import '../models/contact.dart'; import '../models/contact.dart';
class PathSelectionDialog extends StatefulWidget { class PathSelectionDialog extends StatefulWidget {
final List<Contact> availableContacts; final List<Contact> availableContacts;
final String? initialPath;
final String title; final String title;
final String? initialPath;
final String? currentPathLabel; final String? currentPathLabel;
final VoidCallback? onRefresh; final VoidCallback? onRefresh;
const PathSelectionDialog({ const PathSelectionDialog({
super.key, super.key,
required this.availableContacts, required this.availableContacts,
required this.title,
this.initialPath, this.initialPath,
this.title = 'Enter Custom Path',
this.currentPathLabel, this.currentPathLabel,
this.onRefresh, this.onRefresh,
}); });
@@ -24,8 +25,8 @@ class PathSelectionDialog extends StatefulWidget {
static Future<Uint8List?> show( static Future<Uint8List?> show(
BuildContext context, { BuildContext context, {
required List<Contact> availableContacts, required List<Contact> availableContacts,
String? title,
String? initialPath, String? initialPath,
String title = 'Enter Custom Path',
String? currentPathLabel, String? currentPathLabel,
VoidCallback? onRefresh, VoidCallback? onRefresh,
}) { }) {
@@ -33,8 +34,8 @@ class PathSelectionDialog extends StatefulWidget {
context: context, context: context,
builder: (context) => PathSelectionDialog( builder: (context) => PathSelectionDialog(
availableContacts: availableContacts, availableContacts: availableContacts,
title: title ?? context.l10n.path_enterCustomPath,
initialPath: initialPath, initialPath: initialPath,
title: title,
currentPathLabel: currentPathLabel, currentPathLabel: currentPathLabel,
onRefresh: onRefresh, onRefresh: onRefresh,
), ),
@@ -98,6 +99,7 @@ class _PathSelectionDialogState extends State<PathSelectionDialog> {
} }
Future<void> _validateAndSubmit() async { Future<void> _validateAndSubmit() async {
final l10n = context.l10n;
final path = _controller.text.trim().toUpperCase(); final path = _controller.text.trim().toUpperCase();
if (path.isEmpty) { if (path.isEmpty) {
if (mounted) Navigator.pop(context); if (mounted) Navigator.pop(context);
@@ -130,7 +132,7 @@ class _PathSelectionDialogState extends State<PathSelectionDialog> {
if (invalidPrefixes.isNotEmpty) { if (invalidPrefixes.isNotEmpty) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text('Invalid hex prefixes: ${invalidPrefixes.join(", ")}'), content: Text(l10n.path_invalidHexPrefixes(invalidPrefixes.join(", "))),
duration: const Duration(seconds: 3), duration: const Duration(seconds: 3),
backgroundColor: Colors.red, backgroundColor: Colors.red,
), ),
@@ -141,9 +143,9 @@ class _PathSelectionDialogState extends State<PathSelectionDialog> {
// Check max path length (64 hops) // Check max path length (64 hops)
if (pathBytesList.length > 64) { if (pathBytesList.length > 64) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( SnackBar(
content: Text('Path too long. Maximum 64 hops allowed.'), content: Text(l10n.path_tooLong),
duration: Duration(seconds: 3), duration: const Duration(seconds: 3),
backgroundColor: Colors.red, backgroundColor: Colors.red,
), ),
); );
@@ -163,6 +165,7 @@ class _PathSelectionDialogState extends State<PathSelectionDialog> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = context.l10n;
return AlertDialog( return AlertDialog(
title: Text(widget.title), title: Text(widget.title),
content: SingleChildScrollView( content: SingleChildScrollView(
@@ -175,16 +178,16 @@ class _PathSelectionDialogState extends State<PathSelectionDialog> {
if (widget.currentPathLabel != null) ...[ if (widget.currentPathLabel != null) ...[
Row( Row(
children: [ children: [
const Text( Text(
'Current path', l10n.path_currentPathLabel,
style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold), style: const TextStyle(fontSize: 12, fontWeight: FontWeight.bold),
), ),
const Spacer(), const Spacer(),
if (widget.onRefresh != null) if (widget.onRefresh != null)
TextButton.icon( TextButton.icon(
onPressed: widget.onRefresh, onPressed: widget.onRefresh,
icon: const Icon(Icons.refresh, size: 16), icon: const Icon(Icons.refresh, size: 16),
label: const Text('Reload'), label: Text(l10n.common_reload),
), ),
], ],
), ),
@@ -194,23 +197,23 @@ class _PathSelectionDialogState extends State<PathSelectionDialog> {
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
], ],
const Text( Text(
'Enter 2-character hex prefixes for each hop, separated by commas.', l10n.path_hexPrefixInstructions,
style: TextStyle(fontSize: 12, color: Colors.grey), style: const TextStyle(fontSize: 12, color: Colors.grey),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
const Text( Text(
'Example: A1,F2,3C (each node uses first byte of its public key)', l10n.path_hexPrefixExample,
style: TextStyle(fontSize: 11, color: Colors.grey), style: const TextStyle(fontSize: 11, color: Colors.grey),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
TextField( TextField(
controller: _controller, controller: _controller,
decoration: const InputDecoration( decoration: InputDecoration(
labelText: 'Path (hex prefixes)', labelText: l10n.path_labelHexPrefixes,
hintText: 'A1,F2,3C', hintText: l10n.path_hexPrefixExample,
border: OutlineInputBorder(), border: const OutlineInputBorder(),
helperText: 'Max 64 hops. Each prefix is 2 hex characters (1 byte)', helperText: l10n.path_helperMaxHops,
), ),
textCapitalization: TextCapitalization.characters, textCapitalization: TextCapitalization.characters,
maxLength: 191, // 64 hops * 2 chars + 63 commas maxLength: 191, // 64 hops * 2 chars + 63 commas
@@ -220,36 +223,36 @@ class _PathSelectionDialogState extends State<PathSelectionDialog> {
const SizedBox(height: 8), const SizedBox(height: 8),
Row( Row(
children: [ children: [
const Text( Text(
'Or select from contacts:', l10n.path_selectFromContacts,
style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold), style: const TextStyle(fontSize: 12, fontWeight: FontWeight.bold),
), ),
const Spacer(), const Spacer(),
if (_selectedContacts.isNotEmpty) if (_selectedContacts.isNotEmpty)
TextButton( TextButton(
onPressed: _clearSelection, onPressed: _clearSelection,
child: const Text('Clear'), child: Text(l10n.common_clear),
), ),
], ],
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
if (_validContacts.isEmpty) ...[ if (_validContacts.isEmpty) ...[
const Center( Center(
child: Padding( child: Padding(
padding: EdgeInsets.all(16.0), padding: const EdgeInsets.all(16.0),
child: Column( child: Column(
children: [ children: [
Icon(Icons.info_outline, size: 48, color: Colors.grey), const Icon(Icons.info_outline, size: 48, color: Colors.grey),
SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
'No repeaters or room servers found.', l10n.path_noRepeatersFound,
style: TextStyle(fontSize: 14), style: const TextStyle(fontSize: 14),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
'Custom paths require intermediate hops that can relay messages.', l10n.path_customPathsRequire,
style: TextStyle(fontSize: 12, color: Colors.grey), style: const TextStyle(fontSize: 12, color: Colors.grey),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
], ],
@@ -300,11 +303,11 @@ class _PathSelectionDialogState extends State<PathSelectionDialog> {
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
child: const Text('Cancel'), child: Text(l10n.common_cancel),
), ),
TextButton( TextButton(
onPressed: _validateAndSubmit, onPressed: _validateAndSubmit,
child: const Text('Set Path'), child: Text(l10n.path_setPath),
), ),
], ],
); );
+233
View File
@@ -0,0 +1,233 @@
import 'package:flutter/material.dart';
import 'package:qr_flutter/qr_flutter.dart';
/// A reusable QR code display widget for sharing data.
///
/// Features:
/// - Configurable size and colors
/// - Optional logo/icon in center
/// - Automatic theming (light/dark mode aware)
/// - Title and instructions
class QrCodeDisplay extends StatelessWidget {
/// The data to encode in the QR code
final String data;
/// Size of the QR code (width and height)
final double size;
/// Optional widget to display in the center (e.g., app logo)
final Widget? embeddedImage;
/// Size of the embedded image (if provided)
final double embeddedImageSize;
/// Title displayed above the QR code
final String? title;
/// Instructions displayed below the QR code
final String? instructions;
/// Background color of the QR code (defaults to white)
final Color? backgroundColor;
/// Foreground color of the QR code modules (defaults to black)
final Color? foregroundColor;
/// Padding around the QR code
final EdgeInsets padding;
/// Error correction level
final int errorCorrectionLevel;
const QrCodeDisplay({
super.key,
required this.data,
this.size = 200,
this.embeddedImage,
this.embeddedImageSize = 50,
this.title,
this.instructions,
this.backgroundColor,
this.foregroundColor,
this.padding = const EdgeInsets.all(16),
this.errorCorrectionLevel = QrErrorCorrectLevel.M,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
// Default colors based on theme
final bgColor = backgroundColor ?? Colors.white;
final fgColor = foregroundColor ?? Colors.black;
return Padding(
padding: padding,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (title != null) ...[
Text(
title!,
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
],
// QR code container with rounded corners
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: bgColor,
borderRadius: BorderRadius.circular(16),
boxShadow: isDark
? null
: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.1),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: embeddedImage != null
? _buildQrWithEmbeddedImage(fgColor, bgColor)
: _buildSimpleQr(fgColor, bgColor),
),
if (instructions != null) ...[
const SizedBox(height: 16),
Text(
instructions!,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
],
],
),
);
}
Widget _buildSimpleQr(Color fgColor, Color bgColor) {
return QrImageView(
data: data,
version: QrVersions.auto,
size: size,
backgroundColor: bgColor,
errorCorrectionLevel: errorCorrectionLevel,
eyeStyle: QrEyeStyle(
eyeShape: QrEyeShape.square,
color: fgColor,
),
dataModuleStyle: QrDataModuleStyle(
dataModuleShape: QrDataModuleShape.square,
color: fgColor,
),
);
}
Widget _buildQrWithEmbeddedImage(Color fgColor, Color bgColor) {
return Stack(
alignment: Alignment.center,
children: [
QrImageView(
data: data,
version: QrVersions.auto,
size: size,
backgroundColor: bgColor,
// Use higher error correction when embedding image
errorCorrectionLevel: QrErrorCorrectLevel.H,
eyeStyle: QrEyeStyle(
eyeShape: QrEyeShape.square,
color: fgColor,
),
dataModuleStyle: QrDataModuleStyle(
dataModuleShape: QrDataModuleShape.square,
color: fgColor,
),
),
Container(
width: embeddedImageSize,
height: embeddedImageSize,
decoration: BoxDecoration(
color: bgColor,
borderRadius: BorderRadius.circular(8),
),
padding: const EdgeInsets.all(4),
child: embeddedImage,
),
],
);
}
}
/// Dialog to display a QR code for sharing
class QrCodeShareDialog extends StatelessWidget {
final String data;
final String? title;
final String? instructions;
final Widget? embeddedImage;
const QrCodeShareDialog({
super.key,
required this.data,
this.title,
this.instructions,
this.embeddedImage,
});
@override
Widget build(BuildContext context) {
return Dialog(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
QrCodeDisplay(
data: data,
size: 250,
title: title,
instructions: instructions,
embeddedImage: embeddedImage,
padding: EdgeInsets.zero,
),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: () => Navigator.pop(context),
child: const Text('Done'),
),
),
],
),
),
);
}
/// Show the dialog
static Future<void> show({
required BuildContext context,
required String data,
String? title,
String? instructions,
Widget? embeddedImage,
}) {
return showDialog(
context: context,
builder: (context) => QrCodeShareDialog(
data: data,
title: title,
instructions: instructions,
embeddedImage: embeddedImage,
),
);
}
}
+391
View File
@@ -0,0 +1,391 @@
import 'package:flutter/material.dart';
import 'package:mobile_scanner/mobile_scanner.dart';
/// A reusable QR code scanner widget that can be embedded anywhere.
///
/// Features:
/// - Configurable scan window overlay
/// - Flash toggle button
/// - Camera switch button (front/back)
/// - Customizable callbacks for scan results
/// - Optional validation function for QR data
/// - Automatic pause when not visible
/// - Debouncing to prevent duplicate scans
class QrScannerWidget extends StatefulWidget {
/// Called when a valid QR code is scanned
final void Function(String data) onScanned;
/// Optional validator - return true if the QR data is valid
final bool Function(String data)? validator;
/// Optional error callback when validation fails
final void Function(String data)? onValidationFailed;
/// Whether to show the flash toggle button
final bool showFlashButton;
/// Whether to show the camera switch button
final bool showCameraSwitchButton;
/// Custom overlay widget (defaults to scan window frame)
final Widget? overlay;
/// Instructions text shown below the scan window
final String? instructions;
/// Whether to continue scanning after first successful scan
final bool continuousScanning;
/// Debounce duration to prevent duplicate scans
final Duration debounceDuration;
const QrScannerWidget({
super.key,
required this.onScanned,
this.validator,
this.onValidationFailed,
this.showFlashButton = true,
this.showCameraSwitchButton = true,
this.overlay,
this.instructions,
this.continuousScanning = false,
this.debounceDuration = const Duration(milliseconds: 500),
});
@override
State<QrScannerWidget> createState() => _QrScannerWidgetState();
}
class _QrScannerWidgetState extends State<QrScannerWidget>
with WidgetsBindingObserver {
late MobileScannerController _controller;
bool _hasScanned = false;
String? _lastScannedData;
DateTime? _lastScanTime;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_controller = MobileScannerController(
detectionSpeed: DetectionSpeed.normal,
facing: CameraFacing.back,
);
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_controller.dispose();
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
// Handle app lifecycle changes - pause/resume scanner
if (!_controller.value.hasCameraPermission) return;
switch (state) {
case AppLifecycleState.resumed:
_controller.start();
break;
case AppLifecycleState.inactive:
case AppLifecycleState.paused:
case AppLifecycleState.detached:
case AppLifecycleState.hidden:
_controller.stop();
break;
}
}
void _handleDetection(BarcodeCapture capture) {
// Prevent duplicate scans
if (_hasScanned && !widget.continuousScanning) return;
final List<Barcode> barcodes = capture.barcodes;
for (final barcode in barcodes) {
final String? rawValue = barcode.rawValue;
if (rawValue == null || rawValue.isEmpty) continue;
// Debounce - ignore if same data scanned too quickly
final now = DateTime.now();
if (_lastScannedData == rawValue &&
_lastScanTime != null &&
now.difference(_lastScanTime!) < widget.debounceDuration) {
continue;
}
_lastScannedData = rawValue;
_lastScanTime = now;
// Validate if validator provided
if (widget.validator != null && !widget.validator!(rawValue)) {
widget.onValidationFailed?.call(rawValue);
continue;
}
// Mark as scanned to prevent duplicates
if (!widget.continuousScanning) {
setState(() {
_hasScanned = true;
});
_controller.stop();
}
// Notify callback
widget.onScanned(rawValue);
return;
}
}
/// Reset the scanner to allow scanning again
void resetScanner() {
setState(() {
_hasScanned = false;
_lastScannedData = null;
_lastScanTime = null;
});
_controller.start();
}
@override
Widget build(BuildContext context) {
return Stack(
children: [
// Scanner view
MobileScanner(
controller: _controller,
onDetect: _handleDetection,
errorBuilder: (context, error, child) {
return _buildErrorWidget(context, error);
},
),
// Overlay
widget.overlay ?? _buildDefaultOverlay(context),
// Control buttons
Positioned(
bottom: 16,
left: 0,
right: 0,
child: _buildControls(context),
),
],
);
}
Widget _buildDefaultOverlay(BuildContext context) {
return ColorFiltered(
colorFilter: ColorFilter.mode(
Colors.black.withValues(alpha: 0.5),
BlendMode.srcOut,
),
child: Stack(
fit: StackFit.expand,
children: [
Container(
decoration: const BoxDecoration(
color: Colors.black,
backgroundBlendMode: BlendMode.dstOut,
),
),
Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
height: 250,
width: 250,
decoration: BoxDecoration(
color: Colors.red, // This color is used for cutout
borderRadius: BorderRadius.circular(16),
),
),
if (widget.instructions != null) ...[
const SizedBox(height: 24),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 12,
),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.7),
borderRadius: BorderRadius.circular(8),
),
child: Text(
widget.instructions!,
style: const TextStyle(
color: Colors.white,
fontSize: 14,
),
textAlign: TextAlign.center,
),
),
],
],
),
),
],
),
);
}
Widget _buildControls(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (widget.showFlashButton)
ValueListenableBuilder(
valueListenable: _controller,
builder: (context, state, child) {
return IconButton.filled(
onPressed: () => _controller.toggleTorch(),
icon: Icon(
state.torchState == TorchState.on
? Icons.flash_on
: Icons.flash_off,
),
style: IconButton.styleFrom(
backgroundColor: Colors.black54,
foregroundColor: Colors.white,
),
);
},
),
if (widget.showFlashButton && widget.showCameraSwitchButton)
const SizedBox(width: 24),
if (widget.showCameraSwitchButton)
IconButton.filled(
onPressed: () => _controller.switchCamera(),
icon: const Icon(Icons.cameraswitch),
style: IconButton.styleFrom(
backgroundColor: Colors.black54,
foregroundColor: Colors.white,
),
),
],
);
}
Widget _buildErrorWidget(BuildContext context, MobileScannerException error) {
String message;
IconData icon;
switch (error.errorCode) {
case MobileScannerErrorCode.permissionDenied:
message = 'Camera permission denied.\nPlease enable camera access in settings.';
icon = Icons.no_photography;
break;
case MobileScannerErrorCode.unsupported:
message = 'Camera not supported on this device.';
icon = Icons.videocam_off;
break;
default:
message = 'Failed to start camera.\n${error.errorDetails?.message ?? ''}';
icon = Icons.error_outline;
}
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 64, color: Colors.grey),
const SizedBox(height: 16),
Text(
message,
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.grey[600],
fontSize: 16,
),
),
],
),
),
);
}
}
/// A simpler scanner overlay with just corner brackets
class ScannerCornerOverlay extends StatelessWidget {
final double scanWindowSize;
final Color borderColor;
final double borderWidth;
final double cornerLength;
const ScannerCornerOverlay({
super.key,
this.scanWindowSize = 250,
this.borderColor = Colors.white,
this.borderWidth = 3,
this.cornerLength = 30,
});
@override
Widget build(BuildContext context) {
return Center(
child: SizedBox(
width: scanWindowSize,
height: scanWindowSize,
child: CustomPaint(
painter: _CornerPainter(
color: borderColor,
strokeWidth: borderWidth,
cornerLength: cornerLength,
),
),
),
);
}
}
class _CornerPainter extends CustomPainter {
final Color color;
final double strokeWidth;
final double cornerLength;
_CornerPainter({
required this.color,
required this.strokeWidth,
required this.cornerLength,
});
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = color
..strokeWidth = strokeWidth
..style = PaintingStyle.stroke
..strokeCap = StrokeCap.round;
final path = Path();
// Top-left corner
path.moveTo(0, cornerLength);
path.lineTo(0, 0);
path.lineTo(cornerLength, 0);
// Top-right corner
path.moveTo(size.width - cornerLength, 0);
path.lineTo(size.width, 0);
path.lineTo(size.width, cornerLength);
// Bottom-right corner
path.moveTo(size.width, size.height - cornerLength);
path.lineTo(size.width, size.height);
path.lineTo(size.width - cornerLength, size.height);
// Bottom-left corner
path.moveTo(cornerLength, size.height);
path.lineTo(0, size.height);
path.lineTo(0, size.height - cornerLength);
canvas.drawPath(path, paint);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}
+8 -7
View File
@@ -1,6 +1,7 @@
import 'dart:ui'; import 'dart:ui';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../l10n/l10n.dart';
class QuickSwitchBar extends StatelessWidget { class QuickSwitchBar extends StatelessWidget {
final int selectedIndex; final int selectedIndex;
@@ -59,18 +60,18 @@ class QuickSwitchBar extends StatelessWidget {
height: 60, height: 60,
selectedIndex: selectedIndex, selectedIndex: selectedIndex,
onDestinationSelected: onDestinationSelected, onDestinationSelected: onDestinationSelected,
destinations: const [ destinations: [
NavigationDestination( NavigationDestination(
icon: Icon(Icons.people_outline), icon: const Icon(Icons.people_outline),
label: 'Contacts', label: context.l10n.nav_contacts,
), ),
NavigationDestination( NavigationDestination(
icon: Icon(Icons.tag), icon: const Icon(Icons.tag),
label: 'Channels', label: context.l10n.nav_channels,
), ),
NavigationDestination( NavigationDestination(
icon: Icon(Icons.map_outlined), icon: const Icon(Icons.map_outlined),
label: 'Map', label: context.l10n.nav_map,
), ),
], ],
), ),

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