Compare commits

...

76 Commits

Author SHA1 Message Date
446564 87a2807f5b chore: update version to alpha 6 2026-02-08 18:56:24 -08:00
Ryan Malloy daca42701c Notification rate limiting (#110)
* Add notification rate limiting with privacy-safe debug logging

- Add batching system to prevent notification storms (3s rate limit, 5s batch window)
- Queue rapid notifications and show batch summaries
- Debug logs show device names for adverts, sender/channel for messages (no content leaks)
- Remove unused _maxBatchSize constant

Context: Added after getting notification-flooded while evaluating RF flood management. The irony.

* Update notification_service.dart

I made a mistake and removed this

* Add l10n support for notification strings

Addresses PR #110 review feedback to use the translations system:
- Add notification strings to app_en.arb (plurals for batch summary)
- Update NotificationService to use lookupAppLocalizations()
- Wire locale from MaterialApp to NotificationService
- Regenerate localization files

New strings added (English only, translations needed):
- notification_activityTitle: "MeshCore Activity"
- notification_messagesCount: "{count} message(s)"
- notification_channelMessagesCount: "{count} channel message(s)"
- notification_newNodesCount: "{count} new node(s)"
- notification_newTypeDiscovered: "New {type} discovered"
- notification_receivedNewMessage: "Received new message"

* Add notification string translations for all supported languages

Translated notification_activityTitle, notification_messagesCount,
notification_channelMessagesCount, notification_newNodesCount,
notification_newTypeDiscovered, and notification_receivedNewMessage
to: bg, de, es, fr, it, nl, pl, pt, ru, sk, sl, sv, uk, zh

Includes proper ICU plural forms for Slavic languages (few/many/other)
and Slovenian dual form.

* Apply dart format to notification_service.dart

---------

Co-authored-by: Winston Lowe <wel97459@gmail.com>
2026-02-08 18:42:15 -08:00
Ded ea43cf17eb reduce map marker size (#131)
* reduce map marker size

reduces map markers from 80 to 60 px to improve visibility with higher density areas

* add flutter test to actions

* Add GPX export functionality and related UI components

* Refactor GPX export constants to use lowercase naming convention and improve export function error handling

* ran formating

* Enhance GPX export functionality with customizable parameters and improved metadata

* Implement PathTraceMapScreen and refactor path tracing functionality across screens

* Add localization for missing location error in path tracing

* Updated GPX export functionality for contacts and repeaters in multiple languages.

* Add scrollbar to path trace details list for improved navigation

* Integrate SharePlus plugin for enhanced sharing functionality across platforms

* reduce map marker size

reduces map markers from 80 to 60 px to improve visibility with higher density areas

* reduce marker size to improve map clarity and add path trace navigation to path management

---------

Co-authored-by: Winston Lowe <wel97459@gmail.com>
2026-02-08 18:40:58 -08:00
Ded 8ef6e2c656 Merge pull request #130 from zjs81/path-map-rotation
remove rotation in path map
2026-02-08 18:39:48 -08:00
Winston Lowe 24de98d5ee Merge pull request #134 from zjs81/dev-gpx
Added a export to a GPX
2026-02-08 17:15:26 -08:00
Winston Lowe 0fd841b5b5 Merge branch 'main' into dev-gpx 2026-02-08 17:13:18 -08:00
Winston Lowe c365b7889b Merge pull request #141 from zjs81/dev-NewPathTracing
Implement PathTraceMapScreen and refactor path tracing functionality
2026-02-08 17:10:16 -08:00
Winston Lowe 2db30ace6a Integrate SharePlus plugin for enhanced sharing functionality across platforms 2026-02-08 12:26:49 -08:00
Winston Lowe 0d8801fa75 Add scrollbar to path trace details list for improved navigation 2026-02-08 12:25:51 -08:00
Winston Lowe bcae6ac19f Updated GPX export functionality for contacts and repeaters in multiple languages. 2026-02-08 12:14:03 -08:00
Winston Lowe 2f4b230b31 Add localization for missing location error in path tracing 2026-02-08 11:57:04 -08:00
Winston Lowe 98e0b05e73 Implement PathTraceMapScreen and refactor path tracing functionality across screens 2026-02-08 11:32:36 -08:00
Winston Lowe 2a909e6081 Enhance GPX export functionality with customizable parameters and improved metadata 2026-02-07 19:45:02 -08:00
Winston Lowe d1009d3c20 ran formating 2026-02-07 11:07:57 -08:00
Ded 91b1696bc5 Merge pull request #132 from zjs81/add-test-action
add flutter test to actions
2026-02-07 08:33:16 -08:00
Winston Lowe 978ea4790d Refactor GPX export constants to use lowercase naming convention and improve export function error handling 2026-02-05 13:46:05 -08:00
Winston Lowe 8b1228bf8d Add GPX export functionality and related UI components 2026-02-05 13:38:49 -08:00
446564 ddee76ced2 add flutter test to actions 2026-02-05 09:40:31 -08:00
446564 6a3c59fa2c remove rotation in path map
when zooming on the path map view window the rotation was too easy to trigger and
provided little value to understanding the path
2026-02-05 09:24:24 -08:00
Ded a54cc78691 Merge pull request #129 from zjs81/remove-msg-prefix
remove direct msg notification prefix
2026-02-05 08:58:43 -08:00
446564 05fb5a13fa remove direct msg notification prefix
The prefix "New message from " takes up a lot of space and was not localized anyway.
2026-02-05 08:33:07 -08:00
zjs81 c320378be1 Refactor unread message tracking and implement channel caching (#126)
* Refactor unread message tracking and implement channel caching

* formatted files
2026-02-04 20:34:03 -07:00
Ded b3645481c7 Merge pull request #125 from zjs81/reduce-build-steps
stop building twice for pull requests on branches from this repo
2026-02-04 12:42:24 -08:00
Ded 589707aa13 Merge pull request #123 from zjs81/dart-format
This formats the project and adds a workflow to check that each contribution has also been formatted.

plus small fix to get rid of analyzer errors which could be changed to warnings but that's another day.
2026-02-04 12:41:46 -08:00
446564 6070802213 stop building twice for pull requests
we should only run the build steps on a pull request OR a push to main
2026-02-04 09:02:03 -08:00
446564 2525b9425b reduce jobs for flutter and dart
no need to setup the env twice the exact same way as they don't conflict
2026-02-04 08:59:29 -08:00
446564 b786c90514 combine flutter and dart actions
reduce time to complete and stop running twice for pull requests
2026-02-04 08:56:40 -08:00
446564 a35590a407 fix dart format workflow install deps step
needs to use flutter pub get not dart pub get
2026-02-04 08:40:19 -08:00
446564 8d15f7cef6 wrap returns from if blocks
fixes two analyzer errors for return blocks on new lines from if blocks
2026-02-04 08:34:37 -08:00
446564 e449f5e1d5 add dart format workflow
checks code has been formatted with dart format on push and pull request

adds a note in README for contributors
2026-02-04 08:33:49 -08:00
446564 b34d684e67 format dart files
formats all dart files using `dart format .` from the root project dir

this makes the code style repeatable by new contributors and makes PR review easier
2026-02-04 08:32:35 -08:00
Ded 488a286701 Merge pull request #59 from 446564/community-#-names
add community to hashtag channel name
2026-02-03 20:08:42 -08:00
Zach c742d98fbb issue #112 fixes and more 2026-02-01 18:37:14 -07:00
zjs81 1d4c9ad9bd Merge pull request #115 from zjs81/advert-intervals
allow disable repeater adverts
2026-02-01 17:10:46 -07:00
Zach 818f514702 The first issue was that the toggle switch states weren't being initialized when settings were refreshed from the device. The code would correctly update the interval values themselves, but failed to set the corresponding boolean flags that control whether the toggles appear as "on" or "off". This meant that if you refreshed settings from a device that had advertisements disabled (with an interval of zero), the toggles would incorrectly show as enabled even though the device was actually broadcasting no advertisements. We fixed this by adding two lines that explicitly set _advertEnable = _advertInterval > 0 and _floodAdvertEnable = _floodAdvertInterval > 0 after parsing the interval values from device responses.
The second critical bug was in the validation logic that checks whether responses from the device contain valid data. The validator was rejecting any interval values of zero because it checked interval > 0, but zero is now a meaningful and valid value that indicates advertisements are disabled. Without this fix, any time a device reported back that advertisements were disabled, the app would silently discard that information as invalid, leaving the UI out of sync with reality. We changed the validation to use interval >= 0 instead and updated the comment to explicitly document that zero means disabled.

The third fix was a minor code style issue where a single-line if statement was missing braces, causing a linter warning. This doesn't affect functionality but ensures the code meets project standards.
2026-02-01 17:08:53 -07:00
Zach be54419e5b Merge remote-tracking branch 'origin/main' into advert-intervals 2026-02-01 17:03:53 -07:00
zjs81 00eb1a68a6 Merge pull request #118 from wel97459/dev-shareContact
Adds contact shearing
2026-02-01 16:59:21 -07:00
Zach 79ffc21bd6 fix commit 2026-02-01 16:57:17 -07:00
Zach 0374f4f5da Merge remote-tracking branch 'origin/main' into dev-shareContact 2026-02-01 14:18:35 -07:00
Winston Lowe 4650584f9b Merge pull request #117 from wel97459/dev-reconnection
This cures a race condition that was messing up the disconnection handler.
Before the bluetooth device was fully connected _handleDisconnection() was being called from the lisener.
2026-01-31 22:28:35 -08:00
Winston Lowe 8d8b938878 Ran translation script 2026-01-31 22:19:01 -08:00
Ded e3a0bd3b13 Merge pull request #114 from zjs81/obtainum-btn
add obtainium badge
2026-01-31 20:07:08 -08:00
446564 4f83d87f8c use switch for advert enable/disable
move style to align with other toggles and use a switch instead of a checkbox
2026-01-31 17:07:24 -08:00
Winston Lowe 6d7d51f0a4 _requestDeviceInfo added isConnected not already _awaitingSelfInfo 2026-01-31 16:03:05 -08:00
Winston Lowe 33680f0cb9 Replace action buttons with a popup menu for better UI/UX on channels and map screens 2026-01-31 15:25:34 -08:00
Winston Lowe 5115d8bbe3 Added zero-hop contact sharing functionality and related UI updates 2026-01-31 15:00:33 -08:00
Winston Lowe d30e7c4e2c Prevent disconnection handling when already disconnected, curing a race condition. 2026-01-31 14:55:55 -08:00
Winston Lowe 8470171e88 Merge branch 'dev-shareContacts' into dev-shareContact 2026-01-31 08:02:35 -08:00
446564 ede3142d40 allow disable repeater adverts
Adds checkbox to disable adverts and flood adverts

Also updates flood avert range to new max of 168 hours
2026-01-30 11:05:57 -08:00
446564 6712088fcd add obtainium badge
allow users to easily add app to obtainium
https://apps.obtainium.imranr.dev
2026-01-30 08:44:03 -08:00
Ded 7b519854d7 Merge branch 'main' into community-#-names 2026-01-29 08:07:05 -08:00
Zach 90ce46392a feat: optimize reaction message format to reduce airtime
- Reduce reaction payload from ~44 bytes to 9 bytes (5x smaller)
- Use 4-char hex hash (timestamp + sender + first 5 chars) for message ID
- Use 2-char hex emoji index instead of multi-byte UTF-8 emoji
- Format: r:HASH:INDEX (e.g., r:a1b2:00)
- For 1:1 chats, sender is implicit (null) for shorter hash
- Prevent users from reacting to their own messages
- Add room server reaction support with sender identification
- Make emoji lists public in EmojiPicker for shared indexing
- Add 💪 and 🚀 emojis to picker
- Add comprehensive unit tests for reaction helpers
- Update minor dependencies
2026-01-28 23:21:04 -07:00
Zach d61ec217fc feat: add Russian and Ukrainian to language selector
These languages had translation files but were missing from the
settings UI. Adds appSettings_languageRu and appSettings_languageUk
strings and corresponding RadioListTile entries.

Fixes missing languages in app settings.
2026-01-28 22:26:14 -07:00
Zach 3ac81a5448 Merge origin/main into pr-106
Resolve conflict in app_de.arb: keep improved German translation
for community_updateSecret while adding path trace strings from main.
2026-01-28 22:22:43 -07:00
zjs81 7004067839 Merge pull request #108 from wel97459/dev-pathtrace
Path tracing. This adds support to ping and trace route repeaters and room server.
2026-01-28 22:07:14 -07:00
Zach 935b7b07eb Add path trace localizations for all languages
- Translate path trace strings to all 14 supported locales
- Regenerate localization Dart files
- Fix translate.py to also detect empty string values as missing
2026-01-28 22:05:04 -07:00
Zach cdacc54421 Merge remote-tracking branch 'origin/main' into dev-pathtrace 2026-01-28 21:43:07 -07:00
zjs81 bf8f002d55 Merge pull request #111 from wel97459/dev-reconntion
Added disconnection handling, and fixed state changing of navigation on connection.
2026-01-28 21:39:42 -07:00
Zach 998ff50495 fix: restore _handleDisconnection() on battery request failure
This was the author's original intent - use battery request failure
as a signal that the connection is lost.
2026-01-28 21:34:13 -07:00
zjs81 c953a1a798 Merge pull request #105 from erikklavora/main
Updated Slovenian lang
2026-01-28 20:53:49 -07:00
Winston Lowe 42115bf200 Refactor contact handling and enhance UI with new advert options and localized strings 2026-01-28 11:04:34 -08:00
Winston Lowe d0c8fab6fb Add contact import functionality and update UI feedback for import status 2026-01-28 10:19:42 -08:00
Winston Lowe eeb8ff34e8 Implement contact import functionality from clipboard and add relevant UI options 2026-01-26 16:11:21 -08:00
Winston Lowe 641307a316 Added response code for exporting contacts and implement frame listener in contacts_screen.dart 2026-01-26 12:19:45 -08:00
Winston Lowe c37abb63e3 add export and import contact frame builders in meshcore_protocol.dart and implement contact export functionality in contacts_screen.dart 2026-01-26 11:56:42 -08:00
Winston Lowe 898ef1c11c Refactor autofocus logic in login dialogs for better platform handling 2026-01-26 10:40:10 -08:00
Winston Lowe 749f9d4dfd cleaned up. 2026-01-25 12:00:38 -08:00
Winston Lowe 9c1b5899fb Added scroll view to room server login.
Disabled autofocus of password.
2026-01-25 11:55:55 -08:00
Winston Lowe cacb9bc677 Moved all the path tracing logic to the dialog.
refactored repeater hub along with contacts screen to use shortPubKeyHex.
Added localization strings for path tracing, english only.
2026-01-25 10:58:00 -08:00
Winston Lowe 0ebd688787 Added shortPubKeyHex
and added a trace route builder traceRouteBytes
2026-01-25 10:53:28 -08:00
ericz bb18038f60 removed truncation of notification as in Issue #107 2026-01-25 11:40:02 +01:00
Winston Lowe fcf741b20a Got the basic path tracing working. 2026-01-24 20:36:14 -08:00
ericz 88aa104ae5 further translation fixes for german 2026-01-24 18:05:10 +01:00
erikklavora 90f90ad7cf Updated Slovenian lang 2026-01-24 17:05:01 +01:00
Winston Lowe 2089613696 Added the basics for path tracing 2026-01-22 23:42:10 -08:00
446564 4003519deb add community to hashtag channel name
brings behavior in line with community public channels and prefixes the community name

this allows users to use the same radio with multiple clients and be able to tell which
hashtag channel they are using i.e. Scouts #leaders, where previous it was just a private
chanel named #leaders.
2026-01-20 15:26:41 -08:00
115 changed files with 11693 additions and 4074 deletions
+2
View File
@@ -2,6 +2,8 @@ name: Build
on:
push:
branches:
- main
pull_request:
jobs:
@@ -1,8 +1,10 @@
name: Flutter Analyze
name: Flutter and Dart
on:
pull_request:
push:
branches:
- main
jobs:
analyze:
@@ -19,5 +21,11 @@ jobs:
- name: Install dependencies
run: flutter pub get
- name: Analyze
- name: Analyze code
run: flutter analyze --fatal-infos --fatal-warnings
- name: Verify formatting
run: dart format --output=none --set-exit-if-changed .
- name: Run tests
run: flutter test -r github
+27 -2
View File
@@ -6,6 +6,10 @@ Open-source Flutter client for MeshCore LoRa mesh networking devices.
MeshCore Open is a cross-platform mobile application for communicating with MeshCore LoRa mesh network devices via Bluetooth Low Energy (BLE). The app enables long-range, off-grid communication through peer-to-peer messaging, public channels, and mesh networking capabilities.
<a href="http://apps.obtainium.imranr.dev/redirect.html?r=obtainium://add/https://github.com/zjs81/meshcore-open">
<img src="assets/badges/badge_obtainium.png" height="80" align="center" alt="Get it on Obtainium"/>
</a>
## Screenshots
<table>
@@ -21,6 +25,7 @@ MeshCore Open is a cross-platform mobile application for communicating with Mesh
## Features
### Core Functionality
- **Direct Messaging**: Private encrypted conversations with individual contacts
- **Public Channels**: Broadcast messages to channel subscribers on the mesh network
- **Contact Management**: Organize contacts, track last seen times, and manage conversation history
@@ -29,6 +34,7 @@ MeshCore Open is a cross-platform mobile application for communicating with Mesh
- **Message Replies**: Thread conversations with inline reply functionality
### Mesh Network
- **Path Visualization**: View routing paths and signal quality for each contact
- **Route Management**: Manual path overriding and automatic route rotation
- **Signal Metrics**: Real-time SNR (Signal-to-Noise Ratio) tracking
@@ -36,6 +42,7 @@ MeshCore Open is a cross-platform mobile application for communicating with Mesh
- **Repeater Support**: Connect to and manage repeater nodes for extended range
### Map & Location
- **Live Map View**: Real-time visualization of mesh network nodes on an interactive map
- **Node Filtering**: Filter by node type (chat, repeater, sensor) and time range
- **Location Sharing**: Share GPS coordinates and custom markers with contacts
@@ -43,12 +50,14 @@ MeshCore Open is a cross-platform mobile application for communicating with Mesh
- **MGRS Coordinates**: Support for Military Grid Reference System coordinate format
### Device Management
- **BLE Connection**: Scan and connect to MeshCore devices via Bluetooth
- **Device Settings**: Configure radio parameters, power settings, and network options
- **Battery Monitoring**: Real-time battery status with chemistry-specific voltage curves
- **Firmware Updates**: Over-the-air firmware updates via BLE (coming soon)
### Repeater Hub
- **CLI Access**: Full command-line interface to repeater nodes
- **Settings Management**: Configure repeater behavior, power limits, and network settings
- **Statistics Dashboard**: View repeater traffic, connected clients, and system health
@@ -57,6 +66,7 @@ MeshCore Open is a cross-platform mobile application for communicating with Mesh
## Technical Details
### Architecture
- **Framework**: Flutter 3.38.5 / Dart 3.10.4
- **State Management**: Provider pattern with ChangeNotifier
- **BLE Protocol**: Nordic UART Service (NUS) over Bluetooth Low Energy
@@ -64,11 +74,13 @@ MeshCore Open is a cross-platform mobile application for communicating with Mesh
- **Encryption**: End-to-end encryption for private messages using the MeshCore protocol
### Platform Support
-**Android**: Full support (API 21+)
-**iOS**: Full support (iOS 12+)
- 🚧 **Desktop**: Limited support (macOS/Linux/Windows)
### Dependencies
| Package | Purpose |
|---------|---------|
| flutter_blue_plus | Bluetooth Low Energy communication |
@@ -84,6 +96,7 @@ MeshCore Open is a cross-platform mobile application for communicating with Mesh
## Getting Started
### Prerequisites
- Flutter SDK 3.38.5 or later
- Android Studio / Xcode (for mobile development)
- A MeshCore-compatible LoRa device
@@ -91,17 +104,20 @@ MeshCore Open is a cross-platform mobile application for communicating with Mesh
### Installation
1. **Clone the repository**
```bash
git clone https://github.com/zjs81/meshcore-open.git
cd meshcore-open
```
2. **Install dependencies**
```bash
flutter pub get
```
3. **Run the app**
```bash
flutter run
```
@@ -109,11 +125,13 @@ MeshCore Open is a cross-platform mobile application for communicating with Mesh
### Building for Release
**Android APK:**
```bash
flutter build apk --release
```
**iOS:**
```bash
flutter build ios --release
```
@@ -152,25 +170,30 @@ lib/
## BLE Protocol
### Nordic UART Service (NUS)
- **Service UUID**: `6e400001-b5a3-f393-e0a9-e50e24dcca9e`
- **RX Characteristic**: `6e400002-b5a3-f393-e0a9-e50e24dcca9e` (Write to device)
- **TX Characteristic**: `6e400003-b5a3-f393-e0a9-e50e24dcca9e` (Notify from device)
### Device Discovery
Devices are discovered by scanning for BLE advertisements with the name prefix `MeshCore-`
### Message Format
Messages are transmitted as binary frames using a custom protocol optimized for LoRa transmission. See `meshcore_protocol.dart` for frame structure definitions.
## Configuration
### App Settings
- **Theme**: System default, light, or dark mode
- **Notifications**: Configurable for messages, channels, and node advertisements
- **Battery Chemistry**: Support for NMC, LiFePO4, and LiPo battery types
- **Message Retry**: Automatic retry with configurable path clearing
### Device Settings
- **Radio Power**: Transmit power adjustment (10-30 dBm)
- **Frequency**: LoRa frequency configuration
- **Bandwidth**: Channel bandwidth selection
@@ -182,22 +205,24 @@ Messages are transmitted as binary frames using a custom protocol optimized for
This is an open-source project. Contributions are welcome!
### Development Guidelines
- Follow the Flutter style guide
- Use Material 3 design components
- Write clear commit messages
- Test on both Android and iOS before submitting PRs
### Code Style
- Prefer `StatelessWidget` with `Consumer` for reactive UI
- Use `const` constructors where possible
- Keep functions small and focused
- Avoid premature abstractions
- Run dart format on all changes before submitting
## Support
For issues, questions, or feature requests, please open an issue on GitHub:
https://github.com/zjs81/meshcore-open/issues
<https://github.com/zjs81/meshcore-open/issues>
## Donate
+244
View File
@@ -0,0 +1,244 @@
# TestFlight and App Store Deployment Guide
## Prerequisites
- [x] Apple Developer Account ($99/year) - [developer.apple.com](https://developer.apple.com)
- [x] Xcode installed
- [x] Apple Transporter app installed
- [x] App icons ready (1024x1024px)
- [x] Bundle ID configured: `com.monitormx.meshcoreopen`
## Step 1: Register Bundle Identifier
1. Go to [Apple Developer - Identifiers](https://developer.apple.com/account/resources/identifiers/list)
2. Click the **"+"** button
3. Select **"App IDs"** → Continue
4. Select **"App"** → Continue
5. Fill in:
- **Description**: Meshcore Open
- **Bundle ID**: Explicit - `com.monitormx.meshcoreopen`
- **Capabilities**: Leave defaults (or add as needed)
6. Click **Continue****Register**
## Step 2: Create App in App Store Connect
1. Go to [App Store Connect](https://appstoreconnect.apple.com)
2. Sign in with your Apple ID
3. Click **"My Apps"**
4. Click the **"+"** button → **"New App"**
5. Fill in the form:
- **Platforms**: iOS
- **Name**: Meshcore Open
- **Primary Language**: English (U.S.)
- **Bundle ID**: Select `com.monitormx.meshcoreopen` from dropdown
- **SKU**: `meshcore-open-001` (or any unique identifier)
- **User Access**: Full Access
6. Click **"Create"**
## Step 3: Build the IPA
Run these commands from the project directory:
```bash
# Add CocoaPods to PATH
export PATH="/opt/homebrew/lib/ruby/gems/4.0.0/bin:$PATH"
# Clean previous builds
../flutter/bin/flutter clean
# Build IPA for App Store
../flutter/bin/flutter build ipa
```
The IPA will be created at: `build/ios/ipa/meshcore_open.ipa`
## Step 4: Upload to App Store Connect via Transporter
1. **Open Apple Transporter**
- Launch from Applications folder
- Sign in with your Apple ID
2. **Upload the IPA**
- Drag and drop `build/ios/ipa/meshcore_open.ipa` into Transporter
- Click **"Deliver"**
- Wait for upload to complete (usually 1-5 minutes)
3. **Processing**
- Apple will process your build (10-30 minutes)
- You'll receive an email when processing is complete
## Step 5: Configure App Store Connect Metadata
### App Information
1. In App Store Connect, go to your app
2. Fill in required information:
- **Subtitle**: Short description (30 chars max)
- **Privacy Policy URL**: Required for Bluetooth apps
- **Category**: Utilities or Productivity
- **Age Rating**: Complete questionnaire
### App Store Listing
1. Go to **App Store** tab
2. Upload **Screenshots** (required):
- iPhone 6.7" display (1290 x 2796 pixels) - At least 1 screenshot
- iPhone 6.5" display (1242 x 2688 pixels) - At least 1 screenshot
- Optional: iPad screenshots
3. Fill in **Description**:
```
Meshcore Open is a Flutter client for MeshCore LoRa mesh networking devices.
Features:
- BLE connectivity to MeshCore devices
- Real-time mesh network communication
- Map visualization with OpenStreetMap
- Community management with QR code scanning
- Message tracking and retry system
Connect to your MeshCore LoRa device and start communicating over the mesh network.
```
4. **Keywords**: `lora,mesh,networking,bluetooth,communication`
5. **Support URL**: Your GitHub or website URL
6. **Marketing URL**: (Optional)
### Version Information
1. **What's New in This Version**:
```
Initial release of Meshcore Open
- BLE device connectivity
- Mesh network messaging
- Map integration
- Community features
```
2. **Build**: Select the uploaded build once processing completes
## Step 6: TestFlight Setup
### Internal Testing (No Review Required)
1. Go to **TestFlight** tab in App Store Connect
2. Click **Internal Testing** → **"+"** to create a group
3. Name your group (e.g., "Internal Testers")
4. Add yourself as a tester using your email
5. Select the build you uploaded
6. Testers will receive an email with TestFlight invitation
### External Testing (Requires Beta Review)
1. Click **External Testing** → **"+"** to create a group
2. Add build and testers
3. Fill in **Test Information**:
- **What to Test**: Brief description of features
- **Feedback Email**: Your email address
4. Click **Submit for Review**
5. Beta review typically takes 24-48 hours
## Step 7: App Store Submission
Once you're ready for public release:
1. Go to **App Store** tab
2. Complete all required metadata (if not done)
3. Select your build
4. Fill in **App Review Information**:
- **Contact Information**: Your name, phone, email
- **Demo Account**: If app requires login
- **Notes**: Any special instructions for reviewers
5. Answer **Export Compliance** questions:
- Does your app use encryption? **Yes** (uses TLS/HTTPS)
- Is encryption registration required? **No** (standard encryption)
6. Click **Add for Review**
7. Review summary and click **Submit to App Review**
## Step 8: After Submission
- **App Review**: Typically 24-48 hours
- **Common Rejection Reasons**:
- Missing privacy policy
- Incomplete app information
- Crashes or bugs
- Misleading app description
- **If Approved**: You can release immediately or schedule a release date
- **If Rejected**: Address issues and resubmit
## Updating the App
When you need to release an update:
1. **Update version** in `pubspec.yaml`:
```yaml
version: 0.5.0+6 # Increment version (0.5.0) and build number (+6)
```
2. **Build new IPA**:
```bash
export PATH="/opt/homebrew/lib/ruby/gems/4.0.0/bin:$PATH"
../flutter/bin/flutter clean
../flutter/bin/flutter build ipa
```
3. **Upload via Transporter** (same process as above)
4. **Create new version** in App Store Connect:
- Click **"+"** next to versions
- Select version number
- Update "What's New" text
- Select new build
- Submit for review
## macOS Build (Bonus)
To build for macOS:
```bash
export PATH="/opt/homebrew/lib/ruby/gems/4.0.0/bin:$PATH"
../flutter/bin/flutter build macos --release
cd build/macos/Build/Products/Release
zip -r meshcore_open-macos.zip meshcore_open.app
```
Distribution:
- Share the zip file directly
- Users unzip and drag to Applications
- First run: Right-click → Open (to bypass Gatekeeper)
## Troubleshooting
### Build Errors
- **CocoaPods not found**: Ensure PATH includes `/opt/homebrew/lib/ruby/gems/4.0.0/bin`
- **No signing certificate**: Configure Team in Xcode (Signing & Capabilities)
- **Bundle ID mismatch**: Check `ios/Runner.xcodeproj/project.pbxproj`
### Upload Errors
- **No profiles found**: Create app in App Store Connect first
- **Bundle ID not registered**: Register in Apple Developer portal
- **Authentication failed**: Use Transporter app instead of CLI
### TestFlight Issues
- **Build not appearing**: Wait 10-30 minutes for processing
- **Can't add testers**: Check you have available slots (100 internal, 10,000 external)
- **TestFlight crashes**: Check device logs in Xcode → Devices & Simulators
## Important Files
- **iOS IPA**: `build/ios/ipa/meshcore_open.ipa`
- **macOS App**: `build/macos/Build/Products/Release/meshcore_open.app`
- **Bundle ID Config**: `ios/Runner.xcodeproj/project.pbxproj`
- **Version Info**: `pubspec.yaml`
## Useful Links
- [App Store Connect](https://appstoreconnect.apple.com)
- [Apple Developer Portal](https://developer.apple.com/account)
- [TestFlight Documentation](https://developer.apple.com/testflight/)
- [App Store Review Guidelines](https://developer.apple.com/app-store/review/guidelines/)
- [Flutter iOS Deployment](https://docs.flutter.dev/deployment/ios)
## Support
For issues with:
- **App Store Process**: [Apple Developer Support](https://developer.apple.com/contact/)
- **Flutter Build Issues**: [Flutter GitHub](https://github.com/flutter/flutter/issues)
- **Meshcore Open App**: [GitHub Issues](https://github.com/wel97459/meshcore-open/issues)
Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

-7
View File
@@ -57,9 +57,6 @@ PODS:
- 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
@@ -79,7 +76,6 @@ DEPENDENCIES:
- 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`)
@@ -112,8 +108,6 @@ EXTERNAL SOURCES:
: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:
@@ -140,7 +134,6 @@ SPEC CHECKSUMS:
mobile_scanner: af8f71879eaba2bbcb4d86c6a462c3c0e7f23036
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
+271 -181
View File
@@ -24,6 +24,7 @@ import '../services/notification_service.dart';
import '../storage/channel_message_store.dart';
import '../storage/channel_order_store.dart';
import '../storage/channel_settings_store.dart';
import '../storage/channel_store.dart';
import '../storage/contact_settings_store.dart';
import '../storage/contact_store.dart';
import '../storage/message_store.dart';
@@ -67,9 +68,9 @@ class MeshCoreConnector extends ChangeNotifier {
final Map<int, List<ChannelMessage>> _channelMessages = {};
final Set<String> _loadedConversationKeys = {};
final Map<int, Set<String>> _processedChannelReactions =
{}; // channelIndex -> Set of "reactionKey_emoji"
{}; // channelIndex -> Set of "targetHash_emoji"
final Map<String, Set<String>> _processedContactReactions =
{}; // contactPubKeyHex -> Set of "reactionKey_emoji"
{}; // contactPubKeyHex -> Set of "targetHash_emoji"
StreamSubscription<List<ScanResult>>? _scanSubscription;
StreamSubscription<BluetoothConnectionState>? _connectionSubscription;
@@ -95,6 +96,7 @@ class MeshCoreConnector extends ChangeNotifier {
double? _selfLongitude;
bool _isLoadingContacts = false;
bool _isLoadingChannels = false;
bool _hasLoadedChannels = false;
bool _batteryRequested = false;
bool _awaitingSelfInfo = false;
bool _preserveContactsOnRefresh = false;
@@ -122,7 +124,7 @@ class MeshCoreConnector extends ChangeNotifier {
List<Channel> _previousChannelsCache = [];
static const int _maxChannelSyncRetries = 3;
static const int _channelSyncTimeoutMs = 2000; // 2 second timeout per channel
static const Duration _batteryPollInterval = Duration(seconds: 30);
static const Duration _batteryPollInterval = Duration(seconds: 120);
// Services
MessageRetryService? _retryService;
@@ -138,14 +140,15 @@ class MeshCoreConnector extends ChangeNotifier {
final ChannelSettingsStore _channelSettingsStore = ChannelSettingsStore();
final ContactSettingsStore _contactSettingsStore = ContactSettingsStore();
final ContactStore _contactStore = ContactStore();
final ChannelStore _channelStore = ChannelStore();
final UnreadStore _unreadStore = UnreadStore();
List<Channel> _cachedChannels = [];
final Map<int, bool> _channelSmazEnabled = {};
bool _lastSentWasCliCommand =
false; // Track if last sent message was a CLI command
final Map<String, bool> _contactSmazEnabled = {};
final Set<String> _knownContactKeys = {};
final Map<String, int> _contactLastReadMs = {};
final Map<int, int> _channelLastReadMs = {};
final Map<String, int> _contactUnreadCount = {};
bool _unreadStateLoaded = false;
final Map<String, _RepeaterAckContext> _pendingRepeaterAcks = {};
String? _activeContactKey;
@@ -320,17 +323,7 @@ class MeshCoreConnector extends ChangeNotifier {
int getUnreadCountForContactKey(String contactKeyHex) {
if (!_unreadStateLoaded) return 0;
if (!_shouldTrackUnreadForContactKey(contactKeyHex)) return 0;
final messages = _conversations[contactKeyHex];
if (messages == null || messages.isEmpty) return 0;
final lastReadMs = _contactLastReadMs[contactKeyHex] ?? 0;
var count = 0;
for (final message in messages) {
if (message.isOutgoing || message.isCli) continue;
if (message.timestamp.millisecondsSinceEpoch > lastReadMs) {
count++;
}
}
return count;
return _contactUnreadCount[contactKeyHex] ?? 0;
}
int getUnreadCountForChannel(Channel channel) {
@@ -339,17 +332,7 @@ class MeshCoreConnector extends ChangeNotifier {
int getUnreadCountForChannelIndex(int channelIndex) {
if (!_unreadStateLoaded) return 0;
final messages = _channelMessages[channelIndex];
if (messages == null || messages.isEmpty) return 0;
final lastReadMs = _channelLastReadMs[channelIndex] ?? 0;
var count = 0;
for (final message in messages) {
if (message.isOutgoing) continue;
if (message.timestamp.millisecondsSinceEpoch > lastReadMs) {
count++;
}
}
return count;
return _findChannelByIndex(channelIndex)?.unreadCount ?? 0;
}
int getTotalUnreadCount() {
@@ -379,16 +362,17 @@ class MeshCoreConnector extends ChangeNotifier {
}
Future<void> loadUnreadState() async {
_contactLastReadMs
_contactUnreadCount
..clear()
..addAll(await _unreadStore.loadContactLastRead());
_channelLastReadMs
..clear()
..addAll(await _unreadStore.loadChannelLastRead());
..addAll(await _unreadStore.loadContactUnreadCount());
_unreadStateLoaded = true;
notifyListeners();
}
Future<void> loadCachedChannels() async {
_cachedChannels = await _channelStore.loadChannels();
}
void setActiveContact(String? contactKeyHex) {
if (contactKeyHex != null &&
!_shouldTrackUnreadForContactKey(contactKeyHex)) {
@@ -410,17 +394,36 @@ class MeshCoreConnector extends ChangeNotifier {
void markContactRead(String contactKeyHex) {
if (!_shouldTrackUnreadForContactKey(contactKeyHex)) return;
final markMs = _calculateReadTimestampMs(
_conversations[contactKeyHex]?.map((m) => m.timestamp),
);
_setContactLastReadMs(contactKeyHex, markMs);
final previousCount = _contactUnreadCount[contactKeyHex] ?? 0;
if (previousCount > 0) {
_contactUnreadCount[contactKeyHex] = 0;
_appDebugLogService?.info(
'Contact $contactKeyHex marked as read (was $previousCount unread)',
tag: 'Unread',
);
_unreadStore.saveContactUnreadCount(
Map<String, int>.from(_contactUnreadCount),
);
notifyListeners();
}
}
void markChannelRead(int channelIndex) {
final markMs = _calculateReadTimestampMs(
_channelMessages[channelIndex]?.map((m) => m.timestamp),
);
_setChannelLastReadMs(channelIndex, markMs);
final channel = _findChannelByIndex(channelIndex);
if (channel != null && channel.unreadCount > 0) {
final previousCount = channel.unreadCount;
channel.unreadCount = 0;
_appDebugLogService?.info(
'Channel ${channel.name.isNotEmpty ? channel.name : channelIndex} marked as read (was $previousCount unread)',
tag: 'Unread',
);
unawaited(
_channelStore.saveChannels(
_channels.isNotEmpty ? _channels : _cachedChannels,
),
);
notifyListeners();
}
}
Future<void> setChannelSmazEnabled(int channelIndex, bool enabled) async {
@@ -706,7 +709,7 @@ class MeshCoreConnector extends ChangeNotifier {
try {
_connectionSubscription = device.connectionState.listen((state) {
if (state == BluetoothConnectionState.disconnected) {
if (state == BluetoothConnectionState.disconnected && isConnected) {
_handleDisconnection();
}
});
@@ -787,6 +790,9 @@ class MeshCoreConnector extends ChangeNotifier {
// Keep device clock aligned on every connection.
await syncTime();
// Fetch channels so we can track unread counts for incoming messages
unawaited(getChannels());
} catch (e) {
debugPrint("Connection error: $e");
await disconnect(manual: false);
@@ -927,6 +933,7 @@ class MeshCoreConnector extends ChangeNotifier {
_pendingQueueSync = false;
_isSyncingChannels = false;
_channelSyncInFlight = false;
_hasLoadedChannels = false;
_setState(MeshCoreConnectionState.disconnected);
if (!manual) {
@@ -959,13 +966,7 @@ class MeshCoreConnector extends ChangeNotifier {
if (!isConnected) return;
if (_batteryRequested && !force) return;
_batteryRequested = true;
try {
await sendFrame(buildGetBattAndStorageFrame());
} catch (e) {
// Reset flag on error to allow retry
// Don't disconnect on battery request failure - it may be transient
_batteryRequested = false;
}
await sendFrame(buildGetBattAndStorageFrame());
}
void _startBatteryPolling() {
@@ -996,6 +997,7 @@ class MeshCoreConnector extends ChangeNotifier {
}
Future<void> _requestDeviceInfo() async {
if (!isConnected || _awaitingSelfInfo) return;
_awaitingSelfInfo = true;
await sendFrame(buildDeviceQueryFrame());
await sendFrame(buildAppStartFrame());
@@ -1289,15 +1291,12 @@ class MeshCoreConnector extends ChangeNotifier {
if (reactionInfo != null) {
// Check if we've already processed this reaction
_processedChannelReactions.putIfAbsent(channel.index, () => {});
final reactionKey = reactionInfo.reactionKey;
final reactionIdentifier = reactionKey != null
? '${reactionKey}_${reactionInfo.emoji}'
: null;
final reactionIdentifier =
'${reactionInfo.targetHash}_${reactionInfo.emoji}';
if (reactionIdentifier != null &&
_processedChannelReactions[channel.index]!.contains(
reactionIdentifier,
)) {
if (_processedChannelReactions[channel.index]!.contains(
reactionIdentifier,
)) {
// Already processed, don't process again
return;
}
@@ -1311,9 +1310,7 @@ class MeshCoreConnector extends ChangeNotifier {
await _channelMessageStore.saveChannelMessages(channel.index, messages);
// Mark this reaction as processed
if (reactionIdentifier != null) {
_processedChannelReactions[channel.index]!.add(reactionIdentifier);
}
_processedChannelReactions[channel.index]!.add(reactionIdentifier);
notifyListeners();
@@ -1349,8 +1346,10 @@ class MeshCoreConnector extends ChangeNotifier {
unawaited(_persistContacts());
_conversations.remove(contact.publicKeyHex);
_loadedConversationKeys.remove(contact.publicKeyHex);
_contactLastReadMs.remove(contact.publicKeyHex);
_unreadStore.saveContactLastRead(Map<String, int>.from(_contactLastReadMs));
_contactUnreadCount.remove(contact.publicKeyHex);
_unreadStore.saveContactUnreadCount(
Map<String, int>.from(_contactUnreadCount),
);
_messageStore.clearMessages(contact.publicKeyHex);
notifyListeners();
}
@@ -1506,13 +1505,21 @@ class MeshCoreConnector extends ChangeNotifier {
await sendCliCommand('set privacy ${enabled ? 'on' : 'off'}');
}
Future<void> getChannels({int? maxChannels}) async {
Future<void> getChannels({int? maxChannels, bool force = false}) async {
if (!isConnected) return;
if (_isSyncingChannels) {
debugPrint('[ChannelSync] Already syncing channels, ignoring request');
return;
}
// Skip fetching if already loaded and not forced
if (_hasLoadedChannels && !force) {
debugPrint(
'[ChannelSync] Channels already loaded, skipping fetch (use force=true to reload)',
);
return;
}
_isLoadingChannels = true;
_isSyncingChannels = true;
_previousChannelsCache = List<Channel>.from(_channels);
@@ -1617,6 +1624,10 @@ class MeshCoreConnector extends ChangeNotifier {
_cleanupChannelSync(completed: true);
// Cache channels for offline use
_cachedChannels = List<Channel>.from(_channels);
unawaited(_channelStore.saveChannels(_channels));
// Apply ordering and notify UI
_applyChannelOrder();
notifyListeners();
@@ -1632,6 +1643,7 @@ class MeshCoreConnector extends ChangeNotifier {
_totalChannelsToRequest = 0;
if (completed) {
_hasLoadedChannels = true;
_previousChannelsCache.clear();
}
// Keep cache on failure/disconnection for future attempts
@@ -1642,7 +1654,7 @@ class MeshCoreConnector extends ChangeNotifier {
await sendFrame(buildSetChannelFrame(index, name, psk));
// Refresh channels after setting
await getChannels();
await getChannels(force: true);
}
Future<void> deleteChannel(int index) async {
@@ -1650,14 +1662,12 @@ class MeshCoreConnector extends ChangeNotifier {
// Delete by setting empty name and zero PSK
await sendFrame(buildSetChannelFrame(index, '', Uint8List(16)));
_channelLastReadMs.remove(index);
_unreadStore.saveChannelLastRead(Map<int, int>.from(_channelLastReadMs));
// Clear stored messages for this channel
await _channelMessageStore.clearChannelMessages(index);
// Clear in-memory messages for this channel
_channelMessages.remove(index);
// Refresh channels after deleting
await getChannels();
await getChannels(force: true);
}
void _handleFrame(List<int> data) {
@@ -1929,9 +1939,9 @@ class MeshCoreConnector extends ChangeNotifier {
final contact = Contact.fromFrame(frame);
if (contact != null) {
if (contact.type == advTypeRepeater) {
_contactLastReadMs.remove(contact.publicKeyHex);
_unreadStore.saveContactLastRead(
Map<String, int>.from(_contactLastReadMs),
_contactUnreadCount.remove(contact.publicKeyHex);
_unreadStore.saveContactUnreadCount(
Map<String, int>.from(_contactUnreadCount),
);
}
// Check if this is a new contact
@@ -2118,6 +2128,15 @@ class MeshCoreConnector extends ChangeNotifier {
}
if (message != null) {
// Ignore messages from self (device hearing its own broadcast)
// BUT allow repeated messages (pathLength indicates it went through repeater)
if (_selfPublicKey != null &&
message.senderKeyHex == pubKeyToHex(_selfPublicKey!) &&
(message.pathLength == null || message.pathLength == 0)) {
debugPrint('Ignoring direct message from self');
return;
}
final contact = _contacts.cast<Contact?>().firstWhere(
(c) => c?.publicKeyHex == message!.senderKeyHex,
orElse: () => null,
@@ -2147,7 +2166,7 @@ class MeshCoreConnector extends ChangeNotifier {
}
}
_addMessage(message.senderKeyHex, message);
_maybeMarkActiveContactRead(message);
_maybeIncrementContactUnread(message);
notifyListeners();
// Show notification for new incoming message
@@ -2338,7 +2357,7 @@ class MeshCoreConnector extends ChangeNotifier {
pathBytes: message.pathBytes,
);
final isNew = _addChannelMessage(message.channelIndex!, message);
_maybeMarkActiveChannelRead(message);
_maybeIncrementChannelUnread(message, isNew: isNew);
notifyListeners();
if (isNew) {
_maybeNotifyChannelMessage(message);
@@ -2360,7 +2379,9 @@ class MeshCoreConnector extends ChangeNotifier {
final channelHash = payload[0];
final encrypted = Uint8List.fromList(payload.sublist(1));
for (final channel in _channels) {
// Use cached channels as fallback if live channels not yet loaded
final channelsToSearch = _channels.isNotEmpty ? _channels : _cachedChannels;
for (final channel in channelsToSearch) {
if (channel.isEmpty) continue;
final hash = _computeChannelHash(channel.psk);
if (hash != channelHash) continue;
@@ -2399,7 +2420,7 @@ class MeshCoreConnector extends ChangeNotifier {
pathBytes: message.pathBytes,
);
final isNew = _addChannelMessage(channel.index, message);
_maybeMarkActiveChannelRead(message);
_maybeIncrementChannelUnread(message, isNew: isNew);
notifyListeners();
if (isNew) {
final label = channel.name.isEmpty
@@ -2529,6 +2550,15 @@ class MeshCoreConnector extends ChangeNotifier {
'[ChannelSync] Received channel ${channel.index}: ${channel.isEmpty ? "empty" : channel.name}',
);
// Preserve unread count from cached channel
final cachedChannel = _cachedChannels.cast<Channel?>().firstWhere(
(c) => c?.index == channel.index,
orElse: () => null,
);
if (cachedChannel != null) {
channel.unreadCount = cachedChannel.unreadCount;
}
// If we're syncing and this is the channel we're waiting for
if (_isSyncingChannels && _channelSyncInFlight) {
if (channel.index == _nextChannelIndexToRequest) {
@@ -2568,6 +2598,8 @@ class MeshCoreConnector extends ChangeNotifier {
(c) => c.index == channel.index,
);
if (existingIndex >= 0) {
// Preserve unread count from existing channel
channel.unreadCount = _channels[existingIndex].unreadCount;
_channels[existingIndex] = channel;
} else {
_channels.add(channel);
@@ -2618,67 +2650,98 @@ class MeshCoreConnector extends ChangeNotifier {
return contact.type != advTypeRepeater;
}
int _calculateReadTimestampMs(Iterable<DateTime>? timestamps) {
var latestMs = 0;
if (timestamps != null) {
for (final timestamp in timestamps) {
final ms = timestamp.millisecondsSinceEpoch;
if (ms > latestMs) {
latestMs = ms;
}
}
}
return latestMs;
Channel? _findChannelByIndex(int index) {
return _channels.cast<Channel?>().firstWhere(
(c) => c?.index == index,
orElse: () => null,
) ??
_cachedChannels.cast<Channel?>().firstWhere(
(c) => c?.index == index,
orElse: () => null,
);
}
void _setContactLastReadMs(
String contactKeyHex,
int timestampMs, {
bool notify = true,
void _maybeIncrementChannelUnread(
ChannelMessage message, {
required bool isNew,
}) {
if (!_shouldTrackUnreadForContactKey(contactKeyHex)) return;
final existing = _contactLastReadMs[contactKeyHex] ?? 0;
if (timestampMs <= existing) return;
_contactLastReadMs[contactKeyHex] = timestampMs;
_unreadStore.saveContactLastRead(Map<String, int>.from(_contactLastReadMs));
if (notify) {
notifyListeners();
if (!isNew || message.isOutgoing) {
_appDebugLogService?.info(
'Skip unread increment: isNew=$isNew, isOutgoing=${message.isOutgoing}',
tag: 'Unread',
);
return;
}
}
void _setChannelLastReadMs(
int channelIndex,
int timestampMs, {
bool notify = true,
}) {
final existing = _channelLastReadMs[channelIndex] ?? 0;
if (timestampMs <= existing) return;
_channelLastReadMs[channelIndex] = timestampMs;
_unreadStore.saveChannelLastRead(Map<int, int>.from(_channelLastReadMs));
if (notify) {
notifyListeners();
}
}
void _maybeMarkActiveContactRead(Message message) {
if (message.isOutgoing || message.isCli) return;
if (_activeContactKey != message.senderKeyHex) return;
if (!_shouldTrackUnreadForContactKey(message.senderKeyHex)) return;
_setContactLastReadMs(
message.senderKeyHex,
message.timestamp.millisecondsSinceEpoch,
notify: false,
);
}
void _maybeMarkActiveChannelRead(ChannelMessage message) {
if (message.isOutgoing) return;
final channelIndex = message.channelIndex;
if (channelIndex == null || _activeChannelIndex != channelIndex) return;
_setChannelLastReadMs(
channelIndex,
message.timestamp.millisecondsSinceEpoch,
notify: false,
if (channelIndex == null) {
_appDebugLogService?.info(
'Skip unread increment: channelIndex is null',
tag: 'Unread',
);
return;
}
// Don't increment if user is viewing this channel
if (_activeChannelIndex == channelIndex) {
_appDebugLogService?.info(
'Skip unread increment: channel $channelIndex is active',
tag: 'Unread',
);
return;
}
final channel = _findChannelByIndex(channelIndex);
if (channel != null) {
channel.unreadCount++;
_appDebugLogService?.info(
'Channel ${channel.name.isNotEmpty ? channel.name : channelIndex} unread count incremented to ${channel.unreadCount}',
tag: 'Unread',
);
unawaited(
_channelStore.saveChannels(
_channels.isNotEmpty ? _channels : _cachedChannels,
),
);
} else {
_appDebugLogService?.info(
'Channel $channelIndex not found in _channels (${_channels.length}) or _cachedChannels (${_cachedChannels.length})',
tag: 'Unread',
);
}
}
void _maybeIncrementContactUnread(Message message) {
if (message.isOutgoing || message.isCli) {
_appDebugLogService?.info(
'Skip contact unread increment: isOutgoing=${message.isOutgoing}, isCli=${message.isCli}',
tag: 'Unread',
);
return;
}
final contactKey = message.senderKeyHex;
if (!_shouldTrackUnreadForContactKey(contactKey)) {
_appDebugLogService?.info(
'Skip contact unread increment: should not track for $contactKey',
tag: 'Unread',
);
return;
}
// Don't increment if user is viewing this contact
if (_activeContactKey == contactKey) {
_appDebugLogService?.info(
'Skip contact unread increment: contact $contactKey is active',
tag: 'Unread',
);
return;
}
final currentCount = _contactUnreadCount[contactKey] ?? 0;
_contactUnreadCount[contactKey] = currentCount + 1;
_appDebugLogService?.info(
'Contact $contactKey unread count incremented to ${currentCount + 1}',
tag: 'Unread',
);
_unreadStore.saveContactUnreadCount(
Map<String, int>.from(_contactUnreadCount),
);
}
@@ -2689,26 +2752,22 @@ class MeshCoreConnector extends ChangeNotifier {
// Parse reaction info
final reactionInfo = Message.parseReaction(message.text);
if (reactionInfo != null) {
// Check if we've already processed this exact reaction using lightweight key
// Check if we've already processed this exact reaction
_processedContactReactions.putIfAbsent(pubKeyHex, () => {});
final reactionKey = reactionInfo.reactionKey;
final reactionIdentifier = reactionKey != null
? '${reactionKey}_${reactionInfo.emoji}'
: null;
final reactionIdentifier =
'${reactionInfo.targetHash}_${reactionInfo.emoji}';
final isDuplicate =
reactionIdentifier != null &&
_processedContactReactions[pubKeyHex]!.contains(reactionIdentifier);
final isDuplicate = _processedContactReactions[pubKeyHex]!.contains(
reactionIdentifier,
);
if (!isDuplicate) {
// New reaction - process it
_processContactReaction(messages, reactionInfo);
_processContactReaction(messages, reactionInfo, pubKeyHex);
_messageStore.saveMessages(pubKeyHex, messages);
// Mark as processed
if (reactionIdentifier != null) {
_processedContactReactions[pubKeyHex]!.add(reactionIdentifier);
}
_processedContactReactions[pubKeyHex]!.add(reactionIdentifier);
notifyListeners();
}
@@ -2723,15 +2782,53 @@ class MeshCoreConnector extends ChangeNotifier {
void _processContactReaction(
List<Message> messages,
ReactionInfo reactionInfo,
String contactPubKeyHex,
) {
// Find target message by messageId
for (int i = 0; i < messages.length; i++) {
if (messages[i].messageId == reactionInfo.targetMessageId) {
final currentReactions = Map<String, int>.from(messages[i].reactions);
// Find target message by computing hash and comparing
final targetHash = reactionInfo.targetHash;
final contact = _contacts.cast<Contact?>().firstWhere(
(c) => c?.publicKeyHex == contactPubKeyHex,
orElse: () => null,
);
final isRoomServer = contact?.type == advTypeRoom;
for (int i = messages.length - 1; i >= 0; i--) {
final msg = messages[i];
// For 1:1 chats: contact reacts to my outgoing messages only
// For room servers: any message can be reacted to (multi-user)
if (!isRoomServer && !msg.isOutgoing) continue;
final timestampSecs = msg.timestamp.millisecondsSinceEpoch ~/ 1000;
// For room servers, include sender name (resolve from fourByteRoomContactKey)
// For 1:1 chats, sender is implicit (null)
String? senderName;
if (isRoomServer && !msg.isOutgoing) {
// Resolve sender from the message's fourByteRoomContactKey
final senderContact = _contacts.cast<Contact?>().firstWhere(
(c) =>
c != null &&
_matchesPrefix(c.publicKey, msg.fourByteRoomContactKey),
orElse: () => null,
);
senderName = senderContact?.name;
} else if (isRoomServer && msg.isOutgoing) {
senderName = selfName;
}
// For 1:1, senderName stays null
final msgHash = ReactionHelper.computeReactionHash(
timestampSecs,
senderName,
msg.text,
);
if (msgHash == targetHash) {
final currentReactions = Map<String, int>.from(msg.reactions);
currentReactions[reactionInfo.emoji] =
(currentReactions[reactionInfo.emoji] ?? 0) + 1;
messages[i] = messages[i].copyWith(reactions: currentReactions);
messages[i] = msg.copyWith(reactions: currentReactions);
break;
}
}
@@ -2882,18 +2979,14 @@ class MeshCoreConnector extends ChangeNotifier {
// Parse reaction info
final reactionInfo = ChannelMessage.parseReaction(message.text);
if (reactionInfo != null) {
// Check if we've already processed this exact reaction using lightweight key
// Check if we've already processed this exact reaction
_processedChannelReactions.putIfAbsent(channelIndex, () => {});
final reactionKey = reactionInfo.reactionKey;
final reactionIdentifier = reactionKey != null
? '${reactionKey}_${reactionInfo.emoji}'
: null;
final reactionIdentifier =
'${reactionInfo.targetHash}_${reactionInfo.emoji}';
final isDuplicate =
reactionIdentifier != null &&
_processedChannelReactions[channelIndex]!.contains(
reactionIdentifier,
);
final isDuplicate = _processedChannelReactions[channelIndex]!.contains(
reactionIdentifier,
);
if (!isDuplicate) {
// New reaction - process it
@@ -2902,9 +2995,7 @@ class MeshCoreConnector extends ChangeNotifier {
_channelMessageStore.saveChannelMessages(channelIndex, messages);
// Mark as processed
if (reactionIdentifier != null) {
_processedChannelReactions[channelIndex]!.add(reactionIdentifier);
}
_processedChannelReactions[channelIndex]!.add(reactionIdentifier);
}
return false; // Don't add reaction as a visible message
}
@@ -3000,14 +3091,22 @@ class MeshCoreConnector extends ChangeNotifier {
List<ChannelMessage> messages,
ReactionInfo reactionInfo,
) {
// Find target message by messageId
for (int i = 0; i < messages.length; i++) {
if (messages[i].messageId == reactionInfo.targetMessageId) {
final currentReactions = Map<String, int>.from(messages[i].reactions);
// Find target message by computing hash and comparing
final targetHash = reactionInfo.targetHash;
for (int i = messages.length - 1; i >= 0; i--) {
final msg = messages[i];
final timestampSecs = msg.timestamp.millisecondsSinceEpoch ~/ 1000;
final msgHash = ReactionHelper.computeReactionHash(
timestampSecs,
msg.senderName,
msg.text,
);
if (msgHash == targetHash) {
final currentReactions = Map<String, int>.from(msg.reactions);
currentReactions[reactionInfo.emoji] =
(currentReactions[reactionInfo.emoji] ?? 0) + 1;
messages[i] = messages[i].copyWith(reactions: currentReactions);
messages[i] = msg.copyWith(reactions: currentReactions);
notifyListeners();
break;
}
@@ -3049,28 +3148,19 @@ class MeshCoreConnector extends ChangeNotifier {
}
bool _shouldDropSelfChannelMessage(String senderName, Uint8List pathBytes) {
final selfKey = _selfPublicKey;
if (selfKey == null) return false;
if (pathBytes.length < pathHashSize) return false;
final trimmed = senderName.trim();
if (trimmed.isEmpty) return false;
final selfName = _selfName?.trim();
if (selfName == null || selfName.isEmpty) return false;
// If sender name doesn't match, keep the message
if (trimmed != selfName) return false;
final prefix = selfKey.sublist(0, pathHashSize);
for (int i = 0; i + pathHashSize <= pathBytes.length; i += pathHashSize) {
var match = true;
for (int j = 0; j < pathHashSize; j++) {
if (pathBytes[i + j] != prefix[j]) {
match = false;
break;
}
}
if (match) {
return true;
}
}
return false;
// Name matches - this is from self
// Drop only if pathBytes is empty (direct broadcast)
// Keep if pathBytes has data (repeated through another node)
return pathBytes.isEmpty;
}
Uint8List _selectPreferredPathBytes(Uint8List existing, Uint8List incoming) {
+71 -2
View File
@@ -18,6 +18,10 @@ class BufferReader {
return data;
}
void skipBytes(int count) {
_pointer += count;
}
Uint8List readRemainingBytes() => readBytes(remaining);
String readString() =>
@@ -98,6 +102,25 @@ class BufferWriter {
}
writeBytes(bytes);
}
void writeHex(String hex) {
// Validate hex string length is even and not empty
if (hex.isEmpty || hex.length % 2 != 0) {
throw FormatException('Invalid hex string length: ${hex.length}');
}
List<int> result = [];
for (int i = 0; i < hex.length ~/ 2; i++) {
final hexByte = hex.substring(i * 2, i * 2 + 2);
final byte = int.tryParse(hexByte, radix: 16);
if (byte == null) {
throw FormatException(
'Invalid hex characters at position $i: $hexByte',
);
}
result.add(byte);
}
writeBytes(Uint8List.fromList(result));
}
}
// Command codes (to device)
@@ -127,6 +150,7 @@ const int cmdSendStatusReq = 27;
const int cmdGetContactByKey = 30;
const int cmdGetChannel = 31;
const int cmdSetChannel = 32;
const int cmdSendTracePath = 36;
const int cmdGetRadioSettings = 57;
const int cmdGetTelemetryReq = 39;
const int cmdGetCustomVar = 40;
@@ -159,6 +183,7 @@ const int respCodeContactMsgRecv = 7;
const int respCodeChannelMsgRecv = 8;
const int respCodeCurrTime = 9;
const int respCodeNoMoreMessages = 10;
const int respCodeExportContact = 11;
const int respCodeBattAndStorage = 12;
const int respCodeDeviceInfo = 13;
const int respCodeContactMsgRecvV3 = 16;
@@ -176,6 +201,7 @@ const int pushCodeLoginSuccess = 0x85;
const int pushCodeLoginFail = 0x86;
const int pushCodeStatusResponse = 0x87;
const int pushCodeLogRxData = 0x88;
const int pushCodeTraceData = 0x89;
const int pushCodeNewAdvert = 0x8A;
const int pushCodeTelemetryResponse = 0x8B;
const int pushCodeBinaryResponse = 0x8C;
@@ -195,8 +221,10 @@ const int maxFrameSize = 172;
const int appProtocolVersion = 3;
// Matches firmware MAX_TEXT_LEN (10 * CIPHER_BLOCK_SIZE).
const int maxTextPayloadBytes = 160;
const int _sendTextMsgOverheadBytes = 1 + 1 + 1 + 4 + 6 + 1 + 2; // +2 safety margin
const int _sendChannelTextMsgOverheadBytes = 1 + 1 + 1 + 4 + 1 + 2; // +2 safety margin
const int _sendTextMsgOverheadBytes =
1 + 1 + 1 + 4 + 6 + 1 + 2; // +2 safety margin
const int _sendChannelTextMsgOverheadBytes =
1 + 1 + 1 + 4 + 1 + 2; // +2 safety margin
int maxContactMessageBytes() {
final byFrame = maxFrameSize - _sendTextMsgOverheadBytes;
@@ -708,3 +736,44 @@ Uint8List buildSendBinaryReq(Uint8List repeaterPubKey, {Uint8List? payload}) {
}
return writer.toBytes();
}
//Build a trace request frame
//[cmd][tag x4][auth x4][flag][payload]
Uint8List buildTraceReq(int tag, int auth, int flag, {Uint8List? payload}) {
final writer = BufferWriter();
writer.writeByte(cmdSendTracePath);
writer.writeUInt32LE(tag);
writer.writeUInt32LE(auth);
writer.writeByte(flag);
if (payload != null && payload.isNotEmpty) {
writer.writeBytes(payload);
}
return writer.toBytes();
}
// Build a export contact frame
// [cmd][pub_key x32 / if empty exports your contact info]
Uint8List buildExportContactFrame(Uint8List pubKey) {
final writer = BufferWriter();
writer.writeByte(cmdExportContact);
writer.writeBytes(pubKey);
return writer.toBytes();
}
// Build a import contact frame
// [cmd][contact_frame x98+]
Uint8List buildImportContactFrame(String contactFrame) {
final writer = BufferWriter();
writer.writeByte(cmdImportContact);
writer.writeHex(contactFrame);
return writer.toBytes();
}
// Build a export contact frame
// [cmd][pub_key x32]
Uint8List buildZeroHopContact(Uint8List pubKey) {
final writer = BufferWriter();
writer.writeByte(cmdShareContact);
writer.writeBytes(pubKey);
return writer.toBytes();
}
+11 -9
View File
@@ -26,9 +26,11 @@ class CayenneLpp {
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 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
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();
@@ -201,10 +203,10 @@ class CayenneLpp {
break;
}
final channelData = channels.putIfAbsent(channel, () => {
'channel': channel,
'values': <String, dynamic>{},
});
final channelData = channels.putIfAbsent(
channel,
() => {'channel': channel, 'values': <String, dynamic>{}},
);
switch (type) {
case lppGenericSensor:
@@ -254,8 +256,8 @@ class CayenneLpp {
}
}
final List<Map<String, dynamic>> channelsOut = channels.values.toList();
channelsOut.sort((a, b) => a['channel'].compareTo(b['channel']));
return channelsOut;
final List<Map<String, dynamic>> channelsOut = channels.values.toList();
channelsOut.sort((a, b) => a['channel'].compareTo(b['channel']));
return channelsOut;
}
}
+1 -4
View File
@@ -26,10 +26,7 @@ class LinkHandler {
),
child: SelectableText(
url,
style: const TextStyle(
fontSize: 12,
fontFamily: 'monospace',
),
style: const TextStyle(fontSize: 12, fontFamily: 'monospace'),
),
),
],
+57 -42
View File
@@ -1,53 +1,68 @@
class ReactionInfo {
final String targetMessageId;
final String emoji;
final String? reactionKey; // Lightweight key for deduplication: timestamp_senderPrefix
import '../widgets/emoji_picker.dart';
ReactionInfo({
required this.targetMessageId,
required this.emoji,
this.reactionKey,
});
class ReactionInfo {
final String targetHash;
final String emoji;
ReactionInfo({required this.targetHash, required this.emoji});
}
class ReactionHelper {
/// Parse reaction format: r:[messageId]:[emoji]
/// Supports both old format (full messageId) and new format (timestamp_senderPrefix)
static List<String>? _cachedEmojis;
/// Combined list of all reaction emojis in fixed order.
/// Order must stay stable for index compatibility.
static List<String> get reactionEmojis {
return _cachedEmojis ??= [
...EmojiPicker.quickEmojis,
...EmojiPicker.smileys,
...EmojiPicker.gestures,
...EmojiPicker.hearts,
...EmojiPicker.objects,
];
}
/// Convert emoji to 2-char hex index. Returns null if emoji not in list.
static String? emojiToIndex(String emoji) {
final idx = reactionEmojis.indexOf(emoji);
if (idx < 0) return null;
return idx.toRadixString(16).padLeft(2, '0');
}
/// Convert 2-char hex index to emoji. Returns null if invalid index.
static String? indexToEmoji(String hexIndex) {
final idx = int.tryParse(hexIndex, radix: 16);
if (idx == null || idx < 0 || idx >= reactionEmojis.length) return null;
return reactionEmojis[idx];
}
/// Compute a 4-char hex hash for a message reaction.
/// Hash input: timestampSeconds + [senderName] + first 5 chars of text
/// For 1:1 chats, senderName can be null (sender is implicit).
static String computeReactionHash(
int timestampSeconds,
String? senderName,
String text,
) {
final first5 = text.length >= 5 ? text.substring(0, 5) : text;
final input = senderName != null
? '$timestampSeconds$senderName$first5'
: '$timestampSeconds$first5';
// Use hashCode and take lower 16 bits, format as 4 hex chars
final hash = input.hashCode & 0xFFFF;
return hash.toRadixString(16).padLeft(4, '0');
}
/// Parse reaction format: r:HASH:INDEX (where INDEX is 2-char hex emoji index)
/// Returns null if text is not a valid reaction format
static ReactionInfo? parseReaction(String text) {
final regex = RegExp(r'^r:([^:]+):(.+)$');
final regex = RegExp(r'^r:([0-9a-f]{4}):([0-9a-f]{2})$');
final match = regex.firstMatch(text);
if (match == null) return null;
final targetId = match.group(1)!;
final emoji = match.group(2)!;
final emoji = indexToEmoji(match.group(2)!);
if (emoji == null) return null;
// Extract reaction key for deduplication
// If targetId is in new format (timestamp_senderPrefix), use it directly
// Otherwise, extract timestamp from old format (timestamp_nameHash_textHash)
String? reactionKey;
if (targetId.contains('_')) {
final parts = targetId.split('_');
if (parts.length >= 2) {
// New format: timestamp_senderPrefix, or old format with at least timestamp
reactionKey = '${parts[0]}_${parts[1]}';
}
}
return ReactionInfo(
targetMessageId: targetId,
emoji: emoji,
reactionKey: reactionKey,
);
}
/// Generate a lightweight reaction key for a message
/// Format: r:[timestamp]_[senderPrefix]:[emoji]
static String buildReactionText(String timestamp, String senderPrefix, String emoji) {
return 'r:${timestamp}_$senderPrefix:$emoji';
}
/// Extract sender prefix from public key hex (first 8 chars)
static String getSenderPrefix(String senderKeyHex) {
return senderKeyHex.substring(0, 8);
return ReactionInfo(targetHash: match.group(1)!, emoji: emoji);
}
}
+15 -6
View File
@@ -262,8 +262,9 @@ class Smaz {
".com",
];
static final List<Uint8List> _rcbBytes =
_rcb.map((s) => Uint8List.fromList(ascii.encode(s))).toList(growable: false);
static final List<Uint8List> _rcbBytes = _rcb
.map((s) => Uint8List.fromList(ascii.encode(s)))
.toList(growable: false);
static final int _maxEntryLen = _rcbBytes.fold(0, (maxLen, entry) {
return entry.length > maxLen ? entry.length : maxLen;
});
@@ -358,24 +359,32 @@ class Smaz {
final code = input[index];
if (code == _verbatimSingle) {
if (index + 1 >= input.length) {
throw const FormatException('Invalid SMAZ stream: truncated verbatim byte.');
throw const FormatException(
'Invalid SMAZ stream: truncated verbatim byte.',
);
}
out.addByte(input[index + 1]);
index += 2;
} else if (code == _verbatimRun) {
if (index + 1 >= input.length) {
throw const FormatException('Invalid SMAZ stream: truncated verbatim length.');
throw const FormatException(
'Invalid SMAZ stream: truncated verbatim length.',
);
}
final len = input[index + 1] + 1;
final end = index + 2 + len;
if (end > input.length) {
throw const FormatException('Invalid SMAZ stream: truncated verbatim run.');
throw const FormatException(
'Invalid SMAZ stream: truncated verbatim run.',
);
}
out.add(input.sublist(index + 2, end));
index = end;
} else {
if (code >= _rcbBytes.length) {
throw const FormatException('Invalid SMAZ stream: code out of range.');
throw const FormatException(
'Invalid SMAZ stream: code out of range.',
);
}
out.add(_rcbBytes[code]);
index += 1;
+4 -1
View File
@@ -8,7 +8,10 @@ class Utf8LengthLimitingTextInputFormatter extends TextInputFormatter {
const Utf8LengthLimitingTextInputFormatter(this.maxBytes);
@override
TextEditingValue formatEditUpdate(TextEditingValue oldValue, TextEditingValue newValue) {
TextEditingValue formatEditUpdate(
TextEditingValue oldValue,
TextEditingValue newValue,
) {
if (maxBytes <= 0) return oldValue;
final bytes = utf8.encode(newValue.text);
if (bytes.length <= maxBytes) return newValue;
+60 -1
View File
@@ -1533,5 +1533,64 @@
"community_regenerate": "Регенерация",
"community_updateSecret": "Актуализирай тайна",
"community_scanToUpdateSecret": "Сканьорвайте новия QR код, за да актуализирате секрета за \"{name}\"",
"community_secretUpdated": "Секретно обновено за \"{name}\""
"community_secretUpdated": "Секретно обновено за \"{name}\"",
"@contacts_pathTraceTo": {
"placeholders": {
"name": {
"type": "String"
}
}
},
"pathTrace_you": "Вие",
"pathTrace_notAvailable": "Пътека за проследяване не е достъпна.",
"contacts_pathTrace": "Пътен проследяване",
"pathTrace_refreshTooltip": "Обнови Path Trace.",
"pathTrace_failed": "Пътят за проследяване не успя.",
"contacts_repeaterPing": "Пингване на повторителя",
"contacts_repeaterPathTrace": "Трасировка до повторител",
"contacts_ping": "Пинг",
"contacts_chatTraceRoute": "Трасиране на път",
"contacts_roomPathTrace": "Трасиране на път до съ",
"contacts_roomPing": "Ping на сървъра на стаята",
"contacts_pathTraceTo": "Проследи маршрут към {name}",
"appSettings_languageUk": "Украински",
"contacts_clipboardEmpty": "Клипборда е празна.",
"contacts_invalidAdvertFormat": "Невалидни данни за контакт",
"appSettings_languageRu": "Руски",
"contacts_contactImported": "Контактът е импортиран.",
"contacts_zeroHopAdvert": "Реклама без скок",
"contacts_contactImportFailed": "Контактът не е успешно импортиран.",
"contacts_floodAdvert": "Потопна реклама",
"contacts_addContactFromClipboard": "Добави контакт от клипборда",
"contacts_copyAdvertToClipboard": "Копирай обявата в клипборда",
"contacts_ShareContact": "Копирай контакт в клипборда",
"contacts_ShareContactZeroHop": "Сподели контакт чрез обява",
"contacts_contactAdvertCopied": "Рекламата е копирана в клипборда.",
"contacts_zeroHopContactAdvertFailed": "Неуспешно изпращане на контакт.",
"contacts_zeroHopContactAdvertSent": "Изпратен контакт по обява.",
"contacts_contactAdvertCopyFailed": "Копирането на обявата в клипборда не успя.",
"notification_activityTitle": "Активност на MeshCore",
"notification_messagesCount": "{count} {count, plural, =1{съобщение} other{съобщения}}",
"notification_channelMessagesCount": "{count} {count, plural, =1{съобщение в канал} other{съобщения в канали}}",
"notification_newNodesCount": "{count} {count, plural, =1{нов възел} other{нови възли}}",
"notification_newTypeDiscovered": "Открит нов {contactType}",
"notification_receivedNewMessage": "Получено ново съобщение",
"contacts_contactAdvertCopyFailed": "Копирането на обявата в клипборда не успя.",
"settings_gpxExportContactsSubtitle": "Експортира спътници с местоположение в GPX файл.",
"settings_gpxExportRepeatersSubtitle": "Изпраща повторители / roomserver с местоположение в GPX файл.",
"settings_gpxExportAll": "Експортирай всички контакти в GPX",
"settings_gpxExportAllSubtitle": "Експортира всички контакти с местоположение в файл GPX.",
"settings_gpxExportRepeaters": "Експортиране на повтарящи се устройства / сървър на стаята до GPX",
"settings_gpxExportContacts": "Експортирай спътници към GPX",
"settings_gpxExportSuccess": "Успешно изlexport на файл GPX.",
"settings_gpxExportNoContacts": "Няма контакти за изlexport.",
"settings_gpxExportChat": "Местоположения на спътници",
"settings_gpxExportError": "Възникна грешка при изнасяне.",
"settings_gpxExportRepeatersRoom": "Местоположения на повторител и сървър на стаята",
"settings_gpxExportNotAvailable": "Не е поддържан на вашето устройство/ОС",
"settings_gpxExportAllContacts": "Местоположения на всички контакти",
"settings_gpxExportShareText": "Картинни данни изнесени от meshcore-open",
"settings_gpxExportShareSubject": "meshcore-open износ на данни за карта в формат GPX",
"pathTrace_someHopsNoLocation": "Един или повече от хмелите липсва местоположение!"
}
+91 -11
View File
@@ -74,7 +74,7 @@
"settings_title": "Einstellungen",
"settings_deviceInfo": "Geräteinformationen",
"settings_appSettings": "App-Einstellungen",
"settings_appSettingsSubtitle": "Benachrichtigungen, Messaging und Kartenwahrnehmungen",
"settings_appSettingsSubtitle": "Benachrichtigungen, Messaging und Kartenwahrnehmung",
"settings_nodeSettings": "Knoten-Einstellungen",
"settings_nodeName": "Knotenname",
"settings_nodeNameNotSet": "Nicht festgelegt",
@@ -266,7 +266,7 @@
}
}
},
"contacts_manageRepeater": "Wiederholungen verwalten",
"contacts_manageRepeater": "Repeater verwalten",
"contacts_roomLogin": "Raum-Login",
"contacts_openChat": "Öffne Chat",
"contacts_editGroup": "Gruppe bearbeiten",
@@ -360,7 +360,7 @@
"channels_channelIndexLabel": "Kanalindex",
"channels_channelName": "Kanalname",
"channels_usePublicChannel": "Verwende öffentlichen Kanal",
"channels_standardPublicPsk": "Standard-Öffentliche PSK",
"channels_standardPublicPsk": "Öffentliche Standard PSK",
"channels_pskHex": "PSK (Hex)",
"channels_generateRandomPsk": "Zufällige PSK generieren",
"channels_enterChannelName": "Bitte geben Sie einen Kanalnamen ein.",
@@ -489,8 +489,8 @@
}
}
},
"debugFrame_textMessageHeader": "Textnachricht-Frame:",
"debugFrame_destinationPubKey": "- Ziel-Pub-Schlüssel: {pubKey}",
"debugFrame_textMessageHeader": "Textnachrichten Frame:",
"debugFrame_destinationPubKey": "- Ziel-Public-Schlüssel: {pubKey}",
"@debugFrame_destinationPubKey": {
"placeholders": {
"pubKey": {
@@ -1026,7 +1026,7 @@
"repeater_encryptedAdvertInterval": "Intervall der verschlüsselten Ankündigung",
"repeater_dangerZone": "Gefahrenzone",
"repeater_rebootRepeater": "Neustart Repeater",
"repeater_rebootRepeaterSubtitle": "Wiederholen Sie das Repeater-Gerät.",
"repeater_rebootRepeaterSubtitle": "Repeater-Gerät neu starten.",
"repeater_rebootRepeaterConfirm": "Sind Sie sicher, dass Sie diesen Repeater neu starten möchten?",
"repeater_regenerateIdentityKey": "Schlüssel für die Identitätswiederherstellung",
"repeater_regenerateIdentityKeySubtitle": "Neuen öffentlichen/privaten Schlüsselpaar generieren",
@@ -1361,7 +1361,7 @@
"neighbors_receivedData": "Empfangene Nachbarendaten",
"neighbors_requestTimedOut": "Nachbarn melden zeitweise Ausfall.",
"neighbors_errorLoading": "Fehler beim Laden der Nachbarn: {error}",
"neighbors_repeatersNeighbours": "Wiederholer Nachbarn",
"neighbors_repeatersNeighbours": "Nachbarn",
"neighbors_noData": "Keine Nachbardaten verfügbar.",
"channels_joinPrivateChannel": "Treten Sie einem privaten Kanal bei",
"channels_joinPrivateChannelDesc": "Manuelle Eingabe eines geheimen Schlüssels.",
@@ -1528,10 +1528,90 @@
}
},
"community_regenerate": "Neu generieren",
"community_secretRegenerated": "Geheime Wiederherstellung für \"{name}\" erfolgreich",
"community_secretRegenerated": "Wiederherstellung des Schlüssels für \"{name}\" erfolgreich",
"community_regenerateSecretConfirm": "Nehmen Sie den geheimen Schlüssel für \"{name}\" neu auf? Alle Mitglieder müssen den neuen QR-Code scannen, um die Kommunikation fortzusetzen.",
"community_regenerateSecret": "Neu generieren Sie das Geheimnis",
"community_secretUpdated": "Geheime für \"{name}\" aktualisiert",
"community_regenerateSecret": "Neugenerierung des Schlüssels",
"community_secretUpdated": "Schlüssel für \"{name}\" aktualisiert",
"community_scanToUpdateSecret": "Scannen Sie den neuen QR-Code, um das Geheimnis für \"{name}\" zu aktualisieren.",
"community_updateSecret": "Aktualisieren Sie das Geheimnis"
"community_updateSecret": "Aktualisieren Sie den Schlüssel",
"@contacts_pathTraceTo": {
"placeholders": {
"name": {
"type": "String"
}
}
},
"pathTrace_refreshTooltip": "Path Trace aktualisieren.",
"pathTrace_you": "Du",
"pathTrace_failed": "Pfadverfolgung fehlgeschlagen.",
"pathTrace_notAvailable": "Pfadverfolgung nicht verfügbar.",
"contacts_pathTrace": "Pfadverfolgung",
"contacts_ping": "Pingen",
"contacts_repeaterPathTrace": "Pfadverfolgung zum Repeater",
"contacts_repeaterPing": "Repeater pingen",
"contacts_roomPathTrace": "Pfadverfolgung zum Raumserver",
"contacts_roomPing": "Raumserver anpingen",
"contacts_pathTraceTo": "Route nach {name} verfolgen",
"contacts_chatTraceRoute": "Pfadverfolgungsroute",
"appSettings_languageRu": "Russisch",
"contacts_invalidAdvertFormat": "Ungültige Kontaktdaten",
"contacts_clipboardEmpty": "Die Zwischenablage ist leer.",
"appSettings_languageUk": "Ukrainisch",
"contacts_contactImported": "Kontakt wurde importiert.",
"contacts_contactImportFailed": "Kontakt konnte nicht importiert werden",
"contacts_zeroHopAdvert": "Zero-Hop-Anzeige",
"contacts_floodAdvert": "Überflutungsanzeige",
"contacts_addContactFromClipboard": "Kontakt aus Zwischenablage hinzufügen",
"contacts_ShareContactZeroHop": "Kontakt über Anzeige teilen",
"contacts_copyAdvertToClipboard": "Werbung in die Zwischenablage kopieren",
"contacts_ShareContact": "Kontakt in die Zwischenablage kopieren",
"contacts_zeroHopContactAdvertFailed": "Kontakt konnte nicht gesendet werden.",
"contacts_zeroHopContactAdvertSent": "Kontakt über Anzeige gesendet",
"contacts_contactAdvertCopied": "Anzeige in die Zwischenablage kopiert.",
"contacts_contactAdvertCopyFailed": "Kopieren des Werbeinhalts in die Zwischenablage fehlgeschlagen.",
"notification_activityTitle": "MeshCore Aktivität",
"notification_messagesCount": "{count} {count, plural, =1{Nachricht} other{Nachrichten}}",
"@notification_messagesCount": {
"placeholders": {
"count": {"type": "int"}
}
},
"notification_channelMessagesCount": "{count} {count, plural, =1{Kanalnachricht} other{Kanalnachrichten}}",
"@notification_channelMessagesCount": {
"placeholders": {
"count": {"type": "int"}
}
},
"notification_newNodesCount": "{count} {count, plural, =1{neuer Knoten} other{neue Knoten}}",
"@notification_newNodesCount": {
"placeholders": {
"count": {"type": "int"}
}
},
"notification_newTypeDiscovered": "Neuer {contactType} entdeckt",
"@notification_newTypeDiscovered": {
"placeholders": {
"contactType": {"type": "String"}
}
},
"notification_receivedNewMessage": "Neue Nachricht empfangen",
"contacts_contactAdvertCopyFailed": "Kopieren des Werbeinhalts in die Zwischenablage fehlgeschlagen.",
"settings_gpxExportAll": "Alle Kontakte nach GPX exportieren",
"settings_gpxExportAllSubtitle": "Exportiert alle Kontakte mit einem Standort in eine GPX-Datei.",
"settings_gpxExportRepeaters": "Repeater und Raumserver nach GPX exportieren",
"settings_gpxExportContacts": "Begleiter nach GPX exportieren",
"settings_gpxExportRepeatersSubtitle": "Exportiert Repeater und Raumserver mit einem Standort in eine GPX-Datei.",
"settings_gpxExportContactsSubtitle": "Exportiert Begleiter mit einem Ort in eine GPX-Datei.",
"settings_gpxExportRepeatersRoom": "Repeater- und Raumserver-Standorte",
"settings_gpxExportChat": "Begleiterstandorte",
"settings_gpxExportNoContacts": "Keine Kontakte zum Exportieren.",
"settings_gpxExportError": "Beim Export ist ein Fehler aufgetreten.",
"settings_gpxExportNotAvailable": "Nicht auf Ihrem Gerät/Betriebssystem unterstützt",
"settings_gpxExportSuccess": "Erfolgreich GPX-Datei exportiert.",
"settings_gpxExportAllContacts": "Alle Kontaktstandorte",
"settings_gpxExportShareSubject": "meshcore-open GPX-Kartendaten exportieren",
"settings_gpxExportShareText": "Kartendaten aus meshcore-open exportiert",
"pathTrace_someHopsNoLocation": "Eine oder mehrere der Hopfen fehlen einen Standort!"
}
+80 -1
View File
@@ -174,6 +174,8 @@
"appSettings_languageNl": "Nederlands",
"appSettings_languageSk": "Slovenčina",
"appSettings_languageBg": "Български",
"appSettings_languageRu": "Русский",
"appSettings_languageUk": "Українська",
"appSettings_notifications": "Notifications",
"appSettings_enableNotifications": "Enable Notifications",
"appSettings_enableNotificationsSubtitle": "Receive notifications for messages and adverts",
@@ -1308,5 +1310,82 @@
"listFilter_repeaters": "Repeaters",
"listFilter_roomServers": "Room servers",
"listFilter_unreadOnly": "Unread only",
"listFilter_newGroup": "New group"
"listFilter_newGroup": "New group",
"pathTrace_you": "You",
"pathTrace_failed": "Path trace failed.",
"pathTrace_notAvailable": "Path trace not available.",
"pathTrace_refreshTooltip": "Refresh Path Trace.",
"pathTrace_someHopsNoLocation": "One or more of the hops is missing a location!",
"contacts_pathTrace": "Path Trace",
"contacts_ping": "Ping",
"contacts_repeaterPathTrace": "Path trace to repeater",
"contacts_repeaterPing": "Ping repeater",
"contacts_roomPathTrace": "Path trace to room server",
"contacts_roomPing": "Ping room server",
"contacts_chatTraceRoute": "Path trace route",
"contacts_pathTraceTo": "Trace route to {name}",
"@contacts_pathTraceTo": {
"placeholders": {
"name": {"type": "String"}
}
},
"contacts_clipboardEmpty": "Clipboard is empty.",
"contacts_invalidAdvertFormat": "Invalid contact data",
"contacts_contactImported": "Contact has been imported.",
"contacts_contactImportFailed": "Failed to import contact.",
"contacts_zeroHopAdvert":"Zero Hop Advert",
"contacts_floodAdvert":"Flood Advert",
"contacts_copyAdvertToClipboard":"Copy Advert to Clipboard",
"contacts_addContactFromClipboard":"Add Contact from Clipboard",
"contacts_ShareContact": "Copy contact to Clipboard",
"contacts_ShareContactZeroHop": "Share contact by advert",
"contacts_zeroHopContactAdvertSent": "Sent contact by advert.",
"contacts_zeroHopContactAdvertFailed": "Failed to send contact.",
"contacts_contactAdvertCopied": "Advert copied to Clipboard.",
"contacts_contactAdvertCopyFailed": "Copying advert to Clipboard failed.",
"notification_activityTitle": "MeshCore Activity",
"notification_messagesCount": "{count} {count, plural, =1{message} other{messages}}",
"@notification_messagesCount": {
"placeholders": {
"count": {"type": "int"}
}
},
"notification_channelMessagesCount": "{count} {count, plural, =1{channel message} other{channel messages}}",
"@notification_channelMessagesCount": {
"placeholders": {
"count": {"type": "int"}
}
},
"notification_newNodesCount": "{count} {count, plural, =1{new node} other{new nodes}}",
"@notification_newNodesCount": {
"placeholders": {
"count": {"type": "int"}
}
},
"notification_newTypeDiscovered": "New {contactType} discovered",
"@notification_newTypeDiscovered": {
"placeholders": {
"contactType": {"type": "String"}
}
},
"notification_receivedNewMessage": "Received new message",
"settings_gpxExportRepeaters": "Export repeaters / room server to GPX",
"settings_gpxExportRepeatersSubtitle": "Exports repeaters / roomserver with a location to GPX file.",
"settings_gpxExportContacts": "Export companions to GPX",
"settings_gpxExportContactsSubtitle": "Exports companions with a location to GPX file.",
"settings_gpxExportAll": "Export all contacts to GPX",
"settings_gpxExportAllSubtitle": "Exports all contacts with a location to GPX file.",
"settings_gpxExportSuccess": "Successfully exported GPX file.",
"settings_gpxExportNoContacts": "No contacts to export.",
"settings_gpxExportNotAvailable": "Not supported on your device/OS",
"settings_gpxExportError": "There was an error when exporting.",
"settings_gpxExportRepeatersRoom": "Repeater & room server locations",
"settings_gpxExportChat": "Companion locations",
"settings_gpxExportAllContacts": "All contacts locations",
"settings_gpxExportShareText": "Map data exported from meshcore-open",
"settings_gpxExportShareSubject": "meshcore-open GPX map data export"
}
+81 -1
View File
@@ -1533,5 +1533,85 @@
"community_regenerate": "Regenerar",
"community_secretUpdated": "Confidencialidad actualizada para \"{name}\"",
"community_scanToUpdateSecret": "Escanear el nuevo código QR para actualizar el secreto de \"{name}\"",
"community_updateSecret": "Actualizar Contraseña"
"community_updateSecret": "Actualizar Contraseña",
"@contacts_pathTraceTo": {
"placeholders": {
"name": {
"type": "String"
}
}
},
"pathTrace_you": "Tú",
"pathTrace_failed": "El trazado de ruta falló.",
"pathTrace_refreshTooltip": "Actualizar Path Trace",
"contacts_pathTrace": "Rastreo de caminos",
"contacts_repeaterPathTrace": "Rastrear ruta al repetidor",
"contacts_repeaterPing": "Pingar repetidor",
"contacts_ping": "Ping",
"pathTrace_notAvailable": "El trazado de ruta no está disponible.",
"contacts_roomPing": "Pingar servidor de sala",
"contacts_roomPathTrace": "Rastreo de ruta al servidor de la habitación",
"contacts_pathTraceTo": "Rastrear ruta a {name}",
"contacts_chatTraceRoute": "Ruta de trazado",
"appSettings_languageUk": "Ucraniano",
"contacts_clipboardEmpty": "El portapapeles está vacío.",
"appSettings_languageRu": "Ruso",
"contacts_invalidAdvertFormat": "Datos de contacto no válidos",
"contacts_floodAdvert": "Anuncio de inundación",
"contacts_contactImported": "El contacto ha sido importado.",
"contacts_contactImportFailed": "Contacto no se importó correctamente.",
"contacts_zeroHopAdvert": "Anuncio de Zero Hop",
"contacts_ShareContactZeroHop": "Compartir contacto por anuncio",
"contacts_ShareContact": "Copiar contacto al Portapapeles",
"contacts_copyAdvertToClipboard": "Copiar anuncio al portapapeles",
"contacts_addContactFromClipboard": "Agregar contacto desde el portapapeles",
"contacts_zeroHopContactAdvertFailed": "No se pudo enviar el contacto.",
"contacts_zeroHopContactAdvertSent": "Envió contacto por anuncio.",
"contacts_contactAdvertCopied": "Anuncio copiado al Portapapeles.",
"contacts_contactAdvertCopyFailed": "Copiar anuncio al Portapapeles ha fallado.",
"notification_activityTitle": "Actividad de MeshCore",
"notification_messagesCount": "{count} {count, plural, =1{mensaje} other{mensajes}}",
"@notification_messagesCount": {
"placeholders": {
"count": {"type": "int"}
}
},
"notification_channelMessagesCount": "{count} {count, plural, =1{mensaje de canal} other{mensajes de canal}}",
"@notification_channelMessagesCount": {
"placeholders": {
"count": {"type": "int"}
}
},
"notification_newNodesCount": "{count} {count, plural, =1{nuevo nodo} other{nuevos nodos}}",
"@notification_newNodesCount": {
"placeholders": {
"count": {"type": "int"}
}
},
"notification_newTypeDiscovered": "Nuevo {contactType} descubierto",
"@notification_newTypeDiscovered": {
"placeholders": {
"contactType": {"type": "String"}
}
},
"notification_receivedNewMessage": "Nuevo mensaje recibido",
"contacts_contactAdvertCopyFailed": "Copiar anuncio al Portapapeles ha fallado.",
"settings_gpxExportContactsSubtitle": "Exporta compañeros con una ubicación a archivo GPX.",
"settings_gpxExportRepeaters": "Exportar repetidores / servidor de sala a GPX",
"settings_gpxExportSuccess": "Archivo GPX exportado con éxito.",
"settings_gpxExportNoContacts": "No hay contactos para exportar.",
"settings_gpxExportNotAvailable": "No compatible con tu dispositivo/SO",
"settings_gpxExportError": "Hubo un error al exportar.",
"settings_gpxExportRepeatersSubtitle": "Exporta repetidores o roomserver con una ubicación a un archivo GPX.",
"settings_gpxExportAllSubtitle": "Exporta todos los contactos con una ubicación a un archivo GPX.",
"settings_gpxExportAll": "Exportar todos los contactos a GPX",
"settings_gpxExportContacts": "Exportar compañeros a GPX",
"settings_gpxExportChat": "Ubicaciones de compañero",
"settings_gpxExportRepeatersRoom": "Ubicaciones del servidor de repetidor y sala",
"settings_gpxExportAllContacts": "Todas las ubicaciones de contactos",
"settings_gpxExportShareText": "Datos del mapa exportados desde meshcore-open",
"settings_gpxExportShareSubject": "meshcore-open exportación de datos de mapa GPX",
"pathTrace_someHopsNoLocation": "Uno o más de los lúpulos carecen de una ubicación"
}
+60 -1
View File
@@ -1533,5 +1533,64 @@
"community_secretRegenerated": "Mot de passe secret régénéré pour \"{name}\"",
"community_scanToUpdateSecret": "Scanner le nouveau code QR pour mettre à jour le mot de passe pour \"{name}\"",
"community_updateSecret": "Mettre à jour le secret",
"community_secretUpdated": "Modification secrète mise à jour pour \"{name}\""
"community_secretUpdated": "Modification secrète mise à jour pour \"{name}\"",
"@contacts_pathTraceTo": {
"placeholders": {
"name": {
"type": "String"
}
}
},
"pathTrace_you": "Vous",
"pathTrace_refreshTooltip": "Actualiser Path Trace",
"pathTrace_failed": "Traçage du chemin échoué.",
"pathTrace_notAvailable": "Tracé de chemin non disponible.",
"contacts_pathTrace": "Traçage de chemin",
"contacts_repeaterPathTrace": "Tracer le chemin vers le répéteur",
"contacts_repeaterPing": "Pinguer le répéteur",
"contacts_roomPathTrace": "Traçage du chemin vers le serveur de la salle",
"contacts_chatTraceRoute": "Tracer le chemin",
"contacts_pathTraceTo": "Tracer l'itinéraire vers {name}",
"contacts_ping": "Ping",
"contacts_roomPing": "Pinguer le serveur de la salle",
"contacts_invalidAdvertFormat": "Données de contact non valides",
"appSettings_languageUk": "Ukrainien",
"appSettings_languageRu": "Russe",
"contacts_clipboardEmpty": "Le presse-papiers est vide.",
"contacts_contactImported": "Le contact a été importé.",
"contacts_floodAdvert": "Annonce de crue",
"contacts_contactImportFailed": "Échec de l'importation du contact.",
"contacts_zeroHopAdvert": "Annonce Zero Hop",
"contacts_copyAdvertToClipboard": "Copier l'annonce dans le presse-papiers",
"contacts_addContactFromClipboard": "Ajouter un contact depuis le presse-papiers",
"contacts_ShareContact": "Copier le contact dans le presse-papiers",
"contacts_ShareContactZeroHop": "Partager un contact par annonce",
"contacts_contactAdvertCopied": "Annonce copiée dans le presse-papiers.",
"contacts_contactAdvertCopyFailed": "La copie de l'annonce vers le presse-papiers a échoué.",
"contacts_zeroHopContactAdvertSent": "Envoyer un contact par annonce.",
"contacts_zeroHopContactAdvertFailed": "Échec de l'envoi du contact.",
"notification_activityTitle": "Activité MeshCore",
"notification_messagesCount": "{count} {count, plural, =1{message} other{messages}}",
"notification_channelMessagesCount": "{count} {count, plural, =1{message de canal} other{messages de canal}}",
"notification_newNodesCount": "{count} {count, plural, =1{nouveau nœud} other{nouveaux nœuds}}",
"notification_newTypeDiscovered": "Nouveau {contactType} découvert",
"notification_receivedNewMessage": "Nouveau message reçu",
"contacts_zeroHopContactAdvertFailed": "Échec de l'envoi du contact.",
"settings_gpxExportRepeaters": "Exporter les répéteurs / serveur de salle au format GPX",
"settings_gpxExportRepeatersSubtitle": "Exporte les répéteurs / roomserver avec une localisation vers un fichier GPX.",
"settings_gpxExportNoContacts": "Aucun contact à exporter.",
"settings_gpxExportNotAvailable": "Non pris en charge sur votre appareil/Système d'exploitation",
"settings_gpxExportError": "Une erreur s'est produite lors de l'exportation.",
"settings_gpxExportRepeatersRoom": "Emplacements des serveurs de répéteur et de salle",
"settings_gpxExportContacts": "Exporter les compagnons au format GPX",
"settings_gpxExportAll": "Exporter tous les contacts au format GPX",
"settings_gpxExportAllSubtitle": "Exporte tous les contacts avec une localisation vers un fichier GPX.",
"settings_gpxExportContactsSubtitle": "Exporte les compagnons avec un emplacement vers un fichier GPX.",
"settings_gpxExportChat": "Emplacements des compagnons",
"settings_gpxExportSuccess": "Fichier GPX exporté avec succès.",
"settings_gpxExportAllContacts": "Tous les emplacements des contacts",
"settings_gpxExportShareText": "Données de carte exportées à partir de meshcore-open",
"settings_gpxExportShareSubject": "meshcore-open exporter les données de carte GPX",
"pathTrace_someHopsNoLocation": "Une ou plusieurs des houblons manquent d'une localisation !"
}
+60 -1
View File
@@ -1533,5 +1533,64 @@
"community_secretRegenerated": "Codice segreto rigenerato per \"{name}\"",
"community_updateSecret": "Aggiorna Segreto",
"community_secretUpdated": "Segreto aggiornato per \"{name}\"",
"community_scanToUpdateSecret": "Scansiona il nuovo codice QR per aggiornare il segreto di \"{name}\""
"community_scanToUpdateSecret": "Scansiona il nuovo codice QR per aggiornare il segreto di \"{name}\"",
"@contacts_pathTraceTo": {
"placeholders": {
"name": {
"type": "String"
}
}
},
"pathTrace_failed": "Tracciamento del percorso fallito.",
"pathTrace_you": "Tu",
"pathTrace_notAvailable": "Tracciamento del percorso non disponibile.",
"pathTrace_refreshTooltip": "Aggiorna Path Trace.",
"contacts_ping": "Ping",
"contacts_repeaterPathTrace": "Traccia percorso al ripetitore",
"contacts_roomPathTrace": "Traccia del percorso al server della stanza",
"contacts_pathTrace": "Traccia Percorso",
"contacts_repeaterPing": "Ripetitore ping",
"contacts_pathTraceTo": "Traccia percorso verso {name}",
"contacts_roomPing": "Ping al server della stanza",
"contacts_chatTraceRoute": "Traccia percorso path",
"appSettings_languageRu": "Russo",
"contacts_invalidAdvertFormat": "Dati di contatto non validi",
"appSettings_languageUk": "Ucraino",
"contacts_zeroHopAdvert": "Annuncio Zero Hop",
"contacts_floodAdvert": "Annuncio alluvionale",
"contacts_copyAdvertToClipboard": "Copia Annuncio negli Appunti",
"contacts_addContactFromClipboard": "Aggiungere contatto dalla clipboard",
"contacts_clipboardEmpty": "La clipboard è vuota.",
"contacts_ShareContact": "Copia contatto negli Appunti",
"contacts_contactImported": "Il contatto è stato importato.",
"contacts_contactImportFailed": "Contatto non importato con successo.",
"contacts_zeroHopContactAdvertSent": "Inviato contatto tramite annuncio.",
"contacts_contactAdvertCopyFailed": "Copia dell'annuncio nella Clipboard non riuscita.",
"contacts_ShareContactZeroHop": "Condividi contatto tramite annuncio",
"contacts_zeroHopContactAdvertFailed": "Invio del contatto non riuscito.",
"contacts_contactAdvertCopied": "Annuncio copiato negli Appunti.",
"notification_activityTitle": "Attività MeshCore",
"notification_messagesCount": "{count} {count, plural, =1{messaggio} other{messaggi}}",
"notification_channelMessagesCount": "{count} {count, plural, =1{messaggio del canale} other{messaggi del canale}}",
"notification_newNodesCount": "{count} {count, plural, =1{nuovo nodo} other{nuovi nodi}}",
"notification_newTypeDiscovered": "Nuovo {contactType} scoperto",
"notification_receivedNewMessage": "Nuovo messaggio ricevuto",
"contacts_contactAdvertCopied": "Annuncio copiato negli Appunti.",
"settings_gpxExportRepeaters": "Esporta ripetitori / server di stanza in GPX",
"settings_gpxExportContacts": "Esporta compagni in GPX",
"settings_gpxExportSuccess": "Esportazione del file GPX completata con successo.",
"settings_gpxExportNoContacts": "Nessun contatto da esportare.",
"settings_gpxExportNotAvailable": "Non supportato sul tuo dispositivo/Sistema Operativo",
"settings_gpxExportError": "Si è verificato un errore durante l'esportazione.",
"settings_gpxExportRepeatersSubtitle": "Esporta ripetitori / roomserver con una posizione in un file GPX.",
"settings_gpxExportContactsSubtitle": "Esporta i compagni con una posizione in un file GPX.",
"settings_gpxExportAll": "Esporta tutti i contatti in GPX",
"settings_gpxExportAllSubtitle": "Esporta tutti i contatti con una posizione in un file GPX.",
"settings_gpxExportChat": "Posizioni dei compagni",
"settings_gpxExportRepeatersRoom": "Posizioni del server ripetitore e della stanza",
"settings_gpxExportAllContacts": "Tutte le posizioni dei contatti",
"settings_gpxExportShareText": "Dati mappa esportati da meshcore-open",
"settings_gpxExportShareSubject": "meshcore-open esportazione dati mappa GPX",
"pathTrace_someHopsNoLocation": "Uno o più dei luppoli mancano di una posizione!"
}
+300
View File
@@ -946,6 +946,18 @@ abstract class AppLocalizations {
/// **'Български'**
String get appSettings_languageBg;
/// No description provided for @appSettings_languageRu.
///
/// In en, this message translates to:
/// **'Русский'**
String get appSettings_languageRu;
/// No description provided for @appSettings_languageUk.
///
/// In en, this message translates to:
/// **'Українська'**
String get appSettings_languageUk;
/// No description provided for @appSettings_notifications.
///
/// In en, this message translates to:
@@ -4687,6 +4699,294 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'New group'**
String get listFilter_newGroup;
/// No description provided for @pathTrace_you.
///
/// In en, this message translates to:
/// **'You'**
String get pathTrace_you;
/// No description provided for @pathTrace_failed.
///
/// In en, this message translates to:
/// **'Path trace failed.'**
String get pathTrace_failed;
/// No description provided for @pathTrace_notAvailable.
///
/// In en, this message translates to:
/// **'Path trace not available.'**
String get pathTrace_notAvailable;
/// No description provided for @pathTrace_refreshTooltip.
///
/// In en, this message translates to:
/// **'Refresh Path Trace.'**
String get pathTrace_refreshTooltip;
/// No description provided for @pathTrace_someHopsNoLocation.
///
/// In en, this message translates to:
/// **'One or more of the hops is missing a location!'**
String get pathTrace_someHopsNoLocation;
/// No description provided for @contacts_pathTrace.
///
/// In en, this message translates to:
/// **'Path Trace'**
String get contacts_pathTrace;
/// No description provided for @contacts_ping.
///
/// In en, this message translates to:
/// **'Ping'**
String get contacts_ping;
/// No description provided for @contacts_repeaterPathTrace.
///
/// In en, this message translates to:
/// **'Path trace to repeater'**
String get contacts_repeaterPathTrace;
/// No description provided for @contacts_repeaterPing.
///
/// In en, this message translates to:
/// **'Ping repeater'**
String get contacts_repeaterPing;
/// No description provided for @contacts_roomPathTrace.
///
/// In en, this message translates to:
/// **'Path trace to room server'**
String get contacts_roomPathTrace;
/// No description provided for @contacts_roomPing.
///
/// In en, this message translates to:
/// **'Ping room server'**
String get contacts_roomPing;
/// No description provided for @contacts_chatTraceRoute.
///
/// In en, this message translates to:
/// **'Path trace route'**
String get contacts_chatTraceRoute;
/// No description provided for @contacts_pathTraceTo.
///
/// In en, this message translates to:
/// **'Trace route to {name}'**
String contacts_pathTraceTo(String name);
/// No description provided for @contacts_clipboardEmpty.
///
/// In en, this message translates to:
/// **'Clipboard is empty.'**
String get contacts_clipboardEmpty;
/// No description provided for @contacts_invalidAdvertFormat.
///
/// In en, this message translates to:
/// **'Invalid contact data'**
String get contacts_invalidAdvertFormat;
/// No description provided for @contacts_contactImported.
///
/// In en, this message translates to:
/// **'Contact has been imported.'**
String get contacts_contactImported;
/// No description provided for @contacts_contactImportFailed.
///
/// In en, this message translates to:
/// **'Failed to import contact.'**
String get contacts_contactImportFailed;
/// No description provided for @contacts_zeroHopAdvert.
///
/// In en, this message translates to:
/// **'Zero Hop Advert'**
String get contacts_zeroHopAdvert;
/// No description provided for @contacts_floodAdvert.
///
/// In en, this message translates to:
/// **'Flood Advert'**
String get contacts_floodAdvert;
/// No description provided for @contacts_copyAdvertToClipboard.
///
/// In en, this message translates to:
/// **'Copy Advert to Clipboard'**
String get contacts_copyAdvertToClipboard;
/// No description provided for @contacts_addContactFromClipboard.
///
/// In en, this message translates to:
/// **'Add Contact from Clipboard'**
String get contacts_addContactFromClipboard;
/// No description provided for @contacts_ShareContact.
///
/// In en, this message translates to:
/// **'Copy contact to Clipboard'**
String get contacts_ShareContact;
/// No description provided for @contacts_ShareContactZeroHop.
///
/// In en, this message translates to:
/// **'Share contact by advert'**
String get contacts_ShareContactZeroHop;
/// No description provided for @contacts_zeroHopContactAdvertSent.
///
/// In en, this message translates to:
/// **'Sent contact by advert.'**
String get contacts_zeroHopContactAdvertSent;
/// No description provided for @contacts_zeroHopContactAdvertFailed.
///
/// In en, this message translates to:
/// **'Failed to send contact.'**
String get contacts_zeroHopContactAdvertFailed;
/// No description provided for @contacts_contactAdvertCopied.
///
/// In en, this message translates to:
/// **'Advert copied to Clipboard.'**
String get contacts_contactAdvertCopied;
/// No description provided for @contacts_contactAdvertCopyFailed.
///
/// In en, this message translates to:
/// **'Copying advert to Clipboard failed.'**
String get contacts_contactAdvertCopyFailed;
/// No description provided for @notification_activityTitle.
///
/// In en, this message translates to:
/// **'MeshCore Activity'**
String get notification_activityTitle;
/// No description provided for @notification_messagesCount.
///
/// In en, this message translates to:
/// **'{count} {count, plural, =1{message} other{messages}}'**
String notification_messagesCount(int count);
/// No description provided for @notification_channelMessagesCount.
///
/// In en, this message translates to:
/// **'{count} {count, plural, =1{channel message} other{channel messages}}'**
String notification_channelMessagesCount(int count);
/// No description provided for @notification_newNodesCount.
///
/// In en, this message translates to:
/// **'{count} {count, plural, =1{new node} other{new nodes}}'**
String notification_newNodesCount(int count);
/// No description provided for @notification_newTypeDiscovered.
///
/// In en, this message translates to:
/// **'New {contactType} discovered'**
String notification_newTypeDiscovered(String contactType);
/// No description provided for @notification_receivedNewMessage.
///
/// In en, this message translates to:
/// **'Received new message'**
String get notification_receivedNewMessage;
/// No description provided for @settings_gpxExportRepeaters.
///
/// In en, this message translates to:
/// **'Export repeaters / room server to GPX'**
String get settings_gpxExportRepeaters;
/// No description provided for @settings_gpxExportRepeatersSubtitle.
///
/// In en, this message translates to:
/// **'Exports repeaters / roomserver with a location to GPX file.'**
String get settings_gpxExportRepeatersSubtitle;
/// No description provided for @settings_gpxExportContacts.
///
/// In en, this message translates to:
/// **'Export companions to GPX'**
String get settings_gpxExportContacts;
/// No description provided for @settings_gpxExportContactsSubtitle.
///
/// In en, this message translates to:
/// **'Exports companions with a location to GPX file.'**
String get settings_gpxExportContactsSubtitle;
/// No description provided for @settings_gpxExportAll.
///
/// In en, this message translates to:
/// **'Export all contacts to GPX'**
String get settings_gpxExportAll;
/// No description provided for @settings_gpxExportAllSubtitle.
///
/// In en, this message translates to:
/// **'Exports all contacts with a location to GPX file.'**
String get settings_gpxExportAllSubtitle;
/// No description provided for @settings_gpxExportSuccess.
///
/// In en, this message translates to:
/// **'Successfully exported GPX file.'**
String get settings_gpxExportSuccess;
/// No description provided for @settings_gpxExportNoContacts.
///
/// In en, this message translates to:
/// **'No contacts to export.'**
String get settings_gpxExportNoContacts;
/// No description provided for @settings_gpxExportNotAvailable.
///
/// In en, this message translates to:
/// **'Not supported on your device/OS'**
String get settings_gpxExportNotAvailable;
/// No description provided for @settings_gpxExportError.
///
/// In en, this message translates to:
/// **'There was an error when exporting.'**
String get settings_gpxExportError;
/// No description provided for @settings_gpxExportRepeatersRoom.
///
/// In en, this message translates to:
/// **'Repeater & room server locations'**
String get settings_gpxExportRepeatersRoom;
/// No description provided for @settings_gpxExportChat.
///
/// In en, this message translates to:
/// **'Companion locations'**
String get settings_gpxExportChat;
/// No description provided for @settings_gpxExportAllContacts.
///
/// In en, this message translates to:
/// **'All contacts locations'**
String get settings_gpxExportAllContacts;
/// No description provided for @settings_gpxExportShareText.
///
/// In en, this message translates to:
/// **'Map data exported from meshcore-open'**
String get settings_gpxExportShareText;
/// No description provided for @settings_gpxExportShareSubject.
///
/// In en, this message translates to:
/// **'meshcore-open GPX map data export'**
String get settings_gpxExportShareSubject;
}
class _AppLocalizationsDelegate
+192
View File
@@ -450,6 +450,12 @@ class AppLocalizationsBg extends AppLocalizations {
@override
String get appSettings_languageBg => 'Български';
@override
String get appSettings_languageRu => 'Руски';
@override
String get appSettings_languageUk => 'Украински';
@override
String get appSettings_notifications => 'Уведомления';
@@ -2676,4 +2682,190 @@ class AppLocalizationsBg extends AppLocalizations {
@override
String get listFilter_newGroup => 'Нова група';
@override
String get pathTrace_you => 'Вие';
@override
String get pathTrace_failed => 'Пътят за проследяване не успя.';
@override
String get pathTrace_notAvailable => 'Пътека за проследяване не е достъпна.';
@override
String get pathTrace_refreshTooltip => 'Обнови Path Trace.';
@override
String get pathTrace_someHopsNoLocation =>
'Един или повече от хмелите липсва местоположение!';
@override
String get contacts_pathTrace => 'Пътен проследяване';
@override
String get contacts_ping => 'Пинг';
@override
String get contacts_repeaterPathTrace => 'Трасировка до повторител';
@override
String get contacts_repeaterPing => 'Пингване на повторителя';
@override
String get contacts_roomPathTrace => 'Трасиране на път до съ';
@override
String get contacts_roomPing => 'Ping на сървъра на стаята';
@override
String get contacts_chatTraceRoute => 'Трасиране на път';
@override
String contacts_pathTraceTo(String name) {
return 'Проследи маршрут към $name';
}
@override
String get contacts_clipboardEmpty => 'Клипборда е празна.';
@override
String get contacts_invalidAdvertFormat => 'Невалидни данни за контакт';
@override
String get contacts_contactImported => 'Контактът е импортиран.';
@override
String get contacts_contactImportFailed =>
'Контактът не е успешно импортиран.';
@override
String get contacts_zeroHopAdvert => 'Реклама без скок';
@override
String get contacts_floodAdvert => 'Потопна реклама';
@override
String get contacts_copyAdvertToClipboard => 'Копирай обявата в клипборда';
@override
String get contacts_addContactFromClipboard => 'Добави контакт от клипборда';
@override
String get contacts_ShareContact => 'Копирай контакт в клипборда';
@override
String get contacts_ShareContactZeroHop => 'Сподели контакт чрез обява';
@override
String get contacts_zeroHopContactAdvertSent => 'Изпратен контакт по обява.';
@override
String get contacts_zeroHopContactAdvertFailed =>
'Неуспешно изпращане на контакт.';
@override
String get contacts_contactAdvertCopied =>
'Рекламата е копирана в клипборда.';
@override
String get contacts_contactAdvertCopyFailed =>
'Копирането на обявата в клипборда не успя.';
@override
String get notification_activityTitle => 'Активност на MeshCore';
@override
String notification_messagesCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'съобщения',
one: 'съобщение',
);
return '$count $_temp0';
}
@override
String notification_channelMessagesCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'съобщения в канали',
one: 'съобщение в канал',
);
return '$count $_temp0';
}
@override
String notification_newNodesCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'нови възли',
one: 'нов възел',
);
return '$count $_temp0';
}
@override
String notification_newTypeDiscovered(String contactType) {
return 'Открит нов $contactType';
}
@override
String get notification_receivedNewMessage => 'Получено ново съобщение';
@override
String get settings_gpxExportRepeaters =>
'Експортиране на повтарящи се устройства / сървър на стаята до GPX';
@override
String get settings_gpxExportRepeatersSubtitle =>
'Изпраща повторители / roomserver с местоположение в GPX файл.';
@override
String get settings_gpxExportContacts => 'Експортирай спътници към GPX';
@override
String get settings_gpxExportContactsSubtitle =>
'Експортира спътници с местоположение в GPX файл.';
@override
String get settings_gpxExportAll => 'Експортирай всички контакти в GPX';
@override
String get settings_gpxExportAllSubtitle =>
'Експортира всички контакти с местоположение в файл GPX.';
@override
String get settings_gpxExportSuccess => 'Успешно изlexport на файл GPX.';
@override
String get settings_gpxExportNoContacts => 'Няма контакти за изlexport.';
@override
String get settings_gpxExportNotAvailable =>
'Не е поддържан на вашето устройство/ОС';
@override
String get settings_gpxExportError => 'Възникна грешка при изнасяне.';
@override
String get settings_gpxExportRepeatersRoom =>
'Местоположения на повторител и сървър на стаята';
@override
String get settings_gpxExportChat => 'Местоположения на спътници';
@override
String get settings_gpxExportAllContacts =>
'Местоположения на всички контакти';
@override
String get settings_gpxExportShareText =>
'Картинни данни изнесени от meshcore-open';
@override
String get settings_gpxExportShareSubject =>
'meshcore-open износ на данни за карта в формат GPX';
}
+206 -12
View File
@@ -160,7 +160,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get settings_appSettingsSubtitle =>
'Benachrichtigungen, Messaging und Kartenwahrnehmungen';
'Benachrichtigungen, Messaging und Kartenwahrnehmung';
@override
String get settings_nodeSettings => 'Knoten-Einstellungen';
@@ -444,6 +444,12 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get appSettings_languageBg => 'Български';
@override
String get appSettings_languageRu => 'Russisch';
@override
String get appSettings_languageUk => 'Ukrainisch';
@override
String get appSettings_notifications => 'Benachrichtigungen';
@@ -662,7 +668,7 @@ class AppLocalizationsDe extends AppLocalizations {
}
@override
String get contacts_manageRepeater => 'Wiederholungen verwalten';
String get contacts_manageRepeater => 'Repeater verwalten';
@override
String get contacts_manageRoom => 'Raum-Server verwalten';
@@ -796,7 +802,7 @@ class AppLocalizationsDe extends AppLocalizations {
String get channels_usePublicChannel => 'Verwende öffentlichen Kanal';
@override
String get channels_standardPublicPsk => 'Standard-Öffentliche PSK';
String get channels_standardPublicPsk => 'Öffentliche Standard PSK';
@override
String get channels_pskHex => 'PSK (Hex)';
@@ -1029,11 +1035,11 @@ class AppLocalizationsDe extends AppLocalizations {
}
@override
String get debugFrame_textMessageHeader => 'Textnachricht-Frame:';
String get debugFrame_textMessageHeader => 'Textnachrichten Frame:';
@override
String debugFrame_destinationPubKey(String pubKey) {
return '- Ziel-Pub-Schlüssel: $pubKey';
return '- Ziel-Public-Schlüssel: $pubKey';
}
@override
@@ -1888,8 +1894,7 @@ class AppLocalizationsDe extends AppLocalizations {
String get repeater_rebootRepeater => 'Neustart Repeater';
@override
String get repeater_rebootRepeaterSubtitle =>
'Wiederholen Sie das Repeater-Gerät.';
String get repeater_rebootRepeaterSubtitle => 'Repeater-Gerät neu starten.';
@override
String get repeater_rebootRepeaterConfirm =>
@@ -2357,7 +2362,7 @@ class AppLocalizationsDe extends AppLocalizations {
}
@override
String get neighbors_repeatersNeighbours => 'Wiederholer Nachbarn';
String get neighbors_repeatersNeighbours => 'Nachbarn';
@override
String get neighbors_noData => 'Keine Nachbardaten verfügbar.';
@@ -2588,7 +2593,7 @@ class AppLocalizationsDe extends AppLocalizations {
}
@override
String get community_regenerateSecret => 'Neu generieren Sie das Geheimnis';
String get community_regenerateSecret => 'Neugenerierung des Schlüssels';
@override
String community_regenerateSecretConfirm(String name) {
@@ -2600,15 +2605,15 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String community_secretRegenerated(String name) {
return 'Geheime Wiederherstellung für \"$name\" erfolgreich';
return 'Wiederherstellung des Schlüssels für \"$name\" erfolgreich';
}
@override
String get community_updateSecret => 'Aktualisieren Sie das Geheimnis';
String get community_updateSecret => 'Aktualisieren Sie den Schlüssel';
@override
String community_secretUpdated(String name) {
return 'Geheime für \"$name\" aktualisiert';
return 'Schlüssel für \"$name\" aktualisiert';
}
@override
@@ -2681,4 +2686,193 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get listFilter_newGroup => 'Neue Gruppe';
@override
String get pathTrace_you => 'Du';
@override
String get pathTrace_failed => 'Pfadverfolgung fehlgeschlagen.';
@override
String get pathTrace_notAvailable => 'Pfadverfolgung nicht verfügbar.';
@override
String get pathTrace_refreshTooltip => 'Path Trace aktualisieren.';
@override
String get pathTrace_someHopsNoLocation =>
'Eine oder mehrere der Hopfen fehlen einen Standort!';
@override
String get contacts_pathTrace => 'Pfadverfolgung';
@override
String get contacts_ping => 'Pingen';
@override
String get contacts_repeaterPathTrace => 'Pfadverfolgung zum Repeater';
@override
String get contacts_repeaterPing => 'Repeater pingen';
@override
String get contacts_roomPathTrace => 'Pfadverfolgung zum Raumserver';
@override
String get contacts_roomPing => 'Raumserver anpingen';
@override
String get contacts_chatTraceRoute => 'Pfadverfolgungsroute';
@override
String contacts_pathTraceTo(String name) {
return 'Route nach $name verfolgen';
}
@override
String get contacts_clipboardEmpty => 'Die Zwischenablage ist leer.';
@override
String get contacts_invalidAdvertFormat => 'Ungültige Kontaktdaten';
@override
String get contacts_contactImported => 'Kontakt wurde importiert.';
@override
String get contacts_contactImportFailed =>
'Kontakt konnte nicht importiert werden';
@override
String get contacts_zeroHopAdvert => 'Zero-Hop-Anzeige';
@override
String get contacts_floodAdvert => 'Überflutungsanzeige';
@override
String get contacts_copyAdvertToClipboard =>
'Werbung in die Zwischenablage kopieren';
@override
String get contacts_addContactFromClipboard =>
'Kontakt aus Zwischenablage hinzufügen';
@override
String get contacts_ShareContact => 'Kontakt in die Zwischenablage kopieren';
@override
String get contacts_ShareContactZeroHop => 'Kontakt über Anzeige teilen';
@override
String get contacts_zeroHopContactAdvertSent =>
'Kontakt über Anzeige gesendet';
@override
String get contacts_zeroHopContactAdvertFailed =>
'Kontakt konnte nicht gesendet werden.';
@override
String get contacts_contactAdvertCopied =>
'Anzeige in die Zwischenablage kopiert.';
@override
String get contacts_contactAdvertCopyFailed =>
'Kopieren des Werbeinhalts in die Zwischenablage fehlgeschlagen.';
@override
String get notification_activityTitle => 'MeshCore Aktivität';
@override
String notification_messagesCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'Nachrichten',
one: 'Nachricht',
);
return '$count $_temp0';
}
@override
String notification_channelMessagesCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'Kanalnachrichten',
one: 'Kanalnachricht',
);
return '$count $_temp0';
}
@override
String notification_newNodesCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'neue Knoten',
one: 'neuer Knoten',
);
return '$count $_temp0';
}
@override
String notification_newTypeDiscovered(String contactType) {
return 'Neuer $contactType entdeckt';
}
@override
String get notification_receivedNewMessage => 'Neue Nachricht empfangen';
@override
String get settings_gpxExportRepeaters =>
'Repeater und Raumserver nach GPX exportieren';
@override
String get settings_gpxExportRepeatersSubtitle =>
'Exportiert Repeater und Raumserver mit einem Standort in eine GPX-Datei.';
@override
String get settings_gpxExportContacts => 'Begleiter nach GPX exportieren';
@override
String get settings_gpxExportContactsSubtitle =>
'Exportiert Begleiter mit einem Ort in eine GPX-Datei.';
@override
String get settings_gpxExportAll => 'Alle Kontakte nach GPX exportieren';
@override
String get settings_gpxExportAllSubtitle =>
'Exportiert alle Kontakte mit einem Standort in eine GPX-Datei.';
@override
String get settings_gpxExportSuccess => 'Erfolgreich GPX-Datei exportiert.';
@override
String get settings_gpxExportNoContacts => 'Keine Kontakte zum Exportieren.';
@override
String get settings_gpxExportNotAvailable =>
'Nicht auf Ihrem Gerät/Betriebssystem unterstützt';
@override
String get settings_gpxExportError =>
'Beim Export ist ein Fehler aufgetreten.';
@override
String get settings_gpxExportRepeatersRoom =>
'Repeater- und Raumserver-Standorte';
@override
String get settings_gpxExportChat => 'Begleiterstandorte';
@override
String get settings_gpxExportAllContacts => 'Alle Kontaktstandorte';
@override
String get settings_gpxExportShareText =>
'Kartendaten aus meshcore-open exportiert';
@override
String get settings_gpxExportShareSubject =>
'meshcore-open GPX-Kartendaten exportieren';
}
+188
View File
@@ -442,6 +442,12 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get appSettings_languageBg => 'Български';
@override
String get appSettings_languageRu => 'Русский';
@override
String get appSettings_languageUk => 'Українська';
@override
String get appSettings_notifications => 'Notifications';
@@ -2636,4 +2642,186 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get listFilter_newGroup => 'New group';
@override
String get pathTrace_you => 'You';
@override
String get pathTrace_failed => 'Path trace failed.';
@override
String get pathTrace_notAvailable => 'Path trace not available.';
@override
String get pathTrace_refreshTooltip => 'Refresh Path Trace.';
@override
String get pathTrace_someHopsNoLocation =>
'One or more of the hops is missing a location!';
@override
String get contacts_pathTrace => 'Path Trace';
@override
String get contacts_ping => 'Ping';
@override
String get contacts_repeaterPathTrace => 'Path trace to repeater';
@override
String get contacts_repeaterPing => 'Ping repeater';
@override
String get contacts_roomPathTrace => 'Path trace to room server';
@override
String get contacts_roomPing => 'Ping room server';
@override
String get contacts_chatTraceRoute => 'Path trace route';
@override
String contacts_pathTraceTo(String name) {
return 'Trace route to $name';
}
@override
String get contacts_clipboardEmpty => 'Clipboard is empty.';
@override
String get contacts_invalidAdvertFormat => 'Invalid contact data';
@override
String get contacts_contactImported => 'Contact has been imported.';
@override
String get contacts_contactImportFailed => 'Failed to import contact.';
@override
String get contacts_zeroHopAdvert => 'Zero Hop Advert';
@override
String get contacts_floodAdvert => 'Flood Advert';
@override
String get contacts_copyAdvertToClipboard => 'Copy Advert to Clipboard';
@override
String get contacts_addContactFromClipboard => 'Add Contact from Clipboard';
@override
String get contacts_ShareContact => 'Copy contact to Clipboard';
@override
String get contacts_ShareContactZeroHop => 'Share contact by advert';
@override
String get contacts_zeroHopContactAdvertSent => 'Sent contact by advert.';
@override
String get contacts_zeroHopContactAdvertFailed => 'Failed to send contact.';
@override
String get contacts_contactAdvertCopied => 'Advert copied to Clipboard.';
@override
String get contacts_contactAdvertCopyFailed =>
'Copying advert to Clipboard failed.';
@override
String get notification_activityTitle => 'MeshCore Activity';
@override
String notification_messagesCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'messages',
one: 'message',
);
return '$count $_temp0';
}
@override
String notification_channelMessagesCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'channel messages',
one: 'channel message',
);
return '$count $_temp0';
}
@override
String notification_newNodesCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'new nodes',
one: 'new node',
);
return '$count $_temp0';
}
@override
String notification_newTypeDiscovered(String contactType) {
return 'New $contactType discovered';
}
@override
String get notification_receivedNewMessage => 'Received new message';
@override
String get settings_gpxExportRepeaters =>
'Export repeaters / room server to GPX';
@override
String get settings_gpxExportRepeatersSubtitle =>
'Exports repeaters / roomserver with a location to GPX file.';
@override
String get settings_gpxExportContacts => 'Export companions to GPX';
@override
String get settings_gpxExportContactsSubtitle =>
'Exports companions with a location to GPX file.';
@override
String get settings_gpxExportAll => 'Export all contacts to GPX';
@override
String get settings_gpxExportAllSubtitle =>
'Exports all contacts with a location to GPX file.';
@override
String get settings_gpxExportSuccess => 'Successfully exported GPX file.';
@override
String get settings_gpxExportNoContacts => 'No contacts to export.';
@override
String get settings_gpxExportNotAvailable =>
'Not supported on your device/OS';
@override
String get settings_gpxExportError => 'There was an error when exporting.';
@override
String get settings_gpxExportRepeatersRoom =>
'Repeater & room server locations';
@override
String get settings_gpxExportChat => 'Companion locations';
@override
String get settings_gpxExportAllContacts => 'All contacts locations';
@override
String get settings_gpxExportShareText =>
'Map data exported from meshcore-open';
@override
String get settings_gpxExportShareSubject =>
'meshcore-open GPX map data export';
}
+193
View File
@@ -447,6 +447,12 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get appSettings_languageBg => 'Български';
@override
String get appSettings_languageRu => 'Ruso';
@override
String get appSettings_languageUk => 'Ucraniano';
@override
String get appSettings_notifications => 'Notificaciones';
@@ -2675,4 +2681,191 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get listFilter_newGroup => 'Nuevo grupo';
@override
String get pathTrace_you => '';
@override
String get pathTrace_failed => 'El trazado de ruta falló.';
@override
String get pathTrace_notAvailable => 'El trazado de ruta no está disponible.';
@override
String get pathTrace_refreshTooltip => 'Actualizar Path Trace';
@override
String get pathTrace_someHopsNoLocation =>
'Uno o más de los lúpulos carecen de una ubicación';
@override
String get contacts_pathTrace => 'Rastreo de caminos';
@override
String get contacts_ping => 'Ping';
@override
String get contacts_repeaterPathTrace => 'Rastrear ruta al repetidor';
@override
String get contacts_repeaterPing => 'Pingar repetidor';
@override
String get contacts_roomPathTrace =>
'Rastreo de ruta al servidor de la habitación';
@override
String get contacts_roomPing => 'Pingar servidor de sala';
@override
String get contacts_chatTraceRoute => 'Ruta de trazado';
@override
String contacts_pathTraceTo(String name) {
return 'Rastrear ruta a $name';
}
@override
String get contacts_clipboardEmpty => 'El portapapeles está vacío.';
@override
String get contacts_invalidAdvertFormat => 'Datos de contacto no válidos';
@override
String get contacts_contactImported => 'El contacto ha sido importado.';
@override
String get contacts_contactImportFailed =>
'Contacto no se importó correctamente.';
@override
String get contacts_zeroHopAdvert => 'Anuncio de Zero Hop';
@override
String get contacts_floodAdvert => 'Anuncio de inundación';
@override
String get contacts_copyAdvertToClipboard => 'Copiar anuncio al portapapeles';
@override
String get contacts_addContactFromClipboard =>
'Agregar contacto desde el portapapeles';
@override
String get contacts_ShareContact => 'Copiar contacto al Portapapeles';
@override
String get contacts_ShareContactZeroHop => 'Compartir contacto por anuncio';
@override
String get contacts_zeroHopContactAdvertSent => 'Envió contacto por anuncio.';
@override
String get contacts_zeroHopContactAdvertFailed =>
'No se pudo enviar el contacto.';
@override
String get contacts_contactAdvertCopied => 'Anuncio copiado al Portapapeles.';
@override
String get contacts_contactAdvertCopyFailed =>
'Copiar anuncio al Portapapeles ha fallado.';
@override
String get notification_activityTitle => 'Actividad de MeshCore';
@override
String notification_messagesCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'mensajes',
one: 'mensaje',
);
return '$count $_temp0';
}
@override
String notification_channelMessagesCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'mensajes de canal',
one: 'mensaje de canal',
);
return '$count $_temp0';
}
@override
String notification_newNodesCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'nuevos nodos',
one: 'nuevo nodo',
);
return '$count $_temp0';
}
@override
String notification_newTypeDiscovered(String contactType) {
return 'Nuevo $contactType descubierto';
}
@override
String get notification_receivedNewMessage => 'Nuevo mensaje recibido';
@override
String get settings_gpxExportRepeaters =>
'Exportar repetidores / servidor de sala a GPX';
@override
String get settings_gpxExportRepeatersSubtitle =>
'Exporta repetidores o roomserver con una ubicación a un archivo GPX.';
@override
String get settings_gpxExportContacts => 'Exportar compañeros a GPX';
@override
String get settings_gpxExportContactsSubtitle =>
'Exporta compañeros con una ubicación a archivo GPX.';
@override
String get settings_gpxExportAll => 'Exportar todos los contactos a GPX';
@override
String get settings_gpxExportAllSubtitle =>
'Exporta todos los contactos con una ubicación a un archivo GPX.';
@override
String get settings_gpxExportSuccess => 'Archivo GPX exportado con éxito.';
@override
String get settings_gpxExportNoContacts => 'No hay contactos para exportar.';
@override
String get settings_gpxExportNotAvailable =>
'No compatible con tu dispositivo/SO';
@override
String get settings_gpxExportError => 'Hubo un error al exportar.';
@override
String get settings_gpxExportRepeatersRoom =>
'Ubicaciones del servidor de repetidor y sala';
@override
String get settings_gpxExportChat => 'Ubicaciones de compañero';
@override
String get settings_gpxExportAllContacts =>
'Todas las ubicaciones de contactos';
@override
String get settings_gpxExportShareText =>
'Datos del mapa exportados desde meshcore-open';
@override
String get settings_gpxExportShareSubject =>
'meshcore-open exportación de datos de mapa GPX';
}
+200
View File
@@ -448,6 +448,12 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get appSettings_languageBg => 'Български';
@override
String get appSettings_languageRu => 'Russe';
@override
String get appSettings_languageUk => 'Ukrainien';
@override
String get appSettings_notifications => 'Notifications';
@@ -2692,4 +2698,198 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get listFilter_newGroup => 'Nouveau groupe';
@override
String get pathTrace_you => 'Vous';
@override
String get pathTrace_failed => 'Traçage du chemin échoué.';
@override
String get pathTrace_notAvailable => 'Tracé de chemin non disponible.';
@override
String get pathTrace_refreshTooltip => 'Actualiser Path Trace';
@override
String get pathTrace_someHopsNoLocation =>
'Une ou plusieurs des houblons manquent d\'une localisation !';
@override
String get contacts_pathTrace => 'Traçage de chemin';
@override
String get contacts_ping => 'Ping';
@override
String get contacts_repeaterPathTrace => 'Tracer le chemin vers le répéteur';
@override
String get contacts_repeaterPing => 'Pinguer le répéteur';
@override
String get contacts_roomPathTrace =>
'Traçage du chemin vers le serveur de la salle';
@override
String get contacts_roomPing => 'Pinguer le serveur de la salle';
@override
String get contacts_chatTraceRoute => 'Tracer le chemin';
@override
String contacts_pathTraceTo(String name) {
return 'Tracer l\'itinéraire vers $name';
}
@override
String get contacts_clipboardEmpty => 'Le presse-papiers est vide.';
@override
String get contacts_invalidAdvertFormat => 'Données de contact non valides';
@override
String get contacts_contactImported => 'Le contact a été importé.';
@override
String get contacts_contactImportFailed =>
'Échec de l\'importation du contact.';
@override
String get contacts_zeroHopAdvert => 'Annonce Zero Hop';
@override
String get contacts_floodAdvert => 'Annonce de crue';
@override
String get contacts_copyAdvertToClipboard =>
'Copier l\'annonce dans le presse-papiers';
@override
String get contacts_addContactFromClipboard =>
'Ajouter un contact depuis le presse-papiers';
@override
String get contacts_ShareContact =>
'Copier le contact dans le presse-papiers';
@override
String get contacts_ShareContactZeroHop => 'Partager un contact par annonce';
@override
String get contacts_zeroHopContactAdvertSent =>
'Envoyer un contact par annonce.';
@override
String get contacts_zeroHopContactAdvertFailed =>
'Échec de l\'envoi du contact.';
@override
String get contacts_contactAdvertCopied =>
'Annonce copiée dans le presse-papiers.';
@override
String get contacts_contactAdvertCopyFailed =>
'La copie de l\'annonce vers le presse-papiers a échoué.';
@override
String get notification_activityTitle => 'Activité MeshCore';
@override
String notification_messagesCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'messages',
one: 'message',
);
return '$count $_temp0';
}
@override
String notification_channelMessagesCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'messages de canal',
one: 'message de canal',
);
return '$count $_temp0';
}
@override
String notification_newNodesCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'nouveaux nœuds',
one: 'nouveau nœud',
);
return '$count $_temp0';
}
@override
String notification_newTypeDiscovered(String contactType) {
return 'Nouveau $contactType découvert';
}
@override
String get notification_receivedNewMessage => 'Nouveau message reçu';
@override
String get settings_gpxExportRepeaters =>
'Exporter les répéteurs / serveur de salle au format GPX';
@override
String get settings_gpxExportRepeatersSubtitle =>
'Exporte les répéteurs / roomserver avec une localisation vers un fichier GPX.';
@override
String get settings_gpxExportContacts =>
'Exporter les compagnons au format GPX';
@override
String get settings_gpxExportContactsSubtitle =>
'Exporte les compagnons avec un emplacement vers un fichier GPX.';
@override
String get settings_gpxExportAll =>
'Exporter tous les contacts au format GPX';
@override
String get settings_gpxExportAllSubtitle =>
'Exporte tous les contacts avec une localisation vers un fichier GPX.';
@override
String get settings_gpxExportSuccess => 'Fichier GPX exporté avec succès.';
@override
String get settings_gpxExportNoContacts => 'Aucun contact à exporter.';
@override
String get settings_gpxExportNotAvailable =>
'Non pris en charge sur votre appareil/Système d\'exploitation';
@override
String get settings_gpxExportError =>
'Une erreur s\'est produite lors de l\'exportation.';
@override
String get settings_gpxExportRepeatersRoom =>
'Emplacements des serveurs de répéteur et de salle';
@override
String get settings_gpxExportChat => 'Emplacements des compagnons';
@override
String get settings_gpxExportAllContacts =>
'Tous les emplacements des contacts';
@override
String get settings_gpxExportShareText =>
'Données de carte exportées à partir de meshcore-open';
@override
String get settings_gpxExportShareSubject =>
'meshcore-open exporter les données de carte GPX';
}
+197
View File
@@ -446,6 +446,12 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get appSettings_languageBg => 'Български';
@override
String get appSettings_languageRu => 'Russo';
@override
String get appSettings_languageUk => 'Ucraino';
@override
String get appSettings_notifications => 'Notifiche';
@@ -2675,4 +2681,195 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get listFilter_newGroup => 'Nuovo gruppo';
@override
String get pathTrace_you => 'Tu';
@override
String get pathTrace_failed => 'Tracciamento del percorso fallito.';
@override
String get pathTrace_notAvailable =>
'Tracciamento del percorso non disponibile.';
@override
String get pathTrace_refreshTooltip => 'Aggiorna Path Trace.';
@override
String get pathTrace_someHopsNoLocation =>
'Uno o più dei luppoli mancano di una posizione!';
@override
String get contacts_pathTrace => 'Traccia Percorso';
@override
String get contacts_ping => 'Ping';
@override
String get contacts_repeaterPathTrace => 'Traccia percorso al ripetitore';
@override
String get contacts_repeaterPing => 'Ripetitore ping';
@override
String get contacts_roomPathTrace =>
'Traccia del percorso al server della stanza';
@override
String get contacts_roomPing => 'Ping al server della stanza';
@override
String get contacts_chatTraceRoute => 'Traccia percorso path';
@override
String contacts_pathTraceTo(String name) {
return 'Traccia percorso verso $name';
}
@override
String get contacts_clipboardEmpty => 'La clipboard è vuota.';
@override
String get contacts_invalidAdvertFormat => 'Dati di contatto non validi';
@override
String get contacts_contactImported => 'Il contatto è stato importato.';
@override
String get contacts_contactImportFailed =>
'Contatto non importato con successo.';
@override
String get contacts_zeroHopAdvert => 'Annuncio Zero Hop';
@override
String get contacts_floodAdvert => 'Annuncio alluvionale';
@override
String get contacts_copyAdvertToClipboard => 'Copia Annuncio negli Appunti';
@override
String get contacts_addContactFromClipboard =>
'Aggiungere contatto dalla clipboard';
@override
String get contacts_ShareContact => 'Copia contatto negli Appunti';
@override
String get contacts_ShareContactZeroHop =>
'Condividi contatto tramite annuncio';
@override
String get contacts_zeroHopContactAdvertSent =>
'Inviato contatto tramite annuncio.';
@override
String get contacts_zeroHopContactAdvertFailed =>
'Invio del contatto non riuscito.';
@override
String get contacts_contactAdvertCopied => 'Annuncio copiato negli Appunti.';
@override
String get contacts_contactAdvertCopyFailed =>
'Copia dell\'annuncio nella Clipboard non riuscita.';
@override
String get notification_activityTitle => 'Attività MeshCore';
@override
String notification_messagesCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'messaggi',
one: 'messaggio',
);
return '$count $_temp0';
}
@override
String notification_channelMessagesCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'messaggi del canale',
one: 'messaggio del canale',
);
return '$count $_temp0';
}
@override
String notification_newNodesCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'nuovi nodi',
one: 'nuovo nodo',
);
return '$count $_temp0';
}
@override
String notification_newTypeDiscovered(String contactType) {
return 'Nuovo $contactType scoperto';
}
@override
String get notification_receivedNewMessage => 'Nuovo messaggio ricevuto';
@override
String get settings_gpxExportRepeaters =>
'Esporta ripetitori / server di stanza in GPX';
@override
String get settings_gpxExportRepeatersSubtitle =>
'Esporta ripetitori / roomserver con una posizione in un file GPX.';
@override
String get settings_gpxExportContacts => 'Esporta compagni in GPX';
@override
String get settings_gpxExportContactsSubtitle =>
'Esporta i compagni con una posizione in un file GPX.';
@override
String get settings_gpxExportAll => 'Esporta tutti i contatti in GPX';
@override
String get settings_gpxExportAllSubtitle =>
'Esporta tutti i contatti con una posizione in un file GPX.';
@override
String get settings_gpxExportSuccess =>
'Esportazione del file GPX completata con successo.';
@override
String get settings_gpxExportNoContacts => 'Nessun contatto da esportare.';
@override
String get settings_gpxExportNotAvailable =>
'Non supportato sul tuo dispositivo/Sistema Operativo';
@override
String get settings_gpxExportError =>
'Si è verificato un errore durante l\'esportazione.';
@override
String get settings_gpxExportRepeatersRoom =>
'Posizioni del server ripetitore e della stanza';
@override
String get settings_gpxExportChat => 'Posizioni dei compagni';
@override
String get settings_gpxExportAllContacts => 'Tutte le posizioni dei contatti';
@override
String get settings_gpxExportShareText =>
'Dati mappa esportati da meshcore-open';
@override
String get settings_gpxExportShareSubject =>
'meshcore-open esportazione dati mappa GPX';
}
+193
View File
@@ -444,6 +444,12 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get appSettings_languageBg => 'Български';
@override
String get appSettings_languageRu => 'Russisch';
@override
String get appSettings_languageUk => 'Oekraïens';
@override
String get appSettings_notifications => 'Notificaties';
@@ -2666,4 +2672,191 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get listFilter_newGroup => 'Nieuwe groep';
@override
String get pathTrace_you => 'Jij';
@override
String get pathTrace_failed => 'Padtrace mislukt.';
@override
String get pathTrace_notAvailable => 'Padtrace niet beschikbaar.';
@override
String get pathTrace_refreshTooltip => 'Path Trace vernieuwen.';
@override
String get pathTrace_someHopsNoLocation =>
'Een of meer van de hops ontbreken een locatie!';
@override
String get contacts_pathTrace => 'Pad Traceren';
@override
String get contacts_ping => 'Pingen';
@override
String get contacts_repeaterPathTrace => 'Pad traceren naar repeater';
@override
String get contacts_repeaterPing => 'Ping repeater';
@override
String get contacts_roomPathTrace => 'Padtrace naar room server';
@override
String get contacts_roomPing => 'Ping kamer server';
@override
String get contacts_chatTraceRoute => 'Route traceren';
@override
String contacts_pathTraceTo(String name) {
return 'Trace route to $name';
}
@override
String get contacts_clipboardEmpty => 'Knipbord is leeg.';
@override
String get contacts_invalidAdvertFormat => 'Ongeldige contactgegevens';
@override
String get contacts_contactImported => 'Contact is geïmporteerd.';
@override
String get contacts_contactImportFailed =>
'Contact kon niet geïmporteerd worden.';
@override
String get contacts_zeroHopAdvert => 'Zero Hop Reclame';
@override
String get contacts_floodAdvert => 'Overstromingsadvertentie';
@override
String get contacts_copyAdvertToClipboard => 'Advert naar klembord kopiëren';
@override
String get contacts_addContactFromClipboard =>
'Contact uit klembord toevoegen';
@override
String get contacts_ShareContact => 'Kontakt naar Klembord kopiëren';
@override
String get contacts_ShareContactZeroHop => 'Contact delen via advertentie';
@override
String get contacts_zeroHopContactAdvertSent =>
'Contact verzonden via advertentie';
@override
String get contacts_zeroHopContactAdvertFailed =>
'Mislukt om contact te verzenden';
@override
String get contacts_contactAdvertCopied =>
'Reclame gekopieerd naar Klembord.';
@override
String get contacts_contactAdvertCopyFailed =>
'Kopiëren van advertentie naar Clipboard is mislukt.';
@override
String get notification_activityTitle => 'MeshCore Activiteit';
@override
String notification_messagesCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'berichten',
one: 'bericht',
);
return '$count $_temp0';
}
@override
String notification_channelMessagesCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'kanaalberichten',
one: 'kanaalbericht',
);
return '$count $_temp0';
}
@override
String notification_newNodesCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'nieuwe knooppunten',
one: 'nieuw knooppunt',
);
return '$count $_temp0';
}
@override
String notification_newTypeDiscovered(String contactType) {
return 'Nieuw $contactType ontdekt';
}
@override
String get notification_receivedNewMessage => 'Nieuw bericht ontvangen';
@override
String get settings_gpxExportRepeaters =>
'Exporteer repeaters / roomserver naar GPX';
@override
String get settings_gpxExportRepeatersSubtitle =>
'Exporteert repeaters / roomserver met een locatie naar GPX-bestand.';
@override
String get settings_gpxExportContacts => 'Companions exporteren naar GPX';
@override
String get settings_gpxExportContactsSubtitle =>
'Exporteert metgezellen met een locatie naar een GPX-bestand.';
@override
String get settings_gpxExportAll => 'Alle contacten exporteren naar GPX';
@override
String get settings_gpxExportAllSubtitle =>
'Exporteert alle contacten met een locatie naar een GPX-bestand.';
@override
String get settings_gpxExportSuccess => 'Succesvol GPX-bestand geëxporteerd.';
@override
String get settings_gpxExportNoContacts => 'Geen contacten om te exporteren.';
@override
String get settings_gpxExportNotAvailable =>
'Niet ondersteund op uw apparaat/besturingssysteem';
@override
String get settings_gpxExportError => 'Er was een fout bij het exporteren.';
@override
String get settings_gpxExportRepeatersRoom =>
'Repeater- en kamer servers locaties';
@override
String get settings_gpxExportChat => 'Locaties van metgezellen';
@override
String get settings_gpxExportAllContacts => 'Alle contactlocaties';
@override
String get settings_gpxExportShareText =>
'Kaartgegevens geëxporteerd uit meshcore-open';
@override
String get settings_gpxExportShareSubject =>
'meshcore-open GPX kaartgegevens exporteren';
}
+200
View File
@@ -448,6 +448,12 @@ class AppLocalizationsPl extends AppLocalizations {
@override
String get appSettings_languageBg => 'Български';
@override
String get appSettings_languageRu => 'Rosyjski';
@override
String get appSettings_languageUk => 'Ukraińska';
@override
String get appSettings_notifications => 'Powiadomienia';
@@ -2674,4 +2680,198 @@ class AppLocalizationsPl extends AppLocalizations {
@override
String get listFilter_newGroup => 'Nowa grupa';
@override
String get pathTrace_you => 'Ty';
@override
String get pathTrace_failed => 'Śledzenie ścieżki nie powiodło się.';
@override
String get pathTrace_notAvailable => 'Ścieżka śledzenia niedostępna.';
@override
String get pathTrace_refreshTooltip => 'Odśwież ścieżkę.';
@override
String get pathTrace_someHopsNoLocation =>
'Jeden lub więcej z chmieli nie ma określonej lokalizacji!';
@override
String get contacts_pathTrace => 'Śledzenie Ścieżek';
@override
String get contacts_ping => 'Pingować';
@override
String get contacts_repeaterPathTrace => 'Śledzenie ścieżki do repeatera';
@override
String get contacts_repeaterPing => 'Repeater pingowy';
@override
String get contacts_roomPathTrace =>
'Śledzenie ścieżki do serwera pokojowego';
@override
String get contacts_roomPing => 'Pinguj serwer pokoju';
@override
String get contacts_chatTraceRoute => 'Śledź trasę promienia';
@override
String contacts_pathTraceTo(String name) {
return 'Śledź trasę do $name';
}
@override
String get contacts_clipboardEmpty => 'Schowek jest pusty.';
@override
String get contacts_invalidAdvertFormat => 'Nieprawidłowe dane kontaktowe';
@override
String get contacts_contactImported => 'Kontakt został zaimportowany.';
@override
String get contacts_contactImportFailed =>
'Kontakt nie został zaimportowany.';
@override
String get contacts_zeroHopAdvert => 'Reklama Zero Hop';
@override
String get contacts_floodAdvert => 'Reklama powodziowa';
@override
String get contacts_copyAdvertToClipboard => 'Kopiuj ogłoszenie do schowka';
@override
String get contacts_addContactFromClipboard => 'Dodaj kontakt z schowka';
@override
String get contacts_ShareContact => 'Kopiuj kontakt do schowka';
@override
String get contacts_ShareContactZeroHop =>
'Udostępnij kontakt przez ogłoszenie';
@override
String get contacts_zeroHopContactAdvertSent =>
'Wysłano kontakt przez ogłoszenie.';
@override
String get contacts_zeroHopContactAdvertFailed =>
'Nie udało się wysłać kontaktu.';
@override
String get contacts_contactAdvertCopied => 'Reklama skopiowana do schowka.';
@override
String get contacts_contactAdvertCopyFailed =>
'Kopiowanie ogłoszenia do schowka nie powiodło się.';
@override
String get notification_activityTitle => 'Aktywność MeshCore';
@override
String notification_messagesCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'wiadomości',
many: 'wiadomości',
few: 'wiadomości',
one: 'wiadomość',
);
return '$count $_temp0';
}
@override
String notification_channelMessagesCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'wiadomości kanału',
many: 'wiadomości kanału',
few: 'wiadomości kanału',
one: 'wiadomość kanału',
);
return '$count $_temp0';
}
@override
String notification_newNodesCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'nowych węzłów',
many: 'nowych węzłów',
few: 'nowe węzły',
one: 'nowy węzeł',
);
return '$count $_temp0';
}
@override
String notification_newTypeDiscovered(String contactType) {
return 'Nowy $contactType wykryty';
}
@override
String get notification_receivedNewMessage => 'Otrzymano nową wiadomość';
@override
String get settings_gpxExportRepeaters =>
'Eksportuj powtórki / serwer pokojowy do GPX';
@override
String get settings_gpxExportRepeatersSubtitle =>
'Eksportuje powtarzacze / roomserver z lokalizacją do pliku GPX.';
@override
String get settings_gpxExportContacts => 'Eksportuj towarzyszy do GPX';
@override
String get settings_gpxExportContactsSubtitle =>
'Eksportuje towarzyszy z lokalizacją do pliku GPX.';
@override
String get settings_gpxExportAll => 'Eksportuj wszystkie kontakty do GPX';
@override
String get settings_gpxExportAllSubtitle =>
'Eksportuje wszystkie kontakty z lokalizacją do pliku GPX.';
@override
String get settings_gpxExportSuccess => 'Pomyślnie wyeksportowano plik GPX.';
@override
String get settings_gpxExportNoContacts =>
'Brak kontaktów do wyeksportowania.';
@override
String get settings_gpxExportNotAvailable =>
'Nie obsługiwane na Twoim urządzeniu/systemie operacyjnym';
@override
String get settings_gpxExportError => 'Wystąpił błąd podczas eksportowania.';
@override
String get settings_gpxExportRepeatersRoom =>
'Lokalizacje serwerów powtarzających i pomieszczeń';
@override
String get settings_gpxExportChat => 'Lokalizacje towarzyszy';
@override
String get settings_gpxExportAllContacts => 'Wszystkie lokalizacje kontaktów';
@override
String get settings_gpxExportShareText =>
'Dane mapy wyeksportowane z meshcore-open';
@override
String get settings_gpxExportShareSubject =>
'Eksport danych mapy GPX meshcore-open';
}
+192
View File
@@ -448,6 +448,12 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get appSettings_languageBg => 'Български';
@override
String get appSettings_languageRu => 'Russo';
@override
String get appSettings_languageUk => 'Ucraniano';
@override
String get appSettings_notifications => 'Notificações';
@@ -2677,4 +2683,190 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get listFilter_newGroup => 'Novo grupo';
@override
String get pathTrace_you => 'Você';
@override
String get pathTrace_failed => 'Falha no rastreamento de caminho.';
@override
String get pathTrace_notAvailable => 'Traçado de caminho não disponível.';
@override
String get pathTrace_refreshTooltip => 'Atualizar Path Trace.';
@override
String get pathTrace_someHopsNoLocation =>
'Um ou mais dos lúpulos estão sem localização!';
@override
String get contacts_pathTrace => 'Traçado de Caminho';
@override
String get contacts_ping => 'Pingar';
@override
String get contacts_repeaterPathTrace => 'Traçar caminho para repetidor';
@override
String get contacts_repeaterPing => 'Pingar repetidor';
@override
String get contacts_roomPathTrace => 'Traçar caminho para o servidor da sala';
@override
String get contacts_roomPing => 'Pingar servidor da sala';
@override
String get contacts_chatTraceRoute => 'Rastrear rota do caminho';
@override
String contacts_pathTraceTo(String name) {
return 'Rastrear rota para $name';
}
@override
String get contacts_clipboardEmpty => 'Área de Transferência Está Vazia.';
@override
String get contacts_invalidAdvertFormat => 'Dados de Contato Inválidos';
@override
String get contacts_contactImported => 'Contato foi importado.';
@override
String get contacts_contactImportFailed => 'Contato falhou ao ser importado.';
@override
String get contacts_zeroHopAdvert => 'Anúncio Zero Hop';
@override
String get contacts_floodAdvert => 'Anúncio de Inundação';
@override
String get contacts_copyAdvertToClipboard =>
'Copiar Anúncio para Área de Transferência';
@override
String get contacts_addContactFromClipboard =>
'Adicionar Contato da Área de Transferência';
@override
String get contacts_ShareContact =>
'Copiar contato para Área de Transferência';
@override
String get contacts_ShareContactZeroHop => 'Compartilhar contato por anúncio';
@override
String get contacts_zeroHopContactAdvertSent => 'Enviou contato por anúncio.';
@override
String get contacts_zeroHopContactAdvertFailed => 'Falha ao enviar contato.';
@override
String get contacts_contactAdvertCopied =>
'Anúncio copiado para a Área de Transferência.';
@override
String get contacts_contactAdvertCopyFailed =>
'Cópia do anúncio para a Área de Transferência falhou.';
@override
String get notification_activityTitle => 'Atividade MeshCore';
@override
String notification_messagesCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'mensagens',
one: 'mensagem',
);
return '$count $_temp0';
}
@override
String notification_channelMessagesCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'mensagens de canal',
one: 'mensagem de canal',
);
return '$count $_temp0';
}
@override
String notification_newNodesCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'novos nós',
one: 'novo nó',
);
return '$count $_temp0';
}
@override
String notification_newTypeDiscovered(String contactType) {
return 'Novo $contactType descoberto';
}
@override
String get notification_receivedNewMessage => 'Nova mensagem recebida';
@override
String get settings_gpxExportRepeaters =>
'Exportar repetidores / servidor de sala para GPX';
@override
String get settings_gpxExportRepeatersSubtitle =>
'Exporta repetidores / roomserver com localização para arquivo GPX.';
@override
String get settings_gpxExportContacts => 'Exportar companheiros para GPX';
@override
String get settings_gpxExportContactsSubtitle =>
'Exporta companheiros com uma localização para um arquivo GPX.';
@override
String get settings_gpxExportAll => 'Exportar todos os contatos para GPX';
@override
String get settings_gpxExportAllSubtitle =>
'Exporta todos os contatos com uma localização para um arquivo GPX.';
@override
String get settings_gpxExportSuccess => 'Arquivo GPX exportado com sucesso.';
@override
String get settings_gpxExportNoContacts => 'Nenhum contato para exportar.';
@override
String get settings_gpxExportNotAvailable =>
'Não suportado no seu dispositivo/SO';
@override
String get settings_gpxExportError => 'Ocorreu um erro ao exportar.';
@override
String get settings_gpxExportRepeatersRoom =>
'Localizações do servidor de repetidor e sala';
@override
String get settings_gpxExportChat => 'Localizações de companheiros';
@override
String get settings_gpxExportAllContacts => 'Todos os locais de contatos';
@override
String get settings_gpxExportShareText =>
'Dados do mapa exportados do meshcore-open';
@override
String get settings_gpxExportShareSubject =>
'meshcore-open exportação de dados de mapa GPX';
}
+201
View File
@@ -446,6 +446,12 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get appSettings_languageBg => 'Болгарский';
@override
String get appSettings_languageRu => 'Русский';
@override
String get appSettings_languageUk => 'Українська';
@override
String get appSettings_notifications => 'Уведомления';
@@ -2679,4 +2685,199 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get listFilter_newGroup => 'Новая группа';
@override
String get pathTrace_you => 'Вы';
@override
String get pathTrace_failed => 'Путь трассировки не выполнен.';
@override
String get pathTrace_notAvailable => 'Трассировка пути недоступна.';
@override
String get pathTrace_refreshTooltip => 'Обновить Path Trace';
@override
String get pathTrace_someHopsNoLocation =>
'Одному или нескольким хмелям не указано местоположение!';
@override
String get contacts_pathTrace => 'Трассировка пути';
@override
String get contacts_ping => 'Пинговать';
@override
String get contacts_repeaterPathTrace => 'Отследить путь к ретранслятору';
@override
String get contacts_repeaterPing => 'Пинговать повторитель';
@override
String get contacts_roomPathTrace => 'Трассировка пути к серверу комнаты';
@override
String get contacts_roomPing => 'Пинговать сервер комнаты';
@override
String get contacts_chatTraceRoute => 'Трассировка маршрута';
@override
String contacts_pathTraceTo(String name) {
return 'Показать маршрут к $name';
}
@override
String get contacts_clipboardEmpty => 'Буфер обмена пуст.';
@override
String get contacts_invalidAdvertFormat =>
'Недействительные контактные данные';
@override
String get contacts_contactImported => 'Контакт был импортирован';
@override
String get contacts_contactImportFailed => 'Контакт не удалось импортировать';
@override
String get contacts_zeroHopAdvert => 'Реклама Zero Hop';
@override
String get contacts_floodAdvert => 'Рекламный поток';
@override
String get contacts_copyAdvertToClipboard =>
'Копировать рекламу в буфер обмена';
@override
String get contacts_addContactFromClipboard =>
'Добавить контакт из буфера обмена';
@override
String get contacts_ShareContact => 'Копировать контакт в буфер обмена';
@override
String get contacts_ShareContactZeroHop =>
'Поделиться контактом по объявлению';
@override
String get contacts_zeroHopContactAdvertSent =>
'Отправлено сообщение по объявлению.';
@override
String get contacts_zeroHopContactAdvertFailed =>
'Не удалось отправить контакт.';
@override
String get contacts_contactAdvertCopied =>
'Реклама скопирована в буфер обмена.';
@override
String get contacts_contactAdvertCopyFailed =>
'Копирование рекламы в буфер обмена не удалось.';
@override
String get notification_activityTitle => 'Активность MeshCore';
@override
String notification_messagesCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'сообщений',
many: 'сообщений',
few: 'сообщения',
one: 'сообщение',
);
return '$count $_temp0';
}
@override
String notification_channelMessagesCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'сообщений канала',
many: 'сообщений канала',
few: 'сообщения канала',
one: 'сообщение канала',
);
return '$count $_temp0';
}
@override
String notification_newNodesCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'новых узлов',
many: 'новых узлов',
few: 'новых узла',
one: 'новый узел',
);
return '$count $_temp0';
}
@override
String notification_newTypeDiscovered(String contactType) {
return 'Обнаружен новый $contactType';
}
@override
String get notification_receivedNewMessage => 'Получено новое сообщение';
@override
String get settings_gpxExportRepeaters =>
'Экспортировать рипитеры / сервер комнаты в GPX';
@override
String get settings_gpxExportRepeatersSubtitle =>
'Экспортирует ретрансляторы / сервер комнат с местоположением в файл GPX.';
@override
String get settings_gpxExportContacts => 'Экспортировать спутников в GPX';
@override
String get settings_gpxExportContactsSubtitle =>
'Экспортирует спутников с местоположением в файл GPX.';
@override
String get settings_gpxExportAll => 'Экспортировать все контакты в GPX';
@override
String get settings_gpxExportAllSubtitle =>
'Экспортирует все контакты с местоположением в файл GPX.';
@override
String get settings_gpxExportSuccess => 'Успешно экспортирован файл GPX.';
@override
String get settings_gpxExportNoContacts => 'Нет контактов для экспорта.';
@override
String get settings_gpxExportNotAvailable =>
'Не поддерживается на вашем устройстве/ОС';
@override
String get settings_gpxExportError => 'Произошла ошибка при экспорте.';
@override
String get settings_gpxExportRepeatersRoom =>
'Местоположения повторителей и серверов комнат';
@override
String get settings_gpxExportChat => 'Местоположения спутников';
@override
String get settings_gpxExportAllContacts => 'Все местоположения контактов';
@override
String get settings_gpxExportShareText =>
'Данные карты экспортированы из meshcore-open';
@override
String get settings_gpxExportShareSubject =>
'meshcore-open экспорт данных карты GPX';
}
+194
View File
@@ -444,6 +444,12 @@ class AppLocalizationsSk extends AppLocalizations {
@override
String get appSettings_languageBg => 'Български';
@override
String get appSettings_languageRu => 'Ruština';
@override
String get appSettings_languageUk => 'Ukrajinská';
@override
String get appSettings_notifications => 'Upozornenia';
@@ -2662,4 +2668,192 @@ class AppLocalizationsSk extends AppLocalizations {
@override
String get listFilter_newGroup => 'Nová skupina';
@override
String get pathTrace_you => 'Vy';
@override
String get pathTrace_failed => 'Sledovanie cesty zlyhalo.';
@override
String get pathTrace_notAvailable => 'Path trace nie je k dispozícii.';
@override
String get pathTrace_refreshTooltip => 'Obnoviť Path Trace.';
@override
String get pathTrace_someHopsNoLocation =>
'Jedna alebo viac chmeľov chýba lokalita!';
@override
String get contacts_pathTrace => 'Sledovanie lúčov';
@override
String get contacts_ping => 'Pingovať';
@override
String get contacts_repeaterPathTrace => 'Sledovanie cesty k opakovaču';
@override
String get contacts_repeaterPing => 'Pingovať opakovač';
@override
String get contacts_roomPathTrace => 'Sledovanie cesty k serveru miestnosti';
@override
String get contacts_roomPing => 'Ping server miestnosti';
@override
String get contacts_chatTraceRoute => 'Sledovať trasu lúča';
@override
String contacts_pathTraceTo(String name) {
return 'Sledovať trasu k $name';
}
@override
String get contacts_clipboardEmpty => 'Schránka je prázdna.';
@override
String get contacts_invalidAdvertFormat => 'Neplatné kontaktné údaje';
@override
String get contacts_contactImported => 'Kontakt bol importovaný.';
@override
String get contacts_contactImportFailed =>
'Kontakt sa nepodarilo importovať.';
@override
String get contacts_zeroHopAdvert => 'Inzerát Zero Hop';
@override
String get contacts_floodAdvert => 'Inzerát povodní';
@override
String get contacts_copyAdvertToClipboard => 'Kopírovať reklamu do schránky';
@override
String get contacts_addContactFromClipboard => 'Pridať kontakt z schránky';
@override
String get contacts_ShareContact => 'Kopírovať kontakt do schránky';
@override
String get contacts_ShareContactZeroHop => 'Zdieľať kontakt cez inzerát';
@override
String get contacts_zeroHopContactAdvertSent => 'Poslal kontakt cez inzerát.';
@override
String get contacts_zeroHopContactAdvertFailed =>
'Zlyhalo odoslanie kontaktu.';
@override
String get contacts_contactAdvertCopied =>
'Inzerát bol skopírovaný do schránky.';
@override
String get contacts_contactAdvertCopyFailed =>
'Kopírovanie inzerátu do schránky zlyhalo.';
@override
String get notification_activityTitle => 'Aktivita MeshCore';
@override
String notification_messagesCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'správ',
few: 'správy',
one: 'správa',
);
return '$count $_temp0';
}
@override
String notification_channelMessagesCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'správ kanálu',
few: 'správy kanálu',
one: 'správa kanálu',
);
return '$count $_temp0';
}
@override
String notification_newNodesCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'nových uzlov',
few: 'nové uzly',
one: 'nový uzol',
);
return '$count $_temp0';
}
@override
String notification_newTypeDiscovered(String contactType) {
return 'Nový $contactType objavený';
}
@override
String get notification_receivedNewMessage => 'Prijatá nová správa';
@override
String get settings_gpxExportRepeaters =>
'Exportovať repeater / server miestnosti do GPX';
@override
String get settings_gpxExportRepeatersSubtitle =>
'Exportuje repeater / roomserver s lokalitou do súboru GPX.';
@override
String get settings_gpxExportContacts => 'Export sprievodcov do GPX';
@override
String get settings_gpxExportContactsSubtitle =>
'Exportuje sprievodcov s umiestnením do súboru GPX.';
@override
String get settings_gpxExportAll => 'Exportovať všetky kontakty do GPX';
@override
String get settings_gpxExportAllSubtitle =>
'Exportuje všetky kontakty s lokalitou do súboru GPX.';
@override
String get settings_gpxExportSuccess => 'Úspešne exportovaný súbor GPX.';
@override
String get settings_gpxExportNoContacts => 'Žiadne kontakty na export.';
@override
String get settings_gpxExportNotAvailable =>
'Nie je podporované na vašom zariadení/operáciomnom systéme';
@override
String get settings_gpxExportError => 'Vyskytol sa chyba počas exportu.';
@override
String get settings_gpxExportRepeatersRoom =>
'Umiestnenia opakovačov a serverov miestností';
@override
String get settings_gpxExportChat => 'Lokácie sprievodcov';
@override
String get settings_gpxExportAllContacts => 'Všetky kontaktné lokality';
@override
String get settings_gpxExportShareText =>
'Mapové údaje exportované z meshcore-open';
@override
String get settings_gpxExportShareSubject =>
'meshcore-open export dát GPX mapových údajov';
}
+339 -145
View File
@@ -12,7 +12,7 @@ class AppLocalizationsSl extends AppLocalizations {
String get appTitle => 'MeshCore Open';
@override
String get nav_contacts => 'Kontakti';
String get nav_contacts => 'Stiki';
@override
String get nav_channels => 'Kanali';
@@ -144,7 +144,7 @@ class AppLocalizationsSl extends AppLocalizations {
String get scanner_scan => 'Skeniraj';
@override
String get device_quickSwitch => 'Hitro preklopiti';
String get device_quickSwitch => 'Hitro preklop';
@override
String get device_meshcore => 'MeshCore';
@@ -163,16 +163,16 @@ class AppLocalizationsSl extends AppLocalizations {
'Obveščanja, sporoščanje in zemljevidi.';
@override
String get settings_nodeSettings => 'Nastavitve časa';
String get settings_nodeSettings => 'Nastavitev časa';
@override
String get settings_nodeName => 'Ime omrežno mesto';
String get settings_nodeName => 'Ime node-a';
@override
String get settings_nodeNameNotSet => 'Nezavedeno';
String get settings_nodeNameNotSet => 'Ni nastavljeno';
@override
String get settings_nodeNameHint => 'Vnesite ime časa';
String get settings_nodeNameHint => 'Vnesite ime node-a';
@override
String get settings_nodeNameUpdated => 'Ime posodobljeno';
@@ -182,7 +182,7 @@ class AppLocalizationsSl extends AppLocalizations {
@override
String get settings_radioSettingsSubtitle =>
'Frekvenca, moč, razširni faktor';
'Frekvenca, moč, razširitveni faktor';
@override
String get settings_radioSettingsUpdated => 'Radio nastavitve posodobljene';
@@ -201,7 +201,7 @@ class AppLocalizationsSl extends AppLocalizations {
@override
String get settings_locationInvalid =>
'Neveljna zemeljska širina ali dolžina.';
'Neveljavna zemeljska širina ali dolžina.';
@override
String get settings_locationGPSEnable => 'Omogoči GPS';
@@ -224,7 +224,7 @@ class AppLocalizationsSl extends AppLocalizations {
String get settings_longitude => 'Dolžina';
@override
String get settings_privacyMode => 'Mod podjetja';
String get settings_privacyMode => 'Zasebnost';
@override
String get settings_privacyModeSubtitle => 'Skrita imena/lokacije v oglasih';
@@ -234,10 +234,10 @@ class AppLocalizationsSl extends AppLocalizations {
'Omogoči način zasebnosti, da skrijemo tvoje ime in lokacijo v oglasih.';
@override
String get settings_privacyModeEnabled => 'Privatni režim je omogočen.';
String get settings_privacyModeEnabled => 'Privatni način je omogočen.';
@override
String get settings_privacyModeDisabled => 'Privatni režim je onemogočen.';
String get settings_privacyModeDisabled => 'Privatni način je onemogočen.';
@override
String get settings_actions => 'Akcije';
@@ -253,47 +253,46 @@ class AppLocalizationsSl extends AppLocalizations {
String get settings_advertisementSent => 'Oglas poslan';
@override
String get settings_syncTime => 'Ugasniti čas';
String get settings_syncTime => 'Nastavi uro';
@override
String get settings_syncTimeSubtitle => 'Nastavi uro naprave v čas telefona';
String get settings_syncTimeSubtitle => 'Nastavi uro naprave na čas telefona';
@override
String get settings_timeSynchronized => 'Sinhronizirano po času';
String get settings_timeSynchronized => 'Ura sinhronizirana';
@override
String get settings_refreshContacts => 'Ponovno obišči kontakte';
@override
String get settings_refreshContactsSubtitle =>
'Ponovno naloži seznam kontaktov iz naprave';
'Ponovno naloži seznam stikov v napravi';
@override
String get settings_rebootDevice => 'Restart Naprave';
String get settings_rebootDevice => 'Ponovni zagon naprave';
@override
String get settings_rebootDeviceSubtitle =>
'Ponovite zažetek naprave MeshCore';
String get settings_rebootDeviceSubtitle => 'Ponovno zaženi MeshCore napravo';
@override
String get settings_rebootDeviceConfirm =>
'Ste prepričani, da želite ponovno zagon napravke? Boste odvisni od omrežja.';
'Ste prepričani, da želite ponovno zagnati napravo? Povezava bo prekinjena.';
@override
String get settings_debug => 'Napravi popravek';
String get settings_debug => 'Debug';
@override
String get settings_bleDebugLog => 'Logarjev zapis BLE';
String get settings_bleDebugLog => 'BLE debug log (razhroščevanje)';
@override
String get settings_bleDebugLogSubtitle =>
'Navodila BLE, odgovori in surovo podatkovno';
'BLE ukazi, odgovori in surovi podatki';
@override
String get settings_appDebugLog => 'Log zapiske aplikacije';
String get settings_appDebugLog => 'Logi aplikacije';
@override
String get settings_appDebugLogSubtitle => 'Prijavni sporočila aplikacije';
String get settings_appDebugLogSubtitle => 'Debug sporočila aplikacije';
@override
String get settings_about => 'Oglejte si';
@@ -304,11 +303,11 @@ class AppLocalizationsSl extends AppLocalizations {
}
@override
String get settings_aboutLegalese => 'MeshCore Odprtokodni Projekt 2024';
String get settings_aboutLegalese => 'Odprtokodni projekt MeshCore 2024';
@override
String get settings_aboutDescription =>
'Odprtokodni Flutter kličnik za naprave za LoRa mrežo MeshCore.';
'Odprtokodni Flutter klient za naprave za LoRa omrežje MeshCore.';
@override
String get settings_infoName => 'Ime';
@@ -323,10 +322,10 @@ class AppLocalizationsSl extends AppLocalizations {
String get settings_infoBattery => 'Baterija';
@override
String get settings_infoPublicKey => 'Ključ javnega tipa';
String get settings_infoPublicKey => 'Javni ključ';
@override
String get settings_infoContactsCount => 'Število kontaktov';
String get settings_infoContactsCount => 'Število stikov';
@override
String get settings_infoChannelCount => 'Število kanalov';
@@ -350,7 +349,7 @@ class AppLocalizationsSl extends AppLocalizations {
String get settings_frequencyHelper => '300,00 - 2500,00';
@override
String get settings_frequencyInvalid => 'Neveljčna frekvenca (300-2500 MHz)';
String get settings_frequencyInvalid => 'Neveljavna frekvenca (300-2500 MHz)';
@override
String get settings_bandwidth => 'Pasovna širina';
@@ -368,13 +367,13 @@ class AppLocalizationsSl extends AppLocalizations {
String get settings_txPowerHelper => '0 - 22';
@override
String get settings_txPowerInvalid => 'Neveljaven TX moč (0-22 dBm)';
String get settings_txPowerInvalid => 'Neveljavna TX moč (0-22 dBm)';
@override
String get settings_longRange => 'Dolenje območje';
String get settings_longRange => 'DDolg doseg';
@override
String get settings_fastSpeed => 'Hitra hitrost';
String get settings_fastSpeed => 'Visoka hitrost';
@override
String settings_error(String message) {
@@ -391,10 +390,10 @@ class AppLocalizationsSl extends AppLocalizations {
String get appSettings_theme => 'Tema';
@override
String get appSettings_themeSystem => 'Predpomnilnik sistema';
String get appSettings_themeSystem => 'Sistemska tema';
@override
String get appSettings_themeLight => 'Luč';
String get appSettings_themeLight => 'Svetlo';
@override
String get appSettings_themeDark => 'Temno';
@@ -445,10 +444,16 @@ class AppLocalizationsSl extends AppLocalizations {
String get appSettings_languageBg => 'Български';
@override
String get appSettings_notifications => 'Obveščanja';
String get appSettings_languageRu => 'Ruščina';
@override
String get appSettings_enableNotifications => 'Omogoči obveščanje';
String get appSettings_languageUk => 'Ukrajinsko';
@override
String get appSettings_notifications => 'Obvestila';
@override
String get appSettings_enableNotifications => 'Omogoči obvestila';
@override
String get appSettings_enableNotificationsSubtitle =>
@@ -484,7 +489,7 @@ class AppLocalizationsSl extends AppLocalizations {
@override
String get appSettings_advertisementNotificationsSubtitle =>
'Pokaži obvestilo, ko so novi vozlišči odkrivljeni.';
'Pokaži obvestilo, ko so najdene nove naprave.';
@override
String get appSettings_messaging => 'Komuniciranje';
@@ -499,18 +504,19 @@ class AppLocalizationsSl extends AppLocalizations {
@override
String get appSettings_pathsWillBeCleared =>
'Potnice bodo očiščene po 5 neuspešnih poskusih.';
'Počisti pot po 5 neuspešnih poskusih.';
@override
String get appSettings_pathsWillNotBeCleared =>
'Potniški poti ne bodo samodejno čiščeni.';
'Poti ne bodo samodejno čiščene.';
@override
String get appSettings_autoRouteRotation => 'Avtomatsko Občutke in Rotacije';
String get appSettings_autoRouteRotation =>
'Avtomatsko rotacija prenosne poti';
@override
String get appSettings_autoRouteRotationSubtitle =>
'Med spreminjanjem med najboljšimi potmi in plovilnim načinom';
'Menjaj med boljšo potjo in flood načinom';
@override
String get appSettings_autoRouteRotationEnabled =>
@@ -524,16 +530,16 @@ class AppLocalizationsSl extends AppLocalizations {
String get appSettings_battery => 'Baterija';
@override
String get appSettings_batteryChemistry => 'Razem z možnostmi';
String get appSettings_batteryChemistry => 'Kemija baterije';
@override
String appSettings_batteryChemistryPerDevice(String deviceName) {
return 'Nastavitve za naprave ($deviceName)';
return 'Nastavitev za napravo ($deviceName)';
}
@override
String get appSettings_batteryChemistryConnectFirst =>
'Povežite se z napravo za izbiro';
'Za izbiro se poveži z napravo';
@override
String get appSettings_batteryNmc => '18650 NMC (3,0-4,2V)';
@@ -545,52 +551,51 @@ class AppLocalizationsSl extends AppLocalizations {
String get appSettings_batteryLipo => 'LiPo (3,0-4,2V)';
@override
String get appSettings_mapDisplay => 'Prikaz zemljevide';
String get appSettings_mapDisplay => 'Prikaz zemljevida';
@override
String get appSettings_showRepeaters => 'Prikaži ponovitve';
String get appSettings_showRepeaters => 'Prikaži repetitorje';
@override
String get appSettings_showRepeatersSubtitle =>
'Prikaži ponovljalne notranjosti na zemljeploscu';
String get appSettings_showRepeatersSubtitle => 'Prikaži repetitorje na mapi';
@override
String get appSettings_showChatNodes => 'Prikaži čakalne notranjosti';
String get appSettings_showChatNodes => 'Prikaži naprave za klepet';
@override
String get appSettings_showChatNodesSubtitle =>
'Prikaži pogovorni pike na zemljeploscu';
'Prikaži naprave na zemljevidu';
@override
String get appSettings_showOtherNodes => 'Pokaži druge vozlišča';
String get appSettings_showOtherNodes => 'Pokaži druge naprave';
@override
String get appSettings_showOtherNodesSubtitle =>
'Pokaži druge vrste notranjih elementov na zemljevalu.';
'Pokaži druge vrste naprav na zemljevidu.';
@override
String get appSettings_timeFilter => 'Filtri po času';
String get appSettings_timeFilter => 'Filter po času';
@override
String get appSettings_timeFilterShowAll => 'Pokaži vse notranje elemente';
String get appSettings_timeFilterShowAll => 'Pokaži vse naprave';
@override
String appSettings_timeFilterShowLast(int hours) {
return 'Pokaži notranjosti iz zadnjih $hours ur';
return 'Pokaži naprave v zadnjih $hours urah';
}
@override
String get appSettings_mapTimeFilter => 'Filtri časa zemljevida';
String get appSettings_mapTimeFilter => 'Filter časa na zemljevidu';
@override
String get appSettings_showNodesDiscoveredWithin =>
'Pokaži notranje čepke, odkrivene v:';
'Pokaži naprave odkrite v:';
@override
String get appSettings_allTime => 'Vse čase';
String get appSettings_allTime => 'Brez omejitev';
@override
String get appSettings_lastHour => 'Minuto nazaj';
String get appSettings_lastHour => 'V zadnji uri';
@override
String get appSettings_last6Hours => 'Zadnjih 6 ur';
@@ -599,13 +604,13 @@ class AppLocalizationsSl extends AppLocalizations {
String get appSettings_last24Hours => 'Zadnjih 24 ur';
@override
String get appSettings_lastWeek => 'Lepošno';
String get appSettings_lastWeek => 'Prejšnji teden';
@override
String get appSettings_offlineMapCache => 'Omrezni Poudni Arhiv';
String get appSettings_offlineMapCache => 'Shramba zemljevidov brez povezave';
@override
String get appSettings_noAreaSelected => 'Nizkana označena površina';
String get appSettings_noAreaSelected => 'Območje ni izbrano';
@override
String appSettings_areaSelectedZoom(int minZoom, int maxZoom) {
@@ -613,79 +618,78 @@ class AppLocalizationsSl extends AppLocalizations {
}
@override
String get appSettings_debugCard => 'Napravi popravek';
String get appSettings_debugCard => 'Razhroščevanje';
@override
String get appSettings_appDebugLogging => 'Programski Log';
String get appSettings_appDebugLogging => 'Programski dnevnik';
@override
String get appSettings_appDebugLoggingSubtitle =>
'Log aplikacijske debug sporočila za odpravljanje težav';
'Dnevnik debug sporočil za odpravljanje težav';
@override
String get appSettings_appDebugLoggingEnabled =>
'Omogočeno zaznamovanje napak v aplikaciji';
'Beleženje napak v aplikaciji omogočeno';
@override
String get appSettings_appDebugLoggingDisabled =>
'Programski logi aplikacije so onemogočeni.';
'Beleženje napak v aplikacije onemogočeno.';
@override
String get contacts_title => 'Kontakti';
String get contacts_title => 'Stiki';
@override
String get contacts_noContacts => 'Še ni kontaktov.';
String get contacts_noContacts => 'Ni stikov.';
@override
String get contacts_contactsWillAppear =>
'Kontakti se bodo prikazali, ko naprave oglasijo.';
'Stiki se bodo prikazali, ko se naprave oglasijo.';
@override
String get contacts_searchContacts => 'Iskanje kontaktov...';
String get contacts_searchContacts => 'Iskanje stikov...';
@override
String get contacts_noUnreadContacts => 'Nerešeno kontaktov.';
String get contacts_noUnreadContacts => 'Ne prebrani stiki.';
@override
String get contacts_noContactsFound =>
'Niti ena oseba ali skupine ni najdena.';
String get contacts_noContactsFound => 'Stiki niso najdeni.';
@override
String get contacts_deleteContact => 'Izbrisati Kontakt';
String get contacts_deleteContact => 'Izbriši stik';
@override
String contacts_removeConfirm(String contactName) {
return 'Izbrisati $contactName iz kontaktov?';
return 'Izbrišem $contactName iz stikov?';
}
@override
String get contacts_manageRepeater => 'Upravljajte Ponovitve';
String get contacts_manageRepeater => 'Upravljaj Ponovitve';
@override
String get contacts_manageRoom => 'Upravljajte strežnik sobe';
@override
String get contacts_roomLogin => 'Vnos v sobo';
String get contacts_roomLogin => 'Prijava v sobo';
@override
String get contacts_openChat => 'Odprta kleta';
String get contacts_openChat => 'Odpri klepet';
@override
String get contacts_editGroup => 'Uredi Skupino';
String get contacts_editGroup => 'Uredi skupino';
@override
String get contacts_deleteGroup => 'Izbrisati Skupino';
String get contacts_deleteGroup => 'Izbriši skupino';
@override
String contacts_deleteGroupConfirm(String groupName) {
return 'Odpovedati $groupName?';
return 'Izbriši $groupName?';
}
@override
String get contacts_newGroup => 'Novo skupino';
String get contacts_newGroup => 'Nova skupina';
@override
String get contacts_groupName => 'Skupina imena';
String get contacts_groupName => 'Ime skupine';
@override
String get contacts_groupNameRequired => 'Ime skupine je obvezno.';
@@ -696,53 +700,53 @@ class AppLocalizationsSl extends AppLocalizations {
}
@override
String get contacts_filterContacts => 'Filtri kontakt\\,...';
String get contacts_filterContacts => 'Filtriraj stik\\,...';
@override
String get contacts_noContactsMatchFilter =>
'Niti ena oseba ne ustreza vašemu kriteriju.';
'Noben stik ne ustreza vašemu kriteriju.';
@override
String get contacts_noMembers => 'Nič članov.';
String get contacts_noMembers => 'Ni članov.';
@override
String get contacts_lastSeenNow => 'Datum zadnjega vpisa zdaj';
String get contacts_lastSeenNow => 'Nazadnje viden zdaj';
@override
String contacts_lastSeenMinsAgo(int minutes) {
return 'Zadnjič videti $minutes minut nazaj';
return 'Zadnjič viden pred $minutes minutami';
}
@override
String get contacts_lastSeenHourAgo => 'Zadnjič ogledan pred 1 uro.';
String get contacts_lastSeenHourAgo => 'Zadnjič viden pred 1 uro.';
@override
String contacts_lastSeenHoursAgo(int hours) {
return 'Zadnjič videti $hours ur nazaj';
return 'Zadnjič viden pred $hours urami';
}
@override
String get contacts_lastSeenDayAgo => 'Zadnjič ogledan pred 1 dnem';
String get contacts_lastSeenDayAgo => 'Zadnjič viden pred 1 dnem';
@override
String contacts_lastSeenDaysAgo(int days) {
return 'Zadnjič videti $days dni nazaj';
return 'Zadnjič viden pred $days dnem';
}
@override
String get channels_title => 'Kanali';
@override
String get channels_noChannelsConfigured => 'Nekonfigurirane kanale';
String get channels_noChannelsConfigured => 'Kanali še niso konfigurirani';
@override
String get channels_addPublicChannel => 'Dodaj Objavni Kanal';
String get channels_addPublicChannel => 'Dodaj javni kanal';
@override
String get channels_searchChannels => 'Poišči kanale...';
@override
String get channels_noChannelsFound => 'Niti kanalov najti ni.';
String get channels_noChannelsFound => 'Ne najdem kanalov.';
@override
String channels_channelIndex(int index) {
@@ -753,16 +757,16 @@ class AppLocalizationsSl extends AppLocalizations {
String get channels_hashtagChannel => 'Hashtag kanal';
@override
String get channels_public => 'javno';
String get channels_public => 'Javni';
@override
String get channels_private => 'Zasebno';
String get channels_private => 'Zasebni';
@override
String get channels_publicChannel => 'Ogljišna skupina';
String get channels_publicChannel => 'Javni kanal';
@override
String get channels_privateChannel => 'Zatemniščen kanal';
String get channels_privateChannel => 'Zasebni kanal';
@override
String get channels_editChannel => 'Uredi kanal';
@@ -772,7 +776,7 @@ class AppLocalizationsSl extends AppLocalizations {
@override
String channels_deleteChannelConfirm(String name) {
return 'Izbrisati \"$name\"? To se ne da povrniti.';
return 'Izbrišem \"$name\"? To se ne da povrniti.';
}
@override
@@ -912,21 +916,21 @@ class AppLocalizationsSl extends AppLocalizations {
}
@override
String get chat_typeMessage => 'Vnesite sporočilo...';
String get chat_typeMessage => 'Vnesi sporočilo...';
@override
String chat_messageTooLong(int maxBytes) {
return 'Pošiljanje sporočila je onemogočeno, saj je preveliko (maksimalno $maxBytes bajt).';
return 'Pošiljanje sporočila je onemogočeno, saj je preveliko (maksimalno $maxBytes byte-ov).';
}
@override
String get chat_messageCopied => 'Pošljeno sporočilo';
String get chat_messageCopied => 'Sporočilo poslano';
@override
String get chat_messageDeleted => 'Pošiljanje sporočila izbrisano';
String get chat_messageDeleted => 'Sporočilo izbrisano';
@override
String get chat_retryingMessage => 'Ponovna poskus.';
String get chat_retryingMessage => 'Ponovni poskus.';
@override
String chat_retryCount(int current, int max) {
@@ -937,10 +941,10 @@ class AppLocalizationsSl extends AppLocalizations {
String get chat_sendGif => 'Pošlji GIF';
@override
String get chat_reply => 'Odpošlji';
String get chat_reply => 'Odgovori';
@override
String get chat_addReaction => 'Dodaj Reakcijo';
String get chat_addReaction => 'Dodaj reakcijo';
@override
String get chat_me => 'jaz';
@@ -961,19 +965,19 @@ class AppLocalizationsSl extends AppLocalizations {
String get gifPicker_title => 'Izberi GIF';
@override
String get gifPicker_searchHint => 'Iskalite GIF-e...';
String get gifPicker_searchHint => 'Išči GIF-e...';
@override
String get gifPicker_poweredBy => 'Naprodno z GIPHY';
String get gifPicker_poweredBy => 'Napredno z GIPHY';
@override
String get gifPicker_noGifsFound => 'Niti GIF-jev najti ni.';
String get gifPicker_noGifsFound => 'Ne najdem GIF-ov.';
@override
String get gifPicker_failedLoad => 'Neuspešno je naložilo GIF-e';
String get gifPicker_failedLoad => 'Neuspešno nalaganje GIF-a';
@override
String get gifPicker_failedSearch => 'Posodobit neuspešno.';
String get gifPicker_failedSearch => 'Iskanje neuspešno.';
@override
String get gifPicker_noInternet => 'Ni internetne povezave';
@@ -982,35 +986,35 @@ class AppLocalizationsSl extends AppLocalizations {
String get debugLog_appTitle => 'Log zapiske aplikacije';
@override
String get debugLog_bleTitle => 'Logarjev zapis BLE';
String get debugLog_bleTitle => 'Log zapis BLE';
@override
String get debugLog_copyLog => 'Kopiraj zapiske';
String get debugLog_copyLog => 'Kopiraj dnevnik';
@override
String get debugLog_clearLog => 'Pasters log';
String get debugLog_clearLog => 'Briši log';
@override
String get debugLog_copied => 'Kopirana belež poteka.';
String get debugLog_copied => 'Beležka kopirana.';
@override
String get debugLog_bleCopied => 'Kopirana beležke iz BLE';
String get debugLog_bleCopied => 'Kopirana beležka iz BLE';
@override
String get debugLog_noEntries => 'Še ni ustvarjenih debug zapisov.';
String get debugLog_noEntries => 'Ni ustvarjenih debug zapisov.';
@override
String get debugLog_enableInSettings =>
'Omogoči beleženje napak v aplikaciji v nastavitvah';
'Omogoči beleženje napak v nastavitvah aplikacije';
@override
String get debugLog_frames => 'Okna';
String get debugLog_frames => 'Okvirji';
@override
String get debugLog_rawLogRx => 'Svež Log-RX';
@override
String get debugLog_noBleActivity => 'Šele začnite z aktivnostjo BLE.';
String get debugLog_noBleActivity => 'Ni BLE aktivnosti.';
@override
String debugFrame_length(int count) {
@@ -1079,10 +1083,10 @@ class AppLocalizationsSl extends AppLocalizations {
'Zapiske o poti so popolni. Izbriši vnose, da dodaš nove.';
@override
String get chat_hopSingular => 'skoč';
String get chat_hopSingular => 'skok';
@override
String get chat_hopPlural => 'škrabec';
String get chat_hopPlural => 'skokov';
@override
String chat_hopsCount(int count) {
@@ -1103,7 +1107,7 @@ class AppLocalizationsSl extends AppLocalizations {
@override
String get chat_noPathHistoryYet =>
'Še ni shranjenih poti.\nPošlji sporočilo za odkrivanje poti.';
'Ni shranjenih poti.\nPošlji sporočilo za odkrivanje poti.';
@override
String get chat_pathActions => 'Potni ukazi:';
@@ -1115,7 +1119,7 @@ class AppLocalizationsSl extends AppLocalizations {
String get chat_setCustomPathSubtitle => 'Ročno določite potniško pot.';
@override
String get chat_clearPath => 'Čista pot';
String get chat_clearPath => 'Počisti pot';
@override
String get chat_clearPathSubtitle => 'Ob naslednji pošiljanju znova zbrati.';
@@ -1133,7 +1137,7 @@ class AppLocalizationsSl extends AppLocalizations {
'Narejena je bila omrežna modaliteta. Vklopi jo znova preko ikone v meniju aplikacije.';
@override
String get chat_fullPath => 'Polni pot';
String get chat_fullPath => 'Polna pot';
@override
String get chat_pathDetailsNotAvailable =>
@@ -2012,13 +2016,13 @@ class AppLocalizationsSl extends AppLocalizations {
}
@override
String get repeater_cliQuickGetName => 'Dobiti ime';
String get repeater_cliQuickGetName => 'Pridobi ime';
@override
String get repeater_cliQuickGetRadio => 'Dobiti Radiopravo';
@override
String get repeater_cliQuickGetTx => 'Dobiti TX';
String get repeater_cliQuickGetTx => 'Pridobi TX';
@override
String get repeater_cliQuickNeighbors => 'Sosedi';
@@ -2030,7 +2034,7 @@ class AppLocalizationsSl extends AppLocalizations {
String get repeater_cliQuickAdvertise => 'Oglasite';
@override
String get repeater_cliQuickClock => 'Urnik';
String get repeater_cliQuickClock => 'Ura';
@override
String get repeater_cliHelpAdvert => 'Pošlje paket oglasov';
@@ -2153,7 +2157,7 @@ class AppLocalizationsSl extends AppLocalizations {
@override
String get repeater_cliHelpSetPerm =>
'Modificira ACL. Odstrani ustreznu vnos (po predponi pubkeyja), če je \"permissions\" enako nič. Dodaja nov vnos, če je pubkey-hex v celoti in trenutno ni v ACL. Posodobi vnos po ustreznem predponi pubkeyja. Bitje dovoljenj se razlikuje glede na firmware vlogo, vendar so prvi dve bitki: 0 (Gost), 1 (Lezenje samo), 2 (Lezenje in pisanje), 3 (Administrator).';
'Modificira ACL. Odstrani ustrezen vnos (po predponi pubkeyja), če je \"permissions\" enako nič. Dodaja nov vnos, če je pubkey-hex v celoti in trenutno ni v ACL. Posodobi vnos po ustreznem predponi pubkeyja. Bitje dovoljenj se razlikuje glede na firmware vlogo, vendar so prvi dve bitki: 0 (Gost), 1 (Lezenje samo), 2 (Lezenje in pisanje), 3 (Administrator).';
@override
String get repeater_cliHelpGetBridgeType =>
@@ -2261,11 +2265,11 @@ class AppLocalizationsSl extends AppLocalizations {
String get repeater_logging => 'Logiranje';
@override
String get repeater_neighborsRepeaterOnly => 'Sosedi (le za ponovitelja)';
String get repeater_neighborsRepeaterOnly => 'Sosedi (le za repetitorje)';
@override
String get repeater_regionManagementRepeaterOnly =>
'Upravljanje regij (zgolj za ponovitve)';
'Upravljanje regij (zgolj za repetitorje)';
@override
String get repeater_regionNote =>
@@ -2380,13 +2384,13 @@ class AppLocalizationsSl extends AppLocalizations {
String get channelPath_messageDetails => 'Podrobnosti sporočila';
@override
String get channelPath_senderLabel => 'Pošiljalec';
String get channelPath_senderLabel => 'Pošiljatelj';
@override
String get channelPath_timeLabel => 'Čas';
String get channelPath_timeLabel => 'Ura';
@override
String get channelPath_repeatsLabel => 'Ponovi';
String get channelPath_repeatsLabel => 'Ponovitve';
@override
String channelPath_pathLabel(int index) {
@@ -2551,17 +2555,17 @@ class AppLocalizationsSl extends AppLocalizations {
@override
String get community_scanOrCreate =>
'Skenirajte QR kodo ali ustvarite skupnost za začetek.';
'Skeniraj QR kodo ali ustvari skupnost za začetek.';
@override
String get community_manageCommunities => 'Upravljajte skupnosti';
String get community_manageCommunities => 'Upravljanje skupnosti';
@override
String get community_delete => 'Opusti skupnost';
@override
String community_deleteConfirm(String name) {
return 'Zapustiti \"$name\"?';
return 'Zapusti \"$name\"?';
}
@override
@@ -2575,7 +2579,7 @@ class AppLocalizationsSl extends AppLocalizations {
}
@override
String get community_regenerateSecret => 'Preberi nov tajni kôd';
String get community_regenerateSecret => 'Ponovno ustvari geslo';
@override
String community_regenerateSecretConfirm(String name) {
@@ -2587,11 +2591,11 @@ class AppLocalizationsSl extends AppLocalizations {
@override
String community_secretRegenerated(String name) {
return 'Tajna za \"$name\" ponovno ustvarjena';
return 'Geslo za \"$name\" ponovno ustvarjeno';
}
@override
String get community_updateSecret => 'Ažurniraj tajno';
String get community_updateSecret => 'Ažuriraj ključ';
@override
String community_secretUpdated(String name) {
@@ -2600,11 +2604,11 @@ class AppLocalizationsSl extends AppLocalizations {
@override
String community_scanToUpdateSecret(String name) {
return 'Skeniraj nov kôd QR za posodabljanje tajne za $name';
return 'Skeniraj novo QR kodo za posodabljanje ključa za $name';
}
@override
String get community_addHashtagChannel => 'Dodaj Oznako Obštnine';
String get community_addHashtagChannel => 'Dodaj hashtag kanal';
@override
String get community_addHashtagChannelDesc =>
@@ -2618,7 +2622,7 @@ class AppLocalizationsSl extends AppLocalizations {
@override
String get community_regularHashtagDesc =>
'javna oznaka (kateri koli lahko sodelujejo)';
'javna oznaka (kdorkoli lahko sodeluje)';
@override
String get community_communityHashtag => 'Skupnostni hashtag';
@@ -2667,4 +2671,194 @@ class AppLocalizationsSl extends AppLocalizations {
@override
String get listFilter_newGroup => 'Nova skupina';
@override
String get pathTrace_you => 'Ti';
@override
String get pathTrace_failed => 'Sledenje poti ni uspelo.';
@override
String get pathTrace_notAvailable => 'Potni sled ni na voljo.';
@override
String get pathTrace_refreshTooltip => 'Osveži Path Trace.';
@override
String get pathTrace_someHopsNoLocation =>
'Ena ali več hmelju manjka lokacija!';
@override
String get contacts_pathTrace => 'Sledenje poti';
@override
String get contacts_ping => 'Pingati';
@override
String get contacts_repeaterPathTrace => 'Sledi poti do ponavljalnika';
@override
String get contacts_repeaterPing => 'Pinguj ponavljalnik';
@override
String get contacts_roomPathTrace => 'Sledenje poti do strežnika sobe';
@override
String get contacts_roomPing => 'Ping strežnik sobe';
@override
String get contacts_chatTraceRoute => 'Slediti poti žarkov';
@override
String contacts_pathTraceTo(String name) {
return 'Trace route to $name';
}
@override
String get contacts_clipboardEmpty => 'Odložišče je prazno.';
@override
String get contacts_invalidAdvertFormat => 'Neveljavni kontaktne podatke';
@override
String get contacts_contactImported => 'Kontakt je bil uvožen.';
@override
String get contacts_contactImportFailed => 'Kontakt ni bil uspešno uvožen.';
@override
String get contacts_zeroHopAdvert => 'Reklama brez posrednikov';
@override
String get contacts_floodAdvert => 'Poplavna oglás';
@override
String get contacts_copyAdvertToClipboard => 'Kopiraj oglas v odložišče';
@override
String get contacts_addContactFromClipboard => 'Dodaj stik iz odložišča';
@override
String get contacts_ShareContact => 'Kopiraj stik v Odložišče';
@override
String get contacts_ShareContactZeroHop => 'Deliti kontakt prek oglasa';
@override
String get contacts_zeroHopContactAdvertSent => 'Poslano po oglasu.';
@override
String get contacts_zeroHopContactAdvertFailed =>
'Pošiljanje kontakta ni uspelo.';
@override
String get contacts_contactAdvertCopied =>
'Oglas je bil kopiran v odložišče.';
@override
String get contacts_contactAdvertCopyFailed =>
'Kopiranje oglasa v odložišče je spodletelo.';
@override
String get notification_activityTitle => 'Aktivnost MeshCore';
@override
String notification_messagesCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'sporočil',
few: 'sporočila',
two: 'sporočili',
one: 'sporočilo',
);
return '$count $_temp0';
}
@override
String notification_channelMessagesCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'sporočil kanala',
few: 'sporočila kanala',
two: 'sporočili kanala',
one: 'sporočilo kanala',
);
return '$count $_temp0';
}
@override
String notification_newNodesCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'novih vozlišč',
few: 'nova vozlišča',
two: 'novi vozlišči',
one: 'novo vozlišče',
);
return '$count $_temp0';
}
@override
String notification_newTypeDiscovered(String contactType) {
return 'Odkrito novo $contactType';
}
@override
String get notification_receivedNewMessage => 'Prejeto novo sporočilo';
@override
String get settings_gpxExportRepeaters =>
'Izvoz ponoviteljev / strežnika sobe v GPX';
@override
String get settings_gpxExportRepeatersSubtitle =>
'Izvozi ponovljene oddajnike / strežnik sobe z lokacijo v datoteko GPX.';
@override
String get settings_gpxExportContacts => 'Izvoz spremljevalcev v GPX';
@override
String get settings_gpxExportContactsSubtitle =>
'Izvozi spremljevalce z lokacijo v datoteko GPX.';
@override
String get settings_gpxExportAll => 'Izvozi vse kontakte v GPX';
@override
String get settings_gpxExportAllSubtitle =>
'Izvozi vse kontakte z lokacijo v datoteko GPX.';
@override
String get settings_gpxExportSuccess => 'Uspešno izvoz GPX datoteke.';
@override
String get settings_gpxExportNoContacts => 'Ni stikov za izvoz.';
@override
String get settings_gpxExportNotAvailable =>
'Ni podprto na vašem napravi/operacijskem sistemu';
@override
String get settings_gpxExportError => 'Pri izvozu je prišlo do napake.';
@override
String get settings_gpxExportRepeatersRoom =>
'Lokacije ponovljivca in strežnika sobe';
@override
String get settings_gpxExportChat => 'Lokacije spremljevalcev';
@override
String get settings_gpxExportAllContacts => 'Lokacije vseh stikov';
@override
String get settings_gpxExportShareText =>
'Podatki kart izvoženi iz meshcore-open';
@override
String get settings_gpxExportShareSubject =>
'meshcore-open izvoz podatkov GPX karte';
}
+191
View File
@@ -441,6 +441,12 @@ class AppLocalizationsSv extends AppLocalizations {
@override
String get appSettings_languageBg => 'Български';
@override
String get appSettings_languageRu => 'Ryska';
@override
String get appSettings_languageUk => 'Ukrainska';
@override
String get appSettings_notifications => 'Meddelanden';
@@ -2650,4 +2656,189 @@ class AppLocalizationsSv extends AppLocalizations {
@override
String get listFilter_newGroup => 'Ny grupp';
@override
String get pathTrace_you => 'Du';
@override
String get pathTrace_failed => 'Sökvägsföljning misslyckades.';
@override
String get pathTrace_notAvailable => 'Path trace ej tillgänglig.';
@override
String get pathTrace_refreshTooltip => 'Uppdatera Path Trace';
@override
String get pathTrace_someHopsNoLocation =>
'En eller flera av humlen saknar en plats!';
@override
String get contacts_pathTrace => 'Path Trace';
@override
String get contacts_ping => 'Ping';
@override
String get contacts_repeaterPathTrace => 'Vägspårning till repeater';
@override
String get contacts_repeaterPing => 'Ping-repeater';
@override
String get contacts_roomPathTrace => 'Vägspårning till rumserver';
@override
String get contacts_roomPing => 'Ping rumsserver';
@override
String get contacts_chatTraceRoute => 'Spåra rutt';
@override
String contacts_pathTraceTo(String name) {
return 'Spåra rutt till $name';
}
@override
String get contacts_clipboardEmpty => 'Urklipp är tomt.';
@override
String get contacts_invalidAdvertFormat => 'Ogiltiga kontaktuppgifter';
@override
String get contacts_contactImported => 'Kontakt har importerats.';
@override
String get contacts_contactImportFailed => 'Kontakt kunde inte importeras.';
@override
String get contacts_zeroHopAdvert => 'Reklam med nollhopp';
@override
String get contacts_floodAdvert => 'Översvämningsannons';
@override
String get contacts_copyAdvertToClipboard => 'Kopiera annons till urklipp';
@override
String get contacts_addContactFromClipboard =>
'Lägg till kontakt från urklipp';
@override
String get contacts_ShareContact => 'Kopiera kontakt till Urklipp';
@override
String get contacts_ShareContactZeroHop => 'Dela kontakt via annons';
@override
String get contacts_zeroHopContactAdvertSent => 'Skickat kontakt via annons.';
@override
String get contacts_zeroHopContactAdvertFailed =>
'Misslyckades med att skicka kontakt.';
@override
String get contacts_contactAdvertCopied => 'Annons kopierad till Urklipp.';
@override
String get contacts_contactAdvertCopyFailed =>
'Kopiering av annons till Urklipp misslyckades.';
@override
String get notification_activityTitle => 'MeshCore Aktivitet';
@override
String notification_messagesCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'meddelanden',
one: 'meddelande',
);
return '$count $_temp0';
}
@override
String notification_channelMessagesCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'kanalmeddelanden',
one: 'kanalmeddelande',
);
return '$count $_temp0';
}
@override
String notification_newNodesCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'nya noder',
one: 'ny nod',
);
return '$count $_temp0';
}
@override
String notification_newTypeDiscovered(String contactType) {
return 'Ny $contactType upptäckt';
}
@override
String get notification_receivedNewMessage => 'Nytt meddelande mottaget';
@override
String get settings_gpxExportRepeaters =>
'Exportera repeater / rumsservrar till GPX';
@override
String get settings_gpxExportRepeatersSubtitle =>
'Exporterar repeater / roomserver med plats till GPX-fil.';
@override
String get settings_gpxExportContacts => 'Exportera följeslagare till GPX';
@override
String get settings_gpxExportContactsSubtitle =>
'Exporterar följeslagare med en plats till GPX-fil.';
@override
String get settings_gpxExportAll => 'Exportera alla kontakter till GPX';
@override
String get settings_gpxExportAllSubtitle =>
'Exporterar alla kontakter med en plats till GPX-fil.';
@override
String get settings_gpxExportSuccess => 'Har exporterat GPX-fil med framgång';
@override
String get settings_gpxExportNoContacts => 'Inga kontakter att exportera.';
@override
String get settings_gpxExportNotAvailable =>
'Stöds inte på din enhet/operativsystem';
@override
String get settings_gpxExportError =>
'Det uppstod ett fel när data exporterades.';
@override
String get settings_gpxExportRepeatersRoom =>
'Repeater- och rumsserverplatser';
@override
String get settings_gpxExportChat => 'Medhjälparplatser';
@override
String get settings_gpxExportAllContacts => 'Alla kontakters platser';
@override
String get settings_gpxExportShareText =>
'Kartdata exporterad från meshcore-open';
@override
String get settings_gpxExportShareSubject =>
'meshcore-open export av GPX-kartdata';
}
+200
View File
@@ -446,6 +446,12 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String get appSettings_languageBg => 'Български';
@override
String get appSettings_languageRu => 'Російська';
@override
String get appSettings_languageUk => 'Українська';
@override
String get appSettings_notifications => 'Сповіщення';
@@ -2686,4 +2692,198 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String get listFilter_newGroup => 'Нова група';
@override
String get pathTrace_you => 'Ви';
@override
String get pathTrace_failed => 'Відстеження шляху не вдалося.';
@override
String get pathTrace_notAvailable => 'Трасування шляху недоступне.';
@override
String get pathTrace_refreshTooltip => 'Оновити Path Trace';
@override
String get pathTrace_someHopsNoLocation =>
'Одне або більше хмелів відсутнє місце розташування!';
@override
String get contacts_pathTrace => 'Трасування шляхів';
@override
String get contacts_ping => 'Пінгувати';
@override
String get contacts_repeaterPathTrace => 'Трасування шляху до повторювача';
@override
String get contacts_repeaterPing => 'Пінгувати повторювач';
@override
String get contacts_roomPathTrace => 'Трасування шляху до серверу кімнати';
@override
String get contacts_roomPing => 'Пінг сервера кімнати';
@override
String get contacts_chatTraceRoute => 'Трасування шляху';
@override
String contacts_pathTraceTo(String name) {
return 'Відстежити маршрут до $name';
}
@override
String get contacts_clipboardEmpty => 'Буфер обміну порожній';
@override
String get contacts_invalidAdvertFormat => 'Недійсні контактні дані';
@override
String get contacts_contactImported => 'Контакт було імпортовано.';
@override
String get contacts_contactImportFailed => 'Контакт не вдалося імпортувати';
@override
String get contacts_zeroHopAdvert => 'Реклама без перехоплення';
@override
String get contacts_floodAdvert => 'Залив реклами';
@override
String get contacts_copyAdvertToClipboard =>
'Копіювати оголошення в буфер обміну';
@override
String get contacts_addContactFromClipboard =>
'Додати контакт з буфера обміну';
@override
String get contacts_ShareContact => 'Копіювати контакт у буфер обміну';
@override
String get contacts_ShareContactZeroHop =>
'Поділитися контактом за оголошенням';
@override
String get contacts_zeroHopContactAdvertSent =>
'Відправлено контакт за оголошенням';
@override
String get contacts_zeroHopContactAdvertFailed =>
'Не вдалося надіслати контакт.';
@override
String get contacts_contactAdvertCopied =>
'Рекламу скопійовано до буфера обміну.';
@override
String get contacts_contactAdvertCopyFailed =>
'Копіювання оголошення в буфер обміну завершилося невдало';
@override
String get notification_activityTitle => 'Активність MeshCore';
@override
String notification_messagesCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'повідомлень',
many: 'повідомлень',
few: 'повідомлення',
one: 'повідомлення',
);
return '$count $_temp0';
}
@override
String notification_channelMessagesCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'повідомлень каналу',
many: 'повідомлень каналу',
few: 'повідомлення каналу',
one: 'повідомлення каналу',
);
return '$count $_temp0';
}
@override
String notification_newNodesCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'нових вузлів',
many: 'нових вузлів',
few: 'нових вузли',
one: 'новий вузол',
);
return '$count $_temp0';
}
@override
String notification_newTypeDiscovered(String contactType) {
return 'Виявлено новий $contactType';
}
@override
String get notification_receivedNewMessage => 'Отримано нове повідомлення';
@override
String get settings_gpxExportRepeaters =>
'Експортувати ретранслятори / сервер кімнати до GPX';
@override
String get settings_gpxExportRepeatersSubtitle =>
'Експортує ретранслятори / сервер кімнати з місцезнаходженням у файл GPX.';
@override
String get settings_gpxExportContacts => 'Експортувати супутників до GPX';
@override
String get settings_gpxExportContactsSubtitle =>
'Експортує супутників з місцезнаходженням у файл GPX.';
@override
String get settings_gpxExportAll => 'Експортувати всі контакти до GPX';
@override
String get settings_gpxExportAllSubtitle =>
'Експортує всі контакти з місцем розташування у файл GPX.';
@override
String get settings_gpxExportSuccess => 'Успішно експортовано файл GPX.';
@override
String get settings_gpxExportNoContacts => 'Немає контактів для експорту.';
@override
String get settings_gpxExportNotAvailable =>
'Не підтримується на вашому пристрої/операційній системі';
@override
String get settings_gpxExportError => 'Сталася помилка під час експорту.';
@override
String get settings_gpxExportRepeatersRoom =>
'Місцезнаходження повторювача та сервера кімнати';
@override
String get settings_gpxExportChat => 'Місця супутників';
@override
String get settings_gpxExportAllContacts => 'Усі місця контактів';
@override
String get settings_gpxExportShareText =>
'Дані карти експортовані з meshcore-open';
@override
String get settings_gpxExportShareSubject =>
'експорт даних карти meshcore-open у форматі GPX';
}
File diff suppressed because it is too large Load Diff
+60 -1
View File
@@ -1533,5 +1533,64 @@
"community_regenerate": "Regeneer",
"community_updateSecret": "Bijwerken Geheime",
"community_secretUpdated": "Geheim gewijzigd voor \"{name}\"",
"community_scanToUpdateSecret": "Scan de nieuwe QR-code om het geheim voor \"{name}\" bij te werken"
"community_scanToUpdateSecret": "Scan de nieuwe QR-code om het geheim voor \"{name}\" bij te werken",
"@contacts_pathTraceTo": {
"placeholders": {
"name": {
"type": "String"
}
}
},
"pathTrace_you": "Jij",
"pathTrace_failed": "Padtrace mislukt.",
"pathTrace_notAvailable": "Padtrace niet beschikbaar.",
"pathTrace_refreshTooltip": "Path Trace vernieuwen.",
"contacts_pathTrace": "Pad Traceren",
"contacts_ping": "Pingen",
"contacts_repeaterPathTrace": "Pad traceren naar repeater",
"contacts_repeaterPing": "Ping repeater",
"contacts_roomPathTrace": "Padtrace naar room server",
"contacts_roomPing": "Ping kamer server",
"contacts_chatTraceRoute": "Route traceren",
"contacts_pathTraceTo": "Trace route to {name}",
"appSettings_languageUk": "Oekraïens",
"contacts_invalidAdvertFormat": "Ongeldige contactgegevens",
"contacts_contactImportFailed": "Contact kon niet geïmporteerd worden.",
"contacts_zeroHopAdvert": "Zero Hop Reclame",
"contacts_floodAdvert": "Overstromingsadvertentie",
"contacts_copyAdvertToClipboard": "Advert naar klembord kopiëren",
"appSettings_languageRu": "Russisch",
"contacts_clipboardEmpty": "Knipbord is leeg.",
"contacts_addContactFromClipboard": "Contact uit klembord toevoegen",
"contacts_contactImported": "Contact is geïmporteerd.",
"contacts_zeroHopContactAdvertSent": "Contact verzonden via advertentie",
"contacts_contactAdvertCopied": "Reclame gekopieerd naar Klembord.",
"contacts_contactAdvertCopyFailed": "Kopiëren van advertentie naar Clipboard is mislukt.",
"contacts_ShareContact": "Kontakt naar Klembord kopiëren",
"contacts_ShareContactZeroHop": "Contact delen via advertentie",
"contacts_zeroHopContactAdvertFailed": "Mislukt om contact te verzenden",
"notification_activityTitle": "MeshCore Activiteit",
"notification_messagesCount": "{count} {count, plural, =1{bericht} other{berichten}}",
"notification_channelMessagesCount": "{count} {count, plural, =1{kanaalbericht} other{kanaalberichten}}",
"notification_newNodesCount": "{count} {count, plural, =1{nieuw knooppunt} other{nieuwe knooppunten}}",
"notification_newTypeDiscovered": "Nieuw {contactType} ontdekt",
"notification_receivedNewMessage": "Nieuw bericht ontvangen",
"contacts_zeroHopContactAdvertFailed": "Mislukt om contact te verzenden",
"settings_gpxExportRepeatersSubtitle": "Exporteert repeaters / roomserver met een locatie naar GPX-bestand.",
"settings_gpxExportRepeaters": "Exporteer repeaters / roomserver naar GPX",
"settings_gpxExportSuccess": "Succesvol GPX-bestand geëxporteerd.",
"settings_gpxExportNoContacts": "Geen contacten om te exporteren.",
"settings_gpxExportNotAvailable": "Niet ondersteund op uw apparaat/besturingssysteem",
"settings_gpxExportError": "Er was een fout bij het exporteren.",
"settings_gpxExportContacts": "Companions exporteren naar GPX",
"settings_gpxExportAll": "Alle contacten exporteren naar GPX",
"settings_gpxExportAllSubtitle": "Exporteert alle contacten met een locatie naar een GPX-bestand.",
"settings_gpxExportContactsSubtitle": "Exporteert metgezellen met een locatie naar een GPX-bestand.",
"settings_gpxExportRepeatersRoom": "Repeater- en kamer servers locaties",
"settings_gpxExportChat": "Locaties van metgezellen",
"settings_gpxExportAllContacts": "Alle contactlocaties",
"settings_gpxExportShareText": "Kaartgegevens geëxporteerd uit meshcore-open",
"settings_gpxExportShareSubject": "meshcore-open GPX kaartgegevens exporteren",
"pathTrace_someHopsNoLocation": "Een of meer van de hops ontbreken een locatie!"
}
+60 -1
View File
@@ -1533,5 +1533,64 @@
"community_regenerateSecretConfirm": "Regeneruj tajny klucz dla \"{name}\"? Wszyscy członkowie będą musieli zeskanować nowy kod QR, aby kontynuować komunikację.",
"community_scanToUpdateSecret": "Skanuj nowy kod QR, aby zaktualizować sekret dla \"{name}\"",
"community_secretUpdated": "Hasło zaktualizowane dla \"{name}\"",
"community_updateSecret": "Zaktualizuj tajny klucz"
"community_updateSecret": "Zaktualizuj tajny klucz",
"@contacts_pathTraceTo": {
"placeholders": {
"name": {
"type": "String"
}
}
},
"pathTrace_you": "Ty",
"pathTrace_failed": "Śledzenie ścieżki nie powiodło się.",
"pathTrace_notAvailable": "Ścieżka śledzenia niedostępna.",
"contacts_pathTrace": "Śledzenie Ścieżek",
"contacts_ping": "Pingować",
"contacts_repeaterPathTrace": "Śledzenie ścieżki do repeatera",
"contacts_roomPathTrace": "Śledzenie ścieżki do serwera pokojowego",
"contacts_roomPing": "Pinguj serwer pokoju",
"pathTrace_refreshTooltip": "Odśwież ścieżkę.",
"contacts_repeaterPing": "Repeater pingowy",
"contacts_pathTraceTo": "Śledź trasę do {name}",
"contacts_chatTraceRoute": "Śledź trasę promienia",
"appSettings_languageRu": "Rosyjski",
"appSettings_languageUk": "Ukraińska",
"contacts_contactImportFailed": "Kontakt nie został zaimportowany.",
"contacts_zeroHopAdvert": "Reklama Zero Hop",
"contacts_floodAdvert": "Reklama powodziowa",
"contacts_copyAdvertToClipboard": "Kopiuj ogłoszenie do schowka",
"contacts_clipboardEmpty": "Schowek jest pusty.",
"contacts_invalidAdvertFormat": "Nieprawidłowe dane kontaktowe",
"contacts_addContactFromClipboard": "Dodaj kontakt z schowka",
"contacts_contactImported": "Kontakt został zaimportowany.",
"contacts_zeroHopContactAdvertSent": "Wysłano kontakt przez ogłoszenie.",
"contacts_contactAdvertCopied": "Reklama skopiowana do schowka.",
"contacts_contactAdvertCopyFailed": "Kopiowanie ogłoszenia do schowka nie powiodło się.",
"contacts_ShareContactZeroHop": "Udostępnij kontakt przez ogłoszenie",
"contacts_ShareContact": "Kopiuj kontakt do schowka",
"contacts_zeroHopContactAdvertFailed": "Nie udało się wysłać kontaktu.",
"notification_activityTitle": "Aktywność MeshCore",
"notification_messagesCount": "{count} {count, plural, =1{wiadomość} few{wiadomości} many{wiadomości} other{wiadomości}}",
"notification_channelMessagesCount": "{count} {count, plural, =1{wiadomość kanału} few{wiadomości kanału} many{wiadomości kanału} other{wiadomości kanału}}",
"notification_newNodesCount": "{count} {count, plural, =1{nowy węzeł} few{nowe węzły} many{nowych węzłów} other{nowych węzłów}}",
"notification_newTypeDiscovered": "Nowy {contactType} wykryty",
"notification_receivedNewMessage": "Otrzymano nową wiadomość",
"contacts_zeroHopContactAdvertFailed": "Nie udało się wysłać kontaktu.",
"settings_gpxExportContacts": "Eksportuj towarzyszy do GPX",
"settings_gpxExportRepeaters": "Eksportuj powtórki / serwer pokojowy do GPX",
"settings_gpxExportRepeatersSubtitle": "Eksportuje powtarzacze / roomserver z lokalizacją do pliku GPX.",
"settings_gpxExportSuccess": "Pomyślnie wyeksportowano plik GPX.",
"settings_gpxExportNotAvailable": "Nie obsługiwane na Twoim urządzeniu/systemie operacyjnym",
"settings_gpxExportError": "Wystąpił błąd podczas eksportowania.",
"settings_gpxExportRepeatersRoom": "Lokalizacje serwerów powtarzających i pomieszczeń",
"settings_gpxExportContactsSubtitle": "Eksportuje towarzyszy z lokalizacją do pliku GPX.",
"settings_gpxExportAll": "Eksportuj wszystkie kontakty do GPX",
"settings_gpxExportAllSubtitle": "Eksportuje wszystkie kontakty z lokalizacją do pliku GPX.",
"settings_gpxExportAllContacts": "Wszystkie lokalizacje kontaktów",
"settings_gpxExportNoContacts": "Brak kontaktów do wyeksportowania.",
"settings_gpxExportChat": "Lokalizacje towarzyszy",
"settings_gpxExportShareText": "Dane mapy wyeksportowane z meshcore-open",
"settings_gpxExportShareSubject": "Eksport danych mapy GPX meshcore-open",
"pathTrace_someHopsNoLocation": "Jeden lub więcej z chmieli nie ma określonej lokalizacji!"
}
+60 -1
View File
@@ -1533,5 +1533,64 @@
"community_regenerate": "Regenerar",
"community_secretUpdated": "Segredo atualizado para \"{name}\"",
"community_scanToUpdateSecret": "Scanar o novo código QR para atualizar o segredo para \"{name}\"\n\n\n+++++",
"community_updateSecret": "Atualizar Segredo"
"community_updateSecret": "Atualizar Segredo",
"@contacts_pathTraceTo": {
"placeholders": {
"name": {
"type": "String"
}
}
},
"pathTrace_you": "Você",
"pathTrace_failed": "Falha no rastreamento de caminho.",
"pathTrace_notAvailable": "Traçado de caminho não disponível.",
"pathTrace_refreshTooltip": "Atualizar Path Trace.",
"contacts_pathTrace": "Traçado de Caminho",
"contacts_ping": "Pingar",
"contacts_repeaterPathTrace": "Traçar caminho para repetidor",
"contacts_repeaterPing": "Pingar repetidor",
"contacts_roomPathTrace": "Traçar caminho para o servidor da sala",
"contacts_roomPing": "Pingar servidor da sala",
"contacts_chatTraceRoute": "Rastrear rota do caminho",
"contacts_pathTraceTo": "Rastrear rota para {name}",
"contacts_invalidAdvertFormat": "Dados de Contato Inválidos",
"contacts_clipboardEmpty": "Área de Transferência Está Vazia.",
"appSettings_languageUk": "Ucraniano",
"contacts_contactImported": "Contato foi importado.",
"contacts_zeroHopAdvert": "Anúncio Zero Hop",
"contacts_copyAdvertToClipboard": "Copiar Anúncio para Área de Transferência",
"contacts_addContactFromClipboard": "Adicionar Contato da Área de Transferência",
"appSettings_languageRu": "Russo",
"contacts_ShareContact": "Copiar contato para Área de Transferência",
"contacts_contactImportFailed": "Contato falhou ao ser importado.",
"contacts_zeroHopContactAdvertSent": "Enviou contato por anúncio.",
"contacts_contactAdvertCopied": "Anúncio copiado para a Área de Transferência.",
"contacts_floodAdvert": "Anúncio de Inundação",
"contacts_contactAdvertCopyFailed": "Cópia do anúncio para a Área de Transferência falhou.",
"contacts_ShareContactZeroHop": "Compartilhar contato por anúncio",
"contacts_zeroHopContactAdvertFailed": "Falha ao enviar contato.",
"notification_activityTitle": "Atividade MeshCore",
"notification_messagesCount": "{count} {count, plural, =1{mensagem} other{mensagens}}",
"notification_channelMessagesCount": "{count} {count, plural, =1{mensagem de canal} other{mensagens de canal}}",
"notification_newNodesCount": "{count} {count, plural, =1{novo nó} other{novos nós}}",
"notification_newTypeDiscovered": "Novo {contactType} descoberto",
"notification_receivedNewMessage": "Nova mensagem recebida",
"contacts_zeroHopContactAdvertFailed": "Falha ao enviar contato.",
"settings_gpxExportRepeaters": "Exportar repetidores / servidor de sala para GPX",
"settings_gpxExportRepeatersSubtitle": "Exporta repetidores / roomserver com localização para arquivo GPX.",
"settings_gpxExportSuccess": "Arquivo GPX exportado com sucesso.",
"settings_gpxExportAllSubtitle": "Exporta todos os contatos com uma localização para um arquivo GPX.",
"settings_gpxExportNotAvailable": "Não suportado no seu dispositivo/SO",
"settings_gpxExportError": "Ocorreu um erro ao exportar.",
"settings_gpxExportAll": "Exportar todos os contatos para GPX",
"settings_gpxExportContacts": "Exportar companheiros para GPX",
"settings_gpxExportContactsSubtitle": "Exporta companheiros com uma localização para um arquivo GPX.",
"settings_gpxExportRepeatersRoom": "Localizações do servidor de repetidor e sala",
"settings_gpxExportChat": "Localizações de companheiros",
"settings_gpxExportNoContacts": "Nenhum contato para exportar.",
"settings_gpxExportAllContacts": "Todos os locais de contatos",
"settings_gpxExportShareText": "Dados do mapa exportados do meshcore-open",
"settings_gpxExportShareSubject": "meshcore-open exportação de dados de mapa GPX",
"pathTrace_someHopsNoLocation": "Um ou mais dos lúpulos estão sem localização!"
}
+59 -1
View File
@@ -774,5 +774,63 @@
"chat_openLink": "Открыть ссылку?",
"chat_openLinkConfirmation": "Хотите открыть эту ссылку в вашем браузере?",
"neighbors_heardAgo": "Слушал(а): {time} назад",
"chat_invalidLink": "Неправильный формат ссылки"
"chat_invalidLink": "Неправильный формат ссылки",
"@contacts_pathTraceTo": {
"placeholders": {
"name": {
"type": "String"
}
}
},
"pathTrace_you": "Вы",
"pathTrace_failed": "Путь трассировки не выполнен.",
"pathTrace_notAvailable": "Трассировка пути недоступна.",
"pathTrace_refreshTooltip": "Обновить Path Trace",
"contacts_pathTrace": "Трассировка пути",
"contacts_ping": "Пинговать",
"contacts_repeaterPathTrace": "Отследить путь к ретранслятору",
"contacts_repeaterPing": "Пинговать повторитель",
"contacts_roomPathTrace": "Трассировка пути к серверу комнаты",
"contacts_roomPing": "Пинговать сервер комнаты",
"contacts_chatTraceRoute": "Трассировка маршрута",
"contacts_pathTraceTo": "Показать маршрут к {name}",
"contacts_contactImported": "Контакт был импортирован",
"contacts_contactImportFailed": "Контакт не удалось импортировать",
"contacts_invalidAdvertFormat": "Недействительные контактные данные",
"contacts_zeroHopAdvert": "Реклама Zero Hop",
"appSettings_languageUk": "Українська",
"contacts_floodAdvert": "Рекламный поток",
"contacts_clipboardEmpty": "Буфер обмена пуст.",
"contacts_copyAdvertToClipboard": "Копировать рекламу в буфер обмена",
"contacts_ShareContact": "Копировать контакт в буфер обмена",
"contacts_zeroHopContactAdvertFailed": "Не удалось отправить контакт.",
"contacts_contactAdvertCopied": "Реклама скопирована в буфер обмена.",
"contacts_contactAdvertCopyFailed": "Копирование рекламы в буфер обмена не удалось.",
"contacts_addContactFromClipboard": "Добавить контакт из буфера обмена",
"contacts_ShareContactZeroHop": "Поделиться контактом по объявлению",
"contacts_zeroHopContactAdvertSent": "Отправлено сообщение по объявлению.",
"notification_activityTitle": "Активность MeshCore",
"notification_messagesCount": "{count} {count, plural, =1{сообщение} few{сообщения} many{сообщений} other{сообщений}}",
"notification_channelMessagesCount": "{count} {count, plural, =1{сообщение канала} few{сообщения канала} many{сообщений канала} other{сообщений канала}}",
"notification_newNodesCount": "{count} {count, plural, =1{новый узел} few{новых узла} many{новых узлов} other{новых узлов}}",
"notification_newTypeDiscovered": "Обнаружен новый {contactType}",
"notification_receivedNewMessage": "Получено новое сообщение",
"contacts_zeroHopContactAdvertSent": "Отправлено сообщение по объявлению.",
"settings_gpxExportRepeaters": "Экспортировать рипитеры / сервер комнаты в GPX",
"settings_gpxExportRepeatersSubtitle": "Экспортирует ретрансляторы / сервер комнат с местоположением в файл GPX.",
"settings_gpxExportContacts": "Экспортировать спутников в GPX",
"settings_gpxExportNotAvailable": "Не поддерживается на вашем устройстве/ОС",
"settings_gpxExportError": "Произошла ошибка при экспорте.",
"settings_gpxExportRepeatersRoom": "Местоположения повторителей и серверов комнат",
"settings_gpxExportChat": "Местоположения спутников",
"settings_gpxExportContactsSubtitle": "Экспортирует спутников с местоположением в файл GPX.",
"settings_gpxExportAll": "Экспортировать все контакты в GPX",
"settings_gpxExportAllSubtitle": "Экспортирует все контакты с местоположением в файл GPX.",
"settings_gpxExportAllContacts": "Все местоположения контактов",
"settings_gpxExportSuccess": "Успешно экспортирован файл GPX.",
"settings_gpxExportNoContacts": "Нет контактов для экспорта.",
"settings_gpxExportShareText": "Данные карты экспортированы из meshcore-open",
"settings_gpxExportShareSubject": "meshcore-open экспорт данных карты GPX",
"pathTrace_someHopsNoLocation": "Одному или нескольким хмелям не указано местоположение!"
}
+60 -1
View File
@@ -1533,5 +1533,64 @@
"community_regenerateSecret": "Zobraziť nový tajný kód",
"community_scanToUpdateSecret": "Skáňte nový QR kód na aktualizáciu tajného hesla pre \"{name}\"",
"community_updateSecret": "Aktualizovať tajné heslo",
"community_secretUpdated": "Zmena tajnej slova pre \"{name}\""
"community_secretUpdated": "Zmena tajnej slova pre \"{name}\"",
"@contacts_pathTraceTo": {
"placeholders": {
"name": {
"type": "String"
}
}
},
"pathTrace_you": "Vy",
"pathTrace_failed": "Sledovanie cesty zlyhalo.",
"pathTrace_notAvailable": "Path trace nie je k dispozícii.",
"pathTrace_refreshTooltip": "Obnoviť Path Trace.",
"contacts_pathTrace": "Sledovanie lúčov",
"contacts_ping": "Pingovať",
"contacts_repeaterPathTrace": "Sledovanie cesty k opakovaču",
"contacts_repeaterPing": "Pingovať opakovač",
"contacts_roomPathTrace": "Sledovanie cesty k serveru miestnosti",
"contacts_roomPing": "Ping server miestnosti",
"contacts_chatTraceRoute": "Sledovať trasu lúča",
"contacts_pathTraceTo": "Sledovať trasu k {name}",
"contacts_clipboardEmpty": "Schránka je prázdna.",
"appSettings_languageUk": "Ukrajinská",
"contacts_contactImportFailed": "Kontakt sa nepodarilo importovať.",
"contacts_zeroHopAdvert": "Inzerát Zero Hop",
"contacts_floodAdvert": "Inzerát povodní",
"contacts_copyAdvertToClipboard": "Kopírovať reklamu do schránky",
"contacts_invalidAdvertFormat": "Neplatné kontaktné údaje",
"appSettings_languageRu": "Ruština",
"contacts_addContactFromClipboard": "Pridať kontakt z schránky",
"contacts_contactImported": "Kontakt bol importovaný.",
"contacts_zeroHopContactAdvertSent": "Poslal kontakt cez inzerát.",
"contacts_contactAdvertCopied": "Inzerát bol skopírovaný do schránky.",
"contacts_contactAdvertCopyFailed": "Kopírovanie inzerátu do schránky zlyhalo.",
"contacts_zeroHopContactAdvertFailed": "Zlyhalo odoslanie kontaktu.",
"contacts_ShareContactZeroHop": "Zdieľať kontakt cez inzerát",
"contacts_ShareContact": "Kopírovať kontakt do schránky",
"notification_activityTitle": "Aktivita MeshCore",
"notification_messagesCount": "{count} {count, plural, =1{správa} few{správy} other{správ}}",
"notification_channelMessagesCount": "{count} {count, plural, =1{správa kanálu} few{správy kanálu} other{správ kanálu}}",
"notification_newNodesCount": "{count} {count, plural, =1{nový uzol} few{nové uzly} other{nových uzlov}}",
"notification_newTypeDiscovered": "Nový {contactType} objavený",
"notification_receivedNewMessage": "Prijatá nová správa",
"contacts_ShareContact": "Kopírovať kontakt do schránky",
"settings_gpxExportRepeatersSubtitle": "Exportuje repeater / roomserver s lokalitou do súboru GPX.",
"settings_gpxExportContacts": "Export sprievodcov do GPX",
"settings_gpxExportSuccess": "Úspešne exportovaný súbor GPX.",
"settings_gpxExportNoContacts": "Žiadne kontakty na export.",
"settings_gpxExportNotAvailable": "Nie je podporované na vašom zariadení/operáciomnom systéme",
"settings_gpxExportRepeatersRoom": "Umiestnenia opakovačov a serverov miestností",
"settings_gpxExportError": "Vyskytol sa chyba počas exportu.",
"settings_gpxExportAllSubtitle": "Exportuje všetky kontakty s lokalitou do súboru GPX.",
"settings_gpxExportContactsSubtitle": "Exportuje sprievodcov s umiestnením do súboru GPX.",
"settings_gpxExportRepeaters": "Exportovať repeater / server miestnosti do GPX",
"settings_gpxExportAll": "Exportovať všetky kontakty do GPX",
"settings_gpxExportAllContacts": "Všetky kontaktné lokality",
"settings_gpxExportChat": "Lokácie sprievodcov",
"settings_gpxExportShareText": "Mapové údaje exportované z meshcore-open",
"settings_gpxExportShareSubject": "meshcore-open export dát GPX mapových údajov",
"pathTrace_someHopsNoLocation": "Jedna alebo viac chmeľov chýba lokalita!"
}
+202 -143
View File
@@ -1,7 +1,7 @@
{
"@@locale": "sl",
"appTitle": "MeshCore Open",
"nav_contacts": "Kontakti",
"nav_contacts": "Stiki",
"nav_channels": "Kanali",
"nav_map": "Karta",
"common_cancel": "Prekliči",
@@ -69,49 +69,49 @@
},
"scanner_stop": "Prekliči",
"scanner_scan": "Skeniraj",
"device_quickSwitch": "Hitro preklopiti",
"device_quickSwitch": "Hitro preklop",
"device_meshcore": "MeshCore",
"settings_title": "Nastavitve",
"settings_deviceInfo": "Informacije o napravei",
"settings_appSettings": "Nastavitve aplikacije",
"settings_appSettingsSubtitle": "Obveščanja, sporoščanje in zemljevidi.",
"settings_nodeSettings": "Nastavitve časa",
"settings_nodeName": "Ime omrežno mesto",
"settings_nodeNameNotSet": "Nezavedeno",
"settings_nodeNameHint": "Vnesite ime časa",
"settings_nodeSettings": "Nastavitev časa",
"settings_nodeName": "Ime node-a",
"settings_nodeNameNotSet": "Ni nastavljeno",
"settings_nodeNameHint": "Vnesite ime node-a",
"settings_nodeNameUpdated": "Ime posodobljeno",
"settings_radioSettings": "Nastavitve radija",
"settings_radioSettingsSubtitle": "Frekvenca, moč, razširni faktor",
"settings_radioSettingsSubtitle": "Frekvenca, moč, razširitveni faktor",
"settings_radioSettingsUpdated": "Radio nastavitve posodobljene",
"settings_location": "Lokacija",
"settings_locationSubtitle": "GPS koordinate",
"settings_locationUpdated": "Lokacija posodobljena",
"settings_locationBothRequired": "Vnesite širino in dolžino.",
"settings_locationInvalid": "Neveljna zemeljska širina ali dolžina.",
"settings_locationInvalid": "Neveljavna zemeljska širina ali dolžina.",
"settings_latitude": "Širina",
"settings_longitude": "Dolžina",
"settings_privacyMode": "Mod podjetja",
"settings_privacyMode": "Zasebnost",
"settings_privacyModeSubtitle": "Skrita imena/lokacije v oglasih",
"settings_privacyModeToggle": "Omogoči način zasebnosti, da skrijemo tvoje ime in lokacijo v oglasih.",
"settings_privacyModeEnabled": "Privatni režim je omogočen.",
"settings_privacyModeDisabled": "Privatni režim je onemogočen.",
"settings_privacyModeEnabled": "Privatni način je omogočen.",
"settings_privacyModeDisabled": "Privatni način je onemogočen.",
"settings_actions": "Akcije",
"settings_sendAdvertisement": "Pošlji Oglas",
"settings_sendAdvertisementSubtitle": "Trenutna prisotnost v oddajah",
"settings_advertisementSent": "Oglas poslan",
"settings_syncTime": "Ugasniti čas",
"settings_syncTimeSubtitle": "Nastavi uro naprave v čas telefona",
"settings_timeSynchronized": "Sinhronizirano po času",
"settings_syncTime": "Nastavi uro",
"settings_syncTimeSubtitle": "Nastavi uro naprave na čas telefona",
"settings_timeSynchronized": "Ura sinhronizirana",
"settings_refreshContacts": "Ponovno obišči kontakte",
"settings_refreshContactsSubtitle": "Ponovno naloži seznam kontaktov iz naprave",
"settings_rebootDevice": "Restart Naprave",
"settings_rebootDeviceSubtitle": "Ponovite zažetek naprave MeshCore",
"settings_rebootDeviceConfirm": "Ste prepričani, da želite ponovno zagon napravke? Boste odvisni od omrežja.",
"settings_debug": "Napravi popravek",
"settings_bleDebugLog": "Logarjev zapis BLE",
"settings_bleDebugLogSubtitle": "Navodila BLE, odgovori in surovo podatkovno",
"settings_appDebugLog": "Log zapiske aplikacije",
"settings_appDebugLogSubtitle": "Prijavni sporočila aplikacije",
"settings_refreshContactsSubtitle": "Ponovno naloži seznam stikov v napravi",
"settings_rebootDevice": "Ponovni zagon naprave",
"settings_rebootDeviceSubtitle": "Ponovno zaženi MeshCore napravo",
"settings_rebootDeviceConfirm": "Ste prepričani, da želite ponovno zagnati napravo? Povezava bo prekinjena.",
"settings_debug": "Debug",
"settings_bleDebugLog": "BLE debug log (razhroščevanje)",
"settings_bleDebugLogSubtitle": "BLE ukazi, odgovori in surovi podatki",
"settings_appDebugLog": "Logi aplikacije",
"settings_appDebugLogSubtitle": "Debug sporočila aplikacije",
"settings_about": "Oglejte si",
"settings_aboutVersion": "MeshCore Open v{version}",
"@settings_aboutVersion": {
@@ -121,14 +121,14 @@
}
}
},
"settings_aboutLegalese": "MeshCore Odprtokodni Projekt 2024",
"settings_aboutDescription": "Odprtokodni Flutter kličnik za naprave za LoRa mrežo MeshCore.",
"settings_aboutLegalese": "Odprtokodni projekt MeshCore 2024",
"settings_aboutDescription": "Odprtokodni Flutter klient za naprave za LoRa omrežje MeshCore.",
"settings_infoName": "Ime",
"settings_infoId": "ID",
"settings_infoStatus": "Status",
"settings_infoBattery": "Baterija",
"settings_infoPublicKey": "Ključ javnega tipa",
"settings_infoContactsCount": "Število kontaktov",
"settings_infoPublicKey": "Javni ključ",
"settings_infoContactsCount": "Število stikov",
"settings_infoChannelCount": "Število kanalov",
"settings_presets": "Prednastavitve",
"settings_preset915Mhz": "915 MHz",
@@ -136,15 +136,15 @@
"settings_preset433Mhz": "433 MHz",
"settings_frequency": "Frekvenca (MHz)",
"settings_frequencyHelper": "300,00 - 2500,00",
"settings_frequencyInvalid": "Neveljčna frekvenca (300-2500 MHz)",
"settings_frequencyInvalid": "Neveljavna frekvenca (300-2500 MHz)",
"settings_bandwidth": "Pasovna širina",
"settings_spreadingFactor": "Razširitveni faktor",
"settings_codingRate": "Programska hitrost",
"settings_txPower": "TX Moč (dBm)",
"settings_txPowerHelper": "0 - 22",
"settings_txPowerInvalid": "Neveljaven TX moč (0-22 dBm)",
"settings_longRange": "Dolenje območje",
"settings_fastSpeed": "Hitra hitrost",
"settings_txPowerInvalid": "Neveljavna TX moč (0-22 dBm)",
"settings_longRange": "DDolg doseg",
"settings_fastSpeed": "Visoka hitrost",
"settings_error": "Napaka: {message}",
"@settings_error": {
"placeholders": {
@@ -156,8 +156,8 @@
"appSettings_title": "Nastavitve aplikacije",
"appSettings_appearance": "Prikaži",
"appSettings_theme": "Tema",
"appSettings_themeSystem": "Predpomnilnik sistema",
"appSettings_themeLight": "Luč",
"appSettings_themeSystem": "Sistemska tema",
"appSettings_themeLight": "Svetlo",
"appSettings_themeDark": "Temno",
"appSettings_language": "Jezik",
"appSettings_languageSystem": "Sistemska privzeta vrednost",
@@ -174,8 +174,8 @@
"appSettings_languageNl": "Nederlands",
"appSettings_languageSk": "Slovenčina",
"appSettings_languageBg": "Български",
"appSettings_notifications": "Obveščanja",
"appSettings_enableNotifications": "Omogoči obveščanje",
"appSettings_notifications": "Obvestila",
"appSettings_enableNotifications": "Omogoči obvestila",
"appSettings_enableNotificationsSubtitle": "Prejmite obvestila o sporočilih in oglasih",
"appSettings_notificationPermissionDenied": "Odobritev obvestila zavrnjena",
"appSettings_notificationsEnabled": "Obvestila omogočena",
@@ -185,19 +185,19 @@
"appSettings_channelMessageNotifications": "Obvestila o sporočilih kanala",
"appSettings_channelMessageNotificationsSubtitle": "Pokaži obvestilo ob prejemanju sporočil kanala",
"appSettings_advertisementNotifications": "Opozorila o oglasih",
"appSettings_advertisementNotificationsSubtitle": "Pokaži obvestilo, ko so novi vozlišči odkrivljeni.",
"appSettings_advertisementNotificationsSubtitle": "Pokaži obvestilo, ko so najdene nove naprave.",
"appSettings_messaging": "Komuniciranje",
"appSettings_clearPathOnMaxRetry": "Ponovite pot do cilja na največjem štetju",
"appSettings_clearPathOnMaxRetrySubtitle": "Ponovi pot zimske obveščevalne poti po 5 neuspešnih poskusih pošiljanja",
"appSettings_pathsWillBeCleared": "Potnice bodo očiščene po 5 neuspešnih poskusih.",
"appSettings_pathsWillNotBeCleared": "Potniški poti ne bodo samodejno čiščeni.",
"appSettings_autoRouteRotation": "Avtomatsko Občutke in Rotacije",
"appSettings_autoRouteRotationSubtitle": "Med spreminjanjem med najboljšimi potmi in plovilnim načinom",
"appSettings_pathsWillBeCleared": "Počisti pot po 5 neuspešnih poskusih.",
"appSettings_pathsWillNotBeCleared": "Poti ne bodo samodejno čiščene.",
"appSettings_autoRouteRotation": "Avtomatsko rotacija prenosne poti",
"appSettings_autoRouteRotationSubtitle": "Menjaj med boljšo potjo in flood načinom",
"appSettings_autoRouteRotationEnabled": "Samodejno krmilno rotiranje omogočeno",
"appSettings_autoRouteRotationDisabled": "Samodejno krmilno rotiranje je onemogočeno",
"appSettings_battery": "Baterija",
"appSettings_batteryChemistry": "Razem z možnostmi",
"appSettings_batteryChemistryPerDevice": "Nastavitve za naprave ({deviceName})",
"appSettings_batteryChemistry": "Kemija baterije",
"appSettings_batteryChemistryPerDevice": "Nastavitev za napravo ({deviceName})",
"@appSettings_batteryChemistryPerDevice": {
"placeholders": {
"deviceName": {
@@ -205,20 +205,20 @@
}
}
},
"appSettings_batteryChemistryConnectFirst": "Povežite se z napravo za izbiro",
"appSettings_batteryChemistryConnectFirst": "Za izbiro se poveži z napravo",
"appSettings_batteryNmc": "18650 NMC (3,0-4,2V)",
"appSettings_batteryLifepo4": "LiFePO4 (2,63,65 V)",
"appSettings_batteryLipo": "LiPo (3,0-4,2V)",
"appSettings_mapDisplay": "Prikaz zemljevide",
"appSettings_showRepeaters": "Prikaži ponovitve",
"appSettings_showRepeatersSubtitle": "Prikaži ponovljalne notranjosti na zemljeploscu",
"appSettings_showChatNodes": "Prikaži čakalne notranjosti",
"appSettings_showChatNodesSubtitle": "Prikaži pogovorni pike na zemljeploscu",
"appSettings_showOtherNodes": "Pokaži druge vozlišča",
"appSettings_showOtherNodesSubtitle": "Pokaži druge vrste notranjih elementov na zemljevalu.",
"appSettings_timeFilter": "Filtri po času",
"appSettings_timeFilterShowAll": "Pokaži vse notranje elemente",
"appSettings_timeFilterShowLast": "Pokaži notranjosti iz zadnjih {hours} ur",
"appSettings_mapDisplay": "Prikaz zemljevida",
"appSettings_showRepeaters": "Prikaži repetitorje",
"appSettings_showRepeatersSubtitle": "Prikaži repetitorje na mapi",
"appSettings_showChatNodes": "Prikaži naprave za klepet",
"appSettings_showChatNodesSubtitle": "Prikaži naprave na zemljevidu",
"appSettings_showOtherNodes": "Pokaži druge naprave",
"appSettings_showOtherNodesSubtitle": "Pokaži druge vrste naprav na zemljevidu.",
"appSettings_timeFilter": "Filter po času",
"appSettings_timeFilterShowAll": "Pokaži vse naprave",
"appSettings_timeFilterShowLast": "Pokaži naprave v zadnjih {hours} urah",
"@appSettings_timeFilterShowLast": {
"placeholders": {
"hours": {
@@ -226,15 +226,15 @@
}
}
},
"appSettings_mapTimeFilter": "Filtri časa zemljevida",
"appSettings_showNodesDiscoveredWithin": "Pokaži notranje čepke, odkrivene v:",
"appSettings_allTime": "Vse čase",
"appSettings_lastHour": "Minuto nazaj",
"appSettings_mapTimeFilter": "Filter časa na zemljevidu",
"appSettings_showNodesDiscoveredWithin": "Pokaži naprave odkrite v:",
"appSettings_allTime": "Brez omejitev",
"appSettings_lastHour": "V zadnji uri",
"appSettings_last6Hours": "Zadnjih 6 ur",
"appSettings_last24Hours": "Zadnjih 24 ur",
"appSettings_lastWeek": "Lepošno",
"appSettings_offlineMapCache": "Omrezni Poudni Arhiv",
"appSettings_noAreaSelected": "Nizkana označena površina",
"appSettings_lastWeek": "Prejšnji teden",
"appSettings_offlineMapCache": "Shramba zemljevidov brez povezave",
"appSettings_noAreaSelected": "Območje ni izbrano",
"appSettings_areaSelectedZoom": "Izbrano območje (povečava {minZoom}-{maxZoom})",
"@appSettings_areaSelectedZoom": {
"placeholders": {
@@ -246,19 +246,19 @@
}
}
},
"appSettings_debugCard": "Napravi popravek",
"appSettings_appDebugLogging": "Programski Log",
"appSettings_appDebugLoggingSubtitle": "Log aplikacijske debug sporočila za odpravljanje težav",
"appSettings_appDebugLoggingEnabled": "Omogočeno zaznamovanje napak v aplikaciji",
"appSettings_appDebugLoggingDisabled": "Programski logi aplikacije so onemogočeni.",
"contacts_title": "Kontakti",
"contacts_noContacts": "Še ni kontaktov.",
"contacts_contactsWillAppear": "Kontakti se bodo prikazali, ko naprave oglasijo.",
"contacts_searchContacts": "Iskanje kontaktov...",
"contacts_noUnreadContacts": "Nerešeno kontaktov.",
"contacts_noContactsFound": "Niti ena oseba ali skupine ni najdena.",
"contacts_deleteContact": "Izbrisati Kontakt",
"contacts_removeConfirm": "Izbrisati {contactName} iz kontaktov?",
"appSettings_debugCard": "Razhroščevanje",
"appSettings_appDebugLogging": "Programski dnevnik",
"appSettings_appDebugLoggingSubtitle": "Dnevnik debug sporočil za odpravljanje težav",
"appSettings_appDebugLoggingEnabled": "Beleženje napak v aplikaciji omogočeno",
"appSettings_appDebugLoggingDisabled": "Beleženje napak v aplikacije onemogočeno.",
"contacts_title": "Stiki",
"contacts_noContacts": "Ni stikov.",
"contacts_contactsWillAppear": "Stiki se bodo prikazali, ko se naprave oglasijo.",
"contacts_searchContacts": "Iskanje stikov...",
"contacts_noUnreadContacts": "Ne prebrani stiki.",
"contacts_noContactsFound": "Stiki niso najdeni.",
"contacts_deleteContact": "Izbriši stik",
"contacts_removeConfirm": "Izbrišem {contactName} iz stikov?",
"@contacts_removeConfirm": {
"placeholders": {
"contactName": {
@@ -266,12 +266,12 @@
}
}
},
"contacts_manageRepeater": "Upravljajte Ponovitve",
"contacts_roomLogin": "Vnos v sobo",
"contacts_openChat": "Odprta kleta",
"contacts_editGroup": "Uredi Skupino",
"contacts_deleteGroup": "Izbrisati Skupino",
"contacts_deleteGroupConfirm": "Odpovedati {groupName}?",
"contacts_manageRepeater": "Upravljaj Ponovitve",
"contacts_roomLogin": "Prijava v sobo",
"contacts_openChat": "Odpri klepet",
"contacts_editGroup": "Uredi skupino",
"contacts_deleteGroup": "Izbriši skupino",
"contacts_deleteGroupConfirm": "Izbriši {groupName}?",
"@contacts_deleteGroupConfirm": {
"placeholders": {
"groupName": {
@@ -279,8 +279,8 @@
}
}
},
"contacts_newGroup": "Novo skupino",
"contacts_groupName": "Skupina imena",
"contacts_newGroup": "Nova skupina",
"contacts_groupName": "Ime skupine",
"contacts_groupNameRequired": "Ime skupine je obvezno.",
"contacts_groupAlreadyExists": "Skupina \"{name}\" že obstaja",
"@contacts_groupAlreadyExists": {
@@ -290,11 +290,11 @@
}
}
},
"contacts_filterContacts": "Filtri kontakt\\,...",
"contacts_noContactsMatchFilter": "Niti ena oseba ne ustreza vašemu kriteriju.",
"contacts_noMembers": "Nič članov.",
"contacts_lastSeenNow": "Datum zadnjega vpisa zdaj",
"contacts_lastSeenMinsAgo": "Zadnjič videti {minutes} minut nazaj",
"contacts_filterContacts": "Filtriraj stik\\,...",
"contacts_noContactsMatchFilter": "Noben stik ne ustreza vašemu kriteriju.",
"contacts_noMembers": "Ni članov.",
"contacts_lastSeenNow": "Nazadnje viden zdaj",
"contacts_lastSeenMinsAgo": "Zadnjič viden pred {minutes} minutami",
"@contacts_lastSeenMinsAgo": {
"placeholders": {
"minutes": {
@@ -302,8 +302,8 @@
}
}
},
"contacts_lastSeenHourAgo": "Zadnjič ogledan pred 1 uro.",
"contacts_lastSeenHoursAgo": "Zadnjič videti {hours} ur nazaj",
"contacts_lastSeenHourAgo": "Zadnjič viden pred 1 uro.",
"contacts_lastSeenHoursAgo": "Zadnjič viden pred {hours} urami",
"@contacts_lastSeenHoursAgo": {
"placeholders": {
"hours": {
@@ -311,8 +311,8 @@
}
}
},
"contacts_lastSeenDayAgo": "Zadnjič ogledan pred 1 dnem",
"contacts_lastSeenDaysAgo": "Zadnjič videti {days} dni nazaj",
"contacts_lastSeenDayAgo": "Zadnjič viden pred 1 dnem",
"contacts_lastSeenDaysAgo": "Zadnjič viden pred {days} dnem",
"@contacts_lastSeenDaysAgo": {
"placeholders": {
"days": {
@@ -321,10 +321,10 @@
}
},
"channels_title": "Kanali",
"channels_noChannelsConfigured": "Nekonfigurirane kanale",
"channels_addPublicChannel": "Dodaj Objavni Kanal",
"channels_noChannelsConfigured": "Kanali še niso konfigurirani",
"channels_addPublicChannel": "Dodaj javni kanal",
"channels_searchChannels": "Poišči kanale...",
"channels_noChannelsFound": "Niti kanalov najti ni.",
"channels_noChannelsFound": "Ne najdem kanalov.",
"channels_channelIndex": "Kanal {index}",
"@channels_channelIndex": {
"placeholders": {
@@ -334,13 +334,13 @@
}
},
"channels_hashtagChannel": "Hashtag kanal",
"channels_public": "javno",
"channels_private": "Zasebno",
"channels_publicChannel": "Ogljišna skupina",
"channels_privateChannel": "Zatemniščen kanal",
"channels_public": "Javni",
"channels_private": "Zasebni",
"channels_publicChannel": "Javni kanal",
"channels_privateChannel": "Zasebni kanal",
"channels_editChannel": "Uredi kanal",
"channels_deleteChannel": "Pošlji kanal",
"channels_deleteChannelConfirm": "Izbrisati \"{name}\"? To se ne da povrniti.",
"channels_deleteChannelConfirm": "Izbrišem \"{name}\"? To se ne da povrniti.",
"@channels_deleteChannelConfirm": {
"placeholders": {
"name": {
@@ -424,8 +424,8 @@
}
}
},
"chat_typeMessage": "Vnesite sporočilo...",
"chat_messageTooLong": "Pošiljanje sporočila je onemogočeno, saj je preveliko (maksimalno {maxBytes} bajt).",
"chat_typeMessage": "Vnesi sporočilo...",
"chat_messageTooLong": "Pošiljanje sporočila je onemogočeno, saj je preveliko (maksimalno {maxBytes} byte-ov).",
"@chat_messageTooLong": {
"placeholders": {
"maxBytes": {
@@ -433,9 +433,9 @@
}
}
},
"chat_messageCopied": "Pošljeno sporočilo",
"chat_messageDeleted": "Pošiljanje sporočila izbrisano",
"chat_retryingMessage": "Ponovna poskus.",
"chat_messageCopied": "Sporočilo poslano",
"chat_messageDeleted": "Sporočilo izbrisano",
"chat_retryingMessage": "Ponovni poskus.",
"chat_retryCount": "Ponovit {current}/{max}",
"@chat_retryCount": {
"placeholders": {
@@ -448,31 +448,31 @@
}
},
"chat_sendGif": "Pošlji GIF",
"chat_reply": "Odpošlji",
"chat_addReaction": "Dodaj Reakcijo",
"chat_reply": "Odgovori",
"chat_addReaction": "Dodaj reakcijo",
"chat_me": "jaz",
"emojiCategorySmileys": "Emoji",
"emojiCategoryGestures": "Gestikulacije",
"emojiCategoryHearts": "Srce",
"emojiCategoryObjects": "Predmeti",
"gifPicker_title": "Izberi GIF",
"gifPicker_searchHint": "Iskalite GIF-e...",
"gifPicker_poweredBy": "Naprodno z GIPHY",
"gifPicker_noGifsFound": "Niti GIF-jev najti ni.",
"gifPicker_failedLoad": "Neuspešno je naložilo GIF-e",
"gifPicker_failedSearch": "Posodobit neuspešno.",
"gifPicker_searchHint": "Išči GIF-e...",
"gifPicker_poweredBy": "Napredno z GIPHY",
"gifPicker_noGifsFound": "Ne najdem GIF-ov.",
"gifPicker_failedLoad": "Neuspešno nalaganje GIF-a",
"gifPicker_failedSearch": "Iskanje neuspešno.",
"gifPicker_noInternet": "Ni internetne povezave",
"debugLog_appTitle": "Log zapiske aplikacije",
"debugLog_bleTitle": "Logarjev zapis BLE",
"debugLog_copyLog": "Kopiraj zapiske",
"debugLog_clearLog": "Pasters log",
"debugLog_copied": "Kopirana belež poteka.",
"debugLog_bleCopied": "Kopirana beležke iz BLE",
"debugLog_noEntries": "Še ni ustvarjenih debug zapisov.",
"debugLog_enableInSettings": "Omogoči beleženje napak v aplikaciji v nastavitvah",
"debugLog_frames": "Okna",
"debugLog_bleTitle": "Log zapis BLE",
"debugLog_copyLog": "Kopiraj dnevnik",
"debugLog_clearLog": "Briši log",
"debugLog_copied": "Beležka kopirana.",
"debugLog_bleCopied": "Kopirana beležka iz BLE",
"debugLog_noEntries": "Ni ustvarjenih debug zapisov.",
"debugLog_enableInSettings": "Omogoči beleženje napak v nastavitvah aplikacije",
"debugLog_frames": "Okvirji",
"debugLog_rawLogRx": "Svež Log-RX",
"debugLog_noBleActivity": "Šele začnite z aktivnostjo BLE.",
"debugLog_noBleActivity": "Ni BLE aktivnosti.",
"debugFrame_length": "Izhodni rob: {count} bajtov",
"@debugFrame_length": {
"placeholders": {
@@ -542,8 +542,8 @@
"chat_forceFloodMode": "Nasilje obvezati v način",
"chat_recentAckPaths": "Nedavni poti ACK (tap za uporabo):",
"chat_pathHistoryFull": "Zapiske o poti so popolni. Izbriši vnose, da dodaš nove.",
"chat_hopSingular": "skoč",
"chat_hopPlural": "škrabec",
"chat_hopSingular": "skok",
"chat_hopPlural": "skokov",
"chat_hopsCount": "{count} {count, plural, =1{hop} other{hops}}",
"@chat_hopsCount": {
"placeholders": {
@@ -554,16 +554,16 @@
},
"chat_successes": "Uspešni",
"chat_removePath": "Izbriši pot",
"chat_noPathHistoryYet": "Še ni shranjenih poti.\nPošlji sporočilo za odkrivanje poti.",
"chat_noPathHistoryYet": "Ni shranjenih poti.\nPošlji sporočilo za odkrivanje poti.",
"chat_pathActions": "Potni ukazi:",
"chat_setCustomPath": "Nastavi Prilozeno Pot",
"chat_setCustomPathSubtitle": "Ročno določite potniško pot.",
"chat_clearPath": "Čista pot",
"chat_clearPath": "Počisti pot",
"chat_clearPathSubtitle": "Ob naslednji pošiljanju znova zbrati.",
"chat_pathCleared": "Pot je očiščena. Naslednje sporočilo bo ponovno odkril pot.",
"chat_floodModeSubtitle": "Uporabi tipko usmerjevanja v meniju aplikacije.",
"chat_floodModeEnabled": "Narejena je bila omrežna modaliteta. Vklopi jo znova preko ikone v meniju aplikacije.",
"chat_fullPath": "Polni pot",
"chat_fullPath": "Polna pot",
"chat_pathDetailsNotAvailable": "Podrobnosti poti zaenkrat niso na voljo. Poskusite poslati sporočilo za osvežitev.",
"chat_pathSetHops": "Pot nastavljen: {hopCount} {hopCount, plural, =1{hop} other{hops}} - {status}",
"@chat_pathSetHops": {
@@ -1104,13 +1104,13 @@
}
}
},
"repeater_cliQuickGetName": "Dobiti ime",
"repeater_cliQuickGetName": "Pridobi ime",
"repeater_cliQuickGetRadio": "Dobiti Radiopravo",
"repeater_cliQuickGetTx": "Dobiti TX",
"repeater_cliQuickGetTx": "Pridobi TX",
"repeater_cliQuickNeighbors": "Sosedi",
"repeater_cliQuickVersion": "Različica",
"repeater_cliQuickAdvertise": "Oglasite",
"repeater_cliQuickClock": "Urnik",
"repeater_cliQuickClock": "Ura",
"repeater_cliHelpAdvert": "Pošlje paket oglasov",
"repeater_cliHelpReboot": "Ponastavi naprave. (Opomba, lahko pride do 'Timeouta', kar je normalno)",
"repeater_cliHelpClock": "Prikaže trenutno uro po uri naprave.",
@@ -1142,7 +1142,7 @@
"repeater_cliHelpSetBridgeSecret": "Nastavi skrivni dostop za mostove ESPNOW.",
"repeater_cliHelpSetAdcMultiplier": "Nastavi prilagoditev faktorja za prilagoditev poravnalnega napetosti baterije (podprt le na izbranih ploščah).",
"repeater_cliHelpTempRadio": "Nastavi začasne radio parametre za določeno časovno obdobje, kar po preteku časa vrne originalne radio parametre. (ne shranjuje v preferencije).",
"repeater_cliHelpSetPerm": "Modificira ACL. Odstrani ustreznu vnos (po predponi pubkeyja), če je \"permissions\" enako nič. Dodaja nov vnos, če je pubkey-hex v celoti in trenutno ni v ACL. Posodobi vnos po ustreznem predponi pubkeyja. Bitje dovoljenj se razlikuje glede na firmware vlogo, vendar so prvi dve bitki: 0 (Gost), 1 (Lezenje samo), 2 (Lezenje in pisanje), 3 (Administrator).",
"repeater_cliHelpSetPerm": "Modificira ACL. Odstrani ustrezen vnos (po predponi pubkeyja), če je \"permissions\" enako nič. Dodaja nov vnos, če je pubkey-hex v celoti in trenutno ni v ACL. Posodobi vnos po ustreznem predponi pubkeyja. Bitje dovoljenj se razlikuje glede na firmware vlogo, vendar so prvi dve bitki: 0 (Gost), 1 (Lezenje samo), 2 (Lezenje in pisanje), 3 (Administrator).",
"repeater_cliHelpGetBridgeType": "Dobrodošli pri izbiri vrste mostu: brez, rs232, espnow",
"repeater_cliHelpLogStart": "Začnete beleženje paketov v datotekovni sistem.",
"repeater_cliHelpLogStop": "Ustavite beleženje paketov v datotečno sistem.",
@@ -1171,8 +1171,8 @@
"repeater_settingsCategory": "Nastavitve",
"repeater_bridge": "Most",
"repeater_logging": "Logiranje",
"repeater_neighborsRepeaterOnly": "Sosedi (le za ponovitelja)",
"repeater_regionManagementRepeaterOnly": "Upravljanje regij (zgolj za ponovitve)",
"repeater_neighborsRepeaterOnly": "Sosedi (le za repetitorje)",
"repeater_regionManagementRepeaterOnly": "Upravljanje regij (zgolj za repetitorje)",
"repeater_regionNote": "Regionske ukazi so bili uvedeni za upravljanje z regijskimi definicijami in dovolili.",
"repeater_gpsManagement": "Upravljanje GPS",
"repeater_gpsNote": "GPS ukaz je bil uveden za upravljanje z vprašanji, povezanimi z lokacijo.",
@@ -1244,9 +1244,9 @@
"channelPath_repeaterHops": "Skoki ponovitelja",
"channelPath_noHopDetails": "Podrobnosti o paketu za dostavo niso navedene.",
"channelPath_messageDetails": "Podrobnosti sporočila",
"channelPath_senderLabel": "Pošiljalec",
"channelPath_timeLabel": "Čas",
"channelPath_repeatsLabel": "Ponovi",
"channelPath_senderLabel": "Pošiljatelj",
"channelPath_timeLabel": "Ura",
"channelPath_repeatsLabel": "Ponovitve",
"channelPath_pathLabel": "Pot {index}",
"channelPath_observedLabel": "Opazovani",
"channelPath_observedPathTitle": "Opazovana pot {index} • {hops}",
@@ -1478,10 +1478,10 @@
"community_addPublicChannel": "Dodaj Objavni Kanal Komunitarja",
"community_addPublicChannelHint": "Samodejno dodaj javni kanal za to skupnost.",
"community_noCommunities": "Še nobena skupnost se ni pridružila.",
"community_scanOrCreate": "Skenirajte QR kodo ali ustvarite skupnost za začetek.",
"community_manageCommunities": "Upravljajte skupnosti",
"community_scanOrCreate": "Skeniraj QR kodo ali ustvari skupnost za začetek.",
"community_manageCommunities": "Upravljanje skupnosti",
"community_delete": "Opusti skupnost",
"community_deleteConfirm": "Zapustiti \"{name}\"?",
"community_deleteConfirm": "Zapusti \"{name}\"?",
"community_deleteChannelsWarning": "To bo izbrisalo tudi {count} kanal/kanalov in njihova sporočila.",
"@community_deleteChannelsWarning": {
"placeholders": {
@@ -1491,11 +1491,11 @@
}
},
"community_deleted": "Zapustil skupnost \"{name}\"",
"community_addHashtagChannel": "Dodaj Oznako Obštnine",
"community_addHashtagChannel": "Dodaj hashtag kanal",
"community_addHashtagChannelDesc": "Dodajte hashtag kanal za to skupnost.",
"community_selectCommunity": "Izberi skupnost",
"community_regularHashtag": "Oznaka s hashtagom",
"community_regularHashtagDesc": "javna oznaka (kateri koli lahko sodelujejo)",
"community_regularHashtagDesc": "javna oznaka (kdorkoli lahko sodeluje)",
"community_communityHashtag": "Skupnostni hashtag",
"community_communityHashtagDesc": "Izključeno za uporabnike skupnosti",
"community_forCommunity": "Za {name}",
@@ -1527,11 +1527,70 @@
}
}
},
"community_secretRegenerated": "Tajna za \"{name}\" ponovno ustvarjena",
"community_regenerateSecret": "Preberi nov tajni kôd",
"community_secretRegenerated": "Geslo za \"{name}\" ponovno ustvarjeno",
"community_regenerateSecret": "Ponovno ustvari geslo",
"community_regenerateSecretConfirm": "Preberite novo tajno geslo za \"{name}\"? Vsi članici morajo prebrati novo QR kodo, da lahko nadaljujejo s komunikacijo.",
"community_regenerate": "Preberi znova",
"community_scanToUpdateSecret": "Skeniraj nov kôd QR za posodabljanje tajne za {name}",
"community_updateSecret": "Ažurniraj tajno",
"community_secretUpdated": "Skrivnostno spremembo za \"{name}\""
"community_scanToUpdateSecret": "Skeniraj novo QR kodo za posodabljanje ključa za {name}",
"community_updateSecret": "Ažuriraj ključ",
"community_secretUpdated": "Skrivnostno spremembo za \"{name}\"",
"@contacts_pathTraceTo": {
"placeholders": {
"name": {
"type": "String"
}
}
},
"pathTrace_you": "Ti",
"pathTrace_failed": "Sledenje poti ni uspelo.",
"pathTrace_notAvailable": "Potni sled ni na voljo.",
"pathTrace_refreshTooltip": "Osveži Path Trace.",
"contacts_pathTrace": "Sledenje poti",
"contacts_ping": "Pingati",
"contacts_repeaterPathTrace": "Sledi poti do ponavljalnika",
"contacts_repeaterPing": "Pinguj ponavljalnik",
"contacts_roomPathTrace": "Sledenje poti do strežnika sobe",
"contacts_roomPing": "Ping strežnik sobe",
"contacts_chatTraceRoute": "Slediti poti žarkov",
"contacts_pathTraceTo": "Trace route to {name}",
"appSettings_languageRu": "Ruščina",
"appSettings_languageUk": "Ukrajinsko",
"contacts_contactImported": "Kontakt je bil uvožen.",
"contacts_contactImportFailed": "Kontakt ni bil uspešno uvožen.",
"contacts_zeroHopAdvert": "Reklama brez posrednikov",
"contacts_floodAdvert": "Poplavna oglás",
"contacts_invalidAdvertFormat": "Neveljavni kontaktne podatke",
"contacts_clipboardEmpty": "Odložišče je prazno.",
"contacts_copyAdvertToClipboard": "Kopiraj oglas v odložišče",
"contacts_addContactFromClipboard": "Dodaj stik iz odložišča",
"contacts_zeroHopContactAdvertSent": "Poslano po oglasu.",
"contacts_zeroHopContactAdvertFailed": "Pošiljanje kontakta ni uspelo.",
"contacts_contactAdvertCopied": "Oglas je bil kopiran v odložišče.",
"contacts_contactAdvertCopyFailed": "Kopiranje oglasa v odložišče je spodletelo.",
"contacts_ShareContactZeroHop": "Deliti kontakt prek oglasa",
"contacts_ShareContact": "Kopiraj stik v Odložišče",
"notification_activityTitle": "Aktivnost MeshCore",
"notification_messagesCount": "{count} {count, plural, =1{sporočilo} =2{sporočili} few{sporočila} other{sporočil}}",
"notification_channelMessagesCount": "{count} {count, plural, =1{sporočilo kanala} =2{sporočili kanala} few{sporočila kanala} other{sporočil kanala}}",
"notification_newNodesCount": "{count} {count, plural, =1{novo vozlišče} =2{novi vozlišči} few{nova vozlišča} other{novih vozlišč}}",
"notification_newTypeDiscovered": "Odkrito novo {contactType}",
"notification_receivedNewMessage": "Prejeto novo sporočilo",
"contacts_ShareContact": "Kopiraj stik v Odložišče",
"settings_gpxExportAll": "Izvozi vse kontakte v GPX",
"settings_gpxExportContacts": "Izvoz spremljevalcev v GPX",
"settings_gpxExportRepeatersSubtitle": "Izvozi ponovljene oddajnike / strežnik sobe z lokacijo v datoteko GPX.",
"settings_gpxExportRepeaters": "Izvoz ponoviteljev / strežnika sobe v GPX",
"settings_gpxExportError": "Pri izvozu je prišlo do napake.",
"settings_gpxExportRepeatersRoom": "Lokacije ponovljivca in strežnika sobe",
"settings_gpxExportChat": "Lokacije spremljevalcev",
"settings_gpxExportAllContacts": "Lokacije vseh stikov",
"settings_gpxExportContactsSubtitle": "Izvozi spremljevalce z lokacijo v datoteko GPX.",
"settings_gpxExportAllSubtitle": "Izvozi vse kontakte z lokacijo v datoteko GPX.",
"settings_gpxExportSuccess": "Uspešno izvoz GPX datoteke.",
"settings_gpxExportShareText": "Podatki kart izvoženi iz meshcore-open",
"settings_gpxExportNoContacts": "Ni stikov za izvoz.",
"settings_gpxExportNotAvailable": "Ni podprto na vašem napravi/operacijskem sistemu",
"settings_gpxExportShareSubject": "meshcore-open izvoz podatkov GPX karte",
"pathTrace_someHopsNoLocation": "Ena ali več hmelju manjka lokacija!"
}
+60 -1
View File
@@ -1533,5 +1533,64 @@
"community_regenerateSecret": "Regenerera hemlig kod",
"community_scanToUpdateSecret": "Skanna den nya QR-koden för att uppdatera hemligheten för \"{name}\"",
"community_secretUpdated": "Hemlighet uppdaterad för \"{name}\"",
"community_updateSecret": "Uppdatera hemlighet"
"community_updateSecret": "Uppdatera hemlighet",
"@contacts_pathTraceTo": {
"placeholders": {
"name": {
"type": "String"
}
}
},
"pathTrace_you": "Du",
"pathTrace_failed": "Sökvägsföljning misslyckades.",
"pathTrace_notAvailable": "Path trace ej tillgänglig.",
"pathTrace_refreshTooltip": "Uppdatera Path Trace",
"contacts_pathTrace": "Path Trace",
"contacts_ping": "Ping",
"contacts_repeaterPathTrace": "Vägspårning till repeater",
"contacts_repeaterPing": "Ping-repeater",
"contacts_roomPathTrace": "Vägspårning till rumserver",
"contacts_roomPing": "Ping rumsserver",
"contacts_chatTraceRoute": "Spåra rutt",
"contacts_pathTraceTo": "Spåra rutt till {name}",
"contacts_clipboardEmpty": "Urklipp är tomt.",
"appSettings_languageRu": "Ryska",
"contacts_contactImportFailed": "Kontakt kunde inte importeras.",
"contacts_zeroHopAdvert": "Reklam med nollhopp",
"contacts_floodAdvert": "Översvämningsannons",
"contacts_copyAdvertToClipboard": "Kopiera annons till urklipp",
"contacts_invalidAdvertFormat": "Ogiltiga kontaktuppgifter",
"appSettings_languageUk": "Ukrainska",
"contacts_addContactFromClipboard": "Lägg till kontakt från urklipp",
"contacts_contactImported": "Kontakt har importerats.",
"contacts_zeroHopContactAdvertSent": "Skickat kontakt via annons.",
"contacts_contactAdvertCopied": "Annons kopierad till Urklipp.",
"contacts_contactAdvertCopyFailed": "Kopiering av annons till Urklipp misslyckades.",
"contacts_ShareContact": "Kopiera kontakt till Urklipp",
"contacts_zeroHopContactAdvertFailed": "Misslyckades med att skicka kontakt.",
"contacts_ShareContactZeroHop": "Dela kontakt via annons",
"notification_activityTitle": "MeshCore Aktivitet",
"notification_messagesCount": "{count} {count, plural, =1{meddelande} other{meddelanden}}",
"notification_channelMessagesCount": "{count} {count, plural, =1{kanalmeddelande} other{kanalmeddelanden}}",
"notification_newNodesCount": "{count} {count, plural, =1{ny nod} other{nya noder}}",
"notification_newTypeDiscovered": "Ny {contactType} upptäckt",
"notification_receivedNewMessage": "Nytt meddelande mottaget",
"contacts_ShareContactZeroHop": "Dela kontakt via annons",
"settings_gpxExportAll": "Exportera alla kontakter till GPX",
"settings_gpxExportRepeatersSubtitle": "Exporterar repeater / roomserver med plats till GPX-fil.",
"settings_gpxExportSuccess": "Har exporterat GPX-fil med framgång",
"settings_gpxExportNoContacts": "Inga kontakter att exportera.",
"settings_gpxExportNotAvailable": "Stöds inte på din enhet/operativsystem",
"settings_gpxExportRepeatersRoom": "Repeater- och rumsserverplatser",
"settings_gpxExportRepeaters": "Exportera repeater / rumsservrar till GPX",
"settings_gpxExportAllSubtitle": "Exporterar alla kontakter med en plats till GPX-fil.",
"settings_gpxExportContacts": "Exportera följeslagare till GPX",
"settings_gpxExportContactsSubtitle": "Exporterar följeslagare med en plats till GPX-fil.",
"settings_gpxExportChat": "Medhjälparplatser",
"settings_gpxExportError": "Det uppstod ett fel när data exporterades.",
"settings_gpxExportAllContacts": "Alla kontakters platser",
"settings_gpxExportShareSubject": "meshcore-open export av GPX-kartdata",
"settings_gpxExportShareText": "Kartdata exporterad från meshcore-open",
"pathTrace_someHopsNoLocation": "En eller flera av humlen saknar en plats!"
}
+60 -2
View File
@@ -1534,5 +1534,63 @@
"community_secretRegenerated": "Секретний пароль для «{name}» перегенеровано",
"community_scanToUpdateSecret": "Відскануйте новий QR-код, щоб оновити пароль для «{name}»",
"community_updateSecret": "Оновити секрет",
"community_secretUpdated": "Зміну секрету для «{name}» оновлено"
}
"community_secretUpdated": "Зміну секрету для «{name}» оновлено",
"@contacts_pathTraceTo": {
"placeholders": {
"name": {
"type": "String"
}
}
},
"pathTrace_you": "Ви",
"pathTrace_failed": "Відстеження шляху не вдалося.",
"pathTrace_notAvailable": "Трасування шляху недоступне.",
"pathTrace_refreshTooltip": "Оновити Path Trace",
"contacts_pathTrace": "Трасування шляхів",
"contacts_ping": "Пінгувати",
"contacts_repeaterPathTrace": "Трасування шляху до повторювача",
"contacts_repeaterPing": "Пінгувати повторювач",
"contacts_roomPathTrace": "Трасування шляху до серверу кімнати",
"contacts_roomPing": "Пінг сервера кімнати",
"contacts_chatTraceRoute": "Трасування шляху",
"contacts_pathTraceTo": "Відстежити маршрут до {name}",
"contacts_invalidAdvertFormat": "Недійсні контактні дані",
"contacts_contactImported": "Контакт було імпортовано.",
"contacts_contactImportFailed": "Контакт не вдалося імпортувати",
"contacts_zeroHopAdvert": "Реклама без перехоплення",
"contacts_floodAdvert": "Залив реклами",
"contacts_copyAdvertToClipboard": "Копіювати оголошення в буфер обміну",
"contacts_clipboardEmpty": "Буфер обміну порожній",
"appSettings_languageRu": "Російська",
"contacts_ShareContact": "Копіювати контакт у буфер обміну",
"contacts_zeroHopContactAdvertFailed": "Не вдалося надіслати контакт.",
"contacts_contactAdvertCopied": "Рекламу скопійовано до буфера обміну.",
"contacts_contactAdvertCopyFailed": "Копіювання оголошення в буфер обміну завершилося невдало",
"contacts_zeroHopContactAdvertSent": "Відправлено контакт за оголошенням",
"contacts_addContactFromClipboard": "Додати контакт з буфера обміну",
"contacts_ShareContactZeroHop": "Поділитися контактом за оголошенням",
"notification_activityTitle": "Активність MeshCore",
"notification_messagesCount": "{count} {count, plural, =1{повідомлення} few{повідомлення} many{повідомлень} other{повідомлень}}",
"notification_channelMessagesCount": "{count} {count, plural, =1{повідомлення каналу} few{повідомлення каналу} many{повідомлень каналу} other{повідомлень каналу}}",
"notification_newNodesCount": "{count} {count, plural, =1{новий вузол} few{нових вузли} many{нових вузлів} other{нових вузлів}}",
"notification_newTypeDiscovered": "Виявлено новий {contactType}",
"notification_receivedNewMessage": "Отримано нове повідомлення",
"contacts_ShareContactZeroHop": "Поділитися контактом за оголошенням",
"settings_gpxExportRepeaters": "Експортувати ретранслятори / сервер кімнати до GPX",
"settings_gpxExportRepeatersSubtitle": "Експортує ретранслятори / сервер кімнати з місцезнаходженням у файл GPX.",
"settings_gpxExportSuccess": "Успішно експортовано файл GPX.",
"settings_gpxExportNoContacts": "Немає контактів для експорту.",
"settings_gpxExportNotAvailable": "Не підтримується на вашому пристрої/операційній системі",
"settings_gpxExportError": "Сталася помилка під час експорту.",
"settings_gpxExportAllSubtitle": "Експортує всі контакти з місцем розташування у файл GPX.",
"settings_gpxExportAll": "Експортувати всі контакти до GPX",
"settings_gpxExportContactsSubtitle": "Експортує супутників з місцезнаходженням у файл GPX.",
"settings_gpxExportContacts": "Експортувати супутників до GPX",
"settings_gpxExportRepeatersRoom": "Місцезнаходження повторювача та сервера кімнати",
"settings_gpxExportChat": "Місця супутників",
"settings_gpxExportShareText": "Дані карти експортовані з meshcore-open",
"settings_gpxExportAllContacts": "Усі місця контактів",
"settings_gpxExportShareSubject": "експорт даних карти meshcore-open у форматі GPX",
"pathTrace_someHopsNoLocation": "Одне або більше хмелів відсутнє місце розташування!"
}
+571 -512
View File
File diff suppressed because it is too large Load Diff
+31 -12
View File
@@ -60,21 +60,24 @@ void main() async {
await connector.loadContactCache();
await connector.loadChannelSettings();
await connector.loadCachedChannels();
// Load persisted channel messages
await connector.loadAllChannelMessages();
await connector.loadUnreadState();
runApp(MeshCoreApp(
connector: connector,
retryService: retryService,
pathHistoryService: pathHistoryService,
storage: storage,
appSettingsService: appSettingsService,
bleDebugLogService: bleDebugLogService,
appDebugLogService: appDebugLogService,
mapTileCacheService: mapTileCacheService,
));
runApp(
MeshCoreApp(
connector: connector,
retryService: retryService,
pathHistoryService: pathHistoryService,
storage: storage,
appSettingsService: appSettingsService,
bleDebugLogService: bleDebugLogService,
appDebugLogService: appDebugLogService,
mapTileCacheService: mapTileCacheService,
),
);
}
class MeshCoreApp extends StatelessWidget {
@@ -124,10 +127,15 @@ class MeshCoreApp extends StatelessWidget {
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: AppLocalizations.supportedLocales,
locale: _localeFromSetting(settingsService.settings.languageOverride),
locale: _localeFromSetting(
settingsService.settings.languageOverride,
),
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
useMaterial3: true,
snackBarTheme: const SnackBarThemeData(
behavior: SnackBarBehavior.floating,
),
),
darkTheme: ThemeData(
colorScheme: ColorScheme.fromSeed(
@@ -135,8 +143,19 @@ class MeshCoreApp extends StatelessWidget {
brightness: Brightness.dark,
),
useMaterial3: true,
snackBarTheme: const SnackBarThemeData(
behavior: SnackBarBehavior.floating,
),
),
themeMode: _themeModeFromSetting(settingsService.settings.themeMode),
themeMode: _themeModeFromSetting(
settingsService.settings.themeMode,
),
builder: (context, child) {
// Update notification service with resolved locale
final locale = Localizations.localeOf(context);
NotificationService().setLocale(locale);
return child ?? const SizedBox.shrink();
},
home: const ScannerScreen(),
);
},
+18 -11
View File
@@ -76,13 +76,14 @@ class AppSettings {
mapShowRepeaters: json['map_show_repeaters'] as bool? ?? true,
mapShowChatNodes: json['map_show_chat_nodes'] as bool? ?? true,
mapShowOtherNodes: json['map_show_other_nodes'] as bool? ?? true,
mapTimeFilterHours: (json['map_time_filter_hours'] as num?)?.toDouble() ?? 0,
mapTimeFilterHours:
(json['map_time_filter_hours'] as num?)?.toDouble() ?? 0,
mapKeyPrefixEnabled: json['map_key_prefix_enabled'] as bool? ?? false,
mapKeyPrefix: json['map_key_prefix'] as String? ?? '',
mapShowMarkers: json['map_show_markers'] as bool? ?? true,
mapCacheBounds: (json['map_cache_bounds'] as Map?)?.map(
(key, value) => MapEntry(key.toString(), (value as num).toDouble()),
),
(key, value) => MapEntry(key.toString(), (value as num).toDouble()),
),
mapCacheMinZoom: json['map_cache_min_zoom'] as int? ?? 10,
mapCacheMaxZoom: json['map_cache_max_zoom'] as int? ?? 15,
notificationsEnabled: json['notifications_enabled'] as bool? ?? true,
@@ -90,11 +91,13 @@ class AppSettings {
notifyOnNewChannelMessage:
json['notify_on_new_channel_message'] as bool? ?? true,
notifyOnNewAdvert: json['notify_on_new_advert'] as bool? ?? true,
autoRouteRotationEnabled: json['auto_route_rotation_enabled'] as bool? ?? false,
autoRouteRotationEnabled:
json['auto_route_rotation_enabled'] as bool? ?? false,
themeMode: json['theme_mode'] as String? ?? 'system',
languageOverride: json['language_override'] as String?,
appDebugLogEnabled: json['app_debug_log_enabled'] as bool? ?? false,
batteryChemistryByDeviceId: (json['battery_chemistry_by_device_id'] as Map?)?.map(
batteryChemistryByDeviceId:
(json['battery_chemistry_by_device_id'] as Map?)?.map(
(key, value) => MapEntry(key.toString(), value.toString()),
) ??
{},
@@ -132,8 +135,9 @@ class AppSettings {
mapKeyPrefixEnabled: mapKeyPrefixEnabled ?? this.mapKeyPrefixEnabled,
mapKeyPrefix: mapKeyPrefix ?? this.mapKeyPrefix,
mapShowMarkers: mapShowMarkers ?? this.mapShowMarkers,
mapCacheBounds:
mapCacheBounds == _unset ? this.mapCacheBounds : mapCacheBounds as Map<String, double>?,
mapCacheBounds: mapCacheBounds == _unset
? this.mapCacheBounds
: mapCacheBounds as Map<String, double>?,
mapCacheMinZoom: mapCacheMinZoom ?? this.mapCacheMinZoom,
mapCacheMaxZoom: mapCacheMaxZoom ?? this.mapCacheMaxZoom,
notificationsEnabled: notificationsEnabled ?? this.notificationsEnabled,
@@ -141,12 +145,15 @@ class AppSettings {
notifyOnNewChannelMessage:
notifyOnNewChannelMessage ?? this.notifyOnNewChannelMessage,
notifyOnNewAdvert: notifyOnNewAdvert ?? this.notifyOnNewAdvert,
autoRouteRotationEnabled: autoRouteRotationEnabled ?? this.autoRouteRotationEnabled,
autoRouteRotationEnabled:
autoRouteRotationEnabled ?? this.autoRouteRotationEnabled,
themeMode: themeMode ?? this.themeMode,
languageOverride:
languageOverride == _unset ? this.languageOverride : languageOverride as String?,
languageOverride: languageOverride == _unset
? this.languageOverride
: languageOverride as String?,
appDebugLogEnabled: appDebugLogEnabled ?? this.appDebugLogEnabled,
batteryChemistryByDeviceId: batteryChemistryByDeviceId ?? this.batteryChemistryByDeviceId,
batteryChemistryByDeviceId:
batteryChemistryByDeviceId ?? this.batteryChemistryByDeviceId,
);
}
}
+3 -5
View File
@@ -9,11 +9,13 @@ class Channel {
final int index;
final String name;
final Uint8List psk; // 16 bytes
int unreadCount;
Channel({
required this.index,
required this.name,
required this.psk,
this.unreadCount = 0,
});
String get pskHex => _bytesToHex(psk);
@@ -39,11 +41,7 @@ class Channel {
}
static Channel empty(int index) {
return Channel(
index: index,
name: '',
psk: Uint8List(16),
);
return Channel(index: index, name: '', psk: Uint8List(16));
}
static Channel fromHex(int index, String name, String pskHex) {
+23 -16
View File
@@ -59,15 +59,18 @@ class ChannelMessage {
this.replyToSenderName,
this.replyToText,
Map<String, int>? reactions,
}) : messageId = messageId ?? '${timestamp.millisecondsSinceEpoch}_${senderName.hashCode}_${text.hashCode}',
reactions = reactions ?? {},
pathBytes = pathBytes ?? Uint8List(0),
pathVariants = _mergePathVariants(
pathBytes ?? Uint8List(0),
pathVariants,
);
}) : messageId =
messageId ??
'${timestamp.millisecondsSinceEpoch}_${senderName.hashCode}_${text.hashCode}',
reactions = reactions ?? {},
pathBytes = pathBytes ?? Uint8List(0),
pathVariants = _mergePathVariants(
pathBytes ?? Uint8List(0),
pathVariants,
);
String? get senderKeyHex => senderKey != null ? pubKeyToHex(senderKey!) : null;
String? get senderKeyHex =>
senderKey != null ? pubKeyToHex(senderKey!) : null;
ChannelMessage copyWith({
ChannelMessageStatus? status,
@@ -125,8 +128,10 @@ class ChannelMessage {
final hasPathBytesFlag = (data[2] & 0x01) != 0;
final canFitPath = pathLen > 0 && data.length >= cursor + pathLen + 5;
final hasValidTxtType =
cursor < data.length && (data[cursor] == txtTypePlain || data[cursor] == txtTypeCliData);
if ((hasPathBytesFlag || (canFitPath && !hasValidTxtType)) && canFitPath) {
cursor < data.length &&
(data[cursor] == txtTypePlain || data[cursor] == txtTypeCliData);
if ((hasPathBytesFlag || (canFitPath && !hasValidTxtType)) &&
canFitPath) {
pathBytes = Uint8List.fromList(data.sublist(cursor, cursor + pathLen));
cursor += pathLen;
}
@@ -162,7 +167,8 @@ class ChannelMessage {
final potentialSender = text.substring(0, colonIndex);
if (!RegExp(r'[:\[\]]').hasMatch(potentialSender)) {
senderName = potentialSender;
final offset = (colonIndex + 1 < text.length && text[colonIndex + 1] == ' ')
final offset =
(colonIndex + 1 < text.length && text[colonIndex + 1] == ' ')
? colonIndex + 2
: colonIndex + 1;
actualText = text.substring(offset);
@@ -184,7 +190,11 @@ class ChannelMessage {
);
}
static ChannelMessage outgoing(String text, String senderName, int channelIndex) {
static ChannelMessage outgoing(
String text,
String senderName,
int channelIndex,
) {
return ChannelMessage(
senderKey: null,
senderName: senderName,
@@ -249,8 +259,5 @@ class ReplyInfo {
final String mentionedNode;
final String actualMessage;
ReplyInfo({
required this.mentionedNode,
required this.actualMessage,
});
ReplyInfo({required this.mentionedNode, required this.actualMessage});
}
+4 -8
View File
@@ -34,10 +34,7 @@ class Community {
}) : hashtagChannels = hashtagChannels ?? [];
/// Generate a new community with a random 32-byte secret
factory Community.create({
required String id,
required String name,
}) {
factory Community.create({required String id, required String name}) {
final random = Random.secure();
final secret = Uint8List(32);
for (int i = 0; i < 32; i++) {
@@ -84,7 +81,8 @@ class Community {
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>?)
hashtagChannels:
(json['hashtag_channels'] as List<dynamic>?)
?.map((e) => e as String)
.toList() ??
[],
@@ -234,9 +232,7 @@ class Community {
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is Community &&
runtimeType == other.runtimeType &&
id == other.id;
other is Community && runtimeType == other.runtimeType && id == other.id;
@override
int get hashCode => id.hashCode;
+54 -5
View File
@@ -7,7 +7,8 @@ class Contact {
final int type;
final int pathLength; // -1 = flood, 0+ = direct hops (from device)
final Uint8List path; // Path bytes from device
final int? pathOverride; // User's path override: -1 = force flood, null = auto
final int?
pathOverride; // User's path override: -1 = force flood, null = auto
final Uint8List? pathOverrideBytes; // User's path override bytes
final double? latitude;
final double? longitude;
@@ -78,8 +79,12 @@ class Contact {
type: type ?? this.type,
pathLength: pathLength ?? this.pathLength,
path: path ?? this.path,
pathOverride: clearPathOverride ? null : (pathOverride ?? this.pathOverride),
pathOverrideBytes: clearPathOverride ? null : (pathOverrideBytes ?? this.pathOverrideBytes),
pathOverride: clearPathOverride
? null
: (pathOverride ?? this.pathOverride),
pathOverrideBytes: clearPathOverride
? null
: (pathOverrideBytes ?? this.pathOverrideBytes),
latitude: latitude ?? this.latitude,
longitude: longitude ?? this.longitude,
lastSeen: lastSeen ?? this.lastSeen,
@@ -93,15 +98,59 @@ class Contact {
final parts = <String>[];
final groupSize = pathHashSize;
for (int i = 0; i < pathBytes.length; i += groupSize) {
final end = (i + groupSize) <= pathBytes.length ? (i + groupSize) : pathBytes.length;
final end = (i + groupSize) <= pathBytes.length
? (i + groupSize)
: pathBytes.length;
final chunk = pathBytes.sublist(i, end);
parts.add(
chunk.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase()).join(),
chunk
.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase())
.join(),
);
}
return parts.join(',');
}
String get shortPubKeyHex {
return "<${publicKeyHex.substring(0, 8)}...${publicKeyHex.substring(publicKeyHex.length - 8)}>";
}
Uint8List? get traceRouteBytes {
final pathBytes = _pathBytesForDisplay;
Uint8List? traceBytes;
if (pathLength <= 0) {
traceBytes = Uint8List(1);
traceBytes[0] = publicKey[0];
return traceBytes;
}
if (type == advTypeRepeater || type == advTypeRoom) {
final len = (pathBytes.length + pathBytes.length + 1);
traceBytes = Uint8List(len);
traceBytes[pathBytes.length] = publicKey[0];
for (int i = 0; i < pathBytes.length; i++) {
traceBytes[i] = pathBytes[i];
if (i < pathBytes.length) {
traceBytes[len - 1 - i] = pathBytes[i];
}
}
} else {
if (pathBytes.length < 2) {
return pathBytes[0] == 0 ? null : pathBytes;
}
final len = (pathBytes.length + pathBytes.length - 1);
traceBytes = Uint8List(len);
for (int i = 0; i < pathBytes.length; i++) {
traceBytes[i] = pathBytes[i];
if (i < pathBytes.length - 1) {
traceBytes[len - 1 - i] = pathBytes[i];
}
}
}
return traceBytes;
}
Uint8List get _pathBytesForDisplay {
if (pathOverride != null) {
if (pathOverride! < 0) return Uint8List(0);
+5 -15
View File
@@ -2,15 +2,9 @@ class ContactGroup {
final String name;
final List<String> memberKeys;
const ContactGroup({
required this.name,
required this.memberKeys,
});
const ContactGroup({required this.name, required this.memberKeys});
ContactGroup copyWith({
String? name,
List<String>? memberKeys,
}) {
ContactGroup copyWith({String? name, List<String>? memberKeys}) {
return ContactGroup(
name: name ?? this.name,
memberKeys: memberKeys ?? List<String>.from(this.memberKeys),
@@ -18,16 +12,12 @@ class ContactGroup {
}
Map<String, dynamic> toJson() {
return {
'name': name,
'members': memberKeys,
};
return {'name': name, 'members': memberKeys};
}
factory ContactGroup.fromJson(Map<String, dynamic> json) {
final members = (json['members'] as List?)
?.map((value) => value.toString())
.toList() ??
final members =
(json['members'] as List?)?.map((value) => value.toString()).toList() ??
<String>[];
return ContactGroup(
name: json['name'] as String? ?? '',
+5 -4
View File
@@ -43,9 +43,9 @@ class Message {
Uint8List? pathBytes,
Uint8List? fourByteRoomContactKey,
Map<String, int>? reactions,
}) : pathBytes = pathBytes ?? Uint8List(0),
fourByteRoomContactKey = fourByteRoomContactKey ?? Uint8List(0),
reactions = reactions ?? {};
}) : pathBytes = pathBytes ?? Uint8List(0),
fourByteRoomContactKey = fourByteRoomContactKey ?? Uint8List(0),
reactions = reactions ?? {};
String get senderKeyHex => pubKeyToHex(senderKey);
@@ -80,7 +80,8 @@ class Message {
pathLength: pathLength ?? this.pathLength,
pathBytes: pathBytes ?? this.pathBytes,
reactions: reactions ?? this.reactions,
fourByteRoomContactKey: fourByteRoomContactKey ?? this.fourByteRoomContactKey,
fourByteRoomContactKey:
fourByteRoomContactKey ?? this.fourByteRoomContactKey,
);
}
+8 -6
View File
@@ -38,7 +38,8 @@ class PathRecord {
tripTimeMs: json['trip_time_ms'] as int,
timestamp: DateTime.parse(json['timestamp'] as String),
wasFloodDiscovery: json['was_flood'] as bool,
pathBytes: (json['path_bytes'] as List?)?.map((b) => b as int).toList() ?? [],
pathBytes:
(json['path_bytes'] as List?)?.map((b) => b as int).toList() ?? [],
successCount: json['success_count'] as int? ?? 0,
failureCount: json['failure_count'] as int? ?? 0,
);
@@ -65,14 +66,15 @@ class ContactPathHistory {
}
Map<String, dynamic> toJson() {
return {
'recent_paths': recentPaths.map((p) => p.toJson()).toList(),
};
return {'recent_paths': recentPaths.map((p) => p.toJson()).toList()};
}
factory ContactPathHistory.fromJson(
String contactPubKeyHex, Map<String, dynamic> json) {
final pathsList = (json['recent_paths'] as List?)
String contactPubKeyHex,
Map<String, dynamic> json,
) {
final pathsList =
(json['recent_paths'] as List?)
?.map((p) => PathRecord.fromJson(p as Map<String, dynamic>))
.toList() ??
[];
+30 -30
View File
@@ -61,44 +61,44 @@ class RadioSettings {
// Preset configurations
static RadioSettings get preset915MHz => RadioSettings(
frequencyMHz: 915.0,
bandwidth: LoRaBandwidth.bw125,
spreadingFactor: LoRaSpreadingFactor.sf7,
codingRate: LoRaCodingRate.cr4_5,
txPowerDbm: 20,
);
frequencyMHz: 915.0,
bandwidth: LoRaBandwidth.bw125,
spreadingFactor: LoRaSpreadingFactor.sf7,
codingRate: LoRaCodingRate.cr4_5,
txPowerDbm: 20,
);
static RadioSettings get preset868MHz => RadioSettings(
frequencyMHz: 868.0,
bandwidth: LoRaBandwidth.bw125,
spreadingFactor: LoRaSpreadingFactor.sf7,
codingRate: LoRaCodingRate.cr4_5,
txPowerDbm: 14,
);
frequencyMHz: 868.0,
bandwidth: LoRaBandwidth.bw125,
spreadingFactor: LoRaSpreadingFactor.sf7,
codingRate: LoRaCodingRate.cr4_5,
txPowerDbm: 14,
);
static RadioSettings get preset433MHz => RadioSettings(
frequencyMHz: 433.0,
bandwidth: LoRaBandwidth.bw125,
spreadingFactor: LoRaSpreadingFactor.sf7,
codingRate: LoRaCodingRate.cr4_5,
txPowerDbm: 20,
);
frequencyMHz: 433.0,
bandwidth: LoRaBandwidth.bw125,
spreadingFactor: LoRaSpreadingFactor.sf7,
codingRate: LoRaCodingRate.cr4_5,
txPowerDbm: 20,
);
static RadioSettings get presetLongRange => RadioSettings(
frequencyMHz: 915.0,
bandwidth: LoRaBandwidth.bw125,
spreadingFactor: LoRaSpreadingFactor.sf12,
codingRate: LoRaCodingRate.cr4_8,
txPowerDbm: 20,
);
frequencyMHz: 915.0,
bandwidth: LoRaBandwidth.bw125,
spreadingFactor: LoRaSpreadingFactor.sf12,
codingRate: LoRaCodingRate.cr4_8,
txPowerDbm: 20,
);
static RadioSettings get presetFastSpeed => RadioSettings(
frequencyMHz: 915.0,
bandwidth: LoRaBandwidth.bw500,
spreadingFactor: LoRaSpreadingFactor.sf7,
codingRate: LoRaCodingRate.cr4_5,
txPowerDbm: 20,
);
frequencyMHz: 915.0,
bandwidth: LoRaBandwidth.bw500,
spreadingFactor: LoRaSpreadingFactor.sf7,
codingRate: LoRaCodingRate.cr4_5,
txPowerDbm: 20,
);
int get frequencyHz => (frequencyMHz * 1000).round();
int get bandwidthHz => bandwidth.hz;
+30 -8
View File
@@ -26,8 +26,10 @@ class AppDebugLogScreen extends StatelessWidget {
onPressed: hasEntries
? () async {
final text = entries
.map((entry) =>
'[${entry.formattedTime}] [${entry.levelLabel}] [${entry.tag}] ${entry.message}')
.map(
(entry) =>
'[${entry.formattedTime}] [${entry.levelLabel}] [${entry.tag}] ${entry.message}',
)
.join('\n');
await Clipboard.setData(ClipboardData(text: text));
if (!context.mounted) return;
@@ -61,11 +63,17 @@ class AppDebugLogScreen extends StatelessWidget {
leading: _buildLevelIcon(entry.level),
title: Text(
'[${entry.tag}] ${entry.message}',
style: const TextStyle(fontSize: 12, fontFamily: 'monospace'),
style: const TextStyle(
fontSize: 12,
fontFamily: 'monospace',
),
),
subtitle: Text(
entry.formattedTime,
style: TextStyle(fontSize: 10, color: Colors.grey[600]),
style: TextStyle(
fontSize: 10,
color: Colors.grey[600],
),
),
);
},
@@ -74,16 +82,26 @@ class AppDebugLogScreen extends StatelessWidget {
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.bug_report_outlined, size: 64, color: Colors.grey[400]),
Icon(
Icons.bug_report_outlined,
size: 64,
color: Colors.grey[400],
),
const SizedBox(height: 16),
Text(
context.l10n.debugLog_noEntries,
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
style: TextStyle(
fontSize: 16,
color: Colors.grey[600],
),
),
const SizedBox(height: 8),
Text(
context.l10n.debugLog_enableInSettings,
style: TextStyle(fontSize: 12, color: Colors.grey[500]),
style: TextStyle(
fontSize: 12,
color: Colors.grey[500],
),
),
],
),
@@ -99,7 +117,11 @@ class AppDebugLogScreen extends StatelessWidget {
case AppDebugLogLevel.info:
return const Icon(Icons.info_outline, size: 18, color: Colors.blue);
case AppDebugLogLevel.warning:
return const Icon(Icons.warning_amber_outlined, size: 18, color: Colors.orange);
return const Icon(
Icons.warning_amber_outlined,
size: 18,
color: Colors.orange,
);
case AppDebugLogLevel.error:
return const Icon(Icons.error_outline, size: 18, color: Colors.red);
}
+129 -55
View File
@@ -43,7 +43,10 @@ class AppSettingsScreen extends StatelessWidget {
);
}
Widget _buildAppearanceCard(BuildContext context, AppSettingsService settingsService) {
Widget _buildAppearanceCard(
BuildContext context,
AppSettingsService settingsService,
) {
return Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -58,7 +61,9 @@ class AppSettingsScreen extends StatelessWidget {
ListTile(
leading: const Icon(Icons.brightness_6_outlined),
title: Text(context.l10n.appSettings_theme),
subtitle: Text(_themeModeLabel(context, settingsService.settings.themeMode)),
subtitle: Text(
_themeModeLabel(context, settingsService.settings.themeMode),
),
trailing: const Icon(Icons.chevron_right),
onTap: () => _showThemeModeDialog(context, settingsService),
),
@@ -66,7 +71,12 @@ class AppSettingsScreen extends StatelessWidget {
ListTile(
leading: const Icon(Icons.language_outlined),
title: Text(context.l10n.appSettings_language),
subtitle: Text(_languageLabel(context, settingsService.settings.languageOverride)),
subtitle: Text(
_languageLabel(
context,
settingsService.settings.languageOverride,
),
),
trailing: const Icon(Icons.chevron_right),
onTap: () => _showLanguageDialog(context, settingsService),
),
@@ -75,7 +85,10 @@ class AppSettingsScreen extends StatelessWidget {
);
}
Widget _buildNotificationsCard(BuildContext context, AppSettingsService settingsService) {
Widget _buildNotificationsCard(
BuildContext context,
AppSettingsService settingsService,
) {
return Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -90,17 +103,22 @@ class AppSettingsScreen extends StatelessWidget {
SwitchListTile(
secondary: const Icon(Icons.notifications_outlined),
title: Text(context.l10n.appSettings_enableNotifications),
subtitle: Text(context.l10n.appSettings_enableNotificationsSubtitle),
subtitle: Text(
context.l10n.appSettings_enableNotificationsSubtitle,
),
value: settingsService.settings.notificationsEnabled,
onChanged: (value) async {
if (value) {
// Request permission when enabling
final granted = await NotificationService().requestPermissions();
final granted = await NotificationService()
.requestPermissions();
if (!granted) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.appSettings_notificationPermissionDenied),
content: Text(
context.l10n.appSettings_notificationPermissionDenied,
),
duration: const Duration(seconds: 2),
),
);
@@ -113,9 +131,11 @@ class AppSettingsScreen extends StatelessWidget {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(value
? context.l10n.appSettings_notificationsEnabled
: context.l10n.appSettings_notificationsDisabled),
content: Text(
value
? context.l10n.appSettings_notificationsEnabled
: context.l10n.appSettings_notificationsDisabled,
),
duration: const Duration(seconds: 2),
),
);
@@ -126,18 +146,24 @@ class AppSettingsScreen extends StatelessWidget {
SwitchListTile(
secondary: Icon(
Icons.message_outlined,
color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
color: settingsService.settings.notificationsEnabled
? null
: Colors.grey,
),
title: Text(
context.l10n.appSettings_messageNotifications,
style: TextStyle(
color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
color: settingsService.settings.notificationsEnabled
? null
: Colors.grey,
),
),
subtitle: Text(
context.l10n.appSettings_messageNotificationsSubtitle,
style: TextStyle(
color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
color: settingsService.settings.notificationsEnabled
? null
: Colors.grey,
),
),
value: settingsService.settings.notifyOnNewMessage,
@@ -151,18 +177,24 @@ class AppSettingsScreen extends StatelessWidget {
SwitchListTile(
secondary: Icon(
Icons.forum_outlined,
color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
color: settingsService.settings.notificationsEnabled
? null
: Colors.grey,
),
title: Text(
context.l10n.appSettings_channelMessageNotifications,
style: TextStyle(
color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
color: settingsService.settings.notificationsEnabled
? null
: Colors.grey,
),
),
subtitle: Text(
context.l10n.appSettings_channelMessageNotificationsSubtitle,
style: TextStyle(
color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
color: settingsService.settings.notificationsEnabled
? null
: Colors.grey,
),
),
value: settingsService.settings.notifyOnNewChannelMessage,
@@ -176,18 +208,24 @@ class AppSettingsScreen extends StatelessWidget {
SwitchListTile(
secondary: Icon(
Icons.cell_tower,
color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
color: settingsService.settings.notificationsEnabled
? null
: Colors.grey,
),
title: Text(
context.l10n.appSettings_advertisementNotifications,
style: TextStyle(
color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
color: settingsService.settings.notificationsEnabled
? null
: Colors.grey,
),
),
subtitle: Text(
context.l10n.appSettings_advertisementNotificationsSubtitle,
style: TextStyle(
color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
color: settingsService.settings.notificationsEnabled
? null
: Colors.grey,
),
),
value: settingsService.settings.notifyOnNewAdvert,
@@ -202,7 +240,10 @@ class AppSettingsScreen extends StatelessWidget {
);
}
Widget _buildMessagingCard(BuildContext context, AppSettingsService settingsService) {
Widget _buildMessagingCard(
BuildContext context,
AppSettingsService settingsService,
) {
return Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -217,15 +258,19 @@ class AppSettingsScreen extends StatelessWidget {
SwitchListTile(
secondary: const Icon(Icons.refresh_outlined),
title: Text(context.l10n.appSettings_clearPathOnMaxRetry),
subtitle: Text(context.l10n.appSettings_clearPathOnMaxRetrySubtitle),
subtitle: Text(
context.l10n.appSettings_clearPathOnMaxRetrySubtitle,
),
value: settingsService.settings.clearPathOnMaxRetry,
onChanged: (value) {
settingsService.setClearPathOnMaxRetry(value);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(value
? context.l10n.appSettings_pathsWillBeCleared
: context.l10n.appSettings_pathsWillNotBeCleared),
content: Text(
value
? context.l10n.appSettings_pathsWillBeCleared
: context.l10n.appSettings_pathsWillNotBeCleared,
),
duration: const Duration(seconds: 2),
),
);
@@ -241,9 +286,11 @@ class AppSettingsScreen extends StatelessWidget {
settingsService.setAutoRouteRotationEnabled(value);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(value
? context.l10n.appSettings_autoRouteRotationEnabled
: context.l10n.appSettings_autoRouteRotationDisabled),
content: Text(
value
? context.l10n.appSettings_autoRouteRotationEnabled
: context.l10n.appSettings_autoRouteRotationDisabled,
),
duration: const Duration(seconds: 2),
),
);
@@ -254,7 +301,10 @@ class AppSettingsScreen extends StatelessWidget {
);
}
Widget _buildMapSettingsCard(BuildContext context, AppSettingsService settingsService) {
Widget _buildMapSettingsCard(
BuildContext context,
AppSettingsService settingsService,
) {
return Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -302,7 +352,9 @@ class AppSettingsScreen extends StatelessWidget {
subtitle: Text(
settingsService.settings.mapTimeFilterHours == 0
? context.l10n.appSettings_timeFilterShowAll
: context.l10n.appSettings_timeFilterShowLast(settingsService.settings.mapTimeFilterHours.toInt()),
: context.l10n.appSettings_timeFilterShowLast(
settingsService.settings.mapTimeFilterHours.toInt(),
),
),
trailing: const Icon(Icons.chevron_right),
onTap: () => _showTimeFilterDialog(context, settingsService),
@@ -339,8 +391,9 @@ class AppSettingsScreen extends StatelessWidget {
) {
final deviceId = connector.deviceId;
final isConnected = connector.isConnected && deviceId != null;
final selection =
isConnected ? settingsService.batteryChemistryForDevice(deviceId) : 'nmc';
final selection = isConnected
? settingsService.batteryChemistryForDevice(deviceId)
: 'nmc';
return Card(
child: Column(
@@ -358,7 +411,9 @@ class AppSettingsScreen extends StatelessWidget {
title: Text(context.l10n.appSettings_batteryChemistry),
subtitle: Text(
isConnected
? context.l10n.appSettings_batteryChemistryPerDevice(connector.deviceDisplayName)
? context.l10n.appSettings_batteryChemistryPerDevice(
connector.deviceDisplayName,
)
: context.l10n.appSettings_batteryChemistryConnectFirst,
),
trailing: DropdownButton<String>(
@@ -366,7 +421,10 @@ class AppSettingsScreen extends StatelessWidget {
onChanged: isConnected
? (value) {
if (value != null) {
settingsService.setBatteryChemistryForDevice(deviceId, value);
settingsService.setBatteryChemistryForDevice(
deviceId,
value,
);
}
}
: null,
@@ -391,7 +449,10 @@ class AppSettingsScreen extends StatelessWidget {
);
}
void _showThemeModeDialog(BuildContext context, AppSettingsService settingsService) {
void _showThemeModeDialog(
BuildContext context,
AppSettingsService settingsService,
) {
showDialog(
context: context,
builder: (context) => AlertDialog(
@@ -471,12 +532,19 @@ class AppSettingsScreen extends StatelessWidget {
return context.l10n.appSettings_languageSk;
case 'bg':
return context.l10n.appSettings_languageBg;
case 'ru':
return context.l10n.appSettings_languageRu;
case 'uk':
return context.l10n.appSettings_languageUk;
default:
return context.l10n.appSettings_languageSystem;
}
}
void _showLanguageDialog(BuildContext context, AppSettingsService settingsService) {
void _showLanguageDialog(
BuildContext context,
AppSettingsService settingsService,
) {
showDialog(
context: context,
builder: (context) => AlertDialog(
@@ -547,6 +615,14 @@ class AppSettingsScreen extends StatelessWidget {
title: Text(context.l10n.appSettings_languageBg),
value: 'bg',
),
RadioListTile<String?>(
title: Text(context.l10n.appSettings_languageRu),
value: 'ru',
),
RadioListTile<String?>(
title: Text(context.l10n.appSettings_languageUk),
value: 'uk',
),
],
),
),
@@ -561,7 +637,10 @@ class AppSettingsScreen extends StatelessWidget {
);
}
void _showTimeFilterDialog(BuildContext context, AppSettingsService settingsService) {
void _showTimeFilterDialog(
BuildContext context,
AppSettingsService settingsService,
) {
showDialog(
context: context,
builder: (context) => AlertDialog(
@@ -581,33 +660,23 @@ class AppSettingsScreen extends StatelessWidget {
const SizedBox(height: 16),
ListTile(
title: Text(context.l10n.appSettings_allTime),
leading: Radio<double>(
value: 0,
),
leading: Radio<double>(value: 0),
),
ListTile(
title: Text(context.l10n.appSettings_lastHour),
leading: Radio<double>(
value: 1,
),
leading: Radio<double>(value: 1),
),
ListTile(
title: Text(context.l10n.appSettings_last6Hours),
leading: Radio<double>(
value: 6,
),
leading: Radio<double>(value: 6),
),
ListTile(
title: Text(context.l10n.appSettings_last24Hours),
leading: Radio<double>(
value: 24,
),
leading: Radio<double>(value: 24),
),
ListTile(
title: Text(context.l10n.appSettings_lastWeek),
leading: Radio<double>(
value: 168,
),
leading: Radio<double>(value: 168),
),
],
),
@@ -622,7 +691,10 @@ class AppSettingsScreen extends StatelessWidget {
);
}
Widget _buildDebugCard(BuildContext context, AppSettingsService settingsService) {
Widget _buildDebugCard(
BuildContext context,
AppSettingsService settingsService,
) {
return Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -644,9 +716,11 @@ class AppSettingsScreen extends StatelessWidget {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(value
? context.l10n.appSettings_appDebugLoggingEnabled
: context.l10n.appSettings_appDebugLoggingDisabled),
content: Text(
value
? context.l10n.appSettings_appDebugLoggingEnabled
: context.l10n.appSettings_appDebugLoggingDisabled,
),
duration: const Duration(seconds: 2),
),
);
+45 -17
View File
@@ -24,7 +24,9 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
final entries = logService.entries.reversed.toList();
final rawEntries = logService.rawLogRxEntries.reversed.toList();
final showingFrames = _view == _BleLogView.frames;
final hasEntries = showingFrames ? entries.isNotEmpty : rawEntries.isNotEmpty;
final hasEntries = showingFrames
? entries.isNotEmpty
: rawEntries.isNotEmpty;
return Scaffold(
appBar: AppBar(
title: Text(context.l10n.debugLog_bleTitle),
@@ -36,15 +38,23 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
? () async {
final text = showingFrames
? entries
.map((entry) => '${entry.description}\n${entry.hexPreview}\n')
.join('\n')
.map(
(entry) =>
'${entry.description}\n${entry.hexPreview}\n',
)
.join('\n')
: rawEntries
.map((entry) => 'RX RAW_LOG_RX_DATA\n${entry.hexPreview}\n')
.join('\n');
.map(
(entry) =>
'RX RAW_LOG_RX_DATA\n${entry.hexPreview}\n',
)
.join('\n');
await Clipboard.setData(ClipboardData(text: text));
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.debugLog_bleCopied)),
SnackBar(
content: Text(context.l10n.debugLog_bleCopied),
),
);
}
: null,
@@ -68,8 +78,14 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
child: SegmentedButton<_BleLogView>(
segments: [
ButtonSegment(value: _BleLogView.frames, label: Text(context.l10n.debugLog_frames)),
ButtonSegment(value: _BleLogView.rawLogRx, label: Text(context.l10n.debugLog_rawLogRx)),
ButtonSegment(
value: _BleLogView.frames,
label: Text(context.l10n.debugLog_frames),
),
ButtonSegment(
value: _BleLogView.rawLogRx,
label: Text(context.l10n.debugLog_rawLogRx),
),
],
selected: {_view},
onSelectionChanged: (selection) {
@@ -81,7 +97,9 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
Expanded(
child: hasEntries
? ListView.separated(
itemCount: showingFrames ? entries.length : rawEntries.length,
itemCount: showingFrames
? entries.length
: rawEntries.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (context, index) {
if (showingFrames) {
@@ -94,7 +112,9 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
subtitle: Text('${entry.hexPreview}\n$time'),
isThreeLine: true,
leading: Icon(
entry.outgoing ? Icons.upload : Icons.download,
entry.outgoing
? Icons.upload
: Icons.download,
size: 18,
),
);
@@ -131,9 +151,7 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
context: context,
builder: (context) => AlertDialog(
title: Text(info.title),
content: SingleChildScrollView(
child: SelectableText(info.rawHex),
),
content: SingleChildScrollView(child: SelectableText(info.rawHex)),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
@@ -195,11 +213,18 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
}
final payload = raw.sublist(index);
final title = 'RX ${_payloadTypeLabel(payloadType)}${_routeLabel(routeType)} • v$payloadVer';
final title =
'RX ${_payloadTypeLabel(payloadType)}${_routeLabel(routeType)} • v$payloadVer';
final summary = _decodePayloadSummary(payloadType, payload);
final pathSummary = pathLen > 0 ? 'Path=${_bytesToHex(pathBytes)}' : 'Path=none';
final pathSummary = pathLen > 0
? 'Path=${_bytesToHex(pathBytes)}'
: 'Path=none';
final detail = '$summary$pathSummary • len=${raw.length}';
return _RawPacketInfo(title: title, summary: detail, rawHex: _bytesToHex(raw));
return _RawPacketInfo(
title: title,
summary: detail,
rawHex: _bytesToHex(raw),
);
}
String _decodePayloadSummary(int payloadType, Uint8List payload) {
@@ -245,7 +270,10 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
return 'ADVERT (short)';
}
var offset = 0;
final pubKey = _bytesToHex(payload.sublist(offset, offset + 32), spaced: false);
final pubKey = _bytesToHex(
payload.sublist(offset, offset + 32),
spaced: false,
);
offset += 32;
final timestamp = readUint32LE(payload, offset);
offset += 4;
+292 -244
View File
@@ -11,6 +11,7 @@ import '../connector/meshcore_connector.dart';
import '../helpers/chat_scroll_controller.dart';
import '../connector/meshcore_protocol.dart';
import '../helpers/link_handler.dart';
import '../helpers/reaction_helper.dart';
import '../helpers/utf8_length_limiter.dart';
import '../l10n/l10n.dart';
import '../models/channel.dart';
@@ -26,10 +27,7 @@ import 'map_screen.dart';
class ChannelChatScreen extends StatefulWidget {
final Channel channel;
const ChannelChatScreen({
super.key,
required this.channel,
});
const ChannelChatScreen({super.key, required this.channel});
@override
State<ChannelChatScreen> createState() => _ChannelChatScreenState();
@@ -43,6 +41,8 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
final Map<String, GlobalKey> _messageKeys = {};
bool _isLoadingOlder = false;
MeshCoreConnector? _connector;
@override
void initState() {
super.initState();
@@ -50,7 +50,8 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
_scrollController.onScrollNearTop = _loadOlderMessages;
SchedulerBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
context.read<MeshCoreConnector>().setActiveChannel(widget.channel.index);
_connector = context.read<MeshCoreConnector>();
_connector?.setActiveChannel(widget.channel.index);
});
}
@@ -74,7 +75,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
@override
void dispose() {
context.read<MeshCoreConnector>().setActiveChannel(null);
_connector?.setActiveChannel(null);
_textFieldFocusNode.removeListener(_onTextFieldFocusChange);
_textFieldFocusNode.dispose();
_textController.dispose();
@@ -134,15 +135,19 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
children: [
Text(
widget.channel.name.isEmpty
? context.l10n.channels_channelIndex(widget.channel.index)
? context.l10n.channels_channelIndex(
widget.channel.index,
)
: widget.channel.name,
style: const TextStyle(fontSize: 16),
),
Consumer<MeshCoreConnector>(
builder: (context, connector, _) {
final unreadCount =
connector.getUnreadCountForChannelIndex(widget.channel.index);
final privacy = widget.channel.isPublicChannel ? context.l10n.channels_public : context.l10n.channels_private;
final unreadCount = connector
.getUnreadCountForChannelIndex(widget.channel.index);
final privacy = widget.channel.isPublicChannel
? context.l10n.channels_public
: context.l10n.channels_private;
return Text(
'$privacy${context.l10n.chat_unread(unreadCount)}',
overflow: TextOverflow.ellipsis,
@@ -201,7 +206,8 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
// Reverse messages so newest appear at bottom with reverse: true
final reversedMessages = messages.reversed.toList();
final itemCount = reversedMessages.length + (_isLoadingOlder ? 1 : 0);
final itemCount =
reversedMessages.length + (_isLoadingOlder ? 1 : 0);
// Auto-scroll to bottom if user is already at bottom
WidgetsBinding.instance.addPostFrameCallback((_) {
@@ -224,7 +230,9 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
child: CircularProgressIndicator(
strokeWidth: 2,
),
),
),
);
@@ -240,9 +248,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
);
},
),
JumpToBottomButton(
scrollController: _scrollController,
),
JumpToBottomButton(scrollController: _scrollController),
],
);
},
@@ -261,15 +267,21 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
final poi = _parsePoiMessage(message.text);
final displayPath = message.pathBytes.isNotEmpty
? message.pathBytes
: (message.pathVariants.isNotEmpty ? message.pathVariants.first : Uint8List(0));
: (message.pathVariants.isNotEmpty
? message.pathVariants.first
: Uint8List(0));
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
child: Column(
crossAxisAlignment: isOutgoing ? CrossAxisAlignment.end : CrossAxisAlignment.start,
crossAxisAlignment: isOutgoing
? CrossAxisAlignment.end
: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: isOutgoing ? MainAxisAlignment.end : MainAxisAlignment.start,
mainAxisAlignment: isOutgoing
? MainAxisAlignment.end
: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (!isOutgoing) ...[
@@ -281,128 +293,160 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
onTap: () => _showMessagePathInfo(message),
onLongPress: () => _showMessageActions(message),
child: Container(
padding: gifId != null
? const EdgeInsets.all(4)
: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.65,
),
decoration: BoxDecoration(
color: isOutgoing
? Theme.of(context).colorScheme.primaryContainer
: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (!isOutgoing) ...[
Padding(
padding: gifId != null
? const EdgeInsets.only(left: 8, top: 4, bottom: 4)
: EdgeInsets.zero,
child: Text(
message.senderName,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
padding: gifId != null
? const EdgeInsets.all(4)
: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
),
),
if (gifId == null) const SizedBox(height: 4),
],
if (message.replyToMessageId != null) ...[
_buildReplyPreview(message),
const SizedBox(height: 8),
],
if (poi != null)
_buildPoiMessage(context, poi, isOutgoing)
else if (gifId != null)
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: GifMessage(
url: 'https://media.giphy.com/media/$gifId/giphy.gif',
backgroundColor: Colors.transparent,
fallbackTextColor: isOutgoing
? Theme.of(context).colorScheme.onPrimaryContainer.withValues(alpha: 0.7)
: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6),
),
)
else
Linkify(
text: message.text,
style: const TextStyle(fontSize: 14),
linkStyle: const TextStyle(
fontSize: 14,
color: Colors.green,
decoration: TextDecoration.underline,
),
options: const LinkifyOptions(
humanize: false,
defaultToHttps: false,
),
linkifiers: const [UrlLinkifier()],
onOpen: (link) => LinkHandler.handleLinkTap(context, link.url),
),
if (displayPath.isNotEmpty) ...[
const SizedBox(height: 4),
Padding(
padding: gifId != null
? const EdgeInsets.symmetric(horizontal: 8)
: EdgeInsets.zero,
child: Text(
'via ${_formatPathPrefixes(displayPath)}',
style: TextStyle(fontSize: 11, color: Colors.grey[600]),
),
),
],
const SizedBox(height: 4),
Padding(
padding: gifId != null
? const EdgeInsets.only(left: 8, right: 8, bottom: 4)
: EdgeInsets.zero,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
_formatTime(message.timestamp),
style: TextStyle(
fontSize: 11,
color: Colors.grey[600],
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.65,
),
decoration: BoxDecoration(
color: isOutgoing
? Theme.of(context).colorScheme.primaryContainer
: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (!isOutgoing) ...[
Padding(
padding: gifId != null
? const EdgeInsets.only(
left: 8,
top: 4,
bottom: 4,
)
: EdgeInsets.zero,
child: Text(
message.senderName,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
),
),
if (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 (gifId == null) const SizedBox(height: 4),
],
if (message.replyToMessageId != null) ...[
_buildReplyPreview(message),
const SizedBox(height: 8),
],
if (poi != null)
_buildPoiMessage(context, poi, isOutgoing)
else if (gifId != null)
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: GifMessage(
url:
'https://media.giphy.com/media/$gifId/giphy.gif',
backgroundColor: Colors.transparent,
fallbackTextColor: isOutgoing
? Theme.of(context)
.colorScheme
.onPrimaryContainer
.withValues(alpha: 0.7)
: Theme.of(context).colorScheme.onSurface
.withValues(alpha: 0.6),
),
],
if (isOutgoing) ...[
const SizedBox(width: 4),
Icon(
message.status == ChannelMessageStatus.sent
? Icons.check
: message.status == ChannelMessageStatus.pending
)
else
Linkify(
text: message.text,
style: const TextStyle(fontSize: 14),
linkStyle: const TextStyle(
fontSize: 14,
color: Colors.green,
decoration: TextDecoration.underline,
),
options: const LinkifyOptions(
humanize: false,
defaultToHttps: false,
),
linkifiers: const [UrlLinkifier()],
onOpen: (link) =>
LinkHandler.handleLinkTap(context, link.url),
),
if (displayPath.isNotEmpty) ...[
const SizedBox(height: 4),
Padding(
padding: gifId != null
? const EdgeInsets.symmetric(horizontal: 8)
: EdgeInsets.zero,
child: Text(
'via ${_formatPathPrefixes(displayPath)}',
style: TextStyle(
fontSize: 11,
color: Colors.grey[600],
),
),
),
],
const SizedBox(height: 4),
Padding(
padding: gifId != null
? const EdgeInsets.only(
left: 8,
right: 8,
bottom: 4,
)
: EdgeInsets.zero,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
_formatTime(message.timestamp),
style: TextStyle(
fontSize: 11,
color: Colors.grey[600],
),
),
if (message.repeatCount > 0) ...[
const SizedBox(width: 6),
Icon(
Icons.repeat,
size: 12,
color: Colors.grey[600],
),
const SizedBox(width: 2),
Text(
'${message.repeatCount}',
style: TextStyle(
fontSize: 11,
color: Colors.grey[600],
),
),
],
if (isOutgoing) ...[
const SizedBox(width: 4),
Icon(
message.status == ChannelMessageStatus.sent
? Icons.check
: message.status ==
ChannelMessageStatus.pending
? Icons.schedule
: Icons.error_outline,
size: 14,
color: message.status == ChannelMessageStatus.failed
? Colors.red
: Colors.grey[600],
),
],
],
),
size: 14,
color:
message.status ==
ChannelMessageStatus.failed
? Colors.red
: Colors.grey[600],
),
],
],
),
),
],
),
],
),
),
),
),
),
],
),
if (message.reactions.isNotEmpty) ...[
@@ -443,7 +487,10 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
children: [
Icon(Icons.location_on_outlined, size: 14, color: previewTextColor),
const SizedBox(width: 4),
Text(context.l10n.chat_location, style: TextStyle(fontSize: 12, color: previewTextColor)),
Text(
context.l10n.chat_location,
style: TextStyle(fontSize: 12, color: previewTextColor),
),
],
);
} else {
@@ -467,10 +514,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(8),
border: Border(
left: BorderSide(
color: colorScheme.primary,
width: 3,
),
left: BorderSide(color: colorScheme.primary, width: 3),
),
),
child: Column(
@@ -508,17 +552,16 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
color: Theme.of(context).colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3),
color: Theme.of(
context,
).colorScheme.outline.withValues(alpha: 0.3),
width: 1,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
emoji,
style: const TextStyle(fontSize: 16),
),
Text(emoji, style: const TextStyle(fontSize: 16)),
if (count > 1) ...[
const SizedBox(width: 4),
Text(
@@ -545,7 +588,9 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
_PoiInfo? _parsePoiMessage(String text) {
final trimmed = text.trim();
final match = RegExp(r'm:([\-0-9.]+),([\-0-9.]+)\|([^|]*)\|').firstMatch(trimmed);
final match = RegExp(
r'm:([\-0-9.]+),([\-0-9.]+)\|([^|]*)\|',
).firstMatch(trimmed);
if (match == null) return null;
final lat = double.tryParse(match.group(1) ?? '');
final lon = double.tryParse(match.group(2) ?? '');
@@ -556,10 +601,13 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
Widget _buildPoiMessage(BuildContext context, _PoiInfo poi, bool isOutgoing) {
final colorScheme = Theme.of(context).colorScheme;
final textColor =
isOutgoing ? colorScheme.onPrimaryContainer : colorScheme.onSurface;
final textColor = isOutgoing
? colorScheme.onPrimaryContainer
: colorScheme.onSurface;
final metaColor = textColor.withValues(alpha: 0.7);
final channelColor = widget.channel.isPublicChannel ? Colors.orange : Colors.blue;
final channelColor = widget.channel.isPublicChannel
? Colors.orange
: Colors.blue;
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
@@ -587,18 +635,12 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
children: [
Text(
context.l10n.chat_poiShared,
style: TextStyle(
color: textColor,
fontWeight: FontWeight.w600,
),
style: TextStyle(color: textColor, fontWeight: FontWeight.w600),
),
if (poi.label.isNotEmpty)
Text(
poi.label,
style: TextStyle(
color: metaColor,
fontSize: 12,
),
style: TextStyle(color: metaColor, fontSize: 12),
),
],
),
@@ -675,10 +717,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.secondaryContainer,
border: Border(
bottom: BorderSide(
color: Theme.of(context).dividerColor,
width: 1,
),
bottom: BorderSide(color: Theme.of(context).dividerColor, width: 1),
),
),
child: Row(
@@ -707,7 +746,9 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 11,
color: Theme.of(context).colorScheme.onSecondaryContainer.withValues(alpha: 0.7),
color: Theme.of(
context,
).colorScheme.onSecondaryContainer.withValues(alpha: 0.7),
),
),
],
@@ -745,73 +786,76 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
],
),
child: Row(
children: [
IconButton(
icon: const Icon(Icons.gif_box),
onPressed: () => _showGifPicker(context),
tooltip: context.l10n.chat_sendGif,
),
Expanded(
child: ValueListenableBuilder<TextEditingValue>(
valueListenable: _textController,
builder: (context, value, child) {
final gifId = _parseGifId(value.text);
if (gifId != null) {
return Row(
children: [
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: GifMessage(
url: 'https://media.giphy.com/media/$gifId/giphy.gif',
backgroundColor:
Theme.of(context).colorScheme.surfaceContainerHighest,
fallbackTextColor:
Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6),
maxSize: 160,
children: [
IconButton(
icon: const Icon(Icons.gif_box),
onPressed: () => _showGifPicker(context),
tooltip: context.l10n.chat_sendGif,
),
Expanded(
child: ValueListenableBuilder<TextEditingValue>(
valueListenable: _textController,
builder: (context, value, child) {
final gifId = _parseGifId(value.text);
if (gifId != null) {
return Row(
children: [
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: GifMessage(
url:
'https://media.giphy.com/media/$gifId/giphy.gif',
backgroundColor: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
fallbackTextColor: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.6),
maxSize: 160,
),
),
),
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => _textController.clear(),
),
],
);
}
return TextField(
controller: _textController,
focusNode: _textFieldFocusNode,
inputFormatters: [
Utf8LengthLimitingTextInputFormatter(maxBytes),
],
textCapitalization: TextCapitalization.sentences,
decoration: InputDecoration(
hintText: context.l10n.chat_typeMessage,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(24),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
),
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => _textController.clear(),
),
],
);
}
return TextField(
controller: _textController,
focusNode: _textFieldFocusNode,
inputFormatters: [
Utf8LengthLimitingTextInputFormatter(maxBytes),
],
textCapitalization: TextCapitalization.sentences,
decoration: InputDecoration(
hintText: context.l10n.chat_typeMessage,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(24),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
),
maxLines: null,
textInputAction: TextInputAction.send,
onSubmitted: (_) => _sendMessage(),
);
},
),
),
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.send),
onPressed: _sendMessage,
color: Theme.of(context).colorScheme.primary,
),
],
maxLines: null,
textInputAction: TextInputAction.send,
onSubmitted: (_) => _sendMessage(),
);
},
),
),
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.send),
onPressed: _sendMessage,
color: Theme.of(context).colorScheme.primary,
),
],
),
),
],
@@ -877,14 +921,16 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
_setReplyingTo(message);
},
),
ListTile(
leading: const Icon(Icons.add_reaction_outlined),
title: Text(context.l10n.chat_addReaction),
onTap: () {
Navigator.pop(sheetContext);
_showEmojiPicker(message);
},
),
// Can't react to your own messages
if (!message.isOutgoing)
ListTile(
leading: const Icon(Icons.add_reaction_outlined),
title: Text(context.l10n.chat_addReaction),
onTap: () {
Navigator.pop(sheetContext);
_showEmojiPicker(message);
},
),
ListTile(
leading: const Icon(Icons.copy),
title: Text(context.l10n.common_copy),
@@ -926,25 +972,31 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
void _sendReaction(ChannelMessage message, String emoji) {
final connector = context.read<MeshCoreConnector>();
// Send reaction with full messageId to find target, but parser will extract
// lightweight reactionKey (timestamp_senderPrefix) for deduplication
final reactionText = 'r:${message.messageId}:$emoji';
final emojiIndex = ReactionHelper.emojiToIndex(emoji);
if (emojiIndex == null) return; // Unknown emoji, skip
final timestampSecs = message.timestamp.millisecondsSinceEpoch ~/ 1000;
final hash = ReactionHelper.computeReactionHash(
timestampSecs,
message.senderName,
message.text,
);
final reactionText = 'r:$hash:$emojiIndex';
connector.sendChannelMessage(widget.channel, reactionText);
}
void _copyMessageText(String text) {
Clipboard.setData(ClipboardData(text: text));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.chat_messageCopied)),
);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(context.l10n.chat_messageCopied)));
}
Future<void> _deleteMessage(ChannelMessage message) async {
await context.read<MeshCoreConnector>().deleteChannelMessage(message);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.chat_messageDeleted)),
);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(context.l10n.chat_messageDeleted)));
}
String _formatPathPrefixes(Uint8List pathBytes) {
@@ -959,9 +1011,5 @@ class _PoiInfo {
final double lon;
final String label;
const _PoiInfo({
required this.lat,
required this.lon,
required this.label,
});
const _PoiInfo({required this.lat, required this.lon, required this.label});
}
+149 -65
View File
@@ -4,6 +4,7 @@ import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
import 'package:meshcore_open/screens/path_trace_map.dart';
import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart';
@@ -17,17 +18,17 @@ import '../models/contact.dart';
class ChannelMessagePathScreen extends StatelessWidget {
final ChannelMessage message;
const ChannelMessagePathScreen({
super.key,
required this.message,
});
const ChannelMessagePathScreen({super.key, required this.message});
@override
Widget build(BuildContext context) {
return Consumer<MeshCoreConnector>(
builder: (context, connector, _) {
final l10n = context.l10n;
final primaryPath = _selectPrimaryPath(message.pathBytes, message.pathVariants);
final primaryPath = _selectPrimaryPath(
message.pathBytes,
message.pathVariants,
);
final hops = _buildPathHops(primaryPath, connector.contacts, l10n);
final hasHopDetails = primaryPath.isNotEmpty;
final observedLabel = _formatObservedHops(
@@ -41,6 +42,21 @@ class ChannelMessagePathScreen extends StatelessWidget {
appBar: AppBar(
title: Text(l10n.channelPath_title),
actions: [
IconButton(
icon: const Icon(Icons.radar_outlined),
tooltip: l10n.channelPath_viewMap,
onPressed: () => Navigator.push(
context,
MaterialPageRoute(
builder: (context) => PathTraceMapScreen(
title: context.l10n.contacts_repeaterPathTrace,
path: Uint8List.fromList(primaryPath),
flipPathRound: true,
reversePathRound: true,
),
),
),
),
IconButton(
icon: const Icon(Icons.map_outlined),
tooltip: l10n.channelPath_viewMap,
@@ -88,10 +104,7 @@ class ChannelMessagePathScreen extends StatelessWidget {
);
}
Widget _buildSummaryCard(
BuildContext context, {
String? observedLabel,
}) {
Widget _buildSummaryCard(BuildContext context, {String? observedLabel}) {
final l10n = context.l10n;
return Card(
child: Padding(
@@ -105,21 +118,28 @@ class ChannelMessagePathScreen extends StatelessWidget {
),
const SizedBox(height: 8),
_buildDetailRow(l10n.channelPath_senderLabel, message.senderName),
_buildDetailRow(l10n.channelPath_timeLabel, _formatTime(message.timestamp, l10n)),
_buildDetailRow(
l10n.channelPath_timeLabel,
_formatTime(message.timestamp, l10n),
),
if (message.repeatCount > 0)
_buildDetailRow(l10n.channelPath_repeatsLabel, message.repeatCount.toString()),
_buildDetailRow(l10n.channelPath_pathLabelTitle, _formatPathLabel(message.pathLength, l10n)),
if (observedLabel != null) _buildDetailRow(l10n.channelPath_observedLabel, observedLabel),
_buildDetailRow(
l10n.channelPath_repeatsLabel,
message.repeatCount.toString(),
),
_buildDetailRow(
l10n.channelPath_pathLabelTitle,
_formatPathLabel(message.pathLength, l10n),
),
if (observedLabel != null)
_buildDetailRow(l10n.channelPath_observedLabel, observedLabel),
],
),
),
);
}
Widget _buildPathVariants(
BuildContext context,
List<Uint8List> variants,
) {
Widget _buildPathVariants(BuildContext context, List<Uint8List> variants) {
final l10n = context.l10n;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -163,7 +183,7 @@ class ChannelMessagePathScreen extends StatelessWidget {
subtitle: Text(
hop.hasLocation
? '${hop.position!.latitude.toStringAsFixed(5)}, '
'${hop.position!.longitude.toStringAsFixed(5)}'
'${hop.position!.longitude.toStringAsFixed(5)}'
: l10n.channelPath_noLocationData,
),
),
@@ -239,7 +259,6 @@ class ChannelMessagePathScreen extends StatelessWidget {
),
);
}
}
class ChannelMessagePathMapScreen extends StatefulWidget {
@@ -257,8 +276,10 @@ class ChannelMessagePathMapScreen extends StatefulWidget {
_ChannelMessagePathMapScreenState();
}
class _ChannelMessagePathMapScreenState extends State<ChannelMessagePathMapScreen> {
class _ChannelMessagePathMapScreenState
extends State<ChannelMessagePathMapScreen> {
Uint8List? _selectedPath;
double _pathDistance = 0.0;
@override
void initState() {
@@ -270,32 +291,58 @@ class _ChannelMessagePathMapScreenState extends State<ChannelMessagePathMapScree
void didUpdateWidget(ChannelMessagePathMapScreen oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.message != widget.message ||
!_pathsEqual(oldWidget.initialPath ?? Uint8List(0),
widget.initialPath ?? Uint8List(0))) {
!_pathsEqual(
oldWidget.initialPath ?? Uint8List(0),
widget.initialPath ?? Uint8List(0),
)) {
_selectedPath = widget.initialPath;
}
}
double _getPathDistance(List<LatLng> points) {
double totalDistance = 0.0;
final distanceCalculator = Distance();
for (int i = 0; i < points.length - 1; i++) {
totalDistance += distanceCalculator(points[i], points[i + 1]);
}
return totalDistance;
}
@override
Widget build(BuildContext context) {
return Consumer<MeshCoreConnector>(
builder: (context, connector, _) {
final tileCache = context.read<MapTileCacheService>();
final primaryPath =
_selectPrimaryPath(widget.message.pathBytes, widget.message.pathVariants);
final observedPaths =
_buildObservedPaths(primaryPath, widget.message.pathVariants);
final primaryPath = _selectPrimaryPath(
widget.message.pathBytes,
widget.message.pathVariants,
);
final observedPaths = _buildObservedPaths(
primaryPath,
widget.message.pathVariants,
);
final selectedPath = _resolveSelectedPath(
_selectedPath,
observedPaths,
primaryPath,
);
final selectedIndex = _indexForPath(selectedPath, observedPaths);
final hops = _buildPathHops(selectedPath, connector.contacts, context.l10n);
final points = hops
.where((hop) => hop.hasLocation)
.map((hop) => hop.position!)
.toList();
final hops = _buildPathHops(
selectedPath,
connector.contacts,
context.l10n,
);
final points = <LatLng>[];
for (final hop in hops) {
if (hop.hasLocation) {
points.add(hop.position!);
}
}
points.add(LatLng(connector.selfLatitude!, connector.selfLongitude!));
final polylines = points.length > 1
? [
Polyline(
@@ -306,16 +353,20 @@ class _ChannelMessagePathMapScreenState extends State<ChannelMessagePathMapScree
]
: <Polyline>[];
final initialCenter =
points.isNotEmpty ? points.first : const LatLng(0, 0);
final initialCenter = points.isNotEmpty
? points.first
: const LatLng(0, 0);
final initialZoom = points.isNotEmpty ? 13.0 : 2.0;
final bounds = points.length > 1 ? LatLngBounds.fromPoints(points) : null;
final mapKey = ValueKey(_formatPathPrefixes(selectedPath));
final bounds = points.length > 1
? LatLngBounds.fromPoints(points)
: null;
final mapKey = ValueKey(
'${_formatPathPrefixes(selectedPath)},${context.l10n.pathTrace_you}',
);
_pathDistance = _getPathDistance(points);
return Scaffold(
appBar: AppBar(
title: Text(context.l10n.channelPath_mapTitle),
),
appBar: AppBar(title: Text(context.l10n.channelPath_mapTitle)),
body: SafeArea(
top: false,
child: Stack(
@@ -334,6 +385,9 @@ class _ChannelMessagePathMapScreenState extends State<ChannelMessagePathMapScree
),
minZoom: 2.0,
maxZoom: 18.0,
interactionOptions: InteractionOptions(
flags: ~InteractiveFlag.rotate,
),
),
children: [
TileLayer(
@@ -343,30 +397,28 @@ class _ChannelMessagePathMapScreenState extends State<ChannelMessagePathMapScree
MapTileCacheService.userAgentPackageName,
maxZoom: 19,
),
if (polylines.isNotEmpty) PolylineLayer(polylines: polylines),
MarkerLayer(
markers: _buildHopMarkers(hops),
),
if (polylines.isNotEmpty)
PolylineLayer(polylines: polylines),
MarkerLayer(markers: _buildHopMarkers(hops)),
],
),
if (observedPaths.length > 1)
_buildPathSelector(
context,
observedPaths,
selectedIndex,
(index) {
setState(() {
_selectedPath = observedPaths[index].pathBytes;
});
},
),
_buildPathSelector(context, observedPaths, selectedIndex, (
index,
) {
setState(() {
_selectedPath = observedPaths[index].pathBytes;
});
}),
if (points.isEmpty)
Center(
child: Card(
color: Colors.white.withValues(alpha: 0.9),
child: Padding(
padding: EdgeInsets.all(12),
child: Text(context.l10n.channelPath_noRepeaterLocations),
child: Text(
context.l10n.channelPath_noRepeaterLocations,
),
),
),
),
@@ -448,8 +500,8 @@ class _ChannelMessagePathMapScreenState extends State<ChannelMessagePathMapScree
if (hop.hasLocation)
Marker(
point: hop.position!,
width: 40,
height: 40,
width: 35,
height: 35,
child: Container(
decoration: BoxDecoration(
color: Colors.green,
@@ -474,6 +526,39 @@ class _ChannelMessagePathMapScreenState extends State<ChannelMessagePathMapScree
),
),
),
if (context.read<MeshCoreConnector>().selfLatitude != null &&
context.read<MeshCoreConnector>().selfLongitude != null)
Marker(
point: LatLng(
context.read<MeshCoreConnector>().selfLatitude!,
context.read<MeshCoreConnector>().selfLongitude!,
),
width: 35,
height: 35,
child: Container(
decoration: BoxDecoration(
color: Colors.teal,
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.3),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
alignment: Alignment.center,
child: Text(
context.l10n.pathTrace_you,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 12,
),
),
),
),
];
}
@@ -496,7 +581,7 @@ class _ChannelMessagePathMapScreenState extends State<ChannelMessagePathMapScree
Padding(
padding: const EdgeInsets.all(12),
child: Text(
l10n.channelPath_repeaterHops,
'${l10n.channelPath_repeaterHops} (${(_pathDistance / 1609.34).toStringAsFixed(2)} Miles / ${(_pathDistance / 1000).toStringAsFixed(2)} Km)',
style: const TextStyle(fontWeight: FontWeight.w600),
),
),
@@ -525,7 +610,7 @@ class _ChannelMessagePathMapScreenState extends State<ChannelMessagePathMapScree
subtitle: Text(
hop.hasLocation
? '${hop.position!.latitude.toStringAsFixed(5)}, '
'${hop.position!.longitude.toStringAsFixed(5)}'
'${hop.position!.longitude.toStringAsFixed(5)}'
: l10n.channelPath_noLocationData,
),
);
@@ -567,10 +652,7 @@ class _ObservedPath {
final Uint8List pathBytes;
final bool isPrimary;
const _ObservedPath({
required this.pathBytes,
required this.isPrimary,
});
const _ObservedPath({required this.pathBytes, required this.isPrimary});
}
List<_PathHop> _buildPathHops(
@@ -597,10 +679,12 @@ List<_PathHop> _buildPathHops(
Contact? _matchContactForPrefix(List<Contact> contacts, int prefix) {
final matches = contacts
.where((contact) =>
(contact.type == advTypeRepeater || contact.type == advTypeRoom) &&
contact.publicKey.isNotEmpty &&
contact.publicKey[0] == prefix)
.where(
(contact) =>
(contact.type == advTypeRepeater || contact.type == advTypeRoom) &&
contact.publicKey.isNotEmpty &&
contact.publicKey[0] == prefix,
)
.toList();
if (matches.isEmpty) return null;
+48 -21
View File
@@ -121,30 +121,52 @@ class _ChannelsScreenState extends State<ChannelsScreen>
centerTitle: true,
automaticallyImplyLeading: false,
actions: [
if (_communities.isNotEmpty)
IconButton(
icon: const Icon(Icons.groups),
tooltip: context.l10n.community_manageCommunities,
onPressed: () => _showManageCommunitiesDialog(context),
),
IconButton(
icon: const Icon(Icons.bluetooth_disabled),
tooltip: context.l10n.common_disconnect,
onPressed: () => _disconnect(context),
),
IconButton(
icon: const Icon(Icons.tune),
tooltip: context.l10n.common_settings,
onPressed: () => Navigator.push(
context,
MaterialPageRoute(builder: (context) => const SettingsScreen()),
),
PopupMenuButton(
itemBuilder: (context) => [
PopupMenuItem(
child: Row(
children: [
const Icon(Icons.logout, color: Colors.red),
const SizedBox(width: 8),
Text(context.l10n.common_disconnect),
],
),
onTap: () => _disconnect(context),
),
if (_communities.isNotEmpty)
PopupMenuItem(
child: Row(
children: [
const Icon(Icons.groups),
const SizedBox(width: 8),
Text(context.l10n.community_manageCommunities),
],
),
onTap: () => _showManageCommunitiesDialog(context),
),
PopupMenuItem(
child: Row(
children: [
const Icon(Icons.settings),
const SizedBox(width: 8),
Text(context.l10n.settings_title),
],
),
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const SettingsScreen(),
),
),
),
],
icon: const Icon(Icons.more_vert),
),
],
),
body: RefreshIndicator(
onRefresh: () async {
await context.read<MeshCoreConnector>().getChannels();
await context.read<MeshCoreConnector>().getChannels(force: true);
},
child: () {
if (connector.isLoadingChannels) {
@@ -931,7 +953,9 @@ class _ChannelsScreenState extends State<ChannelsScreen>
dialogContext.l10n.community_communityHashtag,
),
subtitle: Text(
dialogContext.l10n.community_communityHashtagDesc,
dialogContext
.l10n
.community_communityHashtagDesc,
),
dense: true,
),
@@ -1026,10 +1050,11 @@ class _ChannelsScreenState extends State<ChannelsScreen>
if (hashtag.startsWith('#')) {
hashtag = hashtag.substring(1);
}
final channelName = '#$hashtag';
final String channelName;
final Uint8List psk;
if (isRegularHashtag) {
channelName = '#$hashtag';
// Regular hashtag - public derivation using SHA256
psk = Channel.derivePskFromHashtag(hashtag);
} else {
@@ -1048,6 +1073,8 @@ class _ChannelsScreenState extends State<ChannelsScreen>
);
return;
}
channelName =
'${selectedCommunity!.name} #$hashtag';
psk = selectedCommunity!
.deriveCommunityHashtagPsk(hashtag);
// Track in community's hashtag list
+376 -237
View File
@@ -6,11 +6,13 @@ import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart';
import 'package:flutter_linkify/flutter_linkify.dart';
import 'package:meshcore_open/screens/path_trace_map.dart';
import 'package:provider/provider.dart';
import 'package:latlong2/latlong.dart';
import '../connector/meshcore_connector.dart';
import '../connector/meshcore_protocol.dart';
import '../helpers/reaction_helper.dart';
import '../helpers/chat_scroll_controller.dart';
import '../helpers/link_handler.dart';
import '../helpers/utf8_length_limiter.dart';
@@ -43,6 +45,7 @@ class _ChatScreenState extends State<ChatScreen> {
final _scrollController = ChatScrollController();
final _textFieldFocusNode = FocusNode();
bool _isLoadingOlder = false;
MeshCoreConnector? _connector;
@override
void initState() {
@@ -51,7 +54,8 @@ class _ChatScreenState extends State<ChatScreen> {
_scrollController.onScrollNearTop = _loadOlderMessages;
SchedulerBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
context.read<MeshCoreConnector>().setActiveContact(widget.contact.publicKeyHex);
_connector = context.read<MeshCoreConnector>();
_connector?.setActiveContact(widget.contact.publicKeyHex);
});
}
@@ -75,7 +79,7 @@ class _ChatScreenState extends State<ChatScreen> {
@override
void dispose() {
context.read<MeshCoreConnector>().setActiveContact(null);
_connector?.setActiveContact(null);
_textFieldFocusNode.removeListener(_onTextFieldFocusChange);
_textFieldFocusNode.dispose();
_textController.dispose();
@@ -90,12 +94,15 @@ class _ChatScreenState extends State<ChatScreen> {
title: Consumer2<PathHistoryService, MeshCoreConnector>(
builder: (context, pathService, connector, _) {
final contact = _resolveContact(connector);
final unreadCount = connector.getUnreadCountForContactKey(widget.contact.publicKeyHex);
final unreadCount = connector.getUnreadCountForContactKey(
widget.contact.publicKeyHex,
);
final unreadLabel = context.l10n.chat_unread(unreadCount);
final pathLabel = _currentPathLabel(contact);
// Show path details if we have path data (from device or override)
final hasPathData = contact.path.isNotEmpty || contact.pathOverrideBytes != null;
final hasPathData =
contact.path.isNotEmpty || contact.pathOverrideBytes != null;
final effectivePath = contact.pathOverrideBytes ?? contact.path;
return Column(
@@ -105,7 +112,9 @@ class _ChatScreenState extends State<ChatScreen> {
Text(contact.name),
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: hasPathData ? () => _showFullPathDialog(context, effectivePath) : null,
onTap: hasPathData
? () => _showFullPathDialog(context, effectivePath)
: null,
child: Text(
'$pathLabel$unreadLabel',
overflow: TextOverflow.ellipsis,
@@ -143,12 +152,20 @@ class _ChatScreenState extends State<ChatScreen> {
value: 'auto',
child: Row(
children: [
Icon(Icons.auto_mode, size: 20, color: !isFloodMode ? Theme.of(context).primaryColor : null),
Icon(
Icons.auto_mode,
size: 20,
color: !isFloodMode
? Theme.of(context).primaryColor
: null,
),
const SizedBox(width: 8),
Text(
context.l10n.chat_autoUseSavedPath,
style: TextStyle(
fontWeight: !isFloodMode ? FontWeight.bold : FontWeight.normal,
fontWeight: !isFloodMode
? FontWeight.bold
: FontWeight.normal,
),
),
],
@@ -158,12 +175,20 @@ class _ChatScreenState extends State<ChatScreen> {
value: 'flood',
child: Row(
children: [
Icon(Icons.waves, size: 20, color: isFloodMode ? Theme.of(context).primaryColor : null),
Icon(
Icons.waves,
size: 20,
color: isFloodMode
? Theme.of(context).primaryColor
: null,
),
const SizedBox(width: 8),
Text(
context.l10n.chat_forceFloodMode,
style: TextStyle(
fontWeight: isFloodMode ? FontWeight.bold : FontWeight.normal,
fontWeight: isFloodMode
? FontWeight.bold
: FontWeight.normal,
),
),
],
@@ -195,9 +220,7 @@ class _ChatScreenState extends State<ChatScreen> {
messages.isEmpty
? _buildEmptyState()
: _buildMessageList(messages, connector),
JumpToBottomButton(
scrollController: _scrollController,
),
JumpToBottomButton(scrollController: _scrollController),
],
),
),
@@ -230,7 +253,10 @@ class _ChatScreenState extends State<ChatScreen> {
);
}
Widget _buildMessageList(List<Message> messages, MeshCoreConnector connector) {
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);
@@ -266,14 +292,21 @@ class _ChatScreenState extends State<ChatScreen> {
if (widget.contact.type == advTypeRoom) {
contact = _resolveContactFrom4Bytes(
connector,
message.fourByteRoomContactKey.isEmpty ? Uint8List.fromList([0, 0, 0, 0]) : message.fourByteRoomContactKey,
message.fourByteRoomContactKey.isEmpty
? Uint8List.fromList([0, 0, 0, 0])
: message.fourByteRoomContactKey,
);
fourByteHex = message.fourByteRoomContactKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join().toUpperCase();
fourByteHex = message.fourByteRoomContactKey
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join()
.toUpperCase();
}
return _MessageBubble(
message: message,
senderName: widget.contact.type == advTypeRoom ? "${contact.name} [$fourByteHex]" : contact.name,
senderName: widget.contact.type == advTypeRoom
? "${contact.name} [$fourByteHex]"
: contact.name,
isRoomServer: widget.contact.type == advTypeRoom,
onTap: () => _openMessagePath(message, contact),
onLongPress: () => _showMessageActions(message, contact),
@@ -289,9 +322,7 @@ class _ChatScreenState extends State<ChatScreen> {
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: colorScheme.surface,
border: Border(
top: BorderSide(color: Theme.of(context).dividerColor),
),
border: Border(top: BorderSide(color: Theme.of(context).dividerColor)),
),
child: SafeArea(
child: Row(
@@ -313,10 +344,12 @@ class _ChatScreenState extends State<ChatScreen> {
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: GifMessage(
url: 'https://media.giphy.com/media/$gifId/giphy.gif',
backgroundColor: colorScheme.surfaceContainerHighest,
fallbackTextColor:
colorScheme.onSurface.withValues(alpha: 0.6),
url:
'https://media.giphy.com/media/$gifId/giphy.gif',
backgroundColor:
colorScheme.surfaceContainerHighest,
fallbackTextColor: colorScheme.onSurface
.withValues(alpha: 0.6),
maxSize: 160,
),
),
@@ -340,7 +373,10 @@ class _ChatScreenState extends State<ChatScreen> {
decoration: InputDecoration(
hintText: context.l10n.chat_typeMessage,
border: const OutlineInputBorder(),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
),
textInputAction: TextInputAction.send,
onSubmitted: (_) => _sendMessage(connector),
@@ -389,14 +425,10 @@ class _ChatScreenState extends State<ChatScreen> {
return;
}
connector.sendMessage(
widget.contact,
text,
);
connector.sendMessage(widget.contact, text);
_textController.clear();
}
void _showPathHistory(BuildContext context) {
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
@@ -421,13 +453,19 @@ class _ChatScreenState extends State<ChatScreen> {
if (paths.isNotEmpty) ...[
Text(
context.l10n.chat_recentAckPaths,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 12,
),
),
if (paths.length >= 100) ...[
const SizedBox(height: 8),
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
decoration: BoxDecoration(
color: Colors.amber[100],
borderRadius: BorderRadius.circular(8),
@@ -446,7 +484,9 @@ class _ChatScreenState extends State<ChatScreen> {
dense: true,
leading: CircleAvatar(
radius: 16,
backgroundColor: path.wasFloodDiscovery ? Colors.blue : Colors.green,
backgroundColor: path.wasFloodDiscovery
? Colors.blue
: Colors.green,
child: Text(
'${path.hopCount}',
style: const TextStyle(fontSize: 12),
@@ -474,23 +514,36 @@ class _ChatScreenState extends State<ChatScreen> {
},
),
path.wasFloodDiscovery
? const Icon(Icons.waves, size: 16, color: Colors.grey)
: const Icon(Icons.route, size: 16, color: Colors.grey),
? const Icon(
Icons.waves,
size: 16,
color: Colors.grey,
)
: const Icon(
Icons.route,
size: 16,
color: Colors.grey,
),
],
),
onLongPress: () => _showFullPathDialog(context, path.pathBytes),
onLongPress: () =>
_showFullPathDialog(context, path.pathBytes),
onTap: () async {
if (path.pathBytes.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.chat_pathDetailsNotAvailable),
content: Text(
context.l10n.chat_pathDetailsNotAvailable,
),
duration: const Duration(seconds: 2),
),
);
return;
}
final pathBytes = Uint8List.fromList(path.pathBytes);
final pathBytes = Uint8List.fromList(
path.pathBytes,
);
final pathLength = path.pathBytes.length;
// Set the path override to persist user's choice
@@ -520,7 +573,10 @@ class _ChatScreenState extends State<ChatScreen> {
const SizedBox(height: 8),
Text(
context.l10n.chat_pathActions,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 12,
),
),
const SizedBox(height: 8),
ListTile(
@@ -530,8 +586,14 @@ class _ChatScreenState extends State<ChatScreen> {
backgroundColor: Colors.purple,
child: Icon(Icons.edit_road, size: 16),
),
title: Text(context.l10n.chat_setCustomPath, style: const TextStyle(fontSize: 14)),
subtitle: Text(context.l10n.chat_setCustomPathSubtitle, style: const TextStyle(fontSize: 11)),
title: Text(
context.l10n.chat_setCustomPath,
style: const TextStyle(fontSize: 14),
),
subtitle: Text(
context.l10n.chat_setCustomPathSubtitle,
style: const TextStyle(fontSize: 11),
),
onTap: () {
Navigator.pop(context);
_showCustomPathDialog(context);
@@ -544,8 +606,14 @@ class _ChatScreenState extends State<ChatScreen> {
backgroundColor: Colors.orange,
child: Icon(Icons.clear_all, size: 16),
),
title: Text(context.l10n.chat_clearPath, style: const TextStyle(fontSize: 14)),
subtitle: Text(context.l10n.chat_clearPathSubtitle, style: const TextStyle(fontSize: 11)),
title: Text(
context.l10n.chat_clearPath,
style: const TextStyle(fontSize: 14),
),
subtitle: Text(
context.l10n.chat_clearPathSubtitle,
style: const TextStyle(fontSize: 11),
),
onTap: () async {
await connector.clearContactPath(widget.contact);
if (!context.mounted) return;
@@ -565,10 +633,19 @@ class _ChatScreenState extends State<ChatScreen> {
backgroundColor: Colors.blue,
child: Icon(Icons.waves, size: 16),
),
title: Text(context.l10n.chat_forceFloodMode, style: const TextStyle(fontSize: 14)),
subtitle: Text(context.l10n.chat_floodModeSubtitle, style: const TextStyle(fontSize: 11)),
title: Text(
context.l10n.chat_forceFloodMode,
style: const TextStyle(fontSize: 14),
),
subtitle: Text(
context.l10n.chat_floodModeSubtitle,
style: const TextStyle(fontSize: 11),
),
onTap: () async {
await connector.setPathOverride(widget.contact, pathLen: -1);
await connector.setPathOverride(
widget.contact,
pathLen: -1,
);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
@@ -597,7 +674,9 @@ class _ChatScreenState extends State<ChatScreen> {
String _formatRelativeTime(DateTime time) {
final diff = DateTime.now().difference(time);
if (diff.inSeconds < 60) return context.l10n.time_justNow;
if (diff.inMinutes < 60) return context.l10n.time_minutesAgo(diff.inMinutes);
if (diff.inMinutes < 60) {
return context.l10n.time_minutesAgo(diff.inMinutes);
}
if (diff.inHours < 24) return context.l10n.time_hoursAgo(diff.inHours);
return context.l10n.time_daysAgo(diff.inDays);
}
@@ -623,6 +702,19 @@ class _ChatScreenState extends State<ChatScreen> {
title: Text(context.l10n.chat_fullPath),
content: SelectableText(formattedPath),
actions: [
TextButton(
onPressed: () => Navigator.push(
context,
MaterialPageRoute(
builder: (context) => PathTraceMapScreen(
title: context.l10n.contacts_repeaterPathTrace,
path: Uint8List.fromList(pathBytes),
flipPathRound: true,
),
),
),
child: Text(context.l10n.contacts_pathTrace),
),
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(context.l10n.common_close),
@@ -639,7 +731,10 @@ class _ChatScreenState extends State<ChatScreen> {
);
}
Contact _resolveContactFrom4Bytes(MeshCoreConnector connector, Uint8List key4Bytes) {
Contact _resolveContactFrom4Bytes(
MeshCoreConnector connector,
Uint8List key4Bytes,
) {
return connector.contacts.firstWhere(
(c) => listEquals(c.publicKey.sublist(0, 4), key4Bytes.sublist(0, 4)),
orElse: () => widget.contact,
@@ -673,12 +768,12 @@ class _ChatScreenState extends State<ChatScreen> {
final status = !connector.isConnected
? context.l10n.chat_pathSavedLocally
: (verified ? context.l10n.chat_pathDeviceConfirmed : context.l10n.chat_pathDeviceNotConfirmed);
: (verified
? context.l10n.chat_pathDeviceConfirmed
: context.l10n.chat_pathDeviceNotConfirmed);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.l10n.chat_pathSetHops(hopCount, status),
),
content: Text(context.l10n.chat_pathSetHops(hopCount, status)),
duration: const Duration(seconds: 3),
),
);
@@ -693,7 +788,9 @@ class _ChatScreenState extends State<ChatScreen> {
builder: (context) => Consumer<MeshCoreConnector>(
builder: (context, connector, _) {
final contact = _resolveContact(connector);
final smazEnabled = connector.isContactSmazEnabled(contact.publicKeyHex);
final smazEnabled = connector.isContactSmazEnabled(
contact.publicKeyHex,
);
return AlertDialog(
title: Text(contact.name),
@@ -709,7 +806,10 @@ class _ChatScreenState extends State<ChatScreen> {
context.l10n.chat_location,
'${contact.latitude?.toStringAsFixed(4)}, ${contact.longitude?.toStringAsFixed(4)}',
),
_buildInfoRow(context.l10n.chat_publicKey, '${contact.publicKeyHex.substring(0, 16)}...'),
_buildInfoRow(
context.l10n.chat_publicKey,
'${contact.publicKeyHex.substring(0, 16)}...',
),
const Divider(),
SwitchListTile(
contentPadding: EdgeInsets.zero,
@@ -717,7 +817,10 @@ class _ChatScreenState extends State<ChatScreen> {
subtitle: Text(context.l10n.chat_compressOutgoingMessages),
value: smazEnabled,
onChanged: (value) {
connector.setContactSmazEnabled(contact.publicKeyHex, value);
connector.setContactSmazEnabled(
contact.publicKeyHex,
value,
);
},
),
],
@@ -764,7 +867,9 @@ class _ChatScreenState extends State<ChatScreen> {
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
final currentContact = _resolveContact(connector);
if (currentContact.pathLength > 0 && currentContact.path.isEmpty && connector.isConnected) {
if (currentContact.pathLength > 0 &&
currentContact.path.isEmpty &&
connector.isConnected) {
connector.getContacts();
}
@@ -785,19 +890,31 @@ class _ChatScreenState extends State<ChatScreen> {
onRefresh: connector.isConnected ? connector.getContacts : null,
);
appLogger.info('PathSelectionDialog returned: ${result?.length ?? 0} bytes, mounted: $mounted', tag: 'ChatScreen');
appLogger.info(
'PathSelectionDialog returned: ${result?.length ?? 0} bytes, mounted: $mounted',
tag: 'ChatScreen',
);
if (result == null) {
appLogger.info('PathSelectionDialog was cancelled or returned null', tag: 'ChatScreen');
appLogger.info(
'PathSelectionDialog was cancelled or returned null',
tag: 'ChatScreen',
);
return;
}
if (!mounted) {
appLogger.warn('Widget not mounted after dialog, cannot set path', tag: 'ChatScreen');
appLogger.warn(
'Widget not mounted after dialog, cannot set path',
tag: 'ChatScreen',
);
return;
}
appLogger.info('Calling setPathOverride for ${widget.contact.name}', tag: 'ChatScreen');
appLogger.info(
'Calling setPathOverride for ${widget.contact.name}',
tag: 'ChatScreen',
);
await connector.setPathOverride(
widget.contact,
pathLen: result.length,
@@ -809,7 +926,6 @@ class _ChatScreenState extends State<ChatScreen> {
await _notifyPathSet(connector, widget.contact, result, result.length);
}
void _openMessagePath(Message message, Contact contact) {
final connector = context.read<MeshCoreConnector>();
final fourByteHex = message.fourByteRoomContactKey
@@ -850,14 +966,16 @@ class _ChatScreenState extends State<ChatScreen> {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.add_reaction_outlined),
title: Text(context.l10n.chat_addReaction),
onTap: () {
Navigator.pop(sheetContext);
_showEmojiPicker(message);
},
),
// Can't react to your own messages
if (!message.isOutgoing)
ListTile(
leading: const Icon(Icons.add_reaction_outlined),
title: Text(context.l10n.chat_addReaction),
onTap: () {
Navigator.pop(sheetContext);
_showEmojiPicker(message, contact);
},
),
ListTile(
leading: const Icon(Icons.copy),
title: Text(context.l10n.common_copy),
@@ -874,8 +992,7 @@ class _ChatScreenState extends State<ChatScreen> {
await _deleteMessage(message);
},
),
if (message.isOutgoing &&
message.status == MessageStatus.failed)
if (message.isOutgoing && message.status == MessageStatus.failed)
ListTile(
leading: const Icon(Icons.refresh),
title: Text(context.l10n.common_retry),
@@ -906,50 +1023,57 @@ class _ChatScreenState extends State<ChatScreen> {
void _copyMessageText(String text) {
Clipboard.setData(ClipboardData(text: text));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.chat_messageCopied)),
);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(context.l10n.chat_messageCopied)));
}
Future<void> _deleteMessage(Message message) async {
await context.read<MeshCoreConnector>().deleteMessage(message);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.chat_messageDeleted)),
);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(context.l10n.chat_messageDeleted)));
}
void _retryMessage(Message message) {
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
// Retry using the contact's current path override setting
connector.sendMessage(
widget.contact,
message.text,
);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.chat_retryingMessage)),
);
connector.sendMessage(widget.contact, message.text);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(context.l10n.chat_retryingMessage)));
}
void _showEmojiPicker(Message message) {
void _showEmojiPicker(Message message, Contact senderContact) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => EmojiPicker(
onEmojiSelected: (emoji) {
_sendReaction(message, emoji);
_sendReaction(message, senderContact, emoji);
},
),
);
}
void _sendReaction(Message message, String emoji) {
void _sendReaction(Message message, Contact senderContact, String emoji) {
final connector = context.read<MeshCoreConnector>();
// Send reaction with messageId if available, otherwise use lightweight format
// Parser will extract reactionKey (timestamp_senderPrefix) for deduplication
final messageId = message.messageId ??
'${message.timestamp.millisecondsSinceEpoch}_${message.senderKeyHex.substring(0, 8)}';
final reactionText = 'r:$messageId:$emoji';
final emojiIndex = ReactionHelper.emojiToIndex(emoji);
if (emojiIndex == null) return; // Unknown emoji, skip
final timestampSecs = message.timestamp.millisecondsSinceEpoch ~/ 1000;
// For room servers, include sender name (like channels) since multiple users
// For 1:1 chats, sender is implicit (null)
final senderName = widget.contact.type == advTypeRoom
? senderContact.name
: null;
final hash = ReactionHelper.computeReactionHash(
timestampSecs,
senderName,
message.text,
);
final reactionText = 'r:$hash:$emojiIndex';
connector.sendMessage(widget.contact, reactionText);
}
}
@@ -978,7 +1102,9 @@ class _MessageBubble extends StatelessWidget {
final isFailed = message.status == MessageStatus.failed;
final bubbleColor = isFailed
? colorScheme.errorContainer
: (isOutgoing ? colorScheme.primary : colorScheme.surfaceContainerHighest);
: (isOutgoing
? colorScheme.primary
: colorScheme.surfaceContainerHighest);
final textColor = isFailed
? colorScheme.onErrorContainer
: (isOutgoing ? colorScheme.onPrimary : colorScheme.onSurface);
@@ -990,13 +1116,17 @@ class _MessageBubble extends StatelessWidget {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Column(
crossAxisAlignment: isOutgoing ? CrossAxisAlignment.end : CrossAxisAlignment.start,
crossAxisAlignment: isOutgoing
? CrossAxisAlignment.end
: CrossAxisAlignment.start,
children: [
GestureDetector(
onTap: onTap,
onLongPress: onLongPress,
child: Row(
mainAxisAlignment: isOutgoing ? MainAxisAlignment.end : MainAxisAlignment.start,
mainAxisAlignment: isOutgoing
? MainAxisAlignment.end
: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (!isOutgoing) ...[
@@ -1005,133 +1135,154 @@ class _MessageBubble extends StatelessWidget {
],
Flexible(
child: Container(
padding: gifId != null
? const EdgeInsets.all(4)
: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.65,
),
decoration: BoxDecoration(
color: bubbleColor,
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (!isOutgoing) ...[
Padding(
padding: gifId != null
? const EdgeInsets.only(left: 8, top: 4, bottom: 4)
: EdgeInsets.zero,
child: Text(
senderName,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: colorScheme.primary,
padding: gifId != null
? const EdgeInsets.all(4)
: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
),
),
if (gifId == null) const SizedBox(height: 4),
],
if (poi != null)
_buildPoiMessage(context, poi, textColor, metaColor)
else if (gifId != null)
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: GifMessage(
url: 'https://media.giphy.com/media/$gifId/giphy.gif',
backgroundColor: Colors.transparent,
fallbackTextColor: textColor.withValues(alpha: 0.7),
),
)
else
Linkify(
text: messageText,
style: TextStyle(
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) ...[
const SizedBox(height: 4),
Padding(
padding: gifId != null
? const EdgeInsets.symmetric(horizontal: 8)
: EdgeInsets.zero,
child: Text(
context.l10n.chat_retryCount(message.retryCount, 4),
style: TextStyle(
fontSize: 10,
color: metaColor,
fontWeight: FontWeight.w500,
),
),
),
],
const SizedBox(height: 4),
Padding(
padding: gifId != null
? const EdgeInsets.only(left: 8, right: 8, bottom: 4)
: EdgeInsets.zero,
child: Wrap(
spacing: 4,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Text(
_formatTime(message.timestamp),
style: TextStyle(
fontSize: 10,
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',
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.65,
),
decoration: BoxDecoration(
color: bubbleColor,
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (!isOutgoing) ...[
Padding(
padding: gifId != null
? const EdgeInsets.only(
left: 8,
top: 4,
bottom: 4,
)
: EdgeInsets.zero,
child: Text(
senderName,
style: TextStyle(
fontSize: 9,
color: isOutgoing ? metaColor : Colors.green[700],
fontSize: 12,
fontWeight: FontWeight.bold,
color: colorScheme.primary,
),
),
],
),
if (gifId == null) const SizedBox(height: 4),
],
),
if (poi != null)
_buildPoiMessage(context, poi, textColor, metaColor)
else if (gifId != null)
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: GifMessage(
url:
'https://media.giphy.com/media/$gifId/giphy.gif',
backgroundColor: Colors.transparent,
fallbackTextColor: textColor.withValues(
alpha: 0.7,
),
),
)
else
Linkify(
text: messageText,
style: TextStyle(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) ...[
const SizedBox(height: 4),
Padding(
padding: gifId != null
? const EdgeInsets.symmetric(horizontal: 8)
: EdgeInsets.zero,
child: Text(
context.l10n.chat_retryCount(
message.retryCount,
4,
),
style: TextStyle(
fontSize: 10,
color: metaColor,
fontWeight: FontWeight.w500,
),
),
),
],
const SizedBox(height: 4),
Padding(
padding: gifId != null
? const EdgeInsets.only(
left: 8,
right: 8,
bottom: 4,
)
: EdgeInsets.zero,
child: Wrap(
spacing: 4,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Text(
_formatTime(message.timestamp),
style: TextStyle(
fontSize: 10,
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],
),
),
],
],
),
),
],
),
],
),
),
),
],
),
),
if (message.reactions.isNotEmpty) ...[
const SizedBox(height: 4),
Padding(
padding: EdgeInsets.only(left: isOutgoing ? 0 : 48),
child: _buildReactionsDisplay(context, message, colorScheme),
),
],
),
),
if (message.reactions.isNotEmpty) ...[
const SizedBox(height: 4),
Padding(
padding: EdgeInsets.only(left: isOutgoing ? 0 : 48),
child: _buildReactionsDisplay(context, message, colorScheme),
),
],
],
],
),
);
}
@@ -1144,8 +1295,9 @@ class _MessageBubble extends StatelessWidget {
_PoiInfo? _parsePoiMessage(String text) {
final trimmed = text.trim();
final match = RegExp(r'^m:([\-0-9.]+),([\-0-9.]+)\|([^|]*)\|.*$')
.firstMatch(trimmed);
final match = RegExp(
r'^m:([\-0-9.]+),([\-0-9.]+)\|([^|]*)\|.*$',
).firstMatch(trimmed);
if (match == null) return null;
final lat = double.tryParse(match.group(1) ?? '');
final lon = double.tryParse(match.group(2) ?? '');
@@ -1186,18 +1338,12 @@ class _MessageBubble extends StatelessWidget {
children: [
Text(
context.l10n.chat_poiShared,
style: TextStyle(
color: textColor,
fontWeight: FontWeight.w600,
),
style: TextStyle(color: textColor, fontWeight: FontWeight.w600),
),
if (poi.label.isNotEmpty)
Text(
poi.label,
style: TextStyle(
color: metaColor,
fontSize: 12,
),
style: TextStyle(color: metaColor, fontSize: 12),
),
],
),
@@ -1206,7 +1352,11 @@ class _MessageBubble extends StatelessWidget {
);
}
Widget _buildReactionsDisplay(BuildContext context, Message message, ColorScheme colorScheme) {
Widget _buildReactionsDisplay(
BuildContext context,
Message message,
ColorScheme colorScheme,
) {
return Wrap(
spacing: 6,
runSpacing: 6,
@@ -1227,10 +1377,7 @@ class _MessageBubble extends StatelessWidget {
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
emoji,
style: const TextStyle(fontSize: 16),
),
Text(emoji, style: const TextStyle(fontSize: 16)),
if (count > 1) ...[
const SizedBox(width: 4),
Text(
@@ -1314,11 +1461,7 @@ class _MessageBubble extends StatelessWidget {
break;
}
return Icon(
icon,
size: 12,
color: color,
);
return Icon(icon, size: 12, color: color);
}
String _formatTime(DateTime time) {
@@ -1333,9 +1476,5 @@ class _PoiInfo {
final double lon;
final String label;
const _PoiInfo({
required this.lat,
required this.lon,
required this.label,
});
const _PoiInfo({required this.lat, required this.lon, required this.label});
}
+330 -28
View File
@@ -1,6 +1,8 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:meshcore_open/screens/path_trace_map.dart';
import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart';
@@ -27,10 +29,9 @@ import 'map_screen.dart';
import 'repeater_hub_screen.dart';
import 'settings_screen.dart';
enum RoomLoginDestination {
chat,
management,
}
enum RoomLoginDestination { chat, management }
enum ContactOperationType { import, export, zeroHopShare }
class ContactsScreen extends StatefulWidget {
final bool hideBackButton;
@@ -52,16 +53,22 @@ class _ContactsScreenState extends State<ContactsScreen>
List<ContactGroup> _groups = [];
Timer? _searchDebounce;
final Set<ContactOperationType> _pendingOperations = {};
StreamSubscription<Uint8List>? _frameSubscription;
@override
void initState() {
super.initState();
_loadGroups();
_setupFrameListener();
}
@override
void dispose() {
_searchDebounce?.cancel();
_searchController.dispose();
_frameSubscription?.cancel();
super.dispose();
}
@@ -77,6 +84,137 @@ class _ContactsScreenState extends State<ContactsScreen>
await _groupStore.saveGroups(_groups);
}
void _setupFrameListener() {
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;
final frameBuffer = BufferReader(frame);
final code = frameBuffer.readUInt8();
if (code == respCodeExportContact) {
final advertPacket = frameBuffer.readRemainingBytes();
// Validate packet has expected minimum size (98+ bytes per protocol)
if (advertPacket.length < 98) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.contacts_invalidAdvertFormat),
),
);
}
_pendingOperations.remove(ContactOperationType.export);
return;
}
final hexString = pubKeyToHex(advertPacket);
Clipboard.setData(ClipboardData(text: "meshcore://$hexString"));
}
if (code == respCodeOk) {
// Show a snackbar indicating success
if (!mounted) return;
if (_pendingOperations.contains(ContactOperationType.import)) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.contacts_contactImported)),
);
}
if (_pendingOperations.contains(ContactOperationType.zeroHopShare)) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.contacts_zeroHopContactAdvertSent),
),
);
}
if (_pendingOperations.contains(ContactOperationType.export)) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.contacts_contactAdvertCopied)),
);
}
_pendingOperations.clear();
}
if (code == respCodeErr) {
// Show a snackbar indicating failure
if (!mounted) return;
if (_pendingOperations.contains(ContactOperationType.import)) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.contacts_contactImportFailed)),
);
}
if (_pendingOperations.contains(ContactOperationType.zeroHopShare)) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.contacts_zeroHopContactAdvertFailed),
),
);
}
if (_pendingOperations.contains(ContactOperationType.export)) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.contacts_contactAdvertCopyFailed),
),
);
}
_pendingOperations.clear();
}
});
}
Future<void> _contactExport(Uint8List pubKey) async {
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
final exportContactFrame = buildExportContactFrame(pubKey);
_pendingOperations.add(ContactOperationType.export);
await connector.sendFrame(exportContactFrame);
}
Future<void> _contactZeroHop(Uint8List pubKey) async {
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
final exportContactZeroHopFrame = buildZeroHopContact(pubKey);
_pendingOperations.add(ContactOperationType.zeroHopShare);
await connector.sendFrame(exportContactZeroHopFrame);
}
Future<void> _contactImport() async {
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
final clipboardData = await Clipboard.getData('text/plain');
if (clipboardData == null || clipboardData.text == null) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.contacts_clipboardEmpty)),
);
}
return;
}
final text = clipboardData.text!.trim();
if (!text.startsWith('meshcore://')) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.contacts_invalidAdvertFormat)),
);
}
return;
}
final hexString = text.substring('meshcore://'.length);
try {
final importContactFrame = buildImportContactFrame(hexString);
_pendingOperations.add(ContactOperationType.import);
await connector.sendFrame(importContactFrame);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.contacts_invalidAdvertFormat)),
);
}
}
}
@override
Widget build(BuildContext context) {
final connector = context.watch<MeshCoreConnector>();
@@ -96,18 +234,94 @@ class _ContactsScreenState extends State<ContactsScreen>
centerTitle: true,
automaticallyImplyLeading: false,
actions: [
IconButton(
icon: const Icon(Icons.bluetooth_disabled),
tooltip: context.l10n.common_disconnect,
onPressed: () => _disconnect(context, connector),
PopupMenuButton(
itemBuilder: (context) => [
PopupMenuItem(
child: Row(
children: [
const Icon(Icons.connect_without_contact),
const SizedBox(width: 8),
Text(context.l10n.contacts_zeroHopAdvert),
],
),
onTap: () => {
connector.sendSelfAdvert(flood: false),
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.settings_advertisementSent),
),
),
},
),
PopupMenuItem(
child: Row(
children: [
const Icon(Icons.cell_tower),
const SizedBox(width: 8),
Text(context.l10n.contacts_floodAdvert),
],
),
onTap: () => {
connector.sendSelfAdvert(flood: true),
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.settings_advertisementSent),
),
),
},
),
PopupMenuItem(
child: Row(
children: [
const Icon(Icons.copy),
const SizedBox(width: 8),
Text(context.l10n.contacts_copyAdvertToClipboard),
],
),
onTap: () => _contactExport(Uint8List.fromList([])),
),
PopupMenuItem(
child: Row(
children: [
const Icon(Icons.paste),
const SizedBox(width: 8),
Text(context.l10n.contacts_addContactFromClipboard),
],
),
onTap: () => _contactImport(),
),
],
icon: const Icon(Icons.connect_without_contact),
),
IconButton(
icon: const Icon(Icons.tune),
tooltip: context.l10n.common_settings,
onPressed: () => Navigator.push(
context,
MaterialPageRoute(builder: (context) => const SettingsScreen()),
),
PopupMenuButton(
itemBuilder: (context) => [
PopupMenuItem(
child: Row(
children: [
const Icon(Icons.logout, color: Colors.red),
const SizedBox(width: 8),
Text(context.l10n.common_disconnect),
],
),
onTap: () => _disconnect(context, connector),
),
PopupMenuItem(
child: Row(
children: [
const Icon(Icons.settings),
const SizedBox(width: 8),
Text(context.l10n.settings_title),
],
),
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const SettingsScreen(),
),
),
),
],
icon: const Icon(Icons.more_vert),
),
],
),
@@ -497,7 +711,8 @@ class _ContactsScreenState extends State<ContactsScreen>
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => destination == RoomLoginDestination.management
builder: (context) =>
destination == RoomLoginDestination.management
? RepeaterHubScreen(repeater: room, password: password)
: ChatScreen(contact: room),
),
@@ -760,7 +975,26 @@ class _ContactsScreenState extends State<ContactsScreen>
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (isRepeater)
if (isRepeater) ...[
ListTile(
leading: const Icon(Icons.radar, color: Colors.green),
title: contact.pathLength > 0
? Text(context.l10n.contacts_pathTrace)
: Text(context.l10n.contacts_ping),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => PathTraceMapScreen(
title: contact.pathLength > 0
? context.l10n.contacts_repeaterPathTrace
: context.l10n.contacts_repeaterPing,
path: contact.traceRouteBytes ?? Uint8List(0),
),
),
);
},
),
ListTile(
leading: const Icon(Icons.cell_tower, color: Colors.orange),
title: Text(context.l10n.contacts_manageRepeater),
@@ -768,8 +1002,27 @@ class _ContactsScreenState extends State<ContactsScreen>
Navigator.pop(sheetContext);
_showRepeaterLogin(context, contact);
},
)
else if (isRoom) ...[
),
] else if (isRoom) ...[
ListTile(
leading: const Icon(Icons.radar, color: Colors.green),
title: contact.pathLength > 0
? Text(context.l10n.contacts_pathTrace)
: Text(context.l10n.contacts_ping),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => PathTraceMapScreen(
title: contact.pathLength > 0
? context.l10n.contacts_roomPathTrace
: context.l10n.contacts_roomPing,
path: contact.traceRouteBytes ?? Uint8List(0),
),
),
);
},
),
ListTile(
leading: const Icon(Icons.room, color: Colors.blue),
title: Text(context.l10n.contacts_roomLogin),
@@ -779,14 +1032,39 @@ class _ContactsScreenState extends State<ContactsScreen>
},
),
ListTile(
leading: const Icon(Icons.room_preferences, color: Colors.orange),
leading: const Icon(
Icons.room_preferences,
color: Colors.orange,
),
title: Text(context.l10n.room_management),
onTap: () {
Navigator.pop(sheetContext);
_showRoomLogin(context, contact, RoomLoginDestination.management);
_showRoomLogin(
context,
contact,
RoomLoginDestination.management,
);
},
),
] else
] else ...[
if (contact.pathLength > 0)
ListTile(
leading: const Icon(Icons.radar, color: Colors.green),
title: Text(context.l10n.contacts_chatTraceRoute),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => PathTraceMapScreen(
title: context.l10n.contacts_pathTraceTo(
contact.name,
),
path: contact.traceRouteBytes ?? Uint8List(0),
),
),
);
},
),
ListTile(
leading: const Icon(Icons.chat),
title: Text(context.l10n.contacts_openChat),
@@ -795,6 +1073,23 @@ class _ContactsScreenState extends State<ContactsScreen>
_openChat(context, contact);
},
),
],
ListTile(
leading: const Icon(Icons.copy),
title: Text(context.l10n.contacts_ShareContact),
onTap: () {
Navigator.pop(sheetContext);
_contactExport(contact.publicKey);
},
),
ListTile(
leading: const Icon(Icons.connect_without_contact),
title: Text(context.l10n.contacts_ShareContactZeroHop),
onTap: () {
Navigator.pop(sheetContext);
_contactZeroHop(contact.publicKey);
},
),
ListTile(
leading: const Icon(Icons.delete, color: Colors.red),
title: Text(
@@ -860,16 +1155,18 @@ class _ContactTile extends StatelessWidget {
@override
Widget build(BuildContext context) {
final shotPublicKey =
"<${contact.publicKeyHex.substring(0, 8)}...${contact.publicKeyHex.substring(contact.publicKeyHex.length - 8)}>";
return ListTile(
leading: CircleAvatar(
backgroundColor: _getTypeColor(contact.type),
child: _buildContactAvatar(contact),
),
title: Text(contact.name),
subtitle: Text(
'${contact.typeLabel}${contact.pathLabel} $shotPublicKey',
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(contact.pathLabel),
Text(contact.shortPubKeyHex, style: TextStyle(fontSize: 12)),
],
),
// Clamp text scaling in trailing section to prevent overflow while
// maintaining accessibility. Primary content (title/subtitle) scales normally.
@@ -891,8 +1188,13 @@ class _ContactTile extends StatelessWidget {
_formatLastSeen(context, lastSeen),
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
),
if (contact.hasLocation)
Icon(Icons.location_on, size: 14, color: Colors.grey[400]),
Row(
mainAxisSize: MainAxisSize.min,
children: [
if (contact.hasLocation)
Icon(Icons.location_on, size: 14, color: Colors.grey[400]),
],
),
],
),
),
+5 -18
View File
@@ -127,9 +127,7 @@ class _DeviceScreenState extends State<DeviceScreen>
return Card(
elevation: 0,
color: colorScheme.surfaceContainerHighest,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
@@ -207,7 +205,6 @@ class _DeviceScreenState extends State<DeviceScreen>
);
}
Widget _buildBatteryIndicator(
MeshCoreConnector connector,
BuildContext context,
@@ -224,11 +221,7 @@ class _DeviceScreenState extends State<DeviceScreen>
final icon = _batteryIcon(percent);
return ActionChip(
avatar: Icon(
icon,
size: 16,
color: colorScheme.onSecondaryContainer,
),
avatar: Icon(icon, size: 16, color: colorScheme.onSecondaryContainer),
label: Text(displayLabel),
labelStyle: theme.textTheme.labelMedium?.copyWith(
color: colorScheme.onSecondaryContainer,
@@ -260,25 +253,19 @@ class _DeviceScreenState extends State<DeviceScreen>
case 0:
Navigator.pushReplacement(
context,
buildQuickSwitchRoute(
const ContactsScreen(hideBackButton: true),
),
buildQuickSwitchRoute(const ContactsScreen(hideBackButton: true)),
);
break;
case 1:
Navigator.pushReplacement(
context,
buildQuickSwitchRoute(
const ChannelsScreen(hideBackButton: true),
),
buildQuickSwitchRoute(const ChannelsScreen(hideBackButton: true)),
);
break;
case 2:
Navigator.pushReplacement(
context,
buildQuickSwitchRoute(
const MapScreen(hideBackButton: true),
),
buildQuickSwitchRoute(const MapScreen(hideBackButton: true)),
);
break;
}
+31 -23
View File
@@ -56,10 +56,7 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
_updateEstimate();
if (bounds != null) {
_mapController.fitCamera(
CameraFit.bounds(
bounds: bounds,
padding: const EdgeInsets.all(48),
),
CameraFit.bounds(bounds: bounds, padding: const EdgeInsets.all(48)),
);
}
}
@@ -72,8 +69,11 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
return;
}
final cacheService = context.read<MapTileCacheService>();
final count =
cacheService.estimateTileCount(_selectedBounds!, _minZoom, _maxZoom);
final count = cacheService.estimateTileCount(
_selectedBounds!,
_minZoom,
_maxZoom,
);
setState(() {
_estimatedTiles = count;
});
@@ -181,9 +181,9 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
result.failed,
)
: context.l10n.mapCache_cachedTiles(result.downloaded);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message)),
);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
}
Future<void> _clearCache() async {
@@ -224,10 +224,7 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
: (_completedTiles / _estimatedTiles).clamp(0.0, 1.0).toDouble();
return Scaffold(
appBar: AppBar(
title: Text(l10n.mapCache_title),
centerTitle: true,
),
appBar: AppBar(title: Text(l10n.mapCache_title), centerTitle: true),
body: Column(
children: [
Expanded(
@@ -290,7 +287,10 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
children: [
Text(
l10n.mapCache_cacheArea,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
const SizedBox(height: 8),
Row(
@@ -304,8 +304,9 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
),
const SizedBox(width: 12),
TextButton(
onPressed:
_isDownloading || selectedBounds == null ? null : _clearBounds,
onPressed: _isDownloading || selectedBounds == null
? null
: _clearBounds,
child: Text(l10n.common_clear),
),
],
@@ -313,11 +314,16 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
const SizedBox(height: 12),
Text(
l10n.mapCache_zoomRange,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
RangeSlider(
values:
RangeValues(_minZoom.toDouble(), _maxZoom.toDouble()),
values: RangeValues(
_minZoom.toDouble(),
_maxZoom.toDouble(),
),
min: 3,
max: 18,
divisions: 15,
@@ -341,10 +347,12 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
const SizedBox(height: 8),
LinearProgressIndicator(value: progressValue),
const SizedBox(height: 4),
Text(l10n.mapCache_downloadedTiles(
_completedTiles,
_estimatedTiles,
)),
Text(
l10n.mapCache_downloadedTiles(
_completedTiles,
_estimatedTiles,
),
),
],
const SizedBox(height: 12),
Row(
+153 -126
View File
@@ -225,13 +225,15 @@ class _MapScreenState extends State<MapScreen> {
}
// Re center map after removed markers have loaded
if (!_hasInitializedMap && _removedMarkersLoaded && hasMapContent) {
if (!_hasInitializedMap && _removedMarkersLoaded) {
_hasInitializedMap = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
_mapController.move(center, initialZoom);
}
});
if (hasMapContent) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
_mapController.move(center, initialZoom);
}
});
}
}
final allowBack = !connector.isConnected;
@@ -245,105 +247,152 @@ class _MapScreenState extends State<MapScreen> {
centerTitle: true,
automaticallyImplyLeading: false,
actions: [
IconButton(
icon: const Icon(Icons.bluetooth_disabled),
tooltip: context.l10n.common_disconnect,
onPressed: () => _disconnect(context, connector),
),
IconButton(
icon: const Icon(Icons.tune),
tooltip: context.l10n.common_settings,
onPressed: () => Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const SettingsScreen(),
PopupMenuButton(
itemBuilder: (context) => [
PopupMenuItem(
child: Row(
children: [
const Icon(Icons.logout, color: Colors.red),
const SizedBox(width: 8),
Text(context.l10n.common_disconnect),
],
),
onTap: () => _disconnect(context, connector),
),
),
PopupMenuItem(
child: Row(
children: [
const Icon(Icons.settings),
const SizedBox(width: 8),
Text(context.l10n.settings_title),
],
),
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const SettingsScreen(),
),
),
),
],
icon: const Icon(Icons.more_vert),
),
],
),
body: !hasMapContent
? _buildEmptyState()
: Stack(
children: [
FlutterMap(
mapController: _mapController,
options: MapOptions(
initialCenter: center,
initialZoom: initialZoom,
minZoom: 2.0,
maxZoom: 18.0,
interactionOptions: InteractionOptions(
flags: ~InteractiveFlag.rotate
),
onTap: (_, latLng) {
if (_isSelectingPoi) {
setState(() {
_isSelectingPoi = false;
});
_shareMarker(
context: context,
connector: connector,
position: latLng,
defaultLabel: context.l10n.map_pointOfInterest,
flags: 'poi',
);
}
},
onLongPress: (_, latLng) {
if (_isSelectingPoi) {
setState(() {
_isSelectingPoi = false;
});
_shareMarker(
context: context,
connector: connector,
position: latLng,
defaultLabel: context.l10n.map_pointOfInterest,
flags: 'poi',
);
return;
}
_showShareMarkerAtPositionSheet(
context: context,
connector: connector,
position: latLng,
);
},
),
children: [
TileLayer(
urlTemplate: kMapTileUrlTemplate,
tileProvider: tileCache.tileProvider,
userAgentPackageName:
MapTileCacheService.userAgentPackageName,
maxZoom: 19,
),
MarkerLayer(
markers: [
if (highlightPosition != null)
Marker(
point: highlightPosition,
width: 40,
height: 40,
child: Icon(
Icons.location_on_outlined,
color: Colors.red[600],
size: 34,
),
),
..._buildMarkers(contactsWithLocation, settings),
...sharedMarkers.map(_buildSharedMarker),
],
),
],
),
_buildLegend(
contactsWithLocation.length,
sharedMarkers.length,
),
],
body: Stack(
children: [
FlutterMap(
mapController: _mapController,
options: MapOptions(
initialCenter: center,
initialZoom: initialZoom,
minZoom: 2.0,
maxZoom: 18.0,
interactionOptions: InteractionOptions(
flags: ~InteractiveFlag.rotate,
),
onTap: (_, latLng) {
if (_isSelectingPoi) {
setState(() {
_isSelectingPoi = false;
});
_shareMarker(
context: context,
connector: connector,
position: latLng,
defaultLabel: context.l10n.map_pointOfInterest,
flags: 'poi',
);
}
},
onLongPress: (_, latLng) {
if (_isSelectingPoi) {
setState(() {
_isSelectingPoi = false;
});
_shareMarker(
context: context,
connector: connector,
position: latLng,
defaultLabel: context.l10n.map_pointOfInterest,
flags: 'poi',
);
return;
}
_showShareMarkerAtPositionSheet(
context: context,
connector: connector,
position: latLng,
);
},
),
children: [
TileLayer(
urlTemplate: kMapTileUrlTemplate,
tileProvider: tileCache.tileProvider,
userAgentPackageName:
MapTileCacheService.userAgentPackageName,
maxZoom: 19,
),
MarkerLayer(
markers: [
if (highlightPosition != null)
Marker(
point: highlightPosition,
width: 40,
height: 40,
child: Icon(
Icons.location_on_outlined,
color: Colors.red[600],
size: 34,
),
),
..._buildMarkers(contactsWithLocation, settings),
...sharedMarkers.map(_buildSharedMarker),
if (connector.selfLatitude != null &&
connector.selfLongitude != null)
Marker(
point: LatLng(
connector.selfLatitude!,
connector.selfLongitude!,
),
width: 35,
height: 35,
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: Colors.teal,
shape: BoxShape.circle,
border: Border.all(
color: Colors.white,
width: 2,
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.3),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
alignment: Alignment.center,
child: Text(
context.l10n.pathTrace_you,
style: const TextStyle(
color: Colors.black,
fontWeight: FontWeight.bold,
fontSize: 12,
),
),
),
),
],
),
],
),
_buildLegend(contactsWithLocation.length, sharedMarkers.length),
],
),
bottomNavigationBar: SafeArea(
top: false,
child: QuickSwitchBar(
@@ -363,28 +412,6 @@ class _MapScreenState extends State<MapScreen> {
);
}
Widget _buildEmptyState() {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.location_off, size: 64, color: Colors.grey[400]),
const SizedBox(height: 16),
Text(
context.l10n.map_noNodesWithLocation,
style: TextStyle(fontSize: 18, color: Colors.grey[600]),
),
const SizedBox(height: 8),
Text(
context.l10n.map_nodesNeedGps,
textAlign: TextAlign.center,
style: TextStyle(fontSize: 14, color: Colors.grey[500]),
),
],
),
);
}
List<Marker> _buildMarkers(List<Contact> contacts, settings) {
final markers = <Marker>[];
@@ -404,14 +431,14 @@ class _MapScreenState extends State<MapScreen> {
final marker = Marker(
point: LatLng(contact.latitude!, contact.longitude!),
width: 80,
height: 80,
width: 35,
height: 35,
child: GestureDetector(
onTap: () => _showNodeInfo(context, contact),
child: Column(
children: [
Container(
padding: const EdgeInsets.all(8),
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: _getNodeColor(contact.type),
shape: BoxShape.circle,
@@ -427,7 +454,7 @@ class _MapScreenState extends State<MapScreen> {
child: Icon(
_getNodeIcon(contact.type),
color: Colors.white,
size: 24,
size: 20,
),
),
],
+569
View File
@@ -0,0 +1,569 @@
import 'dart:async';
import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
import 'package:meshcore_open/connector/meshcore_connector.dart';
import 'package:meshcore_open/connector/meshcore_protocol.dart';
import 'package:meshcore_open/l10n/l10n.dart';
import 'package:meshcore_open/models/contact.dart';
import 'package:meshcore_open/services/map_tile_cache_service.dart';
import 'package:meshcore_open/widgets/snr_indicator.dart';
import 'package:provider/provider.dart';
class PathTraceData {
final Uint8List pathData;
final Uint8List snrData;
final Map<int, Contact> pathContacts;
PathTraceData({
required this.pathData,
required this.snrData,
required this.pathContacts,
});
}
class PathTraceMapScreen extends StatefulWidget {
final String title;
final Uint8List path;
final bool flipPathRound;
final bool reversePathRound;
const PathTraceMapScreen({
super.key,
required this.title,
required this.path,
this.flipPathRound = false,
this.reversePathRound = false,
});
@override
State<PathTraceMapScreen> createState() => _PathTraceMapScreenState();
}
class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
StreamSubscription<Uint8List>? _frameSubscription;
Timer? _timeoutTimer;
bool _isLoading = false;
bool _failed2Loaded = false;
bool _hasData = false;
bool _noLocationErr = false;
PathTraceData? _traceData;
List<LatLng> _points = <LatLng>[];
List<Polyline> _polylines = [];
LatLng? _initialCenter = LatLng(0, 0);
double _initialZoom = 2.0;
LatLngBounds? _bounds;
ValueKey<String> _mapKey = const ValueKey('initial');
double _pathDistance = 0.0;
String _formatPathPrefixes(Uint8List pathBytes) {
return pathBytes
.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase())
.join(',');
}
@override
void initState() {
super.initState();
_setupFrameListener();
_doPathTrace();
}
@override
void dispose() {
_frameSubscription?.cancel();
_timeoutTimer?.cancel();
super.dispose();
}
Uint8List addReturnpath(Uint8List pathBytes) {
Uint8List? traceBytes;
final len = (pathBytes.length + pathBytes.length - 1);
traceBytes = Uint8List(len);
for (int i = 0; i < pathBytes.length; i++) {
traceBytes[i] = pathBytes[i];
if (i < pathBytes.length - 1) {
traceBytes[len - 1 - i] = pathBytes[i];
}
}
return traceBytes;
}
double getPathDistance() {
double totalDistance = 0.0;
final distanceCalculator = Distance();
for (int i = 0; i < _points.length - 1; i++) {
totalDistance += distanceCalculator(_points[i], _points[i + 1]);
}
return totalDistance;
}
Future<void> _doPathTrace() async {
if (mounted) {
setState(() {
_isLoading = true;
_failed2Loaded = false;
_noLocationErr = false;
});
}
final Uint8List path;
Uint8List pathTmp = widget.reversePathRound
? Uint8List.fromList(widget.path.reversed.toList())
: widget.path;
if (widget.flipPathRound) {
path = addReturnpath(pathTmp);
} else {
path = pathTmp;
}
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
final frame = buildTraceReq(
DateTime.now().millisecondsSinceEpoch ~/ 1000,
0, //flags
0, //auth
payload: path,
);
connector.sendFrame(frame);
}
void _setupFrameListener() {
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
Uint8List tagData = Uint8List(4);
// Listen for incoming text messages from the repeater
_frameSubscription = connector.receivedFrames.listen((frame) {
if (frame.isEmpty) return;
final frameBuffer = BufferReader(frame);
final code = frameBuffer.readUInt8();
if (code == respCodeSent) {
frameBuffer.skipBytes(1); //reserved
tagData = frameBuffer.readBytes(4);
final timeoutSeconds = frameBuffer.readUInt32LE();
// Start timeout timer for trace response
_timeoutTimer?.cancel();
_timeoutTimer = Timer(Duration(milliseconds: timeoutSeconds), () {
if (!mounted) return;
setState(() {
_isLoading = false;
_failed2Loaded = true;
});
});
}
// Check if it's a binary response
if (frame.length > 8 &&
code == pushCodeTraceData &&
listEquals(frame.sublist(4, 8), tagData)) {
_timeoutTimer?.cancel();
if (!mounted) return;
frameBuffer.skipBytes(3); //reserved + path length + flag
if (listEquals(frameBuffer.readBytes(4), tagData)) {
_handleTraceResponse(frame);
}
}
});
}
Future<void> _handleTraceResponse(Uint8List frame) async {
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
final buffer = BufferReader(frame);
buffer.skipBytes(2); // Skip push code and reserved byte
int pathLength = buffer.readUInt8();
buffer.skipBytes(5); // Skip Flag byte and tag data
buffer.skipBytes(4); // Skip auth code
Uint8List pathData = buffer.readBytes(pathLength);
Uint8List snrData = buffer.readRemainingBytes();
Map<int, Contact> pathContacts = {};
connector.contacts.where((c) => c.type != advTypeChat).forEach((repeater) {
for (var repeaterData in pathData) {
if (listEquals(
repeater.publicKey.sublist(0, 1),
Uint8List.fromList([repeaterData]),
)) {
pathContacts[repeaterData] = repeater;
}
}
});
setState(() {
_isLoading = false;
_hasData = true;
_traceData = PathTraceData(
pathData: pathData,
snrData: snrData,
pathContacts: pathContacts,
);
_points = <LatLng>[];
_points.add(LatLng(connector.selfLatitude!, connector.selfLongitude!));
for (final hop in _traceData!.pathData) {
final contact = _traceData!.pathContacts[hop];
if (contact != null &&
contact.hasLocation &&
contact.latitude != null &&
contact.longitude != null) {
_points.add(LatLng(contact.latitude!, contact.longitude!));
} else {
_noLocationErr = true;
}
}
_polylines = _points.length > 1
? [
Polyline(
points: _points,
strokeWidth: 4,
color: Colors.blueAccent,
),
]
: <Polyline>[];
_initialCenter = _points.isNotEmpty ? _points.first : const LatLng(0, 0);
_initialZoom = _points.isNotEmpty ? 13.0 : 2.0;
_bounds = _points.length > 1 ? LatLngBounds.fromPoints(_points) : null;
_mapKey = ValueKey(
'${context.l10n.pathTrace_you},${_formatPathPrefixes(_traceData!.pathData)}',
);
_pathDistance = getPathDistance();
});
}
@override
Widget build(BuildContext context) {
return Consumer<MeshCoreConnector>(
builder: (context, connector, _) {
final tileCache = context.read<MapTileCacheService>();
return Scaffold(
appBar: AppBar(
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
FittedBox(
fit: BoxFit.scaleDown,
child: Text(
widget.title,
style: const TextStyle(fontSize: 24),
),
),
],
),
centerTitle: false,
actions: [
IconButton(
icon: _isLoading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.refresh),
onPressed: _isLoading ? null : _doPathTrace,
tooltip: context.l10n.pathTrace_refreshTooltip,
),
],
),
body: SafeArea(
top: false,
child: Stack(
children: [
if (_noLocationErr)
Center(
child: Card(
color: Colors.red,
child: Padding(
padding: EdgeInsets.all(12),
child: Text(
context.l10n.pathTrace_someHopsNoLocation,
style: TextStyle(color: Colors.white),
),
),
),
),
if (!_hasData && !_noLocationErr)
Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (_isLoading) const CircularProgressIndicator(),
const SizedBox(height: 16),
if (!_isLoading && _failed2Loaded)
Text(context.l10n.pathTrace_notAvailable),
],
),
),
if (_hasData && !_noLocationErr)
FlutterMap(
key: _mapKey,
options: MapOptions(
initialCenter: _initialCenter!,
initialZoom: _initialZoom,
initialCameraFit: _bounds == null
? null
: CameraFit.bounds(
bounds: _bounds!,
padding: const EdgeInsets.all(64),
maxZoom: 16,
),
minZoom: 2.0,
maxZoom: 18.0,
),
children: [
TileLayer(
urlTemplate: kMapTileUrlTemplate,
tileProvider: tileCache.tileProvider,
userAgentPackageName:
MapTileCacheService.userAgentPackageName,
maxZoom: 19,
),
if (_polylines.isNotEmpty)
PolylineLayer(polylines: _polylines),
if (_traceData!.pathData.isNotEmpty)
MarkerLayer(
markers: _buildHopMarkers(_traceData!.pathData),
),
],
),
if (_points.isEmpty &&
!_hasData &&
!_isLoading &&
!_failed2Loaded &&
!_noLocationErr)
Center(
child: Card(
color: Colors.white.withValues(alpha: 0.9),
child: Padding(
padding: EdgeInsets.all(12),
child: Text(
context.l10n.channelPath_noRepeaterLocations,
),
),
),
),
if (_hasData && !_noLocationErr)
_buildLegendCard(context, _traceData!),
],
),
),
);
},
);
}
List<Marker> _buildHopMarkers(List<int> pathData) {
return [
for (final hop in pathData)
if (_traceData!.pathContacts[hop]!.hasLocation)
Marker(
point: LatLng(
_traceData!.pathContacts[hop]!.latitude!,
_traceData!.pathContacts[hop]!.longitude!,
),
width: 35,
height: 35,
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: Colors.green,
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.3),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
alignment: Alignment.center,
child: Text(
_traceData!.pathContacts[hop]!.publicKey
.sublist(0, 1)
.map(
(b) => b.toRadixString(16).padLeft(2, '0').toUpperCase(),
)
.join(),
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 12,
),
),
),
),
if (context.read<MeshCoreConnector>().selfLatitude != null &&
context.read<MeshCoreConnector>().selfLongitude != null)
Marker(
point: LatLng(
context.read<MeshCoreConnector>().selfLatitude!,
context.read<MeshCoreConnector>().selfLongitude!,
),
width: 35,
height: 35,
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: Colors.blue,
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.3),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
alignment: Alignment.center,
child: Text(
context.l10n.pathTrace_you,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 12,
),
),
),
),
];
}
String formatDirectionText(PathTraceData pathTraceData, int index) {
if (index == 0 || index == pathTraceData.snrData.length - 1) {
if (index == 0) {
return context.l10n.pathTrace_you;
} else {
final contactName = pathTraceData
.pathContacts[pathTraceData.pathData[pathTraceData.pathData.length -
1]]
?.name;
final hex = pathTraceData.pathData[pathTraceData.pathData.length - 1]
.toRadixString(16)
.padLeft(2, '0')
.toUpperCase();
return contactName != null ? "$hex: $contactName" : hex;
}
} else {
final contactName =
pathTraceData.pathContacts[pathTraceData.pathData[index - 1]]?.name;
final hex = pathTraceData.pathData[index - 1]
.toRadixString(16)
.padLeft(2, '0')
.toUpperCase();
return contactName != null ? "$hex: $contactName" : hex;
}
}
String formatDirectionSubText(PathTraceData pathTraceData, int index) {
if (index == 0 || index == pathTraceData.snrData.length - 1) {
if (index == 0) {
final contactName =
pathTraceData.pathContacts[pathTraceData.pathData[0]]?.name;
final hex = pathTraceData.pathData[0]
.toRadixString(16)
.padLeft(2, '0')
.toUpperCase();
return contactName != null ? "$hex: $contactName" : hex;
} else {
return context.l10n.pathTrace_you;
}
} else {
final contactName =
pathTraceData.pathContacts[pathTraceData.pathData[index]]?.name;
final hex = pathTraceData.pathData[index]
.toRadixString(16)
.padLeft(2, '0')
.toUpperCase();
return contactName != null ? "$hex: $contactName" : hex;
}
}
Widget _buildLegendCard(BuildContext context, PathTraceData pathTraceData) {
final l10n = context.l10n;
final maxHeight = MediaQuery.of(context).size.height * 0.35;
final estimatedHeight = 72.0 + (pathTraceData.pathData.length * 56.0);
final cardHeight = max(96.0, min(maxHeight, estimatedHeight));
return Positioned(
left: 16,
right: 16,
bottom: 16,
child: SizedBox(
height: cardHeight,
child: Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.all(12),
child: Text(
'${l10n.channelPath_repeaterHops} (${(_pathDistance / 1609.34).toStringAsFixed(2)} Miles / ${(_pathDistance / 1000).toStringAsFixed(2)} Km)',
style: const TextStyle(fontWeight: FontWeight.w600),
),
),
const Divider(height: 1),
Expanded(
child: pathTraceData.pathData.isEmpty
? Center(
child: Text(l10n.channelPath_noHopDetailsAvailable),
)
: Scrollbar(
child: ListView.separated(
padding: const EdgeInsets.symmetric(vertical: 4),
itemCount: pathTraceData.pathData.length + 1,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (context, index) {
return Column(
children: [
ListTile(
leading:
index >= pathTraceData.snrData.length / 2
? Icon(Icons.call_received)
: Icon(Icons.call_made),
title: Text(
formatDirectionText(pathTraceData, index),
style: const TextStyle(fontSize: 14),
),
subtitle: Text(
formatDirectionSubText(
pathTraceData,
index,
),
style: const TextStyle(fontSize: 14),
),
trailing: SNRIcon(
snr:
pathTraceData.snrData[index].toSigned(
8,
) /
4.0,
),
onTap: () {
// Handle item tap
},
),
],
);
},
),
),
),
],
),
),
),
);
}
}
+61 -25
View File
@@ -119,14 +119,24 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
// Show debug info if requested
if (showDebug && mounted) {
final frame = buildSendCliCommandFrame(widget.repeater.publicKey, command);
DebugFrameViewer.showFrameDebug(context, frame, context.l10n.repeater_cliCommandFrameTitle);
final frame = buildSendCliCommandFrame(
widget.repeater.publicKey,
command,
);
DebugFrameViewer.showFrameDebug(
context,
frame,
context.l10n.repeater_cliCommandFrameTitle,
);
}
// Send CLI command to repeater with retry
try {
if (_commandService != null) {
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
final connector = Provider.of<MeshCoreConnector>(
context,
listen: false,
);
final repeater = _resolveRepeater(connector);
final response = await _commandService!.sendCommand(
repeater,
@@ -230,7 +240,10 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
Text(l10n.repeater_cliTitle),
Text(
repeater.name,
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.normal),
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.normal,
),
),
],
),
@@ -251,12 +264,20 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
value: 'auto',
child: Row(
children: [
Icon(Icons.auto_mode, size: 20, color: !isFloodMode ? Theme.of(context).primaryColor : null),
Icon(
Icons.auto_mode,
size: 20,
color: !isFloodMode
? Theme.of(context).primaryColor
: null,
),
const SizedBox(width: 8),
Text(
l10n.repeater_autoUseSavedPath,
style: TextStyle(
fontWeight: !isFloodMode ? FontWeight.bold : FontWeight.normal,
fontWeight: !isFloodMode
? FontWeight.bold
: FontWeight.normal,
),
),
],
@@ -266,12 +287,20 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
value: 'flood',
child: Row(
children: [
Icon(Icons.waves, size: 20, color: isFloodMode ? Theme.of(context).primaryColor : null),
Icon(
Icons.waves,
size: 20,
color: isFloodMode
? Theme.of(context).primaryColor
: null,
),
const SizedBox(width: 8),
Text(
l10n.repeater_forceFloodMode,
style: TextStyle(
fontWeight: isFloodMode ? FontWeight.bold : FontWeight.normal,
fontWeight: isFloodMode
? FontWeight.bold
: FontWeight.normal,
),
),
],
@@ -282,7 +311,8 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
IconButton(
icon: const Icon(Icons.timeline),
tooltip: l10n.repeater_pathManagement,
onPressed: () => PathManagementDialog.show(context, contact: repeater),
onPressed: () =>
PathManagementDialog.show(context, contact: repeater),
),
IconButton(
icon: const Icon(Icons.bug_report),
@@ -473,7 +503,10 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
decoration: InputDecoration(
hintText: l10n.repeater_enterCommandHint,
border: const OutlineInputBorder(),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
prefixText: '> ',
),
style: const TextStyle(fontFamily: 'monospace'),
@@ -718,10 +751,7 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
];
final gpsCommands = [
_CommandHelpEntry(
command: 'gps',
description: l10n.repeater_cliHelpGps,
),
_CommandHelpEntry(command: 'gps', description: l10n.repeater_cliHelpGps),
_CommandHelpEntry(
command: 'gps {on|off}',
description: l10n.repeater_cliHelpGpsOnOff,
@@ -758,13 +788,25 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
style: const TextStyle(fontSize: 13),
),
const SizedBox(height: 16),
_buildHelpSection(context, l10n.repeater_general, generalCommands),
_buildHelpSection(
context,
l10n.repeater_general,
generalCommands,
),
const SizedBox(height: 16),
_buildHelpSection(context, l10n.repeater_settingsCategory, settingsCommands),
_buildHelpSection(
context,
l10n.repeater_settingsCategory,
settingsCommands,
),
const SizedBox(height: 16),
_buildHelpSection(context, l10n.repeater_bridge, bridgeCommands),
const SizedBox(height: 16),
_buildHelpSection(context, l10n.repeater_logging, loggingCommands),
_buildHelpSection(
context,
l10n.repeater_logging,
loggingCommands,
),
const SizedBox(height: 16),
_buildHelpSection(
context,
@@ -813,10 +855,7 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
),
if (note != null) ...[
const SizedBox(height: 6),
Text(
note,
style: const TextStyle(fontSize: 12),
),
Text(note, style: const TextStyle(fontSize: 12)),
],
const SizedBox(height: 8),
...commands.map((entry) => _buildHelpCommandCard(context, entry)),
@@ -871,8 +910,5 @@ class _CommandHelpEntry {
final String command;
final String description;
const _CommandHelpEntry({
required this.command,
required this.description,
});
const _CommandHelpEntry({required this.command, required this.description});
}
+1 -1
View File
@@ -73,7 +73,7 @@ class RepeaterHubScreen extends StatelessWidget {
),
const SizedBox(height: 8),
Text(
'<${repeater.publicKeyHex.substring(0, 8)}...${repeater.publicKeyHex.substring(repeater.publicKeyHex.length - 8)}>',
repeater.shortPubKeyHex,
style: TextStyle(fontSize: 14, color: Colors.grey[600]),
),
const SizedBox(height: 8),
+183 -65
View File
@@ -41,7 +41,8 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
// Basic settings
final TextEditingController _nameController = TextEditingController();
final TextEditingController _passwordController = TextEditingController();
final TextEditingController _guestPasswordController = TextEditingController();
final TextEditingController _guestPasswordController =
TextEditingController();
// Radio settings
final TextEditingController _freqController = TextEditingController();
@@ -60,7 +61,9 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
bool _privacyMode = false;
// Advertisement settings
bool _advertEnable = true;
int _advertInterval = 120; // minutes/2
bool _floodAdvertEnable = true;
int _floodAdvertInterval = 12; // hours
int _privAdvertInterval = 60; // minutes
@@ -146,7 +149,10 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
if (_fetchedSettings.isEmpty) return;
final appLog = Provider.of<AppDebugLogService>(context, listen: false);
appLog.info('Updating UI with keys: ${_fetchedSettings.keys.toList()}', tag: 'RadioSettings');
appLog.info(
'Updating UI with keys: ${_fetchedSettings.keys.toList()}',
tag: 'RadioSettings',
);
setState(() {
// Update name
@@ -161,7 +167,10 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
final radioStr = _fetchedSettings['radio']!;
appLog.info('Raw radio string: "$radioStr"', tag: 'RadioSettings');
final parts = radioStr.split(',');
appLog.info('Split into ${parts.length} parts: $parts', tag: 'RadioSettings');
appLog.info(
'Split into ${parts.length} parts: $parts',
tag: 'RadioSettings',
);
if (parts.isNotEmpty) {
final freqText = parts[0].replaceAll(RegExp(r'[^0-9.]'), '').trim();
@@ -193,7 +202,10 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
appLog.info('CR text: "$crText"', tag: 'RadioSettings');
_codingRate = int.tryParse(crText) ?? _codingRate;
}
appLog.info('Final values: freq=${_freqController.text}, bw=$_bandwidth, sf=$_spreadingFactor, cr=$_codingRate', tag: 'RadioSettings');
appLog.info(
'Final values: freq=${_freqController.text}, bw=$_bandwidth, sf=$_spreadingFactor, cr=$_codingRate',
tag: 'RadioSettings',
);
}
if (_fetchedSettings.containsKey('tx')) {
@@ -207,11 +219,17 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
}
if (_fetchedSettings.containsKey('lat')) {
appLog.info('Setting lat to: "${_fetchedSettings['lat']}"', tag: 'RadioSettings');
appLog.info(
'Setting lat to: "${_fetchedSettings['lat']}"',
tag: 'RadioSettings',
);
_latController.text = _fetchedSettings['lat']!;
}
if (_fetchedSettings.containsKey('lon')) {
appLog.info('Setting lon to: "${_fetchedSettings['lon']}"', tag: 'RadioSettings');
appLog.info(
'Setting lon to: "${_fetchedSettings['lon']}"',
tag: 'RadioSettings',
);
_lonController.text = _fetchedSettings['lon']!;
}
@@ -230,12 +248,14 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
_fetchedSettings['advert.interval']!,
_advertInterval,
);
_advertEnable = _advertInterval > 0;
}
if (_fetchedSettings.containsKey('flood.advert.interval')) {
_floodAdvertInterval = _parseIntWithFallback(
_fetchedSettings['flood.advert.interval']!,
_floodAdvertInterval,
);
_floodAdvertEnable = _floodAdvertInterval > 0;
}
if (_fetchedSettings.containsKey('priv.advert.interval')) {
_privAdvertInterval = _parseIntWithFallback(
@@ -268,7 +288,10 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
void _applySettingResponse(String command, String response) {
final appLog = Provider.of<AppDebugLogService>(context, listen: false);
appLog.info('Command: "$command", Raw response: "$response"', tag: 'RadioSettings');
appLog.info(
'Command: "$command", Raw response: "$response"',
tag: 'RadioSettings',
);
final value = _extractCliValue(response);
appLog.info('Extracted value: "$value"', tag: 'RadioSettings');
if (value == null) return;
@@ -280,7 +303,10 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
// Validate response content matches expected format for the command
// This prevents mismatched responses over LoRa where order isn't guaranteed
if (!_validateResponseForCommand(key, value)) {
appLog.warn('Response "$value" does not match expected format for "$key", ignoring', tag: 'RadioSettings');
appLog.warn(
'Response "$value" does not match expected format for "$key", ignoring',
tag: 'RadioSettings',
);
return;
}
@@ -311,7 +337,9 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
// Must have at least 3 commas and start with a frequency-like number
final parts = value.split(',');
if (parts.length < 4) return false;
final freq = double.tryParse(parts[0].replaceAll(RegExp(r'[^0-9.]'), ''));
final freq = double.tryParse(
parts[0].replaceAll(RegExp(r'[^0-9.]'), ''),
);
// Frequency should be in reasonable LoRa range (300-2500 MHz)
return freq != null && freq >= 300 && freq <= 2500;
@@ -339,22 +367,33 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
case 'privacy':
// Boolean values: on/off/true/false/1/0/enabled/disabled
final lower = value.toLowerCase().trim();
return ['on', 'off', 'true', 'false', '1', '0', 'enabled', 'disabled'].contains(lower);
return [
'on',
'off',
'true',
'false',
'1',
'0',
'enabled',
'disabled',
].contains(lower);
case 'advert.interval':
case 'flood.advert.interval':
case 'priv.advert.interval':
// Interval: positive integer
// Interval: non-negative integer (0 means disabled)
if (value.contains(',')) return false;
final interval = int.tryParse(value.replaceAll(RegExp(r'[^0-9]'), ''));
return interval != null && interval > 0;
return interval != null && interval >= 0;
case 'name':
// Name: any non-empty string, but should NOT look like radio settings
if (value.isEmpty) return false;
// If it has 3+ commas and looks like numbers, probably radio data
final commaCount = ','.allMatches(value).length;
if (commaCount >= 3 && RegExp(r'^[\d.,\s]+$').hasMatch(value)) return false;
if (commaCount >= 3 && RegExp(r'^[\d.,\s]+$').hasMatch(value)) {
return false;
}
return true;
default:
@@ -551,7 +590,9 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
final freqMHz = double.tryParse(_freqController.text);
if (freqMHz != null) {
final bwKHz = _bandwidth! / 1000;
commands.add('set radio ${freqMHz.toStringAsFixed(1)} $bwKHz $_spreadingFactor $_codingRate');
commands.add(
'set radio ${freqMHz.toStringAsFixed(1)} $bwKHz $_spreadingFactor $_codingRate',
);
}
}
@@ -590,7 +631,9 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
timestampSeconds: timestampSeconds,
);
await connector.sendFrame(frame);
await Future.delayed(const Duration(milliseconds: 200)); // Delay between commands
await Future.delayed(
const Duration(milliseconds: 200),
); // Delay between commands
}
setState(() {
@@ -614,7 +657,9 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.repeater_errorSavingSettings(e.toString())),
content: Text(
context.l10n.repeater_errorSavingSettings(e.toString()),
),
backgroundColor: Colors.red,
),
);
@@ -699,7 +744,10 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
Text(l10n.repeater_settingsTitle),
Text(
repeater.name,
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.normal),
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.normal,
),
),
],
),
@@ -723,12 +771,20 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
value: 'auto',
child: Row(
children: [
Icon(Icons.auto_mode, size: 20, color: !isFloodMode ? Theme.of(context).primaryColor : null),
Icon(
Icons.auto_mode,
size: 20,
color: !isFloodMode
? Theme.of(context).primaryColor
: null,
),
const SizedBox(width: 8),
Text(
l10n.repeater_autoUseSavedPath,
style: TextStyle(
fontWeight: !isFloodMode ? FontWeight.bold : FontWeight.normal,
fontWeight: !isFloodMode
? FontWeight.bold
: FontWeight.normal,
),
),
],
@@ -738,12 +794,20 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
value: 'flood',
child: Row(
children: [
Icon(Icons.waves, size: 20, color: isFloodMode ? Theme.of(context).primaryColor : null),
Icon(
Icons.waves,
size: 20,
color: isFloodMode
? Theme.of(context).primaryColor
: null,
),
const SizedBox(width: 8),
Text(
l10n.repeater_forceFloodMode,
style: TextStyle(
fontWeight: isFloodMode ? FontWeight.bold : FontWeight.normal,
fontWeight: isFloodMode
? FontWeight.bold
: FontWeight.normal,
),
),
],
@@ -754,7 +818,8 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
IconButton(
icon: const Icon(Icons.timeline),
tooltip: l10n.repeater_pathManagement,
onPressed: () => PathManagementDialog.show(context, contact: repeater),
onPressed: () =>
PathManagementDialog.show(context, contact: repeater),
),
if (_hasChanges)
TextButton.icon(
@@ -865,7 +930,9 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
border: const OutlineInputBorder(),
suffixText: 'MHz',
),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
),
onChanged: (_) => _markChanged(),
),
const SizedBox(height: 16),
@@ -923,10 +990,7 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
border: const OutlineInputBorder(),
),
items: _spreadingFactorOptions.map((sf) {
return DropdownMenuItem(
value: sf,
child: Text('SF$sf'),
);
return DropdownMenuItem(value: sf, child: Text('SF$sf'));
}).toList(),
onChanged: (value) {
if (value != null) {
@@ -945,10 +1009,7 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
border: const OutlineInputBorder(),
),
items: _codingRateOptions.map((cr) {
return DropdownMenuItem(
value: cr,
child: Text('4/$cr'),
);
return DropdownMenuItem(value: cr, child: Text('4/$cr'));
}).toList(),
onChanged: (value) {
if (value != null) {
@@ -988,7 +1049,10 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
helperText: l10n.repeater_latitudeHelper,
border: const OutlineInputBorder(),
),
keyboardType: const TextInputType.numberWithOptions(decimal: true, signed: true),
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
signed: true,
),
onChanged: (_) => _markChanged(),
),
const SizedBox(height: 16),
@@ -999,7 +1063,10 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
helperText: l10n.repeater_longitudeHelper,
border: const OutlineInputBorder(),
),
keyboardType: const TextInputType.numberWithOptions(decimal: true, signed: true),
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
signed: true,
),
onChanged: (_) => _markChanged(),
),
],
@@ -1018,11 +1085,17 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
children: [
Row(
children: [
Icon(Icons.toggle_on, color: Theme.of(context).textTheme.headlineSmall?.color),
Icon(
Icons.toggle_on,
color: Theme.of(context).textTheme.headlineSmall?.color,
),
const SizedBox(width: 8),
Text(
l10n.repeater_features,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
],
),
@@ -1102,7 +1175,7 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
)
: const Icon(Icons.refresh, size: 20),
onPressed: isRefreshing ? null : onRefresh,
tooltip: refreshTooltip,
@@ -1130,40 +1203,72 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
const Divider(),
ListTile(
title: Text(l10n.repeater_localAdvertInterval),
subtitle: Text(l10n.repeater_localAdvertIntervalMinutes(_advertInterval)),
trailing: Text(l10n.repeater_localAdvertIntervalMinutes(_advertInterval)),
subtitle: Text(
l10n.repeater_localAdvertIntervalMinutes(_advertInterval),
),
trailing: Switch(
value: _advertEnable,
onChanged: (value) {
setState(() {
_advertInterval = value ? 60 : 0;
_advertEnable = value;
});
_markChanged();
},
),
),
Slider(
value: _advertInterval.toDouble(),
value: _advertInterval == 0
? 60.toDouble()
: _advertInterval.toDouble(),
min: 60,
max: 240,
divisions: 18,
label: l10n.repeater_localAdvertIntervalMinutes(_advertInterval),
onChanged: (value) {
setState(() {
_advertInterval = value.toInt();
});
_markChanged();
},
onChanged: _advertEnable
? (value) {
setState(() {
_advertInterval = value.toInt();
});
_markChanged();
}
: null,
),
const SizedBox(height: 16),
ListTile(
title: Text(l10n.repeater_floodAdvertInterval),
subtitle: Text(l10n.repeater_floodAdvertIntervalHours(_floodAdvertInterval)),
trailing: Text(l10n.repeater_floodAdvertIntervalHours(_floodAdvertInterval)),
subtitle: Text(
l10n.repeater_floodAdvertIntervalHours(_floodAdvertInterval),
),
trailing: Switch(
value: _floodAdvertEnable,
onChanged: (value) {
setState(() {
_floodAdvertInterval = value ? 3 : 0;
_floodAdvertEnable = value;
});
_markChanged();
},
),
),
Slider(
value: _floodAdvertInterval.toDouble(),
value: _floodAdvertInterval == 0
? 3.toDouble()
: _floodAdvertInterval.toDouble(),
min: 3,
max: 48,
divisions: 45,
label: l10n.repeater_floodAdvertIntervalHours(_floodAdvertInterval),
onChanged: (value) {
setState(() {
_floodAdvertInterval = value.toInt();
});
_markChanged();
},
max: 168,
divisions: 165,
label: l10n.repeater_floodAdvertIntervalHours(
_floodAdvertInterval,
),
onChanged: _floodAdvertEnable
? (value) {
setState(() {
_floodAdvertInterval = value.toInt();
});
_markChanged();
}
: null,
),
// Encrypted advertisement interval - hidden until privacy mode is implemented
// if (_privacyMode) ...[
@@ -1220,10 +1325,15 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
const Divider(),
ListTile(
leading: Icon(Icons.refresh, color: colorScheme.onErrorContainer),
title: Text(l10n.repeater_rebootRepeater, style: TextStyle(color: colorScheme.onErrorContainer)),
title: Text(
l10n.repeater_rebootRepeater,
style: TextStyle(color: colorScheme.onErrorContainer),
),
subtitle: Text(
l10n.repeater_rebootRepeaterSubtitle,
style: TextStyle(color: colorScheme.onErrorContainer.withValues(alpha: 0.8)),
style: TextStyle(
color: colorScheme.onErrorContainer.withValues(alpha: 0.8),
),
),
onTap: () => _confirmAction(
l10n.repeater_rebootRepeater,
@@ -1246,11 +1356,19 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
// ),
// ),
ListTile(
leading: Icon(Icons.delete_forever, color: colorScheme.onErrorContainer),
title: Text(l10n.repeater_eraseFileSystem, style: TextStyle(color: colorScheme.onErrorContainer)),
leading: Icon(
Icons.delete_forever,
color: colorScheme.onErrorContainer,
),
title: Text(
l10n.repeater_eraseFileSystem,
style: TextStyle(color: colorScheme.onErrorContainer),
),
subtitle: Text(
l10n.repeater_eraseFileSystemSubtitle,
style: TextStyle(color: colorScheme.onErrorContainer.withValues(alpha: 0.8)),
style: TextStyle(
color: colorScheme.onErrorContainer.withValues(alpha: 0.8),
),
),
onTap: () => _confirmAction(
l10n.repeater_eraseFileSystem,
@@ -1272,9 +1390,9 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
if (command == 'erase') {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.repeater_eraseSerialOnly)),
);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(l10n.repeater_eraseSerialOnly)));
}
return;
}
+68 -18
View File
@@ -28,7 +28,8 @@ class RepeaterStatusScreen extends StatefulWidget {
class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
static const int _statusPayloadOffset = 8;
static const int _statusStatsSize = 52;
static const int _statusResponseBytes = _statusPayloadOffset + _statusStatsSize;
static const int _statusResponseBytes =
_statusPayloadOffset + _statusStatsSize;
bool _isLoading = false;
StreamSubscription<Uint8List>? _frameSubscription;
@@ -293,7 +294,9 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.repeater_errorLoadingStatus(e.toString())),
content: Text(
context.l10n.repeater_errorLoadingStatus(e.toString()),
),
backgroundColor: Colors.red,
),
);
@@ -327,7 +330,10 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
Text(l10n.repeater_statusTitle),
Text(
repeater.name,
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.normal),
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.normal,
),
),
],
),
@@ -348,12 +354,20 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
value: 'auto',
child: Row(
children: [
Icon(Icons.auto_mode, size: 20, color: !isFloodMode ? Theme.of(context).primaryColor : null),
Icon(
Icons.auto_mode,
size: 20,
color: !isFloodMode
? Theme.of(context).primaryColor
: null,
),
const SizedBox(width: 8),
Text(
l10n.repeater_autoUseSavedPath,
style: TextStyle(
fontWeight: !isFloodMode ? FontWeight.bold : FontWeight.normal,
fontWeight: !isFloodMode
? FontWeight.bold
: FontWeight.normal,
),
),
],
@@ -363,12 +377,20 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
value: 'flood',
child: Row(
children: [
Icon(Icons.waves, size: 20, color: isFloodMode ? Theme.of(context).primaryColor : null),
Icon(
Icons.waves,
size: 20,
color: isFloodMode
? Theme.of(context).primaryColor
: null,
),
const SizedBox(width: 8),
Text(
l10n.repeater_forceFloodMode,
style: TextStyle(
fontWeight: isFloodMode ? FontWeight.bold : FontWeight.normal,
fontWeight: isFloodMode
? FontWeight.bold
: FontWeight.normal,
),
),
],
@@ -379,7 +401,8 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
IconButton(
icon: const Icon(Icons.timeline),
tooltip: l10n.repeater_pathManagement,
onPressed: () => PathManagementDialog.show(context, contact: repeater),
onPressed: () =>
PathManagementDialog.show(context, contact: repeater),
),
IconButton(
icon: _isLoading
@@ -423,11 +446,17 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
children: [
Row(
children: [
Icon(Icons.info_outline, color: Theme.of(context).textTheme.headlineSmall?.color),
Icon(
Icons.info_outline,
color: Theme.of(context).textTheme.headlineSmall?.color,
),
const SizedBox(width: 8),
Text(
l10n.repeater_systemInformation,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
],
),
@@ -453,18 +482,30 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
children: [
Row(
children: [
Icon(Icons.radio, color: Theme.of(context).textTheme.headlineSmall?.color),
Icon(
Icons.radio,
color: Theme.of(context).textTheme.headlineSmall?.color,
),
const SizedBox(width: 8),
Text(
l10n.repeater_radioStatistics,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
],
),
const Divider(),
_buildInfoRow(l10n.repeater_lastRssi, _formatValue(_lastRssi, suffix: ' dB')),
_buildInfoRow(
l10n.repeater_lastRssi,
_formatValue(_lastRssi, suffix: ' dB'),
),
_buildInfoRow(l10n.repeater_lastSnr, _formatSnr(_lastSnr)),
_buildInfoRow(l10n.repeater_noiseFloor, _formatValue(_noiseFloor, suffix: ' dB')),
_buildInfoRow(
l10n.repeater_noiseFloor,
_formatValue(_noiseFloor, suffix: ' dB'),
),
_buildInfoRow(l10n.repeater_txAirtime, _formatDuration(_txAirSecs)),
_buildInfoRow(l10n.repeater_rxAirtime, _formatDuration(_rxAirSecs)),
],
@@ -483,11 +524,17 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
children: [
Row(
children: [
Icon(Icons.analytics, color: Theme.of(context).textTheme.headlineSmall?.color),
Icon(
Icons.analytics,
color: Theme.of(context).textTheme.headlineSmall?.color,
),
const SizedBox(width: 8),
Text(
l10n.repeater_packetStatistics,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
],
),
@@ -561,7 +608,8 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
if (_statusRequestedAt == null) return '';
final dt = _statusRequestedAt!;
final date = '${dt.day}/${dt.month}/${dt.year}';
final time = '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}';
final time =
'${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}';
return '$date $time';
}
@@ -598,7 +646,9 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
final direct = _formatValue(_dupDirect);
return l10n.repeater_duplicatesFloodDirect(flood, direct);
}
if (_packetsRecv == null || _floodRx == null || _directRx == null) return '';
if (_packetsRecv == null || _floodRx == null || _directRx == null) {
return '';
}
final dupTotal = _packetsRecv! - _floodRx! - _directRx!;
if (dupTotal < 0) return '';
return l10n.repeater_duplicatesTotal(dupTotal);
+18 -23
View File
@@ -23,22 +23,21 @@ class _ScannerScreenState extends State<ScannerScreen> {
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) {
} else if (connector.state == MeshCoreConnectionState.connected &&
!_changedNavigation) {
_changedNavigation = true;
if (mounted) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => const ContactsScreen(),
),
MaterialPageRoute(builder: (context) => const ContactsScreen()),
);
}
}
};
connector.addListener(_connectionListener);
}
@@ -67,9 +66,7 @@ class _ScannerScreenState extends State<ScannerScreen> {
_buildStatusBar(context, connector),
// Device list
Expanded(
child: _buildDeviceList(context, connector),
),
Expanded(child: _buildDeviceList(context, connector)),
],
);
},
@@ -77,8 +74,9 @@ class _ScannerScreenState extends State<ScannerScreen> {
),
floatingActionButton: Consumer<MeshCoreConnector>(
builder: (context, connector, child) {
final isScanning = connector.state == MeshCoreConnectionState.scanning;
final isScanning =
connector.state == MeshCoreConnectionState.scanning;
return FloatingActionButton.extended(
onPressed: () {
if (isScanning) {
@@ -87,7 +85,7 @@ class _ScannerScreenState extends State<ScannerScreen> {
connector.startScan();
}
},
icon: isScanning
icon: isScanning
? const SizedBox(
width: 20,
height: 20,
@@ -97,7 +95,11 @@ class _ScannerScreenState extends State<ScannerScreen> {
),
)
: const Icon(Icons.bluetooth_searching),
label: Text(isScanning ? context.l10n.scanner_stop : context.l10n.scanner_scan),
label: Text(
isScanning
? context.l10n.scanner_stop
: context.l10n.scanner_scan,
),
);
},
),
@@ -108,7 +110,7 @@ class _ScannerScreenState extends State<ScannerScreen> {
String statusText;
Color statusColor;
final l10n = context.l10n;
final l10n = context.l10n;
switch (connector.state) {
case MeshCoreConnectionState.scanning:
statusText = l10n.scanner_scanning;
@@ -155,20 +157,13 @@ final l10n = context.l10n;
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.bluetooth,
size: 64,
color: Colors.grey[400],
),
Icon(Icons.bluetooth, size: 64, color: Colors.grey[400]),
const SizedBox(height: 16),
Text(
connector.state == MeshCoreConnectionState.scanning
? context.l10n.scanner_searchingDevices
: context.l10n.scanner_tapToScan,
style: TextStyle(
fontSize: 16,
color: Colors.grey[600],
),
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
),
],
),
+119 -6
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:meshcore_open/utils/gpx_export.dart';
import 'package:meshcore_open/widgets/elements_ui.dart';
import 'package:provider/provider.dart';
import 'package:package_info_plus/package_info_plus.dart';
@@ -57,6 +58,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
const SizedBox(height: 16),
_buildDebugCard(context),
const SizedBox(height: 16),
_buildExportCard(connector),
const SizedBox(height: 16),
_buildAboutCard(context),
],
);
@@ -442,7 +445,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
bool isGPSEnabled = customVars["gps"] == "1";
// Read current interval or default to 900 (15 minutes)
final currentInterval = int.tryParse(customVars["gps_interval"] ?? "") ?? 900;
final currentInterval =
int.tryParse(customVars["gps_interval"] ?? "") ?? 900;
intervalController.text = currentInterval.toString();
showDialog(
@@ -683,6 +687,110 @@ class _SettingsScreenState extends State<SettingsScreen> {
],
);
}
_gpxExport(
GpxExport exporter,
String name,
String description,
String filename,
String shareText,
String subject,
) async {
final l10n = context.l10n;
final result = await exporter.exportGPX(
name,
description,
filename,
shareText,
subject,
);
if (!mounted) return;
switch (result) {
case gpxExportSuccess:
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(l10n.settings_gpxExportSuccess)));
case gpxExportNoContacts:
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.settings_gpxExportNoContacts)),
);
break;
case gpxExportNotAvailable:
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.settings_gpxExportNotAvailable)),
);
break;
case gpxExportFailed:
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(l10n.settings_gpxExportError)));
break;
}
}
_buildExportCard(MeshCoreConnector connector) {
final l10n = context.l10n;
return Card(
child: Column(
children: [
ListTile(
leading: const Icon(Icons.download_outlined),
title: Text(l10n.settings_gpxExportRepeaters),
subtitle: Text(l10n.settings_gpxExportRepeatersSubtitle),
trailing: const Icon(Icons.chevron_right),
onTap: () async {
final exporter = GpxExport(connector);
exporter.addRepeaters();
_gpxExport(
exporter,
l10n.map_repeater,
l10n.settings_gpxExportRepeatersRoom,
"meshcore_repeaters_",
l10n.settings_gpxExportShareText,
l10n.settings_gpxExportShareSubject,
);
},
),
ListTile(
leading: const Icon(Icons.download_outlined),
title: Text(l10n.settings_gpxExportContacts),
subtitle: Text(l10n.settings_gpxExportContactsSubtitle),
trailing: const Icon(Icons.chevron_right),
onTap: () async {
final exporter = GpxExport(connector);
exporter.addContacts();
_gpxExport(
exporter,
l10n.map_repeater,
l10n.settings_gpxExportChat,
"meshcore_contacts_",
l10n.settings_gpxExportShareText,
l10n.settings_gpxExportShareSubject,
);
},
),
ListTile(
leading: const Icon(Icons.download_outlined),
title: Text(l10n.settings_gpxExportAll),
subtitle: Text(l10n.settings_gpxExportAllSubtitle),
trailing: const Icon(Icons.chevron_right),
onTap: () async {
final exporter = GpxExport(connector);
exporter.addAll();
_gpxExport(
exporter,
l10n.map_repeater,
l10n.settings_gpxExportAllContacts,
"meshcore_all_",
l10n.settings_gpxExportShareText,
l10n.settings_gpxExportShareSubject,
);
},
),
],
),
);
}
}
class _RadioSettingsDialog extends StatefulWidget {
@@ -780,10 +888,13 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> {
return;
}
if (txPower == null || txPower < 0 || txPower > 22) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(l10n.settings_txPowerInvalid)));
final maxTxPower = widget.connector.maxTxPower ?? 22;
if (txPower == null || txPower < 0 || txPower > maxTxPower) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('${l10n.settings_txPowerInvalid} (0-$maxTxPower dBm)'),
),
);
return;
}
@@ -932,7 +1043,9 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> {
decoration: InputDecoration(
labelText: l10n.settings_txPower,
border: const OutlineInputBorder(),
helperText: l10n.settings_txPowerHelper,
helperText: widget.connector.maxTxPower != null
? '${l10n.settings_txPowerHelper} (max: ${widget.connector.maxTxPower} dBm)'
: l10n.settings_txPowerHelper,
),
keyboardType: TextInputType.number,
),
+6 -6
View File
@@ -1,10 +1,6 @@
import 'package:flutter/foundation.dart';
enum AppDebugLogLevel {
info,
warning,
error,
}
enum AppDebugLogLevel { info, warning, error }
class AppDebugLogEntry {
final DateTime timestamp;
@@ -51,7 +47,11 @@ class AppDebugLogService extends ChangeNotifier {
notifyListeners();
}
void log(String message, {String tag = 'App', AppDebugLogLevel level = AppDebugLogLevel.info}) {
void log(
String message, {
String tag = 'App',
AppDebugLogLevel level = AppDebugLogLevel.info,
}) {
if (!_enabled) return;
_entries.add(
+11 -7
View File
@@ -82,10 +82,7 @@ class AppSettingsService extends ChangeNotifier {
final safeMin = minZoom <= maxZoom ? minZoom : maxZoom;
final safeMax = minZoom <= maxZoom ? maxZoom : minZoom;
await updateSettings(
_settings.copyWith(
mapCacheMinZoom: safeMin,
mapCacheMaxZoom: safeMax,
),
_settings.copyWith(mapCacheMinZoom: safeMin, mapCacheMaxZoom: safeMax),
);
}
@@ -123,9 +120,16 @@ class AppSettingsService extends ChangeNotifier {
appLogger.setEnabled(value);
}
Future<void> setBatteryChemistryForDevice(String deviceId, String chemistry) async {
final updated = Map<String, String>.from(_settings.batteryChemistryByDeviceId);
Future<void> setBatteryChemistryForDevice(
String deviceId,
String chemistry,
) async {
final updated = Map<String, String>.from(
_settings.batteryChemistryByDeviceId,
);
updated[deviceId] = chemistry;
await updateSettings(_settings.copyWith(batteryChemistryByDeviceId: updated));
await updateSettings(
_settings.copyWith(batteryChemistryByDeviceId: updated),
);
}
}
+4
View File
@@ -156,6 +156,8 @@ class BleDebugLogService extends ChangeNotifier {
return 'CMD_GET_RADIO_SETTINGS';
case cmdSetCustomVar:
return 'CMD_SET_CUSTOM_VAR';
case cmdSendTracePath:
return 'CMD_SEND_TRACE_PATH';
default:
return null;
}
@@ -195,6 +197,8 @@ class BleDebugLogService extends ChangeNotifier {
return 'RESP_CODE_CHANNEL_INFO';
case respCodeRadioSettings:
return 'RESP_CODE_RADIO_SETTINGS';
case pushCodeTraceData:
return 'PUSH_CODE_TRACE_DATA';
default:
return null;
}
+29 -26
View File
@@ -42,20 +42,21 @@ class MapTileCacheService {
late final TileProvider tileProvider;
MapTileCacheService({BaseCacheManager? cacheManager})
: cacheManager = cacheManager ??
CacheManager(
Config(
cacheKey,
stalePeriod: const Duration(days: 365),
maxNrOfCacheObjects: 200000,
),
) {
: cacheManager =
cacheManager ??
CacheManager(
Config(
cacheKey,
stalePeriod: const Duration(days: 365),
maxNrOfCacheObjects: 200000,
),
) {
tileProvider = CachedNetworkTileProvider(cacheManager: this.cacheManager);
}
Map<String, String> get defaultHeaders => {
'User-Agent': 'flutter_map ($userAgentPackageName)',
};
'User-Agent': 'flutter_map ($userAgentPackageName)',
};
Future<void> clearCache() async {
await cacheManager.emptyCache();
@@ -96,17 +97,21 @@ class MapTileCacheService {
final future = cacheManager
.downloadFile(url, key: url, authHeaders: authHeaders)
.then((_) {
completed += 1;
}).catchError((_) {
completed += 1;
failed += 1;
}).whenComplete(() {
onProgress?.call(MapTileCacheProgress(
completed: completed,
total: total,
failed: failed,
));
});
completed += 1;
})
.catchError((_) {
completed += 1;
failed += 1;
})
.whenComplete(() {
onProgress?.call(
MapTileCacheProgress(
completed: completed,
total: total,
failed: failed,
),
);
});
pending.add(future);
if (pending.length >= safeConcurrency) {
@@ -189,11 +194,9 @@ class MapTileCacheService {
int _latToTileY(double lat, int zoom, int maxIndex) {
final n = 1 << zoom;
final rad = lat * math.pi / 180.0;
final value = ((1 -
math.log(math.tan(rad) + 1 / math.cos(rad)) / math.pi) /
2 *
n)
.floor();
final value =
((1 - math.log(math.tan(rad) + 1 / math.cos(rad)) / math.pi) / 2 * n)
.floor();
return value.clamp(0, maxIndex);
}
+140 -57
View File
@@ -25,10 +25,7 @@ class _AckHashMapping {
final String messageId;
final DateTime timestamp;
_AckHashMapping({
required this.messageId,
required this.timestamp,
});
_AckHashMapping({required this.messageId, required this.timestamp});
}
class MessageRetryService extends ChangeNotifier {
@@ -39,11 +36,16 @@ class MessageRetryService extends ChangeNotifier {
final Map<String, Message> _pendingMessages = {};
final Map<String, Contact> _pendingContacts = {};
final Map<String, PathSelection> _pendingPathSelections = {};
final Map<String, _AckHashMapping> _ackHashToMessageId = {}; // ackHashHex messageId + timestamp for O(1) lookup
final Map<String, List<Uint8List>> _expectedAckHashes = {}; // Track all expected ACKs for retries (for history)
final List<_AckHistoryEntry> _ackHistory = []; // Rolling buffer of recent ACK hashes
final Map<String, List<String>> _pendingMessageQueuePerContact = {}; // contactPubKeyHex FIFO queue of messageIds (DEPRECATED - will be removed)
final Map<String, String> _expectedHashToMessageId = {}; // expectedAckHashHex messageId (for matching RESP_CODE_SENT by hash)
final Map<String, _AckHashMapping> _ackHashToMessageId =
{}; // ackHashHex messageId + timestamp for O(1) lookup
final Map<String, List<Uint8List>> _expectedAckHashes =
{}; // Track all expected ACKs for retries (for history)
final List<_AckHistoryEntry> _ackHistory =
[]; // Rolling buffer of recent ACK hashes
final Map<String, List<String>> _pendingMessageQueuePerContact =
{}; // contactPubKeyHex FIFO queue of messageIds (DEPRECATED - will be removed)
final Map<String, String> _expectedHashToMessageId =
{}; // expectedAckHashHex messageId (for matching RESP_CODE_SENT by hash)
Function(Contact, String, int, int)? _sendMessageCallback;
Function(String, Message)? _addMessageCallback;
@@ -130,7 +132,8 @@ class MessageRetryService extends ChangeNotifier {
final messagePathBytes =
pathBytes ?? _resolveMessagePathBytes(contact, useFlood, pathSelection);
final messagePathLength =
pathLength ?? _resolveMessagePathLength(contact, useFlood, pathSelection);
pathLength ??
_resolveMessagePathLength(contact, useFlood, pathSelection);
final message = Message(
senderKey: contact.publicKey,
text: text,
@@ -167,15 +170,25 @@ class MessageRetryService extends ChangeNotifier {
if (_setContactPathCallback != null && _clearContactPathCallback != null) {
if (message.pathLength != null && message.pathLength! < 0) {
// Flood mode - clear the path
debugPrint('Setting flood mode for retry attempt ${message.retryCount}');
debugPrint(
'Setting flood mode for retry attempt ${message.retryCount}',
);
_clearContactPathCallback!(contact);
} else if (message.pathLength != null && message.pathLength! >= 0) {
// Specific path (including direct neighbor with pathLength=0)
final pathStr = message.pathBytes.isEmpty
? 'direct'
: message.pathBytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(',');
debugPrint('Setting path [$pathStr] (${message.pathLength} hops) for retry attempt ${message.retryCount}');
await _setContactPathCallback!(contact, message.pathBytes, message.pathLength!);
: message.pathBytes
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join(',');
debugPrint(
'Setting path [$pathStr] (${message.pathLength} hops) for retry attempt ${message.retryCount}',
);
await _setContactPathCallback!(
contact,
message.pathBytes,
message.pathLength!,
);
}
}
@@ -186,22 +199,30 @@ class MessageRetryService extends ChangeNotifier {
// IMPORTANT: Use the transformed text (with SMAZ encoding if enabled) to match device's hash
final selfPubKey = _getSelfPublicKeyCallback?.call();
if (selfPubKey != null) {
final outboundText = _prepareContactOutboundTextCallback?.call(contact, message.text) ?? message.text;
final outboundText =
_prepareContactOutboundTextCallback?.call(contact, message.text) ??
message.text;
final expectedHash = MessageRetryService.computeExpectedAckHash(
timestampSeconds,
attempt,
outboundText,
selfPubKey,
);
final expectedHashHex = expectedHash.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
final expectedHashHex = expectedHash
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join();
_expectedHashToMessageId[expectedHashHex] = messageId;
final shortText = message.text.length > 20 ? '${message.text.substring(0, 20)}...' : message.text;
final shortText = message.text.length > 20
? '${message.text.substring(0, 20)}...'
: message.text;
_debugLogService?.info(
'Sent "$shortText" to ${contact.name} → expect ACK hash $expectedHashHex (attempt $attempt)',
tag: 'AckHash',
);
debugPrint('Computed expected ACK hash $expectedHashHex for message $messageId');
debugPrint(
'Computed expected ACK hash $expectedHashHex for message $messageId',
);
}
// DEPRECATED: Old queue-based matching (kept for fallback)
@@ -209,17 +230,14 @@ class MessageRetryService extends ChangeNotifier {
_pendingMessageQueuePerContact[contact.publicKeyHex]!.add(messageId);
if (_sendMessageCallback != null) {
_sendMessageCallback!(
contact,
message.text,
attempt,
timestampSeconds,
);
_sendMessageCallback!(contact, message.text, attempt, timestampSeconds);
}
}
void updateMessageFromSent(Uint8List ackHash, int timeoutMs) {
final ackHashHex = ackHash.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
final ackHashHex = ackHash
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join();
// NEW: Try hash-based matching first (fixes LoRa message drops causing mismatches)
String? messageId = _expectedHashToMessageId.remove(ackHashHex);
@@ -230,16 +248,21 @@ class MessageRetryService extends ChangeNotifier {
final message = _pendingMessages[messageId];
if (contact != null && message != null) {
final shortText = message.text.length > 20 ? '${message.text.substring(0, 20)}...' : message.text;
final shortText = message.text.length > 20
? '${message.text.substring(0, 20)}...'
: message.text;
_debugLogService?.info(
'RESP_CODE_SENT received: ACK hash $ackHashHex ✓ matched "$shortText" to ${contact.name}',
tag: 'AckHash',
);
debugPrint('Hash-based match: ACK hash $ackHashHex → message $messageId');
debugPrint(
'Hash-based match: ACK hash $ackHashHex → message $messageId',
);
// Remove from old queue since we matched
_pendingMessageQueuePerContact[contact.publicKeyHex]?.remove(messageId);
if (_pendingMessageQueuePerContact[contact.publicKeyHex]?.isEmpty ?? false) {
if (_pendingMessageQueuePerContact[contact.publicKeyHex]?.isEmpty ??
false) {
_pendingMessageQueuePerContact.remove(contact.publicKeyHex);
}
} else {
@@ -259,7 +282,9 @@ class MessageRetryService extends ChangeNotifier {
'RESP_CODE_SENT: ACK hash $ackHashHex not found in hash table, falling back to queue',
tag: 'AckHash',
);
debugPrint('Hash-based match failed for $ackHashHex, falling back to queue-based matching');
debugPrint(
'Hash-based match failed for $ackHashHex, falling back to queue-based matching',
);
for (var entry in _pendingMessageQueuePerContact.entries) {
final contactKey = entry.key;
@@ -271,7 +296,9 @@ class MessageRetryService extends ChangeNotifier {
if (_pendingMessages.containsKey(candidateMessageId)) {
messageId = candidateMessageId;
contact = _pendingContacts[candidateMessageId];
debugPrint('Queue-based match (fallback): $ackHashHex → message $messageId for $contactKey');
debugPrint(
'Queue-based match (fallback): $ackHashHex → message $messageId for $contactKey',
);
break;
} else {
debugPrint('Dequeued stale message $candidateMessageId - skipping');
@@ -280,7 +307,9 @@ class MessageRetryService extends ChangeNotifier {
if (_pendingMessages.containsKey(nextMessageId)) {
messageId = nextMessageId;
contact = _pendingContacts[nextMessageId];
debugPrint('Queue-based match (fallback): $ackHashHex → message $messageId');
debugPrint(
'Queue-based match (fallback): $ackHashHex → message $messageId',
);
break;
}
}
@@ -306,16 +335,22 @@ class MessageRetryService extends ChangeNotifier {
final selection = _pendingPathSelections[messageId];
if (message == null) {
debugPrint('Message $messageId no longer pending for ACK hash: $ackHashHex');
debugPrint(
'Message $messageId no longer pending for ACK hash: $ackHashHex',
);
_ackHashToMessageId.remove(ackHashHex);
return;
}
// Add this ACK hash to the list of expected ACKs for this message (for history)
_expectedAckHashes[messageId] ??= [];
if (!_expectedAckHashes[messageId]!.any((hash) => listEquals(hash, ackHash))) {
if (!_expectedAckHashes[messageId]!.any(
(hash) => listEquals(hash, ackHash),
)) {
_expectedAckHashes[messageId]!.add(Uint8List.fromList(ackHash));
debugPrint('Added ACK hash $ackHashHex to message $messageId (total: ${_expectedAckHashes[messageId]!.length})');
debugPrint(
'Added ACK hash $ackHashHex to message $messageId (total: ${_expectedAckHashes[messageId]!.length})',
);
}
// Use device-provided timeout, or calculate from radio settings if timeout is 0 or invalid
@@ -330,8 +365,13 @@ class MessageRetryService extends ChangeNotifier {
} else {
pathLengthValue = contact.pathLength;
}
actualTimeout = _calculateTimeoutCallback!(pathLengthValue, message.text.length);
debugPrint('Using calculated timeout: ${actualTimeout}ms for path length $pathLengthValue');
actualTimeout = _calculateTimeoutCallback!(
pathLengthValue,
message.text.length,
);
debugPrint(
'Using calculated timeout: ${actualTimeout}ms for path length $pathLengthValue',
);
}
final updatedMessage = message.copyWith(
@@ -364,16 +404,22 @@ class MessageRetryService extends ChangeNotifier {
final selection = _pendingPathSelections[messageId];
if (message == null || contact == null) {
debugPrint('Timeout fired but message $messageId no longer pending (likely already delivered)');
debugPrint(
'Timeout fired but message $messageId no longer pending (likely already delivered)',
);
return;
}
final shortText = message.text.length > 20 ? '${message.text.substring(0, 20)}...' : message.text;
final shortText = message.text.length > 20
? '${message.text.substring(0, 20)}...'
: message.text;
_debugLogService?.warn(
'Timeout: No ACK received for "$shortText" to ${contact.name} (attempt ${message.retryCount}) → retrying',
tag: 'AckHash',
);
debugPrint('Timeout for message $messageId (retry ${message.retryCount}/${maxRetries - 1})');
debugPrint(
'Timeout for message $messageId (retry ${message.retryCount}/${maxRetries - 1})',
);
if (message.retryCount < maxRetries - 1) {
final backoffMs = 1000 * (1 << message.retryCount);
@@ -402,7 +448,9 @@ class MessageRetryService extends ChangeNotifier {
if (_pendingMessages.containsKey(messageId)) {
_attemptSend(messageId);
} else {
debugPrint('Retry cancelled: message $messageId was delivered while waiting');
debugPrint(
'Retry cancelled: message $messageId was delivered while waiting',
);
}
});
} else {
@@ -420,7 +468,8 @@ class MessageRetryService extends ChangeNotifier {
// Clean up the queue entry for this contact
_pendingMessageQueuePerContact[contact.publicKeyHex]?.remove(messageId);
if (_pendingMessageQueuePerContact[contact.publicKeyHex]?.isEmpty ?? false) {
if (_pendingMessageQueuePerContact[contact.publicKeyHex]?.isEmpty ??
false) {
_pendingMessageQueuePerContact.remove(contact.publicKeyHex);
}
@@ -430,7 +479,13 @@ class MessageRetryService extends ChangeNotifier {
_clearContactPathCallback!(contact);
}
_recordPathResultFromMessage(contact.publicKeyHex, message, selection, false, null);
_recordPathResultFromMessage(
contact.publicKeyHex,
message,
selection,
false,
null,
);
if (_updateMessageCallback != null) {
_updateMessageCallback!(failedMessage);
@@ -443,18 +498,22 @@ class MessageRetryService extends ChangeNotifier {
void _moveAckHashesToHistory(String messageId) {
final ackHashes = _expectedAckHashes.remove(messageId);
if (ackHashes != null && ackHashes.isNotEmpty) {
_ackHistory.add(_AckHistoryEntry(
messageId: messageId,
ackHashes: ackHashes,
timestamp: DateTime.now(),
));
_ackHistory.add(
_AckHistoryEntry(
messageId: messageId,
ackHashes: ackHashes,
timestamp: DateTime.now(),
),
);
// Trim history to max size (rolling buffer)
while (_ackHistory.length > maxAckHistorySize) {
_ackHistory.removeAt(0);
}
debugPrint('Moved ${ackHashes.length} ACK hashes to history for message $messageId (history size: ${_ackHistory.length})');
debugPrint(
'Moved ${ackHashes.length} ACK hashes to history for message $messageId (history size: ${_ackHistory.length})',
);
}
}
@@ -462,7 +521,9 @@ class MessageRetryService extends ChangeNotifier {
for (final entry in _ackHistory) {
for (final expectedHash in entry.ackHashes) {
if (listEquals(expectedHash, ackHash)) {
debugPrint('Found ACK match in history: messageId=${entry.messageId}, age=${DateTime.now().difference(entry.timestamp).inSeconds}s');
debugPrint(
'Found ACK match in history: messageId=${entry.messageId}, age=${DateTime.now().difference(entry.timestamp).inSeconds}s',
);
return true;
}
}
@@ -472,7 +533,9 @@ class MessageRetryService extends ChangeNotifier {
void handleAckReceived(Uint8List ackHash, int tripTimeMs) {
String? matchedMessageId;
final ackHashHex = ackHash.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
final ackHashHex = ackHash
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join();
debugPrint('ACK received: $ackHashHex, trip time: ${tripTimeMs}ms');
@@ -502,7 +565,9 @@ class MessageRetryService extends ChangeNotifier {
tag: 'AckHash',
);
// Fallback: Check against ALL expected ACK hashes (from all retry attempts)
debugPrint('ACK not in mapping, checking _expectedAckHashes (${_expectedAckHashes.length} messages)');
debugPrint(
'ACK not in mapping, checking _expectedAckHashes (${_expectedAckHashes.length} messages)',
);
for (var entry in _expectedAckHashes.entries) {
final messageId = entry.key;
final expectedHashes = entry.value;
@@ -510,7 +575,9 @@ class MessageRetryService extends ChangeNotifier {
for (final expectedHash in expectedHashes) {
if (listEquals(expectedHash, ackHash)) {
matchedMessageId = messageId;
debugPrint('Matched ACK to message via fallback: $matchedMessageId (attempt ${expectedHashes.indexOf(expectedHash)})');
debugPrint(
'Matched ACK to message via fallback: $matchedMessageId (attempt ${expectedHashes.indexOf(expectedHash)})',
);
break;
}
}
@@ -524,7 +591,9 @@ class MessageRetryService extends ChangeNotifier {
final contact = _pendingContacts[matchedMessageId];
final selection = _pendingPathSelections[matchedMessageId];
final shortText = message.text.length > 20 ? '${message.text.substring(0, 20)}...' : message.text;
final shortText = message.text.length > 20
? '${message.text.substring(0, 20)}...'
: message.text;
_debugLogService?.info(
'PUSH_CODE_SEND_CONFIRMED: ACK hash $ackHashHex ✓ "$shortText" delivered to ${contact?.name ?? "unknown"} in ${tripTimeMs}ms',
tag: 'AckHash',
@@ -549,8 +618,11 @@ class MessageRetryService extends ChangeNotifier {
// Clean up the queue entry for this contact (remove any remaining references to this message)
if (contact != null) {
_pendingMessageQueuePerContact[contact.publicKeyHex]?.remove(matchedMessageId);
if (_pendingMessageQueuePerContact[contact.publicKeyHex]?.isEmpty ?? false) {
_pendingMessageQueuePerContact[contact.publicKeyHex]?.remove(
matchedMessageId,
);
if (_pendingMessageQueuePerContact[contact.publicKeyHex]?.isEmpty ??
false) {
_pendingMessageQueuePerContact.remove(contact.publicKeyHex);
}
}
@@ -560,7 +632,13 @@ class MessageRetryService extends ChangeNotifier {
}
if (contact != null) {
_recordPathResultFromMessage(contact.publicKeyHex, message, selection, true, tripTimeMs);
_recordPathResultFromMessage(
contact.publicKeyHex,
message,
selection,
true,
tripTimeMs,
);
}
notifyListeners();
@@ -663,7 +741,12 @@ class MessageRetryService extends ChangeNotifier {
if (_recordPathResultCallback == null) return;
final recordSelection = selection ?? _selectionFromMessage(message);
if (recordSelection == null) return;
_recordPathResultCallback!(contactKey, recordSelection, success, tripTimeMs);
_recordPathResultCallback!(
contactKey,
recordSelection,
success,
tripTimeMs,
);
}
PathSelection? _selectionFromMessage(Message message) {
+274 -18
View File
@@ -1,18 +1,53 @@
import 'dart:ui';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:flutter/foundation.dart';
import '../l10n/app_localizations.dart';
class NotificationService {
static final NotificationService _instance = NotificationService._internal();
factory NotificationService() => _instance;
NotificationService._internal();
final FlutterLocalNotificationsPlugin _notifications = FlutterLocalNotificationsPlugin();
final FlutterLocalNotificationsPlugin _notifications =
FlutterLocalNotificationsPlugin();
bool _isInitialized = false;
// Locale for localized notification strings
Locale _locale = const Locale('en');
/// Set the locale for notification strings (call when app locale changes)
void setLocale(Locale locale) {
_locale = locale;
}
AppLocalizations get _l10n => lookupAppLocalizations(_locale);
// Rate limiting to prevent notification storms
// (Added after getting notification-flooded while evaluating RF flood management. The irony.)
static const _minNotificationInterval = Duration(seconds: 3);
static const _batchWindow = Duration(seconds: 5);
DateTime? _lastNotificationTime;
final List<_PendingNotification> _pendingNotifications = [];
bool _isBatchingActive = false;
bool _suppressNotifications = false;
/// Temporarily suppress all notifications (e.g., during sync)
void suppressNotifications(bool suppress) {
_suppressNotifications = suppress;
if (suppress) {
_pendingNotifications.clear();
}
}
Future<void> initialize() async {
if (_isInitialized) return;
const androidSettings = AndroidInitializationSettings('@mipmap/ic_launcher');
const androidSettings = AndroidInitializationSettings(
'@mipmap/ic_launcher',
);
const iosSettings = DarwinInitializationSettings(
requestAlertPermission: true,
requestBadgePermission: true,
@@ -47,16 +82,20 @@ class NotificationService {
}
// Request Android 13+ notification permission
final androidPlugin = _notifications.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin>();
final androidPlugin = _notifications
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin
>();
if (androidPlugin != null) {
final granted = await androidPlugin.requestNotificationsPermission();
return granted ?? false;
}
// iOS permissions are requested during initialization
final iosPlugin = _notifications.resolvePlatformSpecificImplementation<
IOSFlutterLocalNotificationsPlugin>();
final iosPlugin = _notifications
.resolvePlatformSpecificImplementation<
IOSFlutterLocalNotificationsPlugin
>();
if (iosPlugin != null) {
final granted = await iosPlugin.requestPermissions(
alert: true,
@@ -69,7 +108,7 @@ class NotificationService {
return true;
}
Future<void> showMessageNotification({
Future<void> _showMessageNotificationImpl({
required String contactName,
required String message,
String? contactId,
@@ -111,14 +150,14 @@ class NotificationService {
await _notifications.show(
contactId?.hashCode ?? 0,
'New message from $contactName',
message.length > 100 ? '${message.substring(0, 100)}...' : message,
contactName,
message,
notificationDetails,
payload: 'message:$contactId',
);
}
Future<void> showAdvertNotification({
Future<void> _showAdvertNotificationImpl({
required String contactName,
required String contactType,
String? contactId,
@@ -156,14 +195,14 @@ class NotificationService {
await _notifications.show(
contactId?.hashCode ?? DateTime.now().millisecondsSinceEpoch,
'New $contactType discovered',
_l10n.notification_newTypeDiscovered(contactType),
contactName,
notificationDetails,
payload: 'advert:$contactId',
);
}
Future<void> showChannelMessageNotification({
Future<void> _showChannelMessageNotificationImpl({
required String channelName,
required String message,
int? channelIndex,
@@ -203,9 +242,9 @@ class NotificationService {
macOS: macDetails,
);
final preview = _truncateMessage(message, 30);
final preview = message.trim();
final body = preview.isEmpty
? 'Received new message'
? _l10n.notification_receivedNewMessage
: preview;
await _notifications.show(
@@ -217,10 +256,19 @@ class NotificationService {
);
}
String _truncateMessage(String message, int maxLength) {
final trimmed = message.trim();
if (trimmed.length <= maxLength) return trimmed;
return '${trimmed.substring(0, maxLength)}...';
/// Returns a privacy-safe identifier for debug logging.
/// - advert: shows device name (body contains contactName)
/// - message: shows "from: sender" (avoids logging message content)
/// - channelMessage: shows "in: channel" (avoids logging message content)
String _getNotificationIdentifier(_PendingNotification n) {
switch (n.type) {
case _NotificationType.advert:
return n.body;
case _NotificationType.message:
return 'from: ${n.title}';
case _NotificationType.channelMessage:
return 'in: ${n.title}';
}
}
void _onNotificationTapped(NotificationResponse response) {
@@ -239,4 +287,212 @@ class NotificationService {
Future<void> cancel(int id) async {
await _notifications.cancel(id);
}
//
// Public notification methods (rate limiting is enforced automatically)
//
Future<void> showMessageNotification({
required String contactName,
required String message,
String? contactId,
int? badgeCount,
}) async {
if (_suppressNotifications) return;
_queueNotification(
_PendingNotification(
type: _NotificationType.message,
title: contactName,
body: message,
id: contactId,
badgeCount: badgeCount,
),
);
}
Future<void> showAdvertNotification({
required String contactName,
required String contactType,
String? contactId,
}) async {
if (_suppressNotifications) return;
_queueNotification(
_PendingNotification(
type: _NotificationType.advert,
title: contactType,
body: contactName,
id: contactId,
),
);
}
Future<void> showChannelMessageNotification({
required String channelName,
required String message,
int? channelIndex,
int? badgeCount,
}) async {
if (_suppressNotifications) return;
_queueNotification(
_PendingNotification(
type: _NotificationType.channelMessage,
title: channelName,
body: message,
id: channelIndex?.toString(),
badgeCount: badgeCount,
),
);
}
void _queueNotification(_PendingNotification notification) {
final now = DateTime.now();
// If we recently showed a notification, start batching
if (_lastNotificationTime != null &&
now.difference(_lastNotificationTime!) < _minNotificationInterval) {
_pendingNotifications.add(notification);
debugPrint(
'[Notification] queued: ${notification.type.name} (${_getNotificationIdentifier(notification)})',
);
// Start batch timer if not already running
if (!_isBatchingActive) {
_isBatchingActive = true;
Future.delayed(_batchWindow, _processBatch);
}
return;
}
// Show immediately if enough time has passed
debugPrint(
'[Notification] sent immediately: ${notification.type.name} (${_getNotificationIdentifier(notification)})',
);
_showNotificationImmediately(notification);
_lastNotificationTime = now;
}
Future<void> _processBatch() async {
_isBatchingActive = false;
if (_pendingNotifications.isEmpty) return;
final batch = List<_PendingNotification>.from(_pendingNotifications);
_pendingNotifications.clear();
if (batch.length == 1) {
// Single notification, show normally
_showNotificationImmediately(batch.first);
} else {
// Multiple notifications, show summary
await _showBatchSummary(batch);
}
_lastNotificationTime = DateTime.now();
}
Future<void> _showNotificationImmediately(
_PendingNotification notification,
) async {
switch (notification.type) {
case _NotificationType.message:
await _showMessageNotificationImpl(
contactName: notification.title,
message: notification.body,
contactId: notification.id,
badgeCount: notification.badgeCount,
);
break;
case _NotificationType.advert:
await _showAdvertNotificationImpl(
contactName: notification.body,
contactType: notification.title,
contactId: notification.id,
);
break;
case _NotificationType.channelMessage:
await _showChannelMessageNotificationImpl(
channelName: notification.title,
message: notification.body,
channelIndex: int.tryParse(notification.id ?? ''),
badgeCount: notification.badgeCount,
);
break;
}
}
Future<void> _showBatchSummary(List<_PendingNotification> batch) async {
if (!_isInitialized) await initialize();
// Group by type
final messages = batch
.where((n) => n.type == _NotificationType.message)
.toList();
final adverts = batch
.where((n) => n.type == _NotificationType.advert)
.toList();
final channelMsgs = batch
.where((n) => n.type == _NotificationType.channelMessage)
.toList();
// Build summary text using localized plurals
final parts = <String>[];
if (messages.isNotEmpty) {
parts.add(_l10n.notification_messagesCount(messages.length));
}
if (channelMsgs.isNotEmpty) {
parts.add(_l10n.notification_channelMessagesCount(channelMsgs.length));
}
if (adverts.isNotEmpty) {
parts.add(_l10n.notification_newNodesCount(adverts.length));
}
if (parts.isEmpty) return;
// Show first few device names in batch summary for debugging (only if adverts exist)
final deviceInfo = adverts.isNotEmpty
? ' (${adverts.take(5).map((n) => n.body).join(', ')}${adverts.length > 5 ? ', ...' : ''})'
: '';
debugPrint('[Notification] batch summary: ${parts.join(", ")}$deviceInfo');
const androidDetails = AndroidNotificationDetails(
'batch_summary',
'Activity Summary',
channelDescription: 'Batched notification summaries',
importance: Importance.defaultImportance,
priority: Priority.defaultPriority,
icon: '@mipmap/ic_launcher',
);
const notificationDetails = NotificationDetails(android: androidDetails);
await _notifications.show(
'batch_summary'.hashCode,
_l10n.notification_activityTitle,
parts.join(', '),
notificationDetails,
payload: 'batch',
);
}
}
// Helper class for pending notifications
enum _NotificationType { message, advert, channelMessage }
class _PendingNotification {
final _NotificationType type;
final String title;
final String body;
final String? id;
final int? badgeCount;
_PendingNotification({
required this.type,
required this.title,
required this.body,
this.id,
this.badgeCount,
});
}
+30 -16
View File
@@ -61,7 +61,10 @@ class PathHistoryService extends ChangeNotifier {
int? tripTimeMs,
}) {
if (selection.useFlood) {
final stats = _floodStats.putIfAbsent(contactPubKeyHex, () => _FloodStats());
final stats = _floodStats.putIfAbsent(
contactPubKeyHex,
() => _FloodStats(),
);
if (success) {
stats.successCount += 1;
if (tripTimeMs != null) stats.lastTripTimeMs = tripTimeMs;
@@ -88,23 +91,28 @@ class PathHistoryService extends ChangeNotifier {
}
PathSelection getNextAutoPathSelection(String contactPubKeyHex) {
final ranked = _getRankedPaths(contactPubKeyHex)
.take(_autoRotationTopCount)
.toList();
final ranked = _getRankedPaths(
contactPubKeyHex,
).take(_autoRotationTopCount).toList();
if (ranked.isEmpty) {
return const PathSelection(pathBytes: [], hopCount: -1, useFlood: true);
}
_trackAccess(contactPubKeyHex);
final selections = ranked
.map((path) => PathSelection(
pathBytes: path.pathBytes,
hopCount: path.hopCount,
useFlood: false,
))
.toList()
..add(const PathSelection(pathBytes: [], hopCount: -1, useFlood: true));
final selections =
ranked
.map(
(path) => PathSelection(
pathBytes: path.pathBytes,
hopCount: path.hopCount,
useFlood: false,
),
)
.toList()
..add(
const PathSelection(pathBytes: [], hopCount: -1, useFlood: true),
);
final currentIndex = _autoRotationIndex[contactPubKeyHex] ?? 0;
final selection = selections[currentIndex % selections.length];
@@ -241,7 +249,8 @@ class PathHistoryService extends ChangeNotifier {
}
Future<ContactPathHistory?> _loadHistoryFromStorage(
String contactPubKeyHex) async {
String contactPubKeyHex,
) async {
return await _storage.loadPathHistory(contactPubKeyHex);
}
@@ -308,8 +317,10 @@ class PathHistoryService extends ChangeNotifier {
..removeWhere((p) => p.pathBytes.isEmpty);
ranked.sort((a, b) {
final aRate = (a.successCount + 1) / (a.successCount + a.failureCount + 2);
final bRate = (b.successCount + 1) / (b.successCount + b.failureCount + 2);
final aRate =
(a.successCount + 1) / (a.successCount + a.failureCount + 2);
final bRate =
(b.successCount + 1) / (b.successCount + b.failureCount + 2);
if (aRate != bRate) return bRate.compareTo(aRate);
if (a.successCount != b.successCount) {
return b.successCount.compareTo(a.successCount);
@@ -329,7 +340,10 @@ class PathHistoryService extends ChangeNotifier {
}
void _updateFloodStats(String contactPubKeyHex) {
final stats = _floodStats.putIfAbsent(contactPubKeyHex, () => _FloodStats());
final stats = _floodStats.putIfAbsent(
contactPubKeyHex,
() => _FloodStats(),
);
stats.lastUsed = DateTime.now();
}
+9 -3
View File
@@ -26,7 +26,9 @@ class RepeaterCommandService {
int retries = maxRetries,
}) async {
final repeaterKey = repeater.publicKeyHex;
final hasPending = _pendingCommands.keys.any((id) => id.startsWith(repeaterKey));
final hasPending = _pendingCommands.keys.any(
(id) => id.startsWith(repeaterKey),
);
if (hasPending) {
throw Exception('Another command is still awaiting a response.');
}
@@ -84,7 +86,9 @@ class RepeaterCommandService {
attempt: attempt,
timestampSeconds: timestampSeconds,
);
final responseBytes = frame.length > maxFrameSize ? frame.length : maxFrameSize;
final responseBytes = frame.length > maxFrameSize
? frame.length
: maxFrameSize;
final timeoutMs = _connector.calculateTimeout(
pathLength: pathLengthValue,
messageBytes: responseBytes,
@@ -97,7 +101,9 @@ class RepeaterCommandService {
() {
final completer = _pendingCommands[commandId];
if (completer != null && !completer.isCompleted) {
completer.completeError('Command timeout after $timeoutSeconds seconds');
completer.completeError(
'Command timeout after $timeoutSeconds seconds',
);
_cleanup(commandId);
}
},
+9 -4
View File
@@ -8,7 +8,9 @@ class StorageService {
static const String _repeaterPasswordsKey = 'repeater_passwords';
Future<void> savePathHistory(
String contactPubKeyHex, ContactPathHistory history) async {
String contactPubKeyHex,
ContactPathHistory history,
) async {
final prefs = PrefsManager.instance;
final key = '$_pathHistoryPrefix$contactPubKeyHex';
final jsonStr = jsonEncode(history.toJson());
@@ -39,8 +41,9 @@ class StorageService {
Future<void> clearAllPathHistories() async {
final prefs = PrefsManager.instance;
final keys = prefs.getKeys();
final pathHistoryKeys =
keys.where((key) => key.startsWith(_pathHistoryPrefix));
final pathHistoryKeys = keys.where(
(key) => key.startsWith(_pathHistoryPrefix),
);
for (final key in pathHistoryKeys) {
await prefs.remove(key);
@@ -74,7 +77,9 @@ class StorageService {
/// Save a repeater password by public key hex
Future<void> saveRepeaterPassword(
String repeaterPubKeyHex, String password) async {
String repeaterPubKeyHex,
String password,
) async {
final prefs = PrefsManager.instance;
final passwords = await loadRepeaterPasswords();
passwords[repeaterPubKeyHex] = password;
+14 -6
View File
@@ -8,7 +8,10 @@ class ChannelMessageStore {
static const String _keyPrefix = 'channel_messages_';
/// Save messages for a specific channel
Future<void> saveChannelMessages(int channelIndex, List<ChannelMessage> messages) async {
Future<void> saveChannelMessages(
int channelIndex,
List<ChannelMessage> messages,
) async {
final prefs = PrefsManager.instance;
final key = '$_keyPrefix$channelIndex';
@@ -96,7 +99,8 @@ class ChannelMessageStore {
pathVariants: (json['pathVariants'] as List<dynamic>?)
?.map((entry) => Uint8List.fromList(base64Decode(entry as String)))
.toList(),
repeats: (json['repeats'] as List<dynamic>?)
repeats:
(json['repeats'] as List<dynamic>?)
?.map((entry) => _repeatFromJson(entry as Map<String, dynamic>))
.toList() ??
const [],
@@ -105,15 +109,19 @@ class ChannelMessageStore {
replyToMessageId: json['replyToMessageId'] as String?,
replyToSenderName: json['replyToSenderName'] as String?,
replyToText: json['replyToText'] as String?,
reactions: (json['reactions'] as Map<String, dynamic>?)?.map(
(key, value) => MapEntry(key, value as int),
) ?? {},
reactions:
(json['reactions'] as Map<String, dynamic>?)?.map(
(key, value) => MapEntry(key, value as int),
) ??
{},
);
}
Map<String, dynamic> _repeatToJson(Repeat repeat) {
return {
'repeaterKey': repeat.repeaterKey != null ? base64Encode(repeat.repeaterKey!) : null,
'repeaterKey': repeat.repeaterKey != null
? base64Encode(repeat.repeaterKey!)
: null,
'repeaterName': repeat.repeaterName,
'tripTimeMs': repeat.tripTimeMs,
'path': repeat.path?.map((bytes) => base64Encode(bytes)).toList() ?? [],
+4 -1
View File
@@ -16,7 +16,10 @@ class ChannelOrderStore {
try {
final decoded = jsonDecode(raw);
if (decoded is List) {
return decoded.map((value) => value is int ? value : int.tryParse('$value')).whereType<int>().toList();
return decoded
.map((value) => value is int ? value : int.tryParse('$value'))
.whereType<int>()
.toList();
}
} catch (_) {
// fall through to legacy parse
+50
View File
@@ -0,0 +1,50 @@
import 'dart:convert';
import 'dart:typed_data';
import '../models/channel.dart';
import 'prefs_manager.dart';
class ChannelStore {
static const String _key = 'channels';
Future<List<Channel>> loadChannels() async {
final prefs = PrefsManager.instance;
final jsonStr = prefs.getString(_key);
if (jsonStr == null) return [];
try {
final jsonList = jsonDecode(jsonStr) as List<dynamic>;
return jsonList
.map((entry) => _fromJson(entry as Map<String, dynamic>))
.toList();
} catch (_) {
return [];
}
}
Future<void> saveChannels(List<Channel> channels) async {
final prefs = PrefsManager.instance;
final jsonList = channels.map(_toJson).toList();
await prefs.setString(_key, jsonEncode(jsonList));
}
Map<String, dynamic> _toJson(Channel channel) {
return {
'index': channel.index,
'name': channel.name,
'psk': base64Encode(channel.psk),
'unreadCount': channel.unreadCount,
};
}
Channel _fromJson(Map<String, dynamic> json) {
return Channel(
index: json['index'] as int,
name: json['name'] as String? ?? '',
psk: json['psk'] != null
? Uint8List.fromList(base64Decode(json['psk'] as String))
: Uint8List(16),
unreadCount: json['unreadCount'] as int? ?? 0,
);
}
}
+4 -10
View File
@@ -40,7 +40,7 @@ class CommunityStore {
/// 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) {
@@ -49,7 +49,7 @@ class CommunityStore {
} else {
communities.add(community);
}
await saveCommunities(communities);
}
@@ -92,10 +92,7 @@ class CommunityStore {
}
/// Add a hashtag channel to a community
Future<void> addHashtagChannel(
String communityId,
String hashtag,
) async {
Future<void> addHashtagChannel(String communityId, String hashtag) async {
final community = await getCommunity(communityId);
if (community != null) {
final updated = community.addHashtagChannel(hashtag);
@@ -104,10 +101,7 @@ class CommunityStore {
}
/// Remove a hashtag channel from a community
Future<void> removeHashtagChannel(
String communityId,
String hashtag,
) async {
Future<void> removeHashtagChannel(String communityId, String hashtag) async {
final community = await getCommunity(communityId);
if (community != null) {
final updated = community.removeHashtagChannel(hashtag);
+9 -3
View File
@@ -14,7 +14,9 @@ class ContactStore {
try {
final jsonList = jsonDecode(jsonStr) as List<dynamic>;
return jsonList.map((entry) => _fromJson(entry as Map<String, dynamic>)).toList();
return jsonList
.map((entry) => _fromJson(entry as Map<String, dynamic>))
.toList();
} catch (_) {
return [];
}
@@ -57,12 +59,16 @@ class ContactStore {
: Uint8List(0),
pathOverride: json['pathOverride'] as int?,
pathOverrideBytes: json['pathOverrideBytes'] != null
? Uint8List.fromList(base64Decode(json['pathOverrideBytes'] as String))
? Uint8List.fromList(
base64Decode(json['pathOverrideBytes'] as String),
)
: null,
latitude: (json['latitude'] as num?)?.toDouble(),
longitude: (json['longitude'] as num?)?.toDouble(),
lastSeen: DateTime.fromMillisecondsSinceEpoch(lastSeenMs),
lastMessageAt: DateTime.fromMillisecondsSinceEpoch(lastMessageMs ?? lastSeenMs),
lastMessageAt: DateTime.fromMillisecondsSinceEpoch(
lastMessageMs ?? lastSeenMs,
),
);
}
}
+21 -8
View File
@@ -7,7 +7,10 @@ import 'prefs_manager.dart';
class MessageStore {
static const String _keyPrefix = 'messages_';
Future<void> saveMessages(String contactKeyHex, List<Message> messages) async {
Future<void> saveMessages(
String contactKeyHex,
List<Message> messages,
) async {
final prefs = PrefsManager.instance;
final key = '$_keyPrefix$contactKeyHex';
final jsonList = messages.map(_messageToJson).toList();
@@ -45,12 +48,16 @@ class MessageStore {
'messageId': msg.messageId,
'retryCount': msg.retryCount,
'estimatedTimeoutMs': msg.estimatedTimeoutMs,
'expectedAckHash': msg.expectedAckHash != null ? base64Encode(msg.expectedAckHash!) : null,
'expectedAckHash': msg.expectedAckHash != null
? base64Encode(msg.expectedAckHash!)
: null,
'sentAt': msg.sentAt?.millisecondsSinceEpoch,
'deliveredAt': msg.deliveredAt?.millisecondsSinceEpoch,
'tripTimeMs': msg.tripTimeMs,
'pathLength': msg.pathLength,
'pathBytes': msg.pathBytes.isNotEmpty ? base64Encode(msg.pathBytes) : null,
'pathBytes': msg.pathBytes.isNotEmpty
? base64Encode(msg.pathBytes)
: null,
'reactions': msg.reactions,
'fourByteRoomContactKey': base64Encode(msg.fourByteRoomContactKey),
};
@@ -59,7 +66,9 @@ class MessageStore {
Message _messageFromJson(Map<String, dynamic> json) {
final rawText = json['text'] as String;
final isCli = json['isCli'] as bool? ?? false;
final decodedText = isCli ? rawText : (Smaz.tryDecodePrefixed(rawText) ?? rawText);
final decodedText = isCli
? rawText
: (Smaz.tryDecodePrefixed(rawText) ?? rawText);
return Message(
senderKey: Uint8List.fromList(base64Decode(json['senderKey'] as String)),
text: decodedText,
@@ -84,11 +93,15 @@ class MessageStore {
pathBytes: json['pathBytes'] != null
? Uint8List.fromList(base64Decode(json['pathBytes'] as String))
: Uint8List(0),
reactions: (json['reactions'] as Map<String, dynamic>?)?.map(
(key, value) => MapEntry(key, value as int),
) ?? {},
reactions:
(json['reactions'] as Map<String, dynamic>?)?.map(
(key, value) => MapEntry(key, value as int),
) ??
{},
fourByteRoomContactKey: json['fourByteRoomContactKey'] != null
? Uint8List.fromList(base64Decode(json['fourByteRoomContactKey'] as String))
? Uint8List.fromList(
base64Decode(json['fourByteRoomContactKey'] as String),
)
: null,
);
}
+2 -1
View File
@@ -21,7 +21,8 @@ class PrefsManager {
static SharedPreferences get instance {
if (_instance == null) {
throw StateError(
'PrefsManager not initialized. Call PrefsManager.initialize() in main() before use.');
'PrefsManager not initialized. Call PrefsManager.initialize() in main() before use.',
);
}
return _instance!;
}
+19 -70
View File
@@ -5,27 +5,23 @@ import 'prefs_manager.dart';
/// Storage for unread message tracking with debounced writes to reduce I/O.
class UnreadStore {
static const String _contactLastReadKey = 'contact_last_read';
static const String _channelLastReadKey = 'channel_last_read';
static const String _contactUnreadCountKey = 'contact_unread_count';
// Debounce timers to batch rapid writes
Timer? _contactSaveTimer;
Timer? _channelSaveTimer;
Timer? _contactUnreadSaveTimer;
static const Duration _saveDebounceDuration = Duration(milliseconds: 500);
// Pending write data
Map<String, int>? _pendingContactLastRead;
Map<int, int>? _pendingChannelLastRead;
Map<String, int>? _pendingContactUnreadCount;
/// Dispose timers when no longer needed
void dispose() {
_contactSaveTimer?.cancel();
_channelSaveTimer?.cancel();
_contactUnreadSaveTimer?.cancel();
}
Future<Map<String, int>> loadContactLastRead() async {
Future<Map<String, int>> loadContactUnreadCount() async {
final prefs = PrefsManager.instance;
final jsonStr = prefs.getString(_contactLastReadKey);
final jsonStr = prefs.getString(_contactUnreadCountKey);
if (jsonStr == null) return {};
try {
@@ -36,77 +32,30 @@ class UnreadStore {
}
}
/// Save contact last read timestamps with debouncing.
/// Writes are delayed by 500ms and batched to reduce I/O operations.
void saveContactLastRead(Map<String, int> lastReadMs) {
_pendingContactLastRead = lastReadMs;
void saveContactUnreadCount(Map<String, int> counts) {
_pendingContactUnreadCount = counts;
// Cancel existing timer
_contactSaveTimer?.cancel();
_contactUnreadSaveTimer?.cancel();
// Schedule new write
_contactSaveTimer = Timer(_saveDebounceDuration, () async {
if (_pendingContactLastRead != null) {
await _flushContactLastRead();
_contactUnreadSaveTimer = Timer(_saveDebounceDuration, () async {
if (_pendingContactUnreadCount != null) {
await _flushContactUnreadCount();
}
});
}
Future<Map<int, int>> loadChannelLastRead() async {
final prefs = PrefsManager.instance;
final jsonStr = prefs.getString(_channelLastReadKey);
if (jsonStr == null) return {};
try {
final json = jsonDecode(jsonStr) as Map<String, dynamic>;
return json.map((key, value) => MapEntry(int.parse(key), value as int));
} catch (_) {
return {};
}
}
/// Save channel last read timestamps with debouncing.
/// Writes are delayed by 500ms and batched to reduce I/O operations.
void saveChannelLastRead(Map<int, int> lastReadMs) {
_pendingChannelLastRead = lastReadMs;
_channelSaveTimer?.cancel();
_channelSaveTimer = Timer(_saveDebounceDuration, () async {
if (_pendingChannelLastRead != null) {
await _flushChannelLastRead();
}
});
}
Future<void> _flushContactLastRead() async {
if (_pendingContactLastRead == null) return;
Future<void> _flushContactUnreadCount() async {
if (_pendingContactUnreadCount == null) return;
final prefs = PrefsManager.instance;
final jsonStr = jsonEncode(_pendingContactLastRead);
await prefs.setString(_contactLastReadKey, jsonStr);
_pendingContactLastRead = null;
}
Future<void> _flushChannelLastRead() async {
if (_pendingChannelLastRead == null) return;
final prefs = PrefsManager.instance;
final asString =
_pendingChannelLastRead!.map((key, value) => MapEntry(key.toString(), value));
final jsonStr = jsonEncode(asString);
await prefs.setString(_channelLastReadKey, jsonStr);
_pendingChannelLastRead = null;
final jsonStr = jsonEncode(_pendingContactUnreadCount);
await prefs.setString(_contactUnreadCountKey, jsonStr);
_pendingContactUnreadCount = null;
}
/// Immediately flush pending writes (call before app termination or disposal)
Future<void> flush() async {
_contactSaveTimer?.cancel();
_channelSaveTimer?.cancel();
await Future.wait([
_flushContactLastRead(),
_flushChannelLastRead(),
]);
_contactUnreadSaveTimer?.cancel();
await _flushContactUnreadCount();
}
}
+5 -1
View File
@@ -44,7 +44,11 @@ class AppLogger {
}
/// Log a message with custom level
void log(String message, {String tag = 'App', AppDebugLogLevel level = AppDebugLogLevel.info}) {
void log(
String message, {
String tag = 'App',
AppDebugLogLevel level = AppDebugLogLevel.info,
}) {
if (_enabled && _service != null) {
_service!.log(message, tag: tag, level: level);
}
+179
View File
@@ -0,0 +1,179 @@
import 'package:flutter/foundation.dart';
import 'package:gpx/gpx.dart';
import 'package:meshcore_open/connector/meshcore_connector.dart';
import 'package:meshcore_open/connector/meshcore_protocol.dart';
import 'package:path_provider/path_provider.dart';
import 'dart:io';
import 'package:share_plus/share_plus.dart';
class ContactExport {
final String name;
final double lat;
final double lon;
final String desc;
final double? ele;
ContactExport({
required this.name,
required this.lat,
required this.lon,
required this.desc,
this.ele,
});
}
const int gpxExportFailed = -1;
const int gpxExportSuccess = 1;
const int gpxExportNoContacts = 2;
const int gpxExportCancelled = 3;
const int gpxExportNotAvailable = 4;
class GpxExport {
final MeshCoreConnector _connector;
final List<ContactExport> _contacts = [];
GpxExport(this._connector);
void _addContact(
String name,
double lat,
double lon,
String desc, [
double? ele,
]) {
_contacts.add(
ContactExport(
name: name.trim(),
lat: lat,
lon: lon,
desc: desc.trim(),
ele: ele,
),
);
}
void addRepeaters() {
final contacts = _connector.contacts
.where((c) => c.type == advTypeRepeater || c.type == advTypeRoom)
.toList();
for (var contact in contacts) {
if (contact.latitude == null || contact.longitude == null) {
continue;
}
_addContact(
contact.name,
contact.latitude!,
contact.longitude!,
"Type: ${contact.typeLabel}\nPublic Key: ${contact.publicKeyHex}",
);
}
}
void addContacts() {
final contacts = _connector.contacts
.where((c) => c.type == advTypeChat)
.toList();
for (var contact in contacts) {
if (contact.latitude == null || contact.longitude == null) {
continue;
}
_addContact(
contact.name,
contact.latitude!,
contact.longitude!,
"Type: ${contact.typeLabel}\nPublic Key: ${contact.publicKeyHex}",
);
}
}
void addAll() {
final contacts = _connector.contacts;
for (var contact in contacts.toList()) {
if (contact.latitude == null || contact.longitude == null) {
continue;
}
_addContact(
contact.name,
contact.latitude ?? 0.0,
contact.longitude ?? 0.0,
"Type: ${contact.typeLabel}\nPublic Key: ${contact.publicKeyHex}",
);
}
}
Future<int> exportGPX(
String name,
String description,
String filename,
String shareText,
String subject,
) async {
if (_contacts.isEmpty) {
debugPrint("No repeaters to export nothing to share.");
return gpxExportNoContacts;
}
try {
// 1. Build GPX content (your existing logic unchanged here)
final gpx = Gpx()
..version = '1.1'
..creator = 'meshcore-open exporter'
..metadata = Metadata(
name: name,
desc: description,
time: DateTime.now().toUtc(),
);
gpx.wpts = _contacts
.map(
(c) => Wpt(
lat: c.lat,
lon: c.lon,
ele: c.ele,
name: c.name,
desc: c.desc,
),
)
.toList();
final xml = GpxWriter().asString(gpx, pretty: true);
// 2. Save to file
final dir = await getApplicationDocumentsDirectory();
final timestamp = DateTime.now()
.toUtc()
.toIso8601String()
.replaceAll(':', '-')
.replaceAll('.', '-')
.split('T')
.join('_');
final path = '${dir.path}/$filename$timestamp.gpx';
final file = File(path);
await file.writeAsString(xml);
final result = await SharePlus.instance.share(
ShareParams(text: shareText, subject: subject, files: [XFile(path)]),
);
await file.delete();
switch (result.status) {
case ShareResultStatus.success:
debugPrint('Share successful user completed the action.');
return gpxExportSuccess;
case ShareResultStatus.dismissed:
debugPrint('Share sheet was dismissed / cancelled by user.');
return gpxExportCancelled;
case ShareResultStatus.unavailable:
debugPrint('Sharing is not available on this platform / context.');
return gpxExportNotAvailable;
}
} catch (e, stack) {
debugPrint('Export or share failed: $e\n$stack');
}
return gpxExportFailed;
}
}
+1 -4
View File
@@ -29,10 +29,7 @@ BatteryUi batteryUiForPercent(int? percent) {
class BatteryIndicator extends StatefulWidget {
final MeshCoreConnector connector;
const BatteryIndicator({
super.key,
required this.connector,
});
const BatteryIndicator({super.key, required this.connector});
@override
State<BatteryIndicator> createState() => _BatteryIndicatorState();
+20 -6
View File
@@ -5,7 +5,11 @@ import '../connector/meshcore_protocol.dart';
/// Debug widget to show the hex dump of a frame
class DebugFrameViewer {
static void showFrameDebug(BuildContext context, Uint8List frame, String title) {
static void showFrameDebug(
BuildContext context,
Uint8List frame,
String title,
) {
final hexString = frame
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join(' ');
@@ -14,16 +18,26 @@ class DebugFrameViewer {
details.writeln(context.l10n.debugFrame_length(frame.length));
details.writeln('');
details.writeln(
context.l10n.debugFrame_command(frame[0].toRadixString(16).padLeft(2, '0')),
context.l10n.debugFrame_command(
frame[0].toRadixString(16).padLeft(2, '0'),
),
);
if (frame[0] == cmdSendTxtMsg && frame.length > 37) {
details.writeln('');
details.writeln(context.l10n.debugFrame_textMessageHeader);
details.writeln(context.l10n.debugFrame_destinationPubKey(pubKeyToHex(frame.sublist(1, 33))));
details.writeln(context.l10n.debugFrame_timestamp(readUint32LE(frame, 33)));
details.writeln(
context.l10n.debugFrame_flags(frame[37].toRadixString(16).padLeft(2, '0')),
context.l10n.debugFrame_destinationPubKey(
pubKeyToHex(frame.sublist(1, 33)),
),
);
details.writeln(
context.l10n.debugFrame_timestamp(readUint32LE(frame, 33)),
);
details.writeln(
context.l10n.debugFrame_flags(
frame[37].toRadixString(16).padLeft(2, '0'),
),
);
final txtType = (frame[37] >> 2) & 0x03;
final typeLabel = txtType == txtTypeCliData
@@ -34,7 +48,7 @@ class DebugFrameViewer {
final textBytes = frame.sublist(38);
final nullIdx = textBytes.indexOf(0);
final text = String.fromCharCodes(
nullIdx >= 0 ? textBytes.sublist(0, nullIdx) : textBytes
nullIdx >= 0 ? textBytes.sublist(0, nullIdx) : textBytes,
);
details.writeln(context.l10n.debugFrame_text(text));
}
+4 -12
View File
@@ -7,18 +7,14 @@ class DeviceTile extends StatelessWidget {
final ScanResult scanResult;
final VoidCallback onTap;
const DeviceTile({
super.key,
required this.scanResult,
required this.onTap,
});
const DeviceTile({super.key, required this.scanResult, required this.onTap});
@override
Widget build(BuildContext context) {
final device = scanResult.device;
final rssi = scanResult.rssi;
final name = device.platformName.isNotEmpty
? device.platformName
final name = device.platformName.isNotEmpty
? device.platformName
: scanResult.advertisementData.advName;
return ListTile(
@@ -58,12 +54,8 @@ class DeviceTile extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, color: color),
Text(
'$rssi dBm',
style: TextStyle(fontSize: 10, color: color),
),
Text('$rssi dBm', style: TextStyle(fontSize: 10, color: color)),
],
);
}
}
+204 -34
View File
@@ -5,39 +5,203 @@ import '../l10n/l10n.dart';
class EmojiPicker extends StatelessWidget {
final Function(String) onEmojiSelected;
const EmojiPicker({
super.key,
required this.onEmojiSelected,
});
const EmojiPicker({super.key, required this.onEmojiSelected});
static const List<String> quickEmojis = ['👍', '❤️', '😂', '🎉', '👏', '🔥'];
static const List<String> _smileys = [
'😀', '😃', '😄', '😁', '😅', '😂', '🤣', '😊', '😇', '🙂', '🙃', '😉', '😌', '😍', '🥰', '😘',
'😗', '😙', '😚', '😋', '😛', '😝', '😜', '🤪', '🤨', '🧐', '🤓', '😎', '🥸', '🤩', '🥳', '😏',
'😒', '😞', '😔', '😟', '😕', '🙁', '😣', '😖', '😫', '😩', '🥺', '😢', '😭', '😤', '😠', '😡',
'🤬', '🤯', '😳', '🥵', '🥶', '😱', '😨', '😰', '😥', '😓', '🤗', '🤔', '🤭', '🤫', '🤥', '😶',
];
static const List<String> _gestures = [
'👍', '👎', '👊', '', '🤛', '🤜', '🤞', '✌️', '🤟', '🤘', '👌', '🤌', '🤏', '👈', '👉', '👆',
'👇', '☝️', '👋', '🤚', '🖐️', '', '🖖', '👏', '🙌', '👐', '🤲', '🤝', '🙏', '✍️', '💅', '🤳',
];
static const List<String> _hearts = [
'❤️', '🧡', '💛', '💚', '💙', '💜', '🖤', '🤍', '🤎', '💔', '❤️‍🔥', '❤️‍🩹', '💕', '💞', '💓', '💗',
'💖', '💘', '💝', '💟', '💌', '💢', '💥', '💫', '💦', '💨', '🕳️', '💬', '👁️‍🗨️', '🗨️', '🗯️', '💭',
];
static const List<String> _objects = [
'🎉', '🎊', '🎈', '🎁', '🎀', '🪅', '🪆', '🏆', '🥇', '🥈', '🥉', '', '', '🥎', '🏀', '🏐',
'🏈', '🏉', '🎾', '🥏', '🎳', '🏏', '🏑', '🏒', '🥍', '🏓', '🏸', '🥊', '🥋', '🥅', '', '🔥',
'', '🌟', '', '', '💡', '🔦', '🏮', '🪔', '📱', '💻', '', '📷', '📺', '📻', '🎵', '🎶',
];
static const List<String> smileys = [
'😀',
'😃',
'😄',
'😁',
'😅',
'😂',
'🤣',
'😊',
'😇',
'🙂',
'🙃',
'😉',
'😌',
'😍',
'🥰',
'😘',
'😗',
'😙',
'😚',
'😋',
'😛',
'😝',
'😜',
'🤪',
'🤨',
'🧐',
'🤓',
'😎',
'🥸',
'🤩',
'🥳',
'😏',
'😒',
'😞',
'😔',
'😟',
'😕',
'🙁',
'😣',
'😖',
'😫',
'😩',
'🥺',
'😢',
'😭',
'😤',
'😠',
'😡',
'🤬',
'🤯',
'😳',
'🥵',
'🥶',
'😱',
'😨',
'😰',
'😥',
'😓',
'🤗',
'🤔',
'🤭',
'🤫',
'🤥',
'😶',
];
static const List<String> gestures = [
'👍',
'👎',
'👊',
'',
'🤛',
'🤜',
'🤞',
'✌️',
'🤟',
'🤘',
'👌',
'🤌',
'🤏',
'👈',
'👉',
'👆',
'👇',
'☝️',
'👋',
'🤚',
'🖐️',
'',
'🖖',
'👏',
'🙌',
'👐',
'🤲',
'🤝',
'🙏',
'✍️',
'💅',
'🤳',
'💪',
];
static const List<String> hearts = [
'❤️',
'🧡',
'💛',
'💚',
'💙',
'💜',
'🖤',
'🤍',
'🤎',
'💔',
'❤️‍🔥',
'❤️‍🩹',
'💕',
'💞',
'💓',
'💗',
'💖',
'💘',
'💝',
'💟',
'💌',
'💢',
'💥',
'💫',
'💦',
'💨',
'🕳️',
'💬',
'👁️‍🗨️',
'🗨️',
'🗯️',
'💭',
];
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,
l10n.emojiCategorySmileys: smileys,
l10n.emojiCategoryGestures: gestures,
l10n.emojiCategoryHearts: hearts,
l10n.emojiCategoryObjects: objects,
};
}
@@ -60,7 +224,10 @@ class EmojiPicker extends StatelessWidget {
children: [
Text(
l10n.chat_addReaction,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
IconButton(
icon: const Icon(Icons.close),
@@ -83,7 +250,9 @@ class EmojiPicker extends StatelessWidget {
child: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.secondaryContainer,
color: Theme.of(
context,
).colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(8),
),
child: Text(
@@ -114,11 +283,12 @@ class EmojiPicker extends StatelessWidget {
.map(
(emojis) => GridView.builder(
padding: const EdgeInsets.all(8),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 8,
mainAxisSpacing: 8,
crossAxisSpacing: 8,
),
gridDelegate:
const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 8,
mainAxisSpacing: 8,
crossAxisSpacing: 8,
),
itemCount: emojis.length,
itemBuilder: (context, index) => InkWell(
onTap: () {
+2 -8
View File
@@ -23,10 +23,7 @@ class EmptyState extends StatelessWidget {
children: [
Icon(icon, size: 64, color: Colors.grey[400]),
const SizedBox(height: 16),
Text(
title,
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
),
Text(title, style: TextStyle(fontSize: 16, color: Colors.grey[600])),
if (subtitle != null) ...[
const SizedBox(height: 8),
Text(
@@ -35,10 +32,7 @@ class EmptyState extends StatelessWidget {
textAlign: TextAlign.center,
),
],
if (action != null) ...[
const SizedBox(height: 24),
action!,
],
if (action != null) ...[const SizedBox(height: 24), action!],
],
),
);
+27 -33
View File
@@ -7,10 +7,7 @@ import '../l10n/l10n.dart';
class GifPicker extends StatefulWidget {
final Function(String gifId) onGifSelected;
const GifPicker({
super.key,
required this.onGifSelected,
});
const GifPicker({super.key, required this.onGifSelected});
@override
State<GifPicker> createState() => _GifPickerState();
@@ -45,11 +42,13 @@ class _GifPickerState extends State<GifPicker> {
});
try {
final response = await http.get(
Uri.parse(
'https://api.giphy.com/v1/gifs/trending?api_key=$_giphyApiKey&limit=25&rating=g',
),
).timeout(const Duration(seconds: 10));
final response = await http
.get(
Uri.parse(
'https://api.giphy.com/v1/gifs/trending?api_key=$_giphyApiKey&limit=25&rating=g',
),
)
.timeout(const Duration(seconds: 10));
if (response.statusCode == 200) {
final data = json.decode(response.body);
@@ -85,11 +84,13 @@ class _GifPickerState extends State<GifPicker> {
});
try {
final response = await http.get(
Uri.parse(
'https://api.giphy.com/v1/gifs/search?api_key=$_giphyApiKey&q=${Uri.encodeComponent(query)}&limit=25&rating=g',
),
).timeout(const Duration(seconds: 10));
final response = await http
.get(
Uri.parse(
'https://api.giphy.com/v1/gifs/search?api_key=$_giphyApiKey&q=${Uri.encodeComponent(query)}&limit=25&rating=g',
),
)
.timeout(const Duration(seconds: 10));
if (response.statusCode == 200) {
final data = json.decode(response.body);
@@ -127,7 +128,10 @@ class _GifPickerState extends State<GifPicker> {
const SizedBox(width: 8),
Text(
context.l10n.gifPicker_title,
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
const Spacer(),
IconButton(
@@ -170,18 +174,13 @@ class _GifPickerState extends State<GifPicker> {
const SizedBox(height: 16),
// GIF grid
Expanded(
child: _buildContent(),
),
Expanded(child: _buildContent()),
// Powered by Giphy attribution
const SizedBox(height: 8),
Text(
context.l10n.gifPicker_poweredBy,
style: TextStyle(
fontSize: 11,
color: Colors.grey[600],
),
style: TextStyle(fontSize: 11, color: Colors.grey[600]),
),
],
),
@@ -190,9 +189,7 @@ class _GifPickerState extends State<GifPicker> {
Widget _buildContent() {
if (_isLoading) {
return const Center(
child: CircularProgressIndicator(),
);
return const Center(child: CircularProgressIndicator());
}
if (_error != null) {
@@ -244,7 +241,8 @@ class _GifPickerState extends State<GifPicker> {
itemBuilder: (context, index) {
final gif = _gifs[index];
final gifId = gif['id'] as String;
final previewUrl = gif['images']?['fixed_height_small']?['url'] as String?;
final previewUrl =
gif['images']?['fixed_height_small']?['url'] as String?;
return GestureDetector(
onTap: () {
@@ -265,20 +263,16 @@ class _GifPickerState extends State<GifPicker> {
child: CircularProgressIndicator(
value: loadingProgress.expectedTotalBytes != null
? loadingProgress.cumulativeBytesLoaded /
loadingProgress.expectedTotalBytes!
loadingProgress.expectedTotalBytes!
: null,
),
);
},
errorBuilder: (context, error, stackTrace) {
return const Center(
child: Icon(Icons.error_outline),
);
return const Center(child: Icon(Icons.error_outline));
},
)
: const Center(
child: Icon(Icons.gif_box),
),
: const Center(child: Icon(Icons.gif_box)),
),
),
);
+1 -4
View File
@@ -4,10 +4,7 @@ import '../helpers/chat_scroll_controller.dart';
class JumpToBottomButton extends StatelessWidget {
final ChatScrollController scrollController;
const JumpToBottomButton({
super.key,
required this.scrollController,
});
const JumpToBottomButton({super.key, required this.scrollController});
@override
Widget build(BuildContext context) {
+6 -16
View File
@@ -1,18 +1,9 @@
import 'package:flutter/material.dart';
import '../l10n/l10n.dart';
enum ContactSortOption {
lastSeen,
recentMessages,
name,
}
enum ContactSortOption { lastSeen, recentMessages, name }
enum ContactTypeFilter {
all,
users,
repeaters,
rooms,
}
enum ContactTypeFilter { all, users, repeaters, rooms }
class SortFilterMenuOption {
final int value;
@@ -30,10 +21,7 @@ class SortFilterMenuSection {
final String title;
final List<SortFilterMenuOption> options;
const SortFilterMenuSection({
required this.title,
required this.options,
});
const SortFilterMenuSection({required this.title, required this.options});
}
class SortFilterMenu extends StatelessWidget {
@@ -62,7 +50,9 @@ class SortFilterMenu extends StatelessWidget {
color: theme.colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
);
final visibleSections = sections.where((section) => section.options.isNotEmpty).toList();
final visibleSections = sections
.where((section) => section.options.isNotEmpty)
.toList();
final entries = <PopupMenuEntry<int>>[];
for (int i = 0; i < visibleSections.length; i++) {
final section = visibleSections[i];
+81 -27
View File
@@ -1,6 +1,7 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:meshcore_open/screens/path_trace_map.dart';
import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart';
@@ -10,15 +11,10 @@ import '../services/path_history_service.dart';
import 'path_selection_dialog.dart';
class PathManagementDialog {
static Future<void> show(
BuildContext context, {
required Contact contact,
}) {
static Future<void> show(BuildContext context, {required Contact contact}) {
return showDialog<void>(
context: context,
builder: (context) => _PathManagementDialog(
contact: contact,
),
builder: (context) => _PathManagementDialog(contact: contact),
);
}
}
@@ -26,9 +22,7 @@ class PathManagementDialog {
class _PathManagementDialog extends StatelessWidget {
final Contact contact;
const _PathManagementDialog({
required this.contact,
});
const _PathManagementDialog({required this.contact});
Contact _resolveContact(MeshCoreConnector connector) {
return connector.contacts.firstWhere(
@@ -68,6 +62,19 @@ class _PathManagementDialog extends StatelessWidget {
title: Text(l10n.chat_fullPath),
content: SelectableText(formattedPath),
actions: [
TextButton(
onPressed: () => Navigator.push(
context,
MaterialPageRoute(
builder: (context) => PathTraceMapScreen(
title: context.l10n.contacts_repeaterPathTrace,
path: Uint8List.fromList(pathBytes),
flipPathRound: true,
),
),
),
child: Text(context.l10n.contacts_pathTrace),
),
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(l10n.common_close),
@@ -83,7 +90,9 @@ class _PathManagementDialog extends StatelessWidget {
Contact currentContact,
) 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();
}
@@ -140,13 +149,19 @@ class _PathManagementDialog extends StatelessWidget {
if (paths.isNotEmpty) ...[
Text(
l10n.chat_recentAckPaths,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 12,
),
),
if (paths.length >= 100) ...[
const SizedBox(height: 8),
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
decoration: BoxDecoration(
color: Colors.amberAccent,
borderRadius: BorderRadius.circular(8),
@@ -165,7 +180,9 @@ class _PathManagementDialog extends StatelessWidget {
dense: true,
leading: CircleAvatar(
radius: 16,
backgroundColor: path.wasFloodDiscovery ? Colors.blue : Colors.green,
backgroundColor: path.wasFloodDiscovery
? Colors.blue
: Colors.green,
child: Text(
'${path.hopCount}',
style: const TextStyle(fontSize: 12),
@@ -193,16 +210,27 @@ class _PathManagementDialog extends StatelessWidget {
},
),
path.wasFloodDiscovery
? const Icon(Icons.waves, size: 16, color: Colors.grey)
: const Icon(Icons.route, size: 16, color: Colors.grey),
? const Icon(
Icons.waves,
size: 16,
color: Colors.grey,
)
: const Icon(
Icons.route,
size: 16,
color: Colors.grey,
),
],
),
onLongPress: () => _showFullPathDialog(context, path.pathBytes),
onLongPress: () =>
_showFullPathDialog(context, path.pathBytes),
onTap: () async {
if (path.pathBytes.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.chat_pathDetailsNotAvailable),
content: Text(
l10n.chat_pathDetailsNotAvailable,
),
duration: const Duration(seconds: 2),
),
);
@@ -222,7 +250,9 @@ class _PathManagementDialog extends StatelessWidget {
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.path_usingHopsPath(path.hopCount)),
content: Text(
l10n.path_usingHopsPath(path.hopCount),
),
duration: const Duration(seconds: 2),
),
);
@@ -238,7 +268,10 @@ class _PathManagementDialog extends StatelessWidget {
const SizedBox(height: 8),
Text(
l10n.chat_pathActions,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 12,
),
),
const SizedBox(height: 8),
ListTile(
@@ -248,8 +281,14 @@ class _PathManagementDialog extends StatelessWidget {
backgroundColor: Colors.purple,
child: Icon(Icons.edit_road, size: 16),
),
title: Text(l10n.chat_setCustomPath, style: const TextStyle(fontSize: 14)),
subtitle: Text(l10n.chat_setCustomPathSubtitle, style: const TextStyle(fontSize: 11)),
title: Text(
l10n.chat_setCustomPath,
style: const TextStyle(fontSize: 14),
),
subtitle: Text(
l10n.chat_setCustomPathSubtitle,
style: const TextStyle(fontSize: 11),
),
onTap: () async {
await _setCustomPath(context, connector, currentContact);
},
@@ -261,8 +300,14 @@ class _PathManagementDialog extends StatelessWidget {
backgroundColor: Colors.orange,
child: Icon(Icons.clear_all, size: 16),
),
title: Text(l10n.chat_clearPath, style: const TextStyle(fontSize: 14)),
subtitle: Text(l10n.chat_clearPathSubtitle, style: const TextStyle(fontSize: 11)),
title: Text(
l10n.chat_clearPath,
style: const TextStyle(fontSize: 14),
),
subtitle: Text(
l10n.chat_clearPathSubtitle,
style: const TextStyle(fontSize: 11),
),
onTap: () async {
await connector.clearContactPath(currentContact);
if (!context.mounted) return;
@@ -282,10 +327,19 @@ class _PathManagementDialog extends StatelessWidget {
backgroundColor: Colors.blue,
child: Icon(Icons.waves, size: 16),
),
title: Text(l10n.chat_forceFloodMode, style: const TextStyle(fontSize: 14)),
subtitle: Text(l10n.chat_floodModeSubtitle, style: const TextStyle(fontSize: 11)),
title: Text(
l10n.chat_forceFloodMode,
style: const TextStyle(fontSize: 14),
),
subtitle: Text(
l10n.chat_floodModeSubtitle,
style: const TextStyle(fontSize: 11),
),
onTap: () async {
await connector.setPathOverride(currentContact, pathLen: -1);
await connector.setPathOverride(
currentContact,
pathLen: -1,
);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(

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