Compare commits

..

116 Commits

Author SHA1 Message Date
zjs81 4239fb11ed Fix radio settings to only send repeat byte when the current state is known 2026-02-18 00:07:08 -07:00
zjs81 5fae2e5f73 fix formatting 2026-02-17 23:50:11 -07:00
zjs81 947fafbbb7 Refactor radio settings and localization updates
fixes #72

- Removed preset configurations for 915 MHz, 868 MHz, and 433 MHz from the RadioSettings model.
- Introduced a new list of regional preset configurations for various countries.
- Updated the settings screen to use a dropdown for selecting presets instead of chips.
- Added a switch for enabling client repeat functionality with appropriate warnings for frequency usage.
- Updated localization files for multiple languages to reflect changes in settings related to client repeat functionality.
2026-02-17 23:42:04 -07:00
zjs81 72f0aa7208 Update dependencies and improve code consistency across multiple files 2026-02-14 02:22:45 -07:00
zjs81 f87d4896ab Merge pull request #161 from ChaoticLeah/enhancement/bluetooth-disabled-warning
Add warning when bluetooth is off
2026-02-14 02:00:36 -07:00
zjs81 9250dfec31 Gate the turn on BLE button to android 2026-02-14 01:54:30 -07:00
zjs81 37db955ab2 Fixed banner flash, added enable bluetooth button fixed theme to use app theme colors removed FAB overrides because material 3 does this for us, fixed missing translations. 2026-02-14 01:46:40 -07:00
zjs81 739d9475c0 Merge remote-tracking branch 'origin/main' into enhancement/bluetooth-disabled-warning 2026-02-14 01:28:54 -07:00
zjs81 b526175be4 bump version for android 2026-02-14 01:13:06 -07:00
Winston Lowe 73081862ad Add path tracing functionality (#165)
- Implemented path tracing feature in the map screen, allowing users to add nodes to a path and visualize it on the map.
- Added buttons for starting path tracing, removing the last node, and running the path trace.
- Introduced a new overlay to display current path information and distance.
- Updated localization files for multiple languages to include new strings related to path tracing.
- Refactored map rendering logic to accommodate path tracing visuals.
2026-02-14 01:10:34 -07:00
just_stuff_tm fac062a100 Refine device info layout and add collapsible map legend (#164) 2026-02-12 13:46:28 -07:00
Leah ef6bd78632 Merge branch 'enhancement/bluetooth-disabled-warning' of github.com:ChaoticLeah/meshcore-open into enhancement/bluetooth-disabled-warning 2026-02-12 20:15:58 +01:00
Leah 01c8390989 make stuff unawaited + maybe fix edge case? 2026-02-12 20:14:56 +01:00
Leah c05f813d65 Update lib/screens/scanner_screen.dart
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-12 20:02:56 +01:00
Ded c52b19b09f Merge pull request #162 from just-stuff-tm/fix/battery-layout-overflow
Fix battery chemistry dropdown layout overflow
2026-02-11 21:27:30 -08:00
just_stuff_tm 6a666839b6 Fix battery chemistry dropdown layout overflow 2026-02-12 00:05:00 -05:00
Leah bc77f7e287 Remove unused translation
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-11 23:03:41 +01:00
Leah 9332d8126f linted and added greying out 2026-02-11 22:58:15 +01:00
Leah 9ce00556ec Add warning when bluetooth is off 2026-02-11 22:40:42 +01:00
Ded 4995f5f380 Merge pull request #159 from ChaoticLeah/add-flake-nix
Add flake.nix for development environment
2026-02-11 09:10:46 -08:00
Leah 4e6e7b6061 fix smaller copilot issues 2026-02-11 17:45:15 +01:00
Leah aa350aa4ae fixing copilot issues 2026-02-11 17:33:31 +01:00
Leah dfd38b19e9 add flake.lock 2026-02-11 17:26:43 +01:00
Leah 4afab3f629 remove unnessisary bits and nix darwin stuff 2026-02-11 17:25:44 +01:00
Ded 67816130ac Merge pull request #152 from zjs81/remove-wakelock
remove wakelock
2026-02-11 08:18:57 -08:00
Ded d573f0c312 Merge pull request #158 from ChaoticLeah/update-gitignore
Update .gitignore to exclude .gradle/
2026-02-11 08:16:38 -08:00
Ded 5b699cd624 keep ignores organized 2026-02-11 08:16:07 -08:00
Leah a4d3d248a5 Add flake.nix for development environment 2026-02-11 17:15:49 +01:00
Ded 2a3f2b3a24 Merge pull request #150 from ericszimmermann/german
translations to german updated.
2026-02-11 08:13:28 -08:00
Leah 675083fa01 Update .gitignore to exclude .gradle/ 2026-02-11 17:10:49 +01:00
Ded 5fc4b80b16 Merge pull request #144 from zjs81/support-whisperos
add support for whipseros
2026-02-11 08:03:44 -08:00
446564 84a32c1e67 remove wakelock
was being used to keep ble active which is not what it does

in early testing the ble remains connected with display off and also
when switching apps
2026-02-10 19:38:46 -08:00
ericz 607583060a translations to german updated. 2026-02-10 22:55:39 +01:00
Ded 71cf556b61 Merge pull request #148 from spfmoby/more-better-french-translations
Still better french translations
2026-02-10 11:24:47 -08:00
Zach c26174ad18 Chore bump version 2026-02-10 09:01:56 -07:00
spfmoby 04021a39a1 Better french translations 2026-02-10 08:12:51 +01:00
446564 fe23e9f7a0 add support for whipseros
needed a new ble prefix
2026-02-09 05:36:25 -08:00
Ded d7ec1876af Merge pull request #143 from zjs81/alpha6
chore: update version to alpha 6
2026-02-08 19:07:29 -08:00
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
Zach 92d2b224e7 fix: address PR review issues
- Fix memory leak by adding dispose() to remove connection listener
- Fix typo: changedNavgation -> _changedNavigation
- Add mounted check before navigation to prevent errors
- Remove overly aggressive _handleDisconnection() call on battery request failure
- Only reset battery flag on error to allow retry without disconnecting
2026-01-28 21:29:18 -07:00
Winston Lowe 34a6b5d895 Added error catching to requestBatteryStatus
to call _handleDisconnection when it fails update.

Updated ScannerScreen to manage navigation state logic on connection.
2026-01-28 20:13:40 -08:00
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
119 changed files with 13671 additions and 4831 deletions
+2
View File
@@ -2,6 +2,8 @@ name: Build
on: on:
push: push:
branches:
- main
pull_request: pull_request:
jobs: jobs:
@@ -1,8 +1,10 @@
name: Flutter Analyze name: Flutter and Dart
on: on:
pull_request: pull_request:
push: push:
branches:
- main
jobs: jobs:
analyze: analyze:
@@ -19,5 +21,11 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: flutter pub get run: flutter pub get
- name: Analyze - name: Analyze code
run: flutter analyze --fatal-infos --fatal-warnings 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
+1
View File
@@ -65,6 +65,7 @@ secrets.dart
**/ios/Flutter/Flutter.podspec **/ios/Flutter/Flutter.podspec
# Android # Android
.gradle/
**/android/.gradle/ **/android/.gradle/
**/android/captures/ **/android/captures/
**/android/local.properties **/android/local.properties
+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. 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 ## Screenshots
<table> <table>
@@ -21,6 +25,7 @@ MeshCore Open is a cross-platform mobile application for communicating with Mesh
## Features ## Features
### Core Functionality ### Core Functionality
- **Direct Messaging**: Private encrypted conversations with individual contacts - **Direct Messaging**: Private encrypted conversations with individual contacts
- **Public Channels**: Broadcast messages to channel subscribers on the mesh network - **Public Channels**: Broadcast messages to channel subscribers on the mesh network
- **Contact Management**: Organize contacts, track last seen times, and manage conversation history - **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 - **Message Replies**: Thread conversations with inline reply functionality
### Mesh Network ### Mesh Network
- **Path Visualization**: View routing paths and signal quality for each contact - **Path Visualization**: View routing paths and signal quality for each contact
- **Route Management**: Manual path overriding and automatic route rotation - **Route Management**: Manual path overriding and automatic route rotation
- **Signal Metrics**: Real-time SNR (Signal-to-Noise Ratio) tracking - **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 - **Repeater Support**: Connect to and manage repeater nodes for extended range
### Map & Location ### Map & Location
- **Live Map View**: Real-time visualization of mesh network nodes on an interactive map - **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 - **Node Filtering**: Filter by node type (chat, repeater, sensor) and time range
- **Location Sharing**: Share GPS coordinates and custom markers with contacts - **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 - **MGRS Coordinates**: Support for Military Grid Reference System coordinate format
### Device Management ### Device Management
- **BLE Connection**: Scan and connect to MeshCore devices via Bluetooth - **BLE Connection**: Scan and connect to MeshCore devices via Bluetooth
- **Device Settings**: Configure radio parameters, power settings, and network options - **Device Settings**: Configure radio parameters, power settings, and network options
- **Battery Monitoring**: Real-time battery status with chemistry-specific voltage curves - **Battery Monitoring**: Real-time battery status with chemistry-specific voltage curves
- **Firmware Updates**: Over-the-air firmware updates via BLE (coming soon) - **Firmware Updates**: Over-the-air firmware updates via BLE (coming soon)
### Repeater Hub ### Repeater Hub
- **CLI Access**: Full command-line interface to repeater nodes - **CLI Access**: Full command-line interface to repeater nodes
- **Settings Management**: Configure repeater behavior, power limits, and network settings - **Settings Management**: Configure repeater behavior, power limits, and network settings
- **Statistics Dashboard**: View repeater traffic, connected clients, and system health - **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 ## Technical Details
### Architecture ### Architecture
- **Framework**: Flutter 3.38.5 / Dart 3.10.4 - **Framework**: Flutter 3.38.5 / Dart 3.10.4
- **State Management**: Provider pattern with ChangeNotifier - **State Management**: Provider pattern with ChangeNotifier
- **BLE Protocol**: Nordic UART Service (NUS) over Bluetooth Low Energy - **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 - **Encryption**: End-to-end encryption for private messages using the MeshCore protocol
### Platform Support ### Platform Support
-**Android**: Full support (API 21+) -**Android**: Full support (API 21+)
-**iOS**: Full support (iOS 12+) -**iOS**: Full support (iOS 12+)
- 🚧 **Desktop**: Limited support (macOS/Linux/Windows) - 🚧 **Desktop**: Limited support (macOS/Linux/Windows)
### Dependencies ### Dependencies
| Package | Purpose | | Package | Purpose |
|---------|---------| |---------|---------|
| flutter_blue_plus | Bluetooth Low Energy communication | | 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 ## Getting Started
### Prerequisites ### Prerequisites
- Flutter SDK 3.38.5 or later - Flutter SDK 3.38.5 or later
- Android Studio / Xcode (for mobile development) - Android Studio / Xcode (for mobile development)
- A MeshCore-compatible LoRa device - A MeshCore-compatible LoRa device
@@ -91,17 +104,20 @@ MeshCore Open is a cross-platform mobile application for communicating with Mesh
### Installation ### Installation
1. **Clone the repository** 1. **Clone the repository**
```bash ```bash
git clone https://github.com/zjs81/meshcore-open.git git clone https://github.com/zjs81/meshcore-open.git
cd meshcore-open cd meshcore-open
``` ```
2. **Install dependencies** 2. **Install dependencies**
```bash ```bash
flutter pub get flutter pub get
``` ```
3. **Run the app** 3. **Run the app**
```bash ```bash
flutter run flutter run
``` ```
@@ -109,11 +125,13 @@ MeshCore Open is a cross-platform mobile application for communicating with Mesh
### Building for Release ### Building for Release
**Android APK:** **Android APK:**
```bash ```bash
flutter build apk --release flutter build apk --release
``` ```
**iOS:** **iOS:**
```bash ```bash
flutter build ios --release flutter build ios --release
``` ```
@@ -152,25 +170,30 @@ lib/
## BLE Protocol ## BLE Protocol
### Nordic UART Service (NUS) ### Nordic UART Service (NUS)
- **Service UUID**: `6e400001-b5a3-f393-e0a9-e50e24dcca9e` - **Service UUID**: `6e400001-b5a3-f393-e0a9-e50e24dcca9e`
- **RX Characteristic**: `6e400002-b5a3-f393-e0a9-e50e24dcca9e` (Write to device) - **RX Characteristic**: `6e400002-b5a3-f393-e0a9-e50e24dcca9e` (Write to device)
- **TX Characteristic**: `6e400003-b5a3-f393-e0a9-e50e24dcca9e` (Notify from device) - **TX Characteristic**: `6e400003-b5a3-f393-e0a9-e50e24dcca9e` (Notify from device)
### Device Discovery ### Device Discovery
Devices are discovered by scanning for BLE advertisements with the name prefix `MeshCore-` Devices are discovered by scanning for BLE advertisements with the name prefix `MeshCore-`
### Message Format ### Message Format
Messages are transmitted as binary frames using a custom protocol optimized for LoRa transmission. See `meshcore_protocol.dart` for frame structure definitions. Messages are transmitted as binary frames using a custom protocol optimized for LoRa transmission. See `meshcore_protocol.dart` for frame structure definitions.
## Configuration ## Configuration
### App Settings ### App Settings
- **Theme**: System default, light, or dark mode - **Theme**: System default, light, or dark mode
- **Notifications**: Configurable for messages, channels, and node advertisements - **Notifications**: Configurable for messages, channels, and node advertisements
- **Battery Chemistry**: Support for NMC, LiFePO4, and LiPo battery types - **Battery Chemistry**: Support for NMC, LiFePO4, and LiPo battery types
- **Message Retry**: Automatic retry with configurable path clearing - **Message Retry**: Automatic retry with configurable path clearing
### Device Settings ### Device Settings
- **Radio Power**: Transmit power adjustment (10-30 dBm) - **Radio Power**: Transmit power adjustment (10-30 dBm)
- **Frequency**: LoRa frequency configuration - **Frequency**: LoRa frequency configuration
- **Bandwidth**: Channel bandwidth selection - **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! This is an open-source project. Contributions are welcome!
### Development Guidelines ### Development Guidelines
- Follow the Flutter style guide - Follow the Flutter style guide
- Use Material 3 design components - Use Material 3 design components
- Write clear commit messages - Write clear commit messages
- Test on both Android and iOS before submitting PRs - Test on both Android and iOS before submitting PRs
### Code Style ### Code Style
- Prefer `StatelessWidget` with `Consumer` for reactive UI - Prefer `StatelessWidget` with `Consumer` for reactive UI
- Use `const` constructors where possible - Use `const` constructors where possible
- Keep functions small and focused - Keep functions small and focused
- Avoid premature abstractions - Avoid premature abstractions
- Run dart format on all changes before submitting
## Support ## Support
For issues, questions, or feature requests, please open an issue on GitHub: 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 ## 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)
+1 -1
View File
@@ -83,5 +83,5 @@ flutter {
} }
dependencies { dependencies {
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4") coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Generated
+61
View File
@@ -0,0 +1,61 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1770562336,
"narHash": "sha256-ub1gpAONMFsT/GU2hV6ZWJjur8rJ6kKxdm9IlCT0j84=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "d6c71932130818840fc8fe9509cf50be8c64634f",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}
+86
View File
@@ -0,0 +1,86 @@
{
description = "MeshCore Flutter Application";
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
};
outputs = { self, nixpkgs, flake-utils }:
flake-utils.lib.eachDefaultSystem (system:
let
pkgs = nixpkgs.legacyPackages.${system};
in
{
devShells.default = pkgs.mkShell {
buildInputs = with pkgs; [
# Flutter and Dart
flutter
dart
# Java (required for Android development)
jdk17
# Android development tools
android-tools
gradle
# For the shell hook to set up the environment for Flutter development
gtk3
glib
sysprof
libclang
cmake
ninja
pkg-config
libdatrie
# Additional tools for installing Android SDK if not present
curl
unzip
];
shellHook = ''
echo "MeshCore Flutter Development Environment"
export PKG_CONFIG_PATH="${pkgs.gtk3}/lib/pkgconfig:${pkgs.glib}/lib/pkgconfig:${pkgs.sysprof}/lib/pkgconfig:$PKG_CONFIG_PATH"
export LD_LIBRARY_PATH="${pkgs.lib.makeLibraryPath [pkgs.gtk3 pkgs.glib pkgs.sysprof pkgs.libdatrie]}:$LD_LIBRARY_PATH"
export CMAKE_INSTALL_PREFIX="$PWD/build/bundle"
# Setup Android SDK in home directory (standard location)
export ANDROID_HOME="$HOME/Android/Sdk"
export ANDROID_SDK_ROOT="$ANDROID_HOME"
export PATH="$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:$ANDROID_HOME/tools/bin:$PATH"
echo "Android SDK: $ANDROID_HOME"
echo ""
# Check if Android SDK exists and offer to download if not
if [ ! -d "$ANDROID_HOME" ]; then
echo "WARNING: Android SDK not found at $ANDROID_HOME"
echo ""
echo "To download and set up the Android SDK, run this command:"
echo ""
cat << 'EOF'
mkdir -p ~/Android/Sdk && cd ~/Android/Sdk && \
curl -o cmdline-tools.zip ${if pkgs.stdenv.isDarwin then "https://dl.google.com/android/repository/commandlinetools-mac-10406996_latest.zip" else "https://dl.google.com/android/repository/commandlinetools-linux-10406996_latest.zip"} && \
unzip -q cmdline-tools.zip && \
mkdir -p cmdline-tools/latest && \
mv cmdline-tools/* cmdline-tools/latest/ 2>/dev/null || echo "Warning: failed to move Android cmdline-tools into 'latest' directory; please check your SDK layout." >&2 && \
rm cmdline-tools.zip && \
cd cmdline-tools/latest/bin && \
yes | ./sdkmanager --sdk_root=~/Android/Sdk 'platform-tools' && \
echo "Android SDK setup complete!"
EOF
echo ""
echo "Then run 'flutter doctor' again to verify."
echo ""
else
echo "Android SDK found at $ANDROID_HOME"
fi
echo "To check that everything is set up correctly, run 'flutter doctor' and ensure there are no issues."
'';
};
}
);
}
-7
View File
@@ -57,9 +57,6 @@ PODS:
- nanopb/encode (3.30910.0) - nanopb/encode (3.30910.0)
- package_info_plus (0.4.5): - package_info_plus (0.4.5):
- Flutter - Flutter
- path_provider_foundation (0.0.1):
- Flutter
- FlutterMacOS
- PromisesObjC (2.4.0) - PromisesObjC (2.4.0)
- shared_preferences_foundation (0.0.1): - shared_preferences_foundation (0.0.1):
- Flutter - Flutter
@@ -79,7 +76,6 @@ DEPENDENCIES:
- flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`) - flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`)
- mobile_scanner (from `.symlinks/plugins/mobile_scanner/ios`) - mobile_scanner (from `.symlinks/plugins/mobile_scanner/ios`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/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`) - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`) - sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
@@ -112,8 +108,6 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/mobile_scanner/ios" :path: ".symlinks/plugins/mobile_scanner/ios"
package_info_plus: package_info_plus:
:path: ".symlinks/plugins/package_info_plus/ios" :path: ".symlinks/plugins/package_info_plus/ios"
path_provider_foundation:
:path: ".symlinks/plugins/path_provider_foundation/darwin"
shared_preferences_foundation: shared_preferences_foundation:
:path: ".symlinks/plugins/shared_preferences_foundation/darwin" :path: ".symlinks/plugins/shared_preferences_foundation/darwin"
sqflite_darwin: sqflite_darwin:
@@ -140,7 +134,6 @@ SPEC CHECKSUMS:
mobile_scanner: af8f71879eaba2bbcb4d86c6a462c3c0e7f23036 mobile_scanner: af8f71879eaba2bbcb4d86c6a462c3c0e7f23036
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
+286 -185
View File
@@ -5,7 +5,6 @@ import 'package:crypto/crypto.dart' as crypto;
import 'package:pointycastle/export.dart'; import 'package:pointycastle/export.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter_blue_plus/flutter_blue_plus.dart'; import 'package:flutter_blue_plus/flutter_blue_plus.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
import '../models/channel.dart'; import '../models/channel.dart';
import '../models/channel_message.dart'; import '../models/channel_message.dart';
@@ -24,6 +23,7 @@ import '../services/notification_service.dart';
import '../storage/channel_message_store.dart'; import '../storage/channel_message_store.dart';
import '../storage/channel_order_store.dart'; import '../storage/channel_order_store.dart';
import '../storage/channel_settings_store.dart'; import '../storage/channel_settings_store.dart';
import '../storage/channel_store.dart';
import '../storage/contact_settings_store.dart'; import '../storage/contact_settings_store.dart';
import '../storage/contact_store.dart'; import '../storage/contact_store.dart';
import '../storage/message_store.dart'; import '../storage/message_store.dart';
@@ -67,9 +67,9 @@ class MeshCoreConnector extends ChangeNotifier {
final Map<int, List<ChannelMessage>> _channelMessages = {}; final Map<int, List<ChannelMessage>> _channelMessages = {};
final Set<String> _loadedConversationKeys = {}; final Set<String> _loadedConversationKeys = {};
final Map<int, Set<String>> _processedChannelReactions = final Map<int, Set<String>> _processedChannelReactions =
{}; // channelIndex -> Set of "reactionKey_emoji" {}; // channelIndex -> Set of "targetHash_emoji"
final Map<String, Set<String>> _processedContactReactions = final Map<String, Set<String>> _processedContactReactions =
{}; // contactPubKeyHex -> Set of "reactionKey_emoji" {}; // contactPubKeyHex -> Set of "targetHash_emoji"
StreamSubscription<List<ScanResult>>? _scanSubscription; StreamSubscription<List<ScanResult>>? _scanSubscription;
StreamSubscription<BluetoothConnectionState>? _connectionSubscription; StreamSubscription<BluetoothConnectionState>? _connectionSubscription;
@@ -90,11 +90,14 @@ class MeshCoreConnector extends ChangeNotifier {
int? _currentBwHz; int? _currentBwHz;
int? _currentSf; int? _currentSf;
int? _currentCr; int? _currentCr;
bool? _clientRepeat;
int? _firmwareVerCode;
int? _batteryMillivolts; int? _batteryMillivolts;
double? _selfLatitude; double? _selfLatitude;
double? _selfLongitude; double? _selfLongitude;
bool _isLoadingContacts = false; bool _isLoadingContacts = false;
bool _isLoadingChannels = false; bool _isLoadingChannels = false;
bool _hasLoadedChannels = false;
bool _batteryRequested = false; bool _batteryRequested = false;
bool _awaitingSelfInfo = false; bool _awaitingSelfInfo = false;
bool _preserveContactsOnRefresh = false; bool _preserveContactsOnRefresh = false;
@@ -122,7 +125,7 @@ class MeshCoreConnector extends ChangeNotifier {
List<Channel> _previousChannelsCache = []; List<Channel> _previousChannelsCache = [];
static const int _maxChannelSyncRetries = 3; static const int _maxChannelSyncRetries = 3;
static const int _channelSyncTimeoutMs = 2000; // 2 second timeout per channel 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 // Services
MessageRetryService? _retryService; MessageRetryService? _retryService;
@@ -138,14 +141,15 @@ class MeshCoreConnector extends ChangeNotifier {
final ChannelSettingsStore _channelSettingsStore = ChannelSettingsStore(); final ChannelSettingsStore _channelSettingsStore = ChannelSettingsStore();
final ContactSettingsStore _contactSettingsStore = ContactSettingsStore(); final ContactSettingsStore _contactSettingsStore = ContactSettingsStore();
final ContactStore _contactStore = ContactStore(); final ContactStore _contactStore = ContactStore();
final ChannelStore _channelStore = ChannelStore();
final UnreadStore _unreadStore = UnreadStore(); final UnreadStore _unreadStore = UnreadStore();
List<Channel> _cachedChannels = [];
final Map<int, bool> _channelSmazEnabled = {}; final Map<int, bool> _channelSmazEnabled = {};
bool _lastSentWasCliCommand = bool _lastSentWasCliCommand =
false; // Track if last sent message was a CLI command false; // Track if last sent message was a CLI command
final Map<String, bool> _contactSmazEnabled = {}; final Map<String, bool> _contactSmazEnabled = {};
final Set<String> _knownContactKeys = {}; final Set<String> _knownContactKeys = {};
final Map<String, int> _contactLastReadMs = {}; final Map<String, int> _contactUnreadCount = {};
final Map<int, int> _channelLastReadMs = {};
bool _unreadStateLoaded = false; bool _unreadStateLoaded = false;
final Map<String, _RepeaterAckContext> _pendingRepeaterAcks = {}; final Map<String, _RepeaterAckContext> _pendingRepeaterAcks = {};
String? _activeContactKey; String? _activeContactKey;
@@ -198,6 +202,8 @@ class MeshCoreConnector extends ChangeNotifier {
int? get currentBwHz => _currentBwHz; int? get currentBwHz => _currentBwHz;
int? get currentSf => _currentSf; int? get currentSf => _currentSf;
int? get currentCr => _currentCr; int? get currentCr => _currentCr;
bool? get clientRepeat => _clientRepeat;
int? get firmwareVerCode => _firmwareVerCode;
Map<String, String>? get currentCustomVars => _currentCustomVars; Map<String, String>? get currentCustomVars => _currentCustomVars;
int? get batteryMillivolts => _batteryMillivolts; int? get batteryMillivolts => _batteryMillivolts;
int get maxContacts => _maxContacts; int get maxContacts => _maxContacts;
@@ -320,17 +326,7 @@ class MeshCoreConnector extends ChangeNotifier {
int getUnreadCountForContactKey(String contactKeyHex) { int getUnreadCountForContactKey(String contactKeyHex) {
if (!_unreadStateLoaded) return 0; if (!_unreadStateLoaded) return 0;
if (!_shouldTrackUnreadForContactKey(contactKeyHex)) return 0; if (!_shouldTrackUnreadForContactKey(contactKeyHex)) return 0;
final messages = _conversations[contactKeyHex]; return _contactUnreadCount[contactKeyHex] ?? 0;
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;
} }
int getUnreadCountForChannel(Channel channel) { int getUnreadCountForChannel(Channel channel) {
@@ -339,17 +335,7 @@ class MeshCoreConnector extends ChangeNotifier {
int getUnreadCountForChannelIndex(int channelIndex) { int getUnreadCountForChannelIndex(int channelIndex) {
if (!_unreadStateLoaded) return 0; if (!_unreadStateLoaded) return 0;
final messages = _channelMessages[channelIndex]; return _findChannelByIndex(channelIndex)?.unreadCount ?? 0;
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;
} }
int getTotalUnreadCount() { int getTotalUnreadCount() {
@@ -379,16 +365,17 @@ class MeshCoreConnector extends ChangeNotifier {
} }
Future<void> loadUnreadState() async { Future<void> loadUnreadState() async {
_contactLastReadMs _contactUnreadCount
..clear() ..clear()
..addAll(await _unreadStore.loadContactLastRead()); ..addAll(await _unreadStore.loadContactUnreadCount());
_channelLastReadMs
..clear()
..addAll(await _unreadStore.loadChannelLastRead());
_unreadStateLoaded = true; _unreadStateLoaded = true;
notifyListeners(); notifyListeners();
} }
Future<void> loadCachedChannels() async {
_cachedChannels = await _channelStore.loadChannels();
}
void setActiveContact(String? contactKeyHex) { void setActiveContact(String? contactKeyHex) {
if (contactKeyHex != null && if (contactKeyHex != null &&
!_shouldTrackUnreadForContactKey(contactKeyHex)) { !_shouldTrackUnreadForContactKey(contactKeyHex)) {
@@ -410,17 +397,36 @@ class MeshCoreConnector extends ChangeNotifier {
void markContactRead(String contactKeyHex) { void markContactRead(String contactKeyHex) {
if (!_shouldTrackUnreadForContactKey(contactKeyHex)) return; if (!_shouldTrackUnreadForContactKey(contactKeyHex)) return;
final markMs = _calculateReadTimestampMs( final previousCount = _contactUnreadCount[contactKeyHex] ?? 0;
_conversations[contactKeyHex]?.map((m) => m.timestamp), if (previousCount > 0) {
); _contactUnreadCount[contactKeyHex] = 0;
_setContactLastReadMs(contactKeyHex, markMs); _appDebugLogService?.info(
'Contact $contactKeyHex marked as read (was $previousCount unread)',
tag: 'Unread',
);
_unreadStore.saveContactUnreadCount(
Map<String, int>.from(_contactUnreadCount),
);
notifyListeners();
}
} }
void markChannelRead(int channelIndex) { void markChannelRead(int channelIndex) {
final markMs = _calculateReadTimestampMs( final channel = _findChannelByIndex(channelIndex);
_channelMessages[channelIndex]?.map((m) => m.timestamp), if (channel != null && channel.unreadCount > 0) {
); final previousCount = channel.unreadCount;
_setChannelLastReadMs(channelIndex, markMs); 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 { Future<void> setChannelSmazEnabled(int channelIndex, bool enabled) async {
@@ -655,7 +661,8 @@ class MeshCoreConnector extends ChangeNotifier {
_scanResults.clear(); _scanResults.clear();
for (var result in results) { for (var result in results) {
if (result.device.platformName.startsWith("MeshCore-") || if (result.device.platformName.startsWith("MeshCore-") ||
result.advertisementData.advName.startsWith("MeshCore-")) { result.advertisementData.advName.startsWith("MeshCore-") ||
result.advertisementData.advName.startsWith("Whisper-")) {
_scanResults.add(result); _scanResults.add(result);
} }
} }
@@ -706,7 +713,7 @@ class MeshCoreConnector extends ChangeNotifier {
try { try {
_connectionSubscription = device.connectionState.listen((state) { _connectionSubscription = device.connectionState.listen((state) {
if (state == BluetoothConnectionState.disconnected) { if (state == BluetoothConnectionState.disconnected && isConnected) {
_handleDisconnection(); _handleDisconnection();
} }
}); });
@@ -772,9 +779,6 @@ class MeshCoreConnector extends ChangeNotifier {
_setState(MeshCoreConnectionState.connected); _setState(MeshCoreConnectionState.connected);
// Enable wake lock to prevent BLE disconnection when screen turns off
await WakelockPlus.enable();
await _requestDeviceInfo(); await _requestDeviceInfo();
_startBatteryPolling(); _startBatteryPolling();
final gotSelfInfo = await _waitForSelfInfo( final gotSelfInfo = await _waitForSelfInfo(
@@ -787,6 +791,9 @@ class MeshCoreConnector extends ChangeNotifier {
// Keep device clock aligned on every connection. // Keep device clock aligned on every connection.
await syncTime(); await syncTime();
// Fetch channels so we can track unread counts for incoming messages
unawaited(getChannels());
} catch (e) { } catch (e) {
debugPrint("Connection error: $e"); debugPrint("Connection error: $e");
await disconnect(manual: false); await disconnect(manual: false);
@@ -880,9 +887,6 @@ class MeshCoreConnector extends ChangeNotifier {
_setState(MeshCoreConnectionState.disconnecting); _setState(MeshCoreConnectionState.disconnecting);
_stopBatteryPolling(); _stopBatteryPolling();
// Disable wake lock when disconnecting
await WakelockPlus.disable();
await _notifySubscription?.cancel(); await _notifySubscription?.cancel();
_notifySubscription = null; _notifySubscription = null;
@@ -916,6 +920,8 @@ class MeshCoreConnector extends ChangeNotifier {
_selfName = null; _selfName = null;
_selfLatitude = null; _selfLatitude = null;
_selfLongitude = null; _selfLongitude = null;
_clientRepeat = null;
_firmwareVerCode = null;
_batteryMillivolts = null; _batteryMillivolts = null;
_batteryRequested = false; _batteryRequested = false;
_awaitingSelfInfo = false; _awaitingSelfInfo = false;
@@ -927,6 +933,7 @@ class MeshCoreConnector extends ChangeNotifier {
_pendingQueueSync = false; _pendingQueueSync = false;
_isSyncingChannels = false; _isSyncingChannels = false;
_channelSyncInFlight = false; _channelSyncInFlight = false;
_hasLoadedChannels = false;
_setState(MeshCoreConnectionState.disconnected); _setState(MeshCoreConnectionState.disconnected);
if (!manual) { if (!manual) {
@@ -990,6 +997,7 @@ class MeshCoreConnector extends ChangeNotifier {
} }
Future<void> _requestDeviceInfo() async { Future<void> _requestDeviceInfo() async {
if (!isConnected || _awaitingSelfInfo) return;
_awaitingSelfInfo = true; _awaitingSelfInfo = true;
await sendFrame(buildDeviceQueryFrame()); await sendFrame(buildDeviceQueryFrame());
await sendFrame(buildAppStartFrame()); await sendFrame(buildAppStartFrame());
@@ -1283,15 +1291,12 @@ class MeshCoreConnector extends ChangeNotifier {
if (reactionInfo != null) { if (reactionInfo != null) {
// Check if we've already processed this reaction // Check if we've already processed this reaction
_processedChannelReactions.putIfAbsent(channel.index, () => {}); _processedChannelReactions.putIfAbsent(channel.index, () => {});
final reactionKey = reactionInfo.reactionKey; final reactionIdentifier =
final reactionIdentifier = reactionKey != null '${reactionInfo.targetHash}_${reactionInfo.emoji}';
? '${reactionKey}_${reactionInfo.emoji}'
: null;
if (reactionIdentifier != null && if (_processedChannelReactions[channel.index]!.contains(
_processedChannelReactions[channel.index]!.contains( reactionIdentifier,
reactionIdentifier, )) {
)) {
// Already processed, don't process again // Already processed, don't process again
return; return;
} }
@@ -1305,9 +1310,7 @@ class MeshCoreConnector extends ChangeNotifier {
await _channelMessageStore.saveChannelMessages(channel.index, messages); await _channelMessageStore.saveChannelMessages(channel.index, messages);
// Mark this reaction as processed // Mark this reaction as processed
if (reactionIdentifier != null) { _processedChannelReactions[channel.index]!.add(reactionIdentifier);
_processedChannelReactions[channel.index]!.add(reactionIdentifier);
}
notifyListeners(); notifyListeners();
@@ -1343,8 +1346,10 @@ class MeshCoreConnector extends ChangeNotifier {
unawaited(_persistContacts()); unawaited(_persistContacts());
_conversations.remove(contact.publicKeyHex); _conversations.remove(contact.publicKeyHex);
_loadedConversationKeys.remove(contact.publicKeyHex); _loadedConversationKeys.remove(contact.publicKeyHex);
_contactLastReadMs.remove(contact.publicKeyHex); _contactUnreadCount.remove(contact.publicKeyHex);
_unreadStore.saveContactLastRead(Map<String, int>.from(_contactLastReadMs)); _unreadStore.saveContactUnreadCount(
Map<String, int>.from(_contactUnreadCount),
);
_messageStore.clearMessages(contact.publicKeyHex); _messageStore.clearMessages(contact.publicKeyHex);
notifyListeners(); notifyListeners();
} }
@@ -1500,13 +1505,21 @@ class MeshCoreConnector extends ChangeNotifier {
await sendCliCommand('set privacy ${enabled ? 'on' : 'off'}'); 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 (!isConnected) return;
if (_isSyncingChannels) { if (_isSyncingChannels) {
debugPrint('[ChannelSync] Already syncing channels, ignoring request'); debugPrint('[ChannelSync] Already syncing channels, ignoring request');
return; 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; _isLoadingChannels = true;
_isSyncingChannels = true; _isSyncingChannels = true;
_previousChannelsCache = List<Channel>.from(_channels); _previousChannelsCache = List<Channel>.from(_channels);
@@ -1611,6 +1624,10 @@ class MeshCoreConnector extends ChangeNotifier {
_cleanupChannelSync(completed: true); _cleanupChannelSync(completed: true);
// Cache channels for offline use
_cachedChannels = List<Channel>.from(_channels);
unawaited(_channelStore.saveChannels(_channels));
// Apply ordering and notify UI // Apply ordering and notify UI
_applyChannelOrder(); _applyChannelOrder();
notifyListeners(); notifyListeners();
@@ -1626,6 +1643,7 @@ class MeshCoreConnector extends ChangeNotifier {
_totalChannelsToRequest = 0; _totalChannelsToRequest = 0;
if (completed) { if (completed) {
_hasLoadedChannels = true;
_previousChannelsCache.clear(); _previousChannelsCache.clear();
} }
// Keep cache on failure/disconnection for future attempts // Keep cache on failure/disconnection for future attempts
@@ -1636,7 +1654,7 @@ class MeshCoreConnector extends ChangeNotifier {
await sendFrame(buildSetChannelFrame(index, name, psk)); await sendFrame(buildSetChannelFrame(index, name, psk));
// Refresh channels after setting // Refresh channels after setting
await getChannels(); await getChannels(force: true);
} }
Future<void> deleteChannel(int index) async { Future<void> deleteChannel(int index) async {
@@ -1644,14 +1662,12 @@ class MeshCoreConnector extends ChangeNotifier {
// Delete by setting empty name and zero PSK // Delete by setting empty name and zero PSK
await sendFrame(buildSetChannelFrame(index, '', Uint8List(16))); await sendFrame(buildSetChannelFrame(index, '', Uint8List(16)));
_channelLastReadMs.remove(index);
_unreadStore.saveChannelLastRead(Map<int, int>.from(_channelLastReadMs));
// Clear stored messages for this channel // Clear stored messages for this channel
await _channelMessageStore.clearChannelMessages(index); await _channelMessageStore.clearChannelMessages(index);
// Clear in-memory messages for this channel // Clear in-memory messages for this channel
_channelMessages.remove(index); _channelMessages.remove(index);
// Refresh channels after deleting // Refresh channels after deleting
await getChannels(); await getChannels(force: true);
} }
void _handleFrame(List<int> data) { void _handleFrame(List<int> data) {
@@ -1810,6 +1826,13 @@ class MeshCoreConnector extends ChangeNotifier {
void _handleDeviceInfo(Uint8List frame) { void _handleDeviceInfo(Uint8List frame) {
if (frame.length < 4) return; if (frame.length < 4) return;
_firmwareVerCode = frame[1];
// Parse client_repeat from firmware v9+ (byte 80)
if (frame.length >= 81) {
_clientRepeat = frame[80] != 0;
}
// Firmware reports MAX_CONTACTS / 2 for v3+ device info. // Firmware reports MAX_CONTACTS / 2 for v3+ device info.
final reportedContacts = frame[2]; final reportedContacts = frame[2];
final reportedChannels = frame[3]; final reportedChannels = frame[3];
@@ -1830,8 +1853,8 @@ class MeshCoreConnector extends ChangeNotifier {
unawaited(getChannels(maxChannels: nextMaxChannels)); unawaited(getChannels(maxChannels: nextMaxChannels));
} }
} }
notifyListeners();
} }
notifyListeners();
} }
void _handleNoMoreMessages() { void _handleNoMoreMessages() {
@@ -1923,9 +1946,9 @@ class MeshCoreConnector extends ChangeNotifier {
final contact = Contact.fromFrame(frame); final contact = Contact.fromFrame(frame);
if (contact != null) { if (contact != null) {
if (contact.type == advTypeRepeater) { if (contact.type == advTypeRepeater) {
_contactLastReadMs.remove(contact.publicKeyHex); _contactUnreadCount.remove(contact.publicKeyHex);
_unreadStore.saveContactLastRead( _unreadStore.saveContactUnreadCount(
Map<String, int>.from(_contactLastReadMs), Map<String, int>.from(_contactUnreadCount),
); );
} }
// Check if this is a new contact // Check if this is a new contact
@@ -2112,6 +2135,15 @@ class MeshCoreConnector extends ChangeNotifier {
} }
if (message != null) { 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( final contact = _contacts.cast<Contact?>().firstWhere(
(c) => c?.publicKeyHex == message!.senderKeyHex, (c) => c?.publicKeyHex == message!.senderKeyHex,
orElse: () => null, orElse: () => null,
@@ -2141,7 +2173,7 @@ class MeshCoreConnector extends ChangeNotifier {
} }
} }
_addMessage(message.senderKeyHex, message); _addMessage(message.senderKeyHex, message);
_maybeMarkActiveContactRead(message); _maybeIncrementContactUnread(message);
notifyListeners(); notifyListeners();
// Show notification for new incoming message // Show notification for new incoming message
@@ -2332,7 +2364,7 @@ class MeshCoreConnector extends ChangeNotifier {
pathBytes: message.pathBytes, pathBytes: message.pathBytes,
); );
final isNew = _addChannelMessage(message.channelIndex!, message); final isNew = _addChannelMessage(message.channelIndex!, message);
_maybeMarkActiveChannelRead(message); _maybeIncrementChannelUnread(message, isNew: isNew);
notifyListeners(); notifyListeners();
if (isNew) { if (isNew) {
_maybeNotifyChannelMessage(message); _maybeNotifyChannelMessage(message);
@@ -2354,7 +2386,9 @@ class MeshCoreConnector extends ChangeNotifier {
final channelHash = payload[0]; final channelHash = payload[0];
final encrypted = Uint8List.fromList(payload.sublist(1)); 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; if (channel.isEmpty) continue;
final hash = _computeChannelHash(channel.psk); final hash = _computeChannelHash(channel.psk);
if (hash != channelHash) continue; if (hash != channelHash) continue;
@@ -2393,7 +2427,7 @@ class MeshCoreConnector extends ChangeNotifier {
pathBytes: message.pathBytes, pathBytes: message.pathBytes,
); );
final isNew = _addChannelMessage(channel.index, message); final isNew = _addChannelMessage(channel.index, message);
_maybeMarkActiveChannelRead(message); _maybeIncrementChannelUnread(message, isNew: isNew);
notifyListeners(); notifyListeners();
if (isNew) { if (isNew) {
final label = channel.name.isEmpty final label = channel.name.isEmpty
@@ -2523,6 +2557,15 @@ class MeshCoreConnector extends ChangeNotifier {
'[ChannelSync] Received channel ${channel.index}: ${channel.isEmpty ? "empty" : channel.name}', '[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 we're syncing and this is the channel we're waiting for
if (_isSyncingChannels && _channelSyncInFlight) { if (_isSyncingChannels && _channelSyncInFlight) {
if (channel.index == _nextChannelIndexToRequest) { if (channel.index == _nextChannelIndexToRequest) {
@@ -2562,6 +2605,8 @@ class MeshCoreConnector extends ChangeNotifier {
(c) => c.index == channel.index, (c) => c.index == channel.index,
); );
if (existingIndex >= 0) { if (existingIndex >= 0) {
// Preserve unread count from existing channel
channel.unreadCount = _channels[existingIndex].unreadCount;
_channels[existingIndex] = channel; _channels[existingIndex] = channel;
} else { } else {
_channels.add(channel); _channels.add(channel);
@@ -2612,67 +2657,98 @@ class MeshCoreConnector extends ChangeNotifier {
return contact.type != advTypeRepeater; return contact.type != advTypeRepeater;
} }
int _calculateReadTimestampMs(Iterable<DateTime>? timestamps) { Channel? _findChannelByIndex(int index) {
var latestMs = 0; return _channels.cast<Channel?>().firstWhere(
if (timestamps != null) { (c) => c?.index == index,
for (final timestamp in timestamps) { orElse: () => null,
final ms = timestamp.millisecondsSinceEpoch; ) ??
if (ms > latestMs) { _cachedChannels.cast<Channel?>().firstWhere(
latestMs = ms; (c) => c?.index == index,
} orElse: () => null,
} );
}
return latestMs;
} }
void _setContactLastReadMs( void _maybeIncrementChannelUnread(
String contactKeyHex, ChannelMessage message, {
int timestampMs, { required bool isNew,
bool notify = true,
}) { }) {
if (!_shouldTrackUnreadForContactKey(contactKeyHex)) return; if (!isNew || message.isOutgoing) {
final existing = _contactLastReadMs[contactKeyHex] ?? 0; _appDebugLogService?.info(
if (timestampMs <= existing) return; 'Skip unread increment: isNew=$isNew, isOutgoing=${message.isOutgoing}',
_contactLastReadMs[contactKeyHex] = timestampMs; tag: 'Unread',
_unreadStore.saveContactLastRead(Map<String, int>.from(_contactLastReadMs)); );
if (notify) { return;
notifyListeners();
} }
}
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; final channelIndex = message.channelIndex;
if (channelIndex == null || _activeChannelIndex != channelIndex) return; if (channelIndex == null) {
_setChannelLastReadMs( _appDebugLogService?.info(
channelIndex, 'Skip unread increment: channelIndex is null',
message.timestamp.millisecondsSinceEpoch, tag: 'Unread',
notify: false, );
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),
); );
} }
@@ -2683,26 +2759,22 @@ class MeshCoreConnector extends ChangeNotifier {
// Parse reaction info // Parse reaction info
final reactionInfo = Message.parseReaction(message.text); final reactionInfo = Message.parseReaction(message.text);
if (reactionInfo != null) { 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, () => {}); _processedContactReactions.putIfAbsent(pubKeyHex, () => {});
final reactionKey = reactionInfo.reactionKey; final reactionIdentifier =
final reactionIdentifier = reactionKey != null '${reactionInfo.targetHash}_${reactionInfo.emoji}';
? '${reactionKey}_${reactionInfo.emoji}'
: null;
final isDuplicate = final isDuplicate = _processedContactReactions[pubKeyHex]!.contains(
reactionIdentifier != null && reactionIdentifier,
_processedContactReactions[pubKeyHex]!.contains(reactionIdentifier); );
if (!isDuplicate) { if (!isDuplicate) {
// New reaction - process it // New reaction - process it
_processContactReaction(messages, reactionInfo); _processContactReaction(messages, reactionInfo, pubKeyHex);
_messageStore.saveMessages(pubKeyHex, messages); _messageStore.saveMessages(pubKeyHex, messages);
// Mark as processed // Mark as processed
if (reactionIdentifier != null) { _processedContactReactions[pubKeyHex]!.add(reactionIdentifier);
_processedContactReactions[pubKeyHex]!.add(reactionIdentifier);
}
notifyListeners(); notifyListeners();
} }
@@ -2717,15 +2789,53 @@ class MeshCoreConnector extends ChangeNotifier {
void _processContactReaction( void _processContactReaction(
List<Message> messages, List<Message> messages,
ReactionInfo reactionInfo, ReactionInfo reactionInfo,
String contactPubKeyHex,
) { ) {
// Find target message by messageId // Find target message by computing hash and comparing
for (int i = 0; i < messages.length; i++) { final targetHash = reactionInfo.targetHash;
if (messages[i].messageId == reactionInfo.targetMessageId) { final contact = _contacts.cast<Contact?>().firstWhere(
final currentReactions = Map<String, int>.from(messages[i].reactions); (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] =
(currentReactions[reactionInfo.emoji] ?? 0) + 1; (currentReactions[reactionInfo.emoji] ?? 0) + 1;
messages[i] = messages[i].copyWith(reactions: currentReactions); messages[i] = msg.copyWith(reactions: currentReactions);
break; break;
} }
} }
@@ -2876,18 +2986,14 @@ class MeshCoreConnector extends ChangeNotifier {
// Parse reaction info // Parse reaction info
final reactionInfo = ChannelMessage.parseReaction(message.text); final reactionInfo = ChannelMessage.parseReaction(message.text);
if (reactionInfo != null) { 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, () => {}); _processedChannelReactions.putIfAbsent(channelIndex, () => {});
final reactionKey = reactionInfo.reactionKey; final reactionIdentifier =
final reactionIdentifier = reactionKey != null '${reactionInfo.targetHash}_${reactionInfo.emoji}';
? '${reactionKey}_${reactionInfo.emoji}'
: null;
final isDuplicate = final isDuplicate = _processedChannelReactions[channelIndex]!.contains(
reactionIdentifier != null && reactionIdentifier,
_processedChannelReactions[channelIndex]!.contains( );
reactionIdentifier,
);
if (!isDuplicate) { if (!isDuplicate) {
// New reaction - process it // New reaction - process it
@@ -2896,9 +3002,7 @@ class MeshCoreConnector extends ChangeNotifier {
_channelMessageStore.saveChannelMessages(channelIndex, messages); _channelMessageStore.saveChannelMessages(channelIndex, messages);
// Mark as processed // Mark as processed
if (reactionIdentifier != null) { _processedChannelReactions[channelIndex]!.add(reactionIdentifier);
_processedChannelReactions[channelIndex]!.add(reactionIdentifier);
}
} }
return false; // Don't add reaction as a visible message return false; // Don't add reaction as a visible message
} }
@@ -2994,14 +3098,22 @@ class MeshCoreConnector extends ChangeNotifier {
List<ChannelMessage> messages, List<ChannelMessage> messages,
ReactionInfo reactionInfo, ReactionInfo reactionInfo,
) { ) {
// Find target message by messageId // Find target message by computing hash and comparing
for (int i = 0; i < messages.length; i++) { final targetHash = reactionInfo.targetHash;
if (messages[i].messageId == reactionInfo.targetMessageId) { for (int i = messages.length - 1; i >= 0; i--) {
final currentReactions = Map<String, int>.from(messages[i].reactions); 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] =
(currentReactions[reactionInfo.emoji] ?? 0) + 1; (currentReactions[reactionInfo.emoji] ?? 0) + 1;
messages[i] = messages[i].copyWith(reactions: currentReactions); messages[i] = msg.copyWith(reactions: currentReactions);
notifyListeners(); notifyListeners();
break; break;
} }
@@ -3043,28 +3155,19 @@ class MeshCoreConnector extends ChangeNotifier {
} }
bool _shouldDropSelfChannelMessage(String senderName, Uint8List pathBytes) { bool _shouldDropSelfChannelMessage(String senderName, Uint8List pathBytes) {
final selfKey = _selfPublicKey;
if (selfKey == null) return false;
if (pathBytes.length < pathHashSize) return false;
final trimmed = senderName.trim(); final trimmed = senderName.trim();
if (trimmed.isEmpty) return false; if (trimmed.isEmpty) return false;
final selfName = _selfName?.trim(); final selfName = _selfName?.trim();
if (selfName == null || selfName.isEmpty) return false; if (selfName == null || selfName.isEmpty) return false;
// If sender name doesn't match, keep the message
if (trimmed != selfName) return false; if (trimmed != selfName) return false;
final prefix = selfKey.sublist(0, pathHashSize);
for (int i = 0; i + pathHashSize <= pathBytes.length; i += pathHashSize) { // Name matches - this is from self
var match = true; // Drop only if pathBytes is empty (direct broadcast)
for (int j = 0; j < pathHashSize; j++) { // Keep if pathBytes has data (repeated through another node)
if (pathBytes[i + j] != prefix[j]) { return pathBytes.isEmpty;
match = false;
break;
}
}
if (match) {
return true;
}
}
return false;
} }
Uint8List _selectPreferredPathBytes(Uint8List existing, Uint8List incoming) { Uint8List _selectPreferredPathBytes(Uint8List existing, Uint8List incoming) {
@@ -3118,8 +3221,6 @@ class MeshCoreConnector extends ChangeNotifier {
} }
void _handleDisconnection() { void _handleDisconnection() {
// Disable wake lock when connection is lost
WakelockPlus.disable();
_stopBatteryPolling(); _stopBatteryPolling();
for (final entry in _pendingRepeaterAcks.values) { for (final entry in _pendingRepeaterAcks.values) {
+84 -4
View File
@@ -18,6 +18,10 @@ class BufferReader {
return data; return data;
} }
void skipBytes(int count) {
_pointer += count;
}
Uint8List readRemainingBytes() => readBytes(remaining); Uint8List readRemainingBytes() => readBytes(remaining);
String readString() => String readString() =>
@@ -98,6 +102,25 @@ class BufferWriter {
} }
writeBytes(bytes); 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) // Command codes (to device)
@@ -127,6 +150,7 @@ const int cmdSendStatusReq = 27;
const int cmdGetContactByKey = 30; const int cmdGetContactByKey = 30;
const int cmdGetChannel = 31; const int cmdGetChannel = 31;
const int cmdSetChannel = 32; const int cmdSetChannel = 32;
const int cmdSendTracePath = 36;
const int cmdGetRadioSettings = 57; const int cmdGetRadioSettings = 57;
const int cmdGetTelemetryReq = 39; const int cmdGetTelemetryReq = 39;
const int cmdGetCustomVar = 40; const int cmdGetCustomVar = 40;
@@ -159,6 +183,7 @@ const int respCodeContactMsgRecv = 7;
const int respCodeChannelMsgRecv = 8; const int respCodeChannelMsgRecv = 8;
const int respCodeCurrTime = 9; const int respCodeCurrTime = 9;
const int respCodeNoMoreMessages = 10; const int respCodeNoMoreMessages = 10;
const int respCodeExportContact = 11;
const int respCodeBattAndStorage = 12; const int respCodeBattAndStorage = 12;
const int respCodeDeviceInfo = 13; const int respCodeDeviceInfo = 13;
const int respCodeContactMsgRecvV3 = 16; const int respCodeContactMsgRecvV3 = 16;
@@ -176,6 +201,7 @@ const int pushCodeLoginSuccess = 0x85;
const int pushCodeLoginFail = 0x86; const int pushCodeLoginFail = 0x86;
const int pushCodeStatusResponse = 0x87; const int pushCodeStatusResponse = 0x87;
const int pushCodeLogRxData = 0x88; const int pushCodeLogRxData = 0x88;
const int pushCodeTraceData = 0x89;
const int pushCodeNewAdvert = 0x8A; const int pushCodeNewAdvert = 0x8A;
const int pushCodeTelemetryResponse = 0x8B; const int pushCodeTelemetryResponse = 0x8B;
const int pushCodeBinaryResponse = 0x8C; const int pushCodeBinaryResponse = 0x8C;
@@ -195,8 +221,10 @@ const int maxFrameSize = 172;
const int appProtocolVersion = 3; const int appProtocolVersion = 3;
// Matches firmware MAX_TEXT_LEN (10 * CIPHER_BLOCK_SIZE). // Matches firmware MAX_TEXT_LEN (10 * CIPHER_BLOCK_SIZE).
const int maxTextPayloadBytes = 160; const int maxTextPayloadBytes = 160;
const int _sendTextMsgOverheadBytes = 1 + 1 + 1 + 4 + 6 + 1 + 2; // +2 safety margin const int _sendTextMsgOverheadBytes =
const int _sendChannelTextMsgOverheadBytes = 1 + 1 + 1 + 4 + 1 + 2; // +2 safety margin 1 + 1 + 1 + 4 + 6 + 1 + 2; // +2 safety margin
const int _sendChannelTextMsgOverheadBytes =
1 + 1 + 1 + 4 + 1 + 2; // +2 safety margin
int maxContactMessageBytes() { int maxContactMessageBytes() {
final byFrame = maxFrameSize - _sendTextMsgOverheadBytes; final byFrame = maxFrameSize - _sendTextMsgOverheadBytes;
@@ -522,18 +550,29 @@ Uint8List buildSetChannelFrame(int channelIndex, String name, Uint8List psk) {
} }
// Build CMD_SET_RADIO_PARAMS frame // Build CMD_SET_RADIO_PARAMS frame
// Format: [cmd][freq x4][bw x4][sf][cr] // Format: [cmd][freq x4][bw x4][sf][cr] (pre-v9)
// [cmd][freq x4][bw x4][sf][cr][repeat] (firmware v9+)
// freq: frequency in Hz (300000-2500000) // freq: frequency in Hz (300000-2500000)
// bw: bandwidth in Hz (7000-500000) // bw: bandwidth in Hz (7000-500000)
// sf: spreading factor (5-12) // sf: spreading factor (5-12)
// cr: coding rate (5-8) // cr: coding rate (5-8)
Uint8List buildSetRadioParamsFrame(int freqHz, int bwHz, int sf, int cr) { // clientRepeat: enable off-grid packet repeat (firmware v9+, omit for older)
Uint8List buildSetRadioParamsFrame(
int freqHz,
int bwHz,
int sf,
int cr, {
bool? clientRepeat,
}) {
final writer = BufferWriter(); final writer = BufferWriter();
writer.writeByte(cmdSetRadioParams); writer.writeByte(cmdSetRadioParams);
writer.writeUInt32LE(freqHz); writer.writeUInt32LE(freqHz);
writer.writeUInt32LE(bwHz); writer.writeUInt32LE(bwHz);
writer.writeByte(sf); writer.writeByte(sf);
writer.writeByte(cr); writer.writeByte(cr);
if (clientRepeat != null) {
writer.writeByte(clientRepeat ? 1 : 0);
}
return writer.toBytes(); return writer.toBytes();
} }
@@ -708,3 +747,44 @@ Uint8List buildSendBinaryReq(Uint8List repeaterPubKey, {Uint8List? payload}) {
} }
return writer.toBytes(); 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 lppUnixTime = 133; // 4 bytes, unsigned
static const int lppGyrometer = 134; // 2 bytes per axis, 0.01 °/s 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 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 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(); final BufferWriter _writer = BufferWriter();
@@ -201,10 +203,10 @@ class CayenneLpp {
break; break;
} }
final channelData = channels.putIfAbsent(channel, () => { final channelData = channels.putIfAbsent(
'channel': channel, channel,
'values': <String, dynamic>{}, () => {'channel': channel, 'values': <String, dynamic>{}},
}); );
switch (type) { switch (type) {
case lppGenericSensor: case lppGenericSensor:
@@ -254,8 +256,8 @@ class CayenneLpp {
} }
} }
final List<Map<String, dynamic>> channelsOut = channels.values.toList(); final List<Map<String, dynamic>> channelsOut = channels.values.toList();
channelsOut.sort((a, b) => a['channel'].compareTo(b['channel'])); channelsOut.sort((a, b) => a['channel'].compareTo(b['channel']));
return channelsOut; return channelsOut;
} }
} }
+1 -4
View File
@@ -26,10 +26,7 @@ class LinkHandler {
), ),
child: SelectableText( child: SelectableText(
url, url,
style: const TextStyle( style: const TextStyle(fontSize: 12, fontFamily: 'monospace'),
fontSize: 12,
fontFamily: 'monospace',
),
), ),
), ),
], ],
+57 -42
View File
@@ -1,53 +1,68 @@
class ReactionInfo { import '../widgets/emoji_picker.dart';
final String targetMessageId;
final String emoji;
final String? reactionKey; // Lightweight key for deduplication: timestamp_senderPrefix
ReactionInfo({ class ReactionInfo {
required this.targetMessageId, final String targetHash;
required this.emoji, final String emoji;
this.reactionKey,
}); ReactionInfo({required this.targetHash, required this.emoji});
} }
class ReactionHelper { class ReactionHelper {
/// Parse reaction format: r:[messageId]:[emoji] static List<String>? _cachedEmojis;
/// Supports both old format (full messageId) and new format (timestamp_senderPrefix)
/// 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) { 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); final match = regex.firstMatch(text);
if (match == null) return null; if (match == null) return null;
final targetId = match.group(1)!; final emoji = indexToEmoji(match.group(2)!);
final emoji = match.group(2)!; if (emoji == null) return null;
// Extract reaction key for deduplication return ReactionInfo(targetHash: match.group(1)!, emoji: emoji);
// 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);
} }
} }
+15 -6
View File
@@ -262,8 +262,9 @@ class Smaz {
".com", ".com",
]; ];
static final List<Uint8List> _rcbBytes = static final List<Uint8List> _rcbBytes = _rcb
_rcb.map((s) => Uint8List.fromList(ascii.encode(s))).toList(growable: false); .map((s) => Uint8List.fromList(ascii.encode(s)))
.toList(growable: false);
static final int _maxEntryLen = _rcbBytes.fold(0, (maxLen, entry) { static final int _maxEntryLen = _rcbBytes.fold(0, (maxLen, entry) {
return entry.length > maxLen ? entry.length : maxLen; return entry.length > maxLen ? entry.length : maxLen;
}); });
@@ -358,24 +359,32 @@ class Smaz {
final code = input[index]; final code = input[index];
if (code == _verbatimSingle) { if (code == _verbatimSingle) {
if (index + 1 >= input.length) { 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]); out.addByte(input[index + 1]);
index += 2; index += 2;
} else if (code == _verbatimRun) { } else if (code == _verbatimRun) {
if (index + 1 >= input.length) { 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 len = input[index + 1] + 1;
final end = index + 2 + len; final end = index + 2 + len;
if (end > input.length) { 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)); out.add(input.sublist(index + 2, end));
index = end; index = end;
} else { } else {
if (code >= _rcbBytes.length) { 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]); out.add(_rcbBytes[code]);
index += 1; index += 1;
+4 -1
View File
@@ -8,7 +8,10 @@ class Utf8LengthLimitingTextInputFormatter extends TextInputFormatter {
const Utf8LengthLimitingTextInputFormatter(this.maxBytes); const Utf8LengthLimitingTextInputFormatter(this.maxBytes);
@override @override
TextEditingValue formatEditUpdate(TextEditingValue oldValue, TextEditingValue newValue) { TextEditingValue formatEditUpdate(
TextEditingValue oldValue,
TextEditingValue newValue,
) {
if (maxBytes <= 0) return oldValue; if (maxBytes <= 0) return oldValue;
final bytes = utf8.encode(newValue.text); final bytes = utf8.encode(newValue.text);
if (bytes.length <= maxBytes) return newValue; if (bytes.length <= maxBytes) return newValue;
+69 -6
View File
@@ -131,9 +131,6 @@
"settings_infoContactsCount": "Брой контакти", "settings_infoContactsCount": "Брой контакти",
"settings_infoChannelCount": "Брой канали", "settings_infoChannelCount": "Брой канали",
"settings_presets": "Предварителни настройки", "settings_presets": "Предварителни настройки",
"settings_preset915Mhz": "915 MHz",
"settings_preset868Mhz": "868 MHz",
"settings_preset433Mhz": "433 MHz",
"settings_frequency": "Честота (MHz)", "settings_frequency": "Честота (MHz)",
"settings_frequencyHelper": "300.0 - 2500.0", "settings_frequencyHelper": "300.0 - 2500.0",
"settings_frequencyInvalid": "Невалидна честота (300-2500 MHz)", "settings_frequencyInvalid": "Невалидна честота (300-2500 MHz)",
@@ -143,8 +140,6 @@
"settings_txPower": "TX Мощност (dBm)", "settings_txPower": "TX Мощност (dBm)",
"settings_txPowerHelper": "0 - 22", "settings_txPowerHelper": "0 - 22",
"settings_txPowerInvalid": "Невалидна мощност на TX (0-22 dBm)", "settings_txPowerInvalid": "Невалидна мощност на TX (0-22 dBm)",
"settings_longRange": "Дълъг обхват",
"settings_fastSpeed": "Бърза скорост",
"settings_error": "Грешка: {message}", "settings_error": "Грешка: {message}",
"@settings_error": { "@settings_error": {
"placeholders": { "placeholders": {
@@ -1533,5 +1528,73 @@
"community_regenerate": "Регенерация", "community_regenerate": "Регенерация",
"community_updateSecret": "Актуализирай тайна", "community_updateSecret": "Актуализирай тайна",
"community_scanToUpdateSecret": "Сканьорвайте новия QR код, за да актуализирате секрета за \"{name}\"", "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": "Получено ново съобщение",
"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": "Един или повече от хмелите липсва местоположение!",
"map_pathTraceCancelled": "Отменен е следването на пътя.",
"pathTrace_clearTooltip": "Изчисти пътя",
"map_removeLast": "Премахни Последно",
"map_runTrace": "Изпълни Път на Следване",
"map_tapToAdd": "Натиснете върху възлите, за да ги добавите към пътя.",
"scanner_bluetoothOff": "Bluetooth е изключен.",
"scanner_enableBluetooth": "Активирайте Bluetooth",
"scanner_bluetoothOffMessage": "Моля, активирайте Bluetooth, за да сканирате за устройства.",
"settings_clientRepeatSubtitle": "Позволете на това устройство да предава пакети към мрежата за други устройства.",
"settings_clientRepeatFreqWarning": "За повторение извън мрежата са необходими честоти от 433, 869 или 918 MHz.",
"settings_clientRepeat": "Без електричество – повторение"
} }
+131 -40
View File
@@ -74,7 +74,7 @@
"settings_title": "Einstellungen", "settings_title": "Einstellungen",
"settings_deviceInfo": "Geräteinformationen", "settings_deviceInfo": "Geräteinformationen",
"settings_appSettings": "App-Einstellungen", "settings_appSettings": "App-Einstellungen",
"settings_appSettingsSubtitle": "Benachrichtigungen, Messaging und Kartenwahrnehmungen", "settings_appSettingsSubtitle": "Benachrichtigungen, Messaging und Kartenwahrnehmung",
"settings_nodeSettings": "Knoten-Einstellungen", "settings_nodeSettings": "Knoten-Einstellungen",
"settings_nodeName": "Knotenname", "settings_nodeName": "Knotenname",
"settings_nodeNameNotSet": "Nicht festgelegt", "settings_nodeNameNotSet": "Nicht festgelegt",
@@ -96,14 +96,14 @@
"settings_privacyModeEnabled": "Datenschutzmodus aktiviert", "settings_privacyModeEnabled": "Datenschutzmodus aktiviert",
"settings_privacyModeDisabled": "Datenschutzmodus deaktiviert", "settings_privacyModeDisabled": "Datenschutzmodus deaktiviert",
"settings_actions": "Aktionen", "settings_actions": "Aktionen",
"settings_sendAdvertisement": "Sende eine Ankündigung", "settings_sendAdvertisement": "Sende Ankündigung",
"settings_sendAdvertisementSubtitle": "Sende Ankündigung", "settings_sendAdvertisementSubtitle": "Sende eine Ankündigung",
"settings_advertisementSent": "Ankündigung gesendet", "settings_advertisementSent": "Ankündigung gesendet",
"settings_syncTime": "Zeitsynchronisierung", "settings_syncTime": "Zeitsynchronisierung",
"settings_syncTimeSubtitle": "Stelle die Gerätezeit auf die Uhrzeit des Telefons ein", "settings_syncTimeSubtitle": "Stelle die Gerätezeit auf die Uhrzeit des Telefons ein",
"settings_timeSynchronized": "Zeit synchronisiert", "settings_timeSynchronized": "Zeit synchronisiert",
"settings_refreshContacts": "Kontakte aktualisieren", "settings_refreshContacts": "Kontakte aktualisieren",
"settings_refreshContactsSubtitle": "Kontakte-Liste vom Gerät neu laden", "settings_refreshContactsSubtitle": "Kontakt-Liste vom Gerät neu laden",
"settings_rebootDevice": "Gerät neu starten", "settings_rebootDevice": "Gerät neu starten",
"settings_rebootDeviceSubtitle": "MeshCore-Gerät neu starten", "settings_rebootDeviceSubtitle": "MeshCore-Gerät neu starten",
"settings_rebootDeviceConfirm": "Sind Sie sicher, dass Sie das Gerät neu starten möchten? Sie werden getrennt.", "settings_rebootDeviceConfirm": "Sind Sie sicher, dass Sie das Gerät neu starten möchten? Sie werden getrennt.",
@@ -131,9 +131,6 @@
"settings_infoContactsCount": "Anzahl Kontakte", "settings_infoContactsCount": "Anzahl Kontakte",
"settings_infoChannelCount": "Anzahl Kanäle", "settings_infoChannelCount": "Anzahl Kanäle",
"settings_presets": "Voreinstellungen", "settings_presets": "Voreinstellungen",
"settings_preset915Mhz": "915 MHz",
"settings_preset868Mhz": "868 MHz",
"settings_preset433Mhz": "433 MHz",
"settings_frequency": "Frequenz (MHz)", "settings_frequency": "Frequenz (MHz)",
"settings_frequencyHelper": "300,00 - 2.500,00", "settings_frequencyHelper": "300,00 - 2.500,00",
"settings_frequencyInvalid": "Ungültige Frequenz (300-2500 MHz)", "settings_frequencyInvalid": "Ungültige Frequenz (300-2500 MHz)",
@@ -143,8 +140,6 @@
"settings_txPower": "TX-Leistung (dBm)", "settings_txPower": "TX-Leistung (dBm)",
"settings_txPowerHelper": "0 - 22", "settings_txPowerHelper": "0 - 22",
"settings_txPowerInvalid": "Ungültige TX-Leistung (0-22 dBm)", "settings_txPowerInvalid": "Ungültige TX-Leistung (0-22 dBm)",
"settings_longRange": "Grosse Reichweite",
"settings_fastSpeed": "Schnelle Geschwindigkeit",
"settings_error": "Fehler: {message}", "settings_error": "Fehler: {message}",
"@settings_error": { "@settings_error": {
"placeholders": { "placeholders": {
@@ -266,7 +261,7 @@
} }
} }
}, },
"contacts_manageRepeater": "Wiederholungen verwalten", "contacts_manageRepeater": "Repeater verwalten",
"contacts_roomLogin": "Raum-Login", "contacts_roomLogin": "Raum-Login",
"contacts_openChat": "Öffne Chat", "contacts_openChat": "Öffne Chat",
"contacts_editGroup": "Gruppe bearbeiten", "contacts_editGroup": "Gruppe bearbeiten",
@@ -360,7 +355,7 @@
"channels_channelIndexLabel": "Kanalindex", "channels_channelIndexLabel": "Kanalindex",
"channels_channelName": "Kanalname", "channels_channelName": "Kanalname",
"channels_usePublicChannel": "Verwende öffentlichen Kanal", "channels_usePublicChannel": "Verwende öffentlichen Kanal",
"channels_standardPublicPsk": "Standard-Öffentliche PSK", "channels_standardPublicPsk": "Öffentliche Standard PSK",
"channels_pskHex": "PSK (Hex)", "channels_pskHex": "PSK (Hex)",
"channels_generateRandomPsk": "Zufällige PSK generieren", "channels_generateRandomPsk": "Zufällige PSK generieren",
"channels_enterChannelName": "Bitte geben Sie einen Kanalnamen ein.", "channels_enterChannelName": "Bitte geben Sie einen Kanalnamen ein.",
@@ -489,8 +484,8 @@
} }
} }
}, },
"debugFrame_textMessageHeader": "Textnachricht-Frame:", "debugFrame_textMessageHeader": "Textnachrichten Frame:",
"debugFrame_destinationPubKey": "- Ziel-Pub-Schlüssel: {pubKey}", "debugFrame_destinationPubKey": "- Ziel-Public-Schlüssel: {pubKey}",
"@debugFrame_destinationPubKey": { "@debugFrame_destinationPubKey": {
"placeholders": { "placeholders": {
"pubKey": { "pubKey": {
@@ -540,7 +535,7 @@
"chat_routingMode": "Routenmodus", "chat_routingMode": "Routenmodus",
"chat_autoUseSavedPath": "Automatisch (gespeicherten Pfad verwenden)", "chat_autoUseSavedPath": "Automatisch (gespeicherten Pfad verwenden)",
"chat_forceFloodMode": "Flut-Modus erzwingen", "chat_forceFloodMode": "Flut-Modus erzwingen",
"chat_recentAckPaths": "Aktuelle ACK-Pfade (tasten, um zu verwenden):", "chat_recentAckPaths": "Aktuelle ACK-Pfade (antippen, um zu verwenden):",
"chat_pathHistoryFull": "Die Pfadhistorie ist voll. Entferne Einträge, um neue hinzuzufügen.", "chat_pathHistoryFull": "Die Pfadhistorie ist voll. Entferne Einträge, um neue hinzuzufügen.",
"chat_hopSingular": "Sprung", "chat_hopSingular": "Sprung",
"chat_hopPlural": "Sprünge", "chat_hopPlural": "Sprünge",
@@ -554,7 +549,7 @@
}, },
"chat_successes": "Erfolgreich", "chat_successes": "Erfolgreich",
"chat_removePath": "Pfad entfernen", "chat_removePath": "Pfad entfernen",
"chat_noPathHistoryYet": "Keine eine Pfadhistorie vorhanden.\nSende eine Nachricht, um Pfade zu entdecken.", "chat_noPathHistoryYet": "Keine Pfadhistorie vorhanden.\nSende eine Nachricht, um Pfade zu entdecken.",
"chat_pathActions": "Pfadaktionen:", "chat_pathActions": "Pfadaktionen:",
"chat_setCustomPath": "Lege benutzerdefinierten Pfad fest", "chat_setCustomPath": "Lege benutzerdefinierten Pfad fest",
"chat_setCustomPathSubtitle": "Manuellen Routenpfad festlegen", "chat_setCustomPathSubtitle": "Manuellen Routenpfad festlegen",
@@ -717,7 +712,7 @@
"mapCache_cacheArea": "Zwischenspeicherbereich", "mapCache_cacheArea": "Zwischenspeicherbereich",
"mapCache_useCurrentView": "Aktuelle Ansicht verwenden", "mapCache_useCurrentView": "Aktuelle Ansicht verwenden",
"mapCache_zoomRange": "Zoom Bereich", "mapCache_zoomRange": "Zoom Bereich",
"mapCache_estimatedTiles": "Geschätzte Fliesen: {count}", "mapCache_estimatedTiles": "Geschätzte Kacheln: {count}",
"@mapCache_estimatedTiles": { "@mapCache_estimatedTiles": {
"placeholders": { "placeholders": {
"count": { "count": {
@@ -854,7 +849,7 @@
}, },
"path_enterCustomPath": "Gebe Pfad ein", "path_enterCustomPath": "Gebe Pfad ein",
"path_currentPathLabel": "Aktueller Pfad", "path_currentPathLabel": "Aktueller Pfad",
"path_hexPrefixInstructions": "Gebe für jeden Hopfen 2-stellige Hex-Präfixe ein, getrennt durch Kommas.", "path_hexPrefixInstructions": "Gebe für jeden Zwischen-Hop das 2-stellige Hex-Präfix ein, getrennt durch Kommas.",
"path_hexPrefixExample": "Beispiel: A1,F2,3C (jeder Knoten verwendet den ersten Byte seines öffentlichen Schlüssels)", "path_hexPrefixExample": "Beispiel: A1,F2,3C (jeder Knoten verwendet den ersten Byte seines öffentlichen Schlüssels)",
"path_labelHexPrefixes": "Pfad (Hex-Präfixe)", "path_labelHexPrefixes": "Pfad (Hex-Präfixe)",
"path_helperMaxHops": "Max 64 Sprünge. Jede Präfixe ist 2 Hexadezimalzeichen (1 Byte)", "path_helperMaxHops": "Max 64 Sprünge. Jede Präfixe ist 2 Hexadezimalzeichen (1 Byte)",
@@ -887,7 +882,7 @@
"repeater_forceFloodMode": "Flut-Modus erzwingen", "repeater_forceFloodMode": "Flut-Modus erzwingen",
"repeater_pathManagement": "Pfadverwaltung", "repeater_pathManagement": "Pfadverwaltung",
"repeater_refresh": "Aktualisieren", "repeater_refresh": "Aktualisieren",
"repeater_statusRequestTimeout": "Statusanfrage zeitweise fehlgeschlagen.", "repeater_statusRequestTimeout": "Statusanfrage durch Timeout fehlgeschlagen.",
"repeater_errorLoadingStatus": "Fehler beim Laden des Status: {error}", "repeater_errorLoadingStatus": "Fehler beim Laden des Status: {error}",
"@repeater_errorLoadingStatus": { "@repeater_errorLoadingStatus": {
"placeholders": { "placeholders": {
@@ -957,7 +952,7 @@
} }
} }
}, },
"repeater_duplicatesFloodDirect": "Überflut: {flood}, Direkt: {direct}", "repeater_duplicatesFloodDirect": "Flut: {flood}, Direkt: {direct}",
"@repeater_duplicatesFloodDirect": { "@repeater_duplicatesFloodDirect": {
"placeholders": { "placeholders": {
"flood": { "flood": {
@@ -983,7 +978,7 @@
"repeater_adminPassword": "Admin-Passwort", "repeater_adminPassword": "Admin-Passwort",
"repeater_adminPasswordHelper": "Vollzugriffspasswort", "repeater_adminPasswordHelper": "Vollzugriffspasswort",
"repeater_guestPassword": "Gast-Passwort", "repeater_guestPassword": "Gast-Passwort",
"repeater_guestPasswordHelper": "Schreibgeschützter Zugriffspasswort", "repeater_guestPasswordHelper": "Schreibgeschütztes Zugriffspasswort",
"repeater_radioSettings": "Funk Einstellungen", "repeater_radioSettings": "Funk Einstellungen",
"repeater_frequencyMhz": "Frequenz (MHz)", "repeater_frequencyMhz": "Frequenz (MHz)",
"repeater_frequencyHelper": "300-2500 MHz", "repeater_frequencyHelper": "300-2500 MHz",
@@ -1026,7 +1021,7 @@
"repeater_encryptedAdvertInterval": "Intervall der verschlüsselten Ankündigung", "repeater_encryptedAdvertInterval": "Intervall der verschlüsselten Ankündigung",
"repeater_dangerZone": "Gefahrenzone", "repeater_dangerZone": "Gefahrenzone",
"repeater_rebootRepeater": "Neustart Repeater", "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_rebootRepeaterConfirm": "Sind Sie sicher, dass Sie diesen Repeater neu starten möchten?",
"repeater_regenerateIdentityKey": "Schlüssel für die Identitätswiederherstellung", "repeater_regenerateIdentityKey": "Schlüssel für die Identitätswiederherstellung",
"repeater_regenerateIdentityKeySubtitle": "Neuen öffentlichen/privaten Schlüsselpaar generieren", "repeater_regenerateIdentityKeySubtitle": "Neuen öffentlichen/privaten Schlüsselpaar generieren",
@@ -1086,11 +1081,11 @@
} }
}, },
"repeater_cliTitle": "Repeater CLI", "repeater_cliTitle": "Repeater CLI",
"repeater_debugNextCommand": "Fehlersuche Nächster Befehl", "repeater_debugNextCommand": "Fehlersuche des nächsten Befehls",
"repeater_commandHelp": "Hilfe", "repeater_commandHelp": "Hilfe",
"repeater_clearHistory": "Löschen der Historie", "repeater_clearHistory": "Löschen der Historie",
"repeater_noCommandsSent": "Noch keine Befehle gesendet.", "repeater_noCommandsSent": "Noch keine Befehle gesendet.",
"repeater_typeCommandOrUseQuick": "Geben Sie einen Befehl unten ein oder verwenden Sie Schnellbefehle", "repeater_typeCommandOrUseQuick": "Geben Sie unten einen Befehl ein oder verwenden Sie die Schnellbefehle",
"repeater_enterCommandHint": "Geben Sie den Befehl ein...", "repeater_enterCommandHint": "Geben Sie den Befehl ein...",
"repeater_previousCommand": "Vorhergehende Aktion", "repeater_previousCommand": "Vorhergehende Aktion",
"repeater_nextCommand": "Nächste Aktion", "repeater_nextCommand": "Nächste Aktion",
@@ -1132,7 +1127,7 @@
"repeater_cliHelpSetLat": "Legt die Breitengrad der Ankündigung fest. (dezimale Grad)", "repeater_cliHelpSetLat": "Legt die Breitengrad der Ankündigung fest. (dezimale Grad)",
"repeater_cliHelpSetLon": "Legt die Längengrade der Ankündigung fest. (dezimale Grad)", "repeater_cliHelpSetLon": "Legt die Längengrade der Ankündigung fest. (dezimale Grad)",
"repeater_cliHelpSetRadio": "Legt komplett neue Radio-Parameter fest und speichert diese als Präferenzen. Benötigt einen \"Reboot\"-Befehl, um sie anzuwenden.", "repeater_cliHelpSetRadio": "Legt komplett neue Radio-Parameter fest und speichert diese als Präferenzen. Benötigt einen \"Reboot\"-Befehl, um sie anzuwenden.",
"repeater_cliHelpSetRxDelay": "Sets (experimentell) als Basis (muss > 1 sein für den Effekt) zur Anwendung einer leichten Verzögerung bei empfangenen Paketen, basierend auf Signalstärke/Punktzahl. Auf 0 setzen, um die Funktion zu deaktivieren.", "repeater_cliHelpSetRxDelay": "Fügt eine leichte Verzögerung bei empfangenen Paketen hinzu, basierend auf Signalstärke/Punktzahl. Auf 0 setzen, um die Funktion zu deaktivieren.",
"repeater_cliHelpSetTxDelay": "Legt einen Faktor fest, der mit der Zeit bei voller Zuluft für ein Flood-Mode-Paket und mit einem zufälligen Slot-System multipliziert wird, um dessen Weiterleitung zu verzögern (um Kollisionen zu vermeiden).", "repeater_cliHelpSetTxDelay": "Legt einen Faktor fest, der mit der Zeit bei voller Zuluft für ein Flood-Mode-Paket und mit einem zufälligen Slot-System multipliziert wird, um dessen Weiterleitung zu verzögern (um Kollisionen zu vermeiden).",
"repeater_cliHelpSetDirectTxDelay": "Ähnlich wie txdelay, aber zum Anwenden einer zufälligen Verzögerung bei der Weiterleitung von Direktmodus-Paketen.", "repeater_cliHelpSetDirectTxDelay": "Ähnlich wie txdelay, aber zum Anwenden einer zufälligen Verzögerung bei der Weiterleitung von Direktmodus-Paketen.",
"repeater_cliHelpSetBridgeEnabled": "Brücke aktivieren/deaktivieren.", "repeater_cliHelpSetBridgeEnabled": "Brücke aktivieren/deaktivieren.",
@@ -1143,14 +1138,14 @@
"repeater_cliHelpSetAdcMultiplier": "Legt einen benutzerdefinierten Faktor zur Anpassung der gemeldeten Batteriewirkspannung fest (nur auf ausgewählten Boards unterstützt).", "repeater_cliHelpSetAdcMultiplier": "Legt einen benutzerdefinierten Faktor zur Anpassung der gemeldeten Batteriewirkspannung fest (nur auf ausgewählten Boards unterstützt).",
"repeater_cliHelpTempRadio": "Legt vorübergehende Funkparameter für die angegebene Anzahl von Minuten fest und kehrt anschließend zu den ursprünglichen Funkparametern zurück (wird nicht in den Einstellungen gespeichert).", "repeater_cliHelpTempRadio": "Legt vorübergehende Funkparameter für die angegebene Anzahl von Minuten fest und kehrt anschließend zu den ursprünglichen Funkparametern zurück (wird nicht in den Einstellungen gespeichert).",
"repeater_cliHelpSetPerm": "Ändert die ACL. Entfernt das passende Eintragen (durch Pubkey-Präfix), wenn \"permissions\" auf 0 steht. Fügt ein neues Eintragen hinzu, wenn die Pubkey-Hex-Länge vollständig ist und nicht bereits in der ACL vorhanden ist. Aktualisiert das Eintragen anhand des übereinstimmenden Pubkey-Präfix. Berechtigungsbits variieren je nach Firmware-Rolle, aber die unteren 2 Bits sind: 0 (Gast), 1 (Nur Lesen), 2 (Lesen/Schreiben), 3 (Admin)", "repeater_cliHelpSetPerm": "Ändert die ACL. Entfernt das passende Eintragen (durch Pubkey-Präfix), wenn \"permissions\" auf 0 steht. Fügt ein neues Eintragen hinzu, wenn die Pubkey-Hex-Länge vollständig ist und nicht bereits in der ACL vorhanden ist. Aktualisiert das Eintragen anhand des übereinstimmenden Pubkey-Präfix. Berechtigungsbits variieren je nach Firmware-Rolle, aber die unteren 2 Bits sind: 0 (Gast), 1 (Nur Lesen), 2 (Lesen/Schreiben), 3 (Admin)",
"repeater_cliHelpGetBridgeType": "Ruft Brückentyp none, rs232, espnow ab.", "repeater_cliHelpGetBridgeType": "Ruft Brückentyp: none, rs232, espnow ab.",
"repeater_cliHelpLogStart": "Beginnt die Paketprotokollierung in das Dateisystem.", "repeater_cliHelpLogStart": "Beginnt die Paketprotokollierung in das Dateisystem.",
"repeater_cliHelpLogStop": "Stoppt das Paketprotokollieren in das Dateisystem.", "repeater_cliHelpLogStop": "Stoppt das Paketprotokollieren in das Dateisystem.",
"repeater_cliHelpLogErase": "Löscht die Paketprotokolle aus dem Dateisystem.", "repeater_cliHelpLogErase": "Löscht die Paketprotokolle aus dem Dateisystem.",
"repeater_cliHelpNeighbors": "Zeigt eine Liste anderer Repeater-Knoten an, die über Zero-Hop-Ankündigung gehört wurden. Jede Zeile ist id-prefix-hex:timestamp:snr-times-4", "repeater_cliHelpNeighbors": "Zeigt eine Liste anderer Repeater-Knoten an, die über Zero-Hop-Ankündigung gehört wurden. Jede Zeile ist id-prefix-hex:timestamp:snr-times-4",
"repeater_cliHelpNeighborRemove": "Entfernt das erste übereinstimmende Element (über Pubkey-Präfix (hex)) aus der Liste der Nachbarn.", "repeater_cliHelpNeighborRemove": "Entfernt das erste übereinstimmende Element (über Pubkey-Präfix (hex)) aus der Liste der Nachbarn.",
"repeater_cliHelpRegion": "Listet alle definierten Regionen auf.", "repeater_cliHelpRegion": "Listet alle definierten Regionen auf.",
"repeater_cliHelpRegionLoad": "Hinweis: Dies ist ein spezieller Mehrbefehl-Aufruf. Jeder nachfolgende Befehl ist ein Regionsname (eingedruckt mit Leerzeichen zur Angabe der übergeordneten Hierarchie, mit mindestens einem Leerzeichen). Beendet durch das Senden einer Leerzeile/des Befehls.", "repeater_cliHelpRegionLoad": "Hinweis: Dies ist ein spezieller Mehrbefehl-Aufruf. Jeder nachfolgende Befehl ist ein Regionsname (eingeckt mit Leerzeichen zur Angabe der übergeordneten Hierarchie, mit mindestens einem Leerzeichen). Beendet durch das Senden einer Leerzeile.",
"repeater_cliHelpRegionGet": "Sucht die Region mit dem gegebenen Namenspräfix (oder \"\\\" für den globalen Scope) und antwortet mit \"-> region-name (parent-name) 'F'\".", "repeater_cliHelpRegionGet": "Sucht die Region mit dem gegebenen Namenspräfix (oder \"\\\" für den globalen Scope) und antwortet mit \"-> region-name (parent-name) 'F'\".",
"repeater_cliHelpRegionPut": "Fügt eine Region-Definition mit dem angegebenen Namen hinzu oder aktualisiert diese.", "repeater_cliHelpRegionPut": "Fügt eine Region-Definition mit dem angegebenen Namen hinzu oder aktualisiert diese.",
"repeater_cliHelpRegionRemove": "Löscht eine Regiondefinition mit dem angegebenen Namen. (muss genau übereinstimmen und keine Kindregionen haben)", "repeater_cliHelpRegionRemove": "Löscht eine Regiondefinition mit dem angegebenen Namen. (muss genau übereinstimmen und keine Kindregionen haben)",
@@ -1243,7 +1238,7 @@
"channelPath_otherObservedPaths": "Sonstige beobachtete Pfade", "channelPath_otherObservedPaths": "Sonstige beobachtete Pfade",
"channelPath_repeaterHops": "Repeater-Sprünge", "channelPath_repeaterHops": "Repeater-Sprünge",
"channelPath_noHopDetails": "Die Detailangaben für dieses Paket sind nicht verfügbar.", "channelPath_noHopDetails": "Die Detailangaben für dieses Paket sind nicht verfügbar.",
"channelPath_messageDetails": "Nachrichtsdetails", "channelPath_messageDetails": "Nachrichtendetails",
"channelPath_senderLabel": "Sender", "channelPath_senderLabel": "Sender",
"channelPath_timeLabel": "Zeit", "channelPath_timeLabel": "Zeit",
"channelPath_repeatsLabel": "Wiederholungen", "channelPath_repeatsLabel": "Wiederholungen",
@@ -1347,7 +1342,7 @@
"listFilter_users": "Benutzer", "listFilter_users": "Benutzer",
"listFilter_repeaters": "Repeater", "listFilter_repeaters": "Repeater",
"listFilter_roomServers": "Raumserver", "listFilter_roomServers": "Raumserver",
"listFilter_unreadOnly": "Nur nicht gelesen", "listFilter_unreadOnly": "Nicht gelesen",
"listFilter_newGroup": "Neue Gruppe", "listFilter_newGroup": "Neue Gruppe",
"@neighbors_errorLoading": { "@neighbors_errorLoading": {
"placeholders": { "placeholders": {
@@ -1358,11 +1353,11 @@
}, },
"repeater_neighbours": "Nachbarn", "repeater_neighbours": "Nachbarn",
"repeater_neighboursSubtitle": "Anzahl der Hop-Nachbarn anzeigen.", "repeater_neighboursSubtitle": "Anzahl der Hop-Nachbarn anzeigen.",
"neighbors_receivedData": "Empfangene Nachbarendaten", "neighbors_receivedData": "Empfangene Nachbarsdaten",
"neighbors_requestTimedOut": "Nachbarn melden zeitweise Ausfall.", "neighbors_requestTimedOut": "Anfrage durch Timeout fehlgeschlagen.",
"neighbors_errorLoading": "Fehler beim Laden der Nachbarn: {error}", "neighbors_errorLoading": "Fehler beim Laden der Nachbarn: {error}",
"neighbors_repeatersNeighbours": "Wiederholer Nachbarn", "neighbors_repeatersNeighbours": "Nachbarn",
"neighbors_noData": "Keine Nachbardaten verfügbar.", "neighbors_noData": "Keine Nachbarsdaten verfügbar.",
"channels_joinPrivateChannel": "Treten Sie einem privaten Kanal bei", "channels_joinPrivateChannel": "Treten Sie einem privaten Kanal bei",
"channels_joinPrivateChannelDesc": "Manuelle Eingabe eines geheimen Schlüssels.", "channels_joinPrivateChannelDesc": "Manuelle Eingabe eines geheimen Schlüssels.",
"channels_createPrivateChannel": "Erstelle einen privaten Kanal", "channels_createPrivateChannel": "Erstelle einen privaten Kanal",
@@ -1389,8 +1384,8 @@
} }
} }
}, },
"neighbors_heardAgo": "Hörte: {time} vor her.", "neighbors_heardAgo": "Gehört vor: {time}",
"neighbors_unknownContact": "Unbekannte {pubkey}", "neighbors_unknownContact": "Unbekannt {pubkey}",
"settings_locationGPSEnable": "GPS aktivieren", "settings_locationGPSEnable": "GPS aktivieren",
"settings_locationGPSEnableSubtitle": "Aktiviert GPS zur automatischen Aktualisierung des Standorts.", "settings_locationGPSEnableSubtitle": "Aktiviert GPS zur automatischen Aktualisierung des Standorts.",
"settings_locationIntervalSec": "Intervall für GPS (Sekunden)", "settings_locationIntervalSec": "Intervall für GPS (Sekunden)",
@@ -1493,9 +1488,9 @@
"community_deleted": "Community \"{name}\" verlassen", "community_deleted": "Community \"{name}\" verlassen",
"community_addHashtagChannel": "Füge einen Community-Hashtag hinzu", "community_addHashtagChannel": "Füge einen Community-Hashtag hinzu",
"community_addHashtagChannelDesc": "Füge einen Hashtag-Kanal für diese Community hinzu", "community_addHashtagChannelDesc": "Füge einen Hashtag-Kanal für diese Community hinzu",
"community_selectCommunity": "Wählen Sie Community", "community_selectCommunity": "Wählen Sie eine Community",
"community_regularHashtag": "Regulärer Hashtag", "community_regularHashtag": "Regulärer Hashtag",
"community_regularHashtagDesc": "Öffentliches Hashtag (jeder kann teilnehmen)", "community_regularHashtagDesc": "Öffentlicher Hashtag (jeder kann teilnehmen)",
"community_communityHashtagDesc": "Nur für Mitglieder der Community", "community_communityHashtagDesc": "Nur für Mitglieder der Community",
"community_forCommunity": "Für {name}", "community_forCommunity": "Für {name}",
"community_communityHashtag": "Community Hashtag", "community_communityHashtag": "Community Hashtag",
@@ -1528,10 +1523,106 @@
} }
}, },
"community_regenerate": "Neu generieren", "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_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_regenerateSecret": "Neugenerierung des Schlüssels",
"community_secretUpdated": "Geheime für \"{name}\" aktualisiert", "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_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-Ankündigung",
"contacts_floodAdvert": "Flut-Ankündigung",
"contacts_addContactFromClipboard": "Kontakt aus Zwischenablage hinzufügen",
"contacts_ShareContactZeroHop": "Kontakt über Anzeige teilen",
"contacts_copyAdvertToClipboard": "Ankündigung 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 der Ankündigung 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",
"settings_gpxExportAll": "Alle Knoten als GPX exportieren",
"settings_gpxExportAllSubtitle": "Exportiert alle Knoten mit einem Standort in eine GPX-Datei.",
"settings_gpxExportRepeaters": "Repeater und Raumserver als GPX exportieren",
"settings_gpxExportContacts": "Kontakte als GPX exportieren",
"settings_gpxExportRepeatersSubtitle": "Exportiert Repeater und Raumserver mit einem Standort in eine GPX-Datei.",
"settings_gpxExportContactsSubtitle": "Exportiert Kontakte mit einem Ort in eine GPX-Datei.",
"settings_gpxExportRepeatersRoom": "Repeater- und Raumserver-Standorte",
"settings_gpxExportChat": "Kontaktstandorte",
"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": "GPX-Datei erfolgreich exportiert.",
"settings_gpxExportAllContacts": "Alle Kontaktstandorte",
"settings_gpxExportShareSubject": "GPX-Kartendaten aus meshcore-open exportieren",
"settings_gpxExportShareText": "GPX-Kartendaten aus meshcore-open exportiert",
"pathTrace_someHopsNoLocation": "Bei einer oder mehreren Knoten fehlt der Standort!",
"map_removeLast": "Letztes Entfernen",
"map_tapToAdd": "Tippen Sie auf Knoten, um sie zum Pfad hinzuzufügen.",
"map_runTrace": "Pfadverlauf ausführen",
"pathTrace_clearTooltip": "Pfad löschen",
"map_pathTraceCancelled": "Pfadverfolgung abgebrochen.",
"scanner_bluetoothOffMessage": "Bitte aktivieren Sie Bluetooth, um nach Geräten zu suchen.",
"scanner_bluetoothOff": "Bluetooth ist deaktiviert.",
"scanner_enableBluetooth": "Bluetooth aktivieren",
"settings_clientRepeat": "Wiederholung, ohne Stromanschluss",
"settings_clientRepeatFreqWarning": "Die Kommunikation ohne Stromversorgung erfordert Frequenzen von 433, 869 oder 918 MHz.",
"settings_clientRepeatSubtitle": "Ermöglichen Sie diesem Gerät, Mesh-Pakete für andere zu wiederholen."
} }
+473 -157
View File
File diff suppressed because it is too large Load Diff
+97 -6
View File
@@ -131,9 +131,6 @@
"settings_infoContactsCount": "Número de contactos", "settings_infoContactsCount": "Número de contactos",
"settings_infoChannelCount": "Número de canales", "settings_infoChannelCount": "Número de canales",
"settings_presets": "Preajustes", "settings_presets": "Preajustes",
"settings_preset915Mhz": "915 MHz",
"settings_preset868Mhz": "868 MHz",
"settings_preset433Mhz": "433 MHz",
"settings_frequency": "Frecuencia (MHz)", "settings_frequency": "Frecuencia (MHz)",
"settings_frequencyHelper": "300,0 - 2500,0", "settings_frequencyHelper": "300,0 - 2500,0",
"settings_frequencyInvalid": "Frecuencia inválida (300-2500 MHz)", "settings_frequencyInvalid": "Frecuencia inválida (300-2500 MHz)",
@@ -143,8 +140,6 @@
"settings_txPower": "TX Potencia (dBm)", "settings_txPower": "TX Potencia (dBm)",
"settings_txPowerHelper": "0 - 22", "settings_txPowerHelper": "0 - 22",
"settings_txPowerInvalid": "Potencia de TX inválida (0-22 dBm)", "settings_txPowerInvalid": "Potencia de TX inválida (0-22 dBm)",
"settings_longRange": "Largo Alcance",
"settings_fastSpeed": "Velocidad Rápida",
"settings_error": "Error: {message}", "settings_error": "Error: {message}",
"@settings_error": { "@settings_error": {
"placeholders": { "placeholders": {
@@ -1533,5 +1528,101 @@
"community_regenerate": "Regenerar", "community_regenerate": "Regenerar",
"community_secretUpdated": "Confidencialidad actualizada para \"{name}\"", "community_secretUpdated": "Confidencialidad actualizada para \"{name}\"",
"community_scanToUpdateSecret": "Escanear el nuevo código QR para actualizar el secreto de \"{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",
"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",
"pathTrace_clearTooltip": "Borrar ruta",
"map_runTrace": "Ejecutar Rastreo de Ruta",
"map_tapToAdd": "Pulse en los nodos para agregarlos al camino.",
"map_removeLast": "Eliminar último",
"map_pathTraceCancelled": "Rastreo de ruta cancelado.",
"scanner_bluetoothOffMessage": "Por favor, active el Bluetooth para escanear dispositivos.",
"scanner_bluetoothOff": "Bluetooth está desactivado.",
"scanner_enableBluetooth": "Habilitar Bluetooth",
"settings_clientRepeatFreqWarning": "Para la comunicación fuera de la red, se requiere una frecuencia de 433, 869 o 918 MHz.",
"settings_clientRepeat": "Repetir sin conexión",
"settings_clientRepeatSubtitle": "Permita que este dispositivo repita los paquetes de red para otros usuarios."
} }
+101 -38
View File
@@ -131,9 +131,6 @@
"settings_infoContactsCount": "Nombre de contacts", "settings_infoContactsCount": "Nombre de contacts",
"settings_infoChannelCount": "Nombre de canaux", "settings_infoChannelCount": "Nombre de canaux",
"settings_presets": "Préréglages", "settings_presets": "Préréglages",
"settings_preset915Mhz": "915 MHz",
"settings_preset868Mhz": "868 MHz",
"settings_preset433Mhz": "433 MHz",
"settings_frequency": "Fréquence (MHz)", "settings_frequency": "Fréquence (MHz)",
"settings_frequencyHelper": "300,0 - 2 500,0", "settings_frequencyHelper": "300,0 - 2 500,0",
"settings_frequencyInvalid": "Fréquence invalide (300-2500 MHz)", "settings_frequencyInvalid": "Fréquence invalide (300-2500 MHz)",
@@ -143,8 +140,6 @@
"settings_txPower": "TX Puissance (dBm)", "settings_txPower": "TX Puissance (dBm)",
"settings_txPowerHelper": "0 - 22", "settings_txPowerHelper": "0 - 22",
"settings_txPowerInvalid": "Puissance TX invalide (0-22 dBm)", "settings_txPowerInvalid": "Puissance TX invalide (0-22 dBm)",
"settings_longRange": "Portée Longue",
"settings_fastSpeed": "Vitesse Rapide",
"settings_error": "Erreur : {message}", "settings_error": "Erreur : {message}",
"@settings_error": { "@settings_error": {
"placeholders": { "placeholders": {
@@ -210,8 +205,8 @@
"appSettings_batteryLifepo4": "LiFePO4 (2,6-3,65V)", "appSettings_batteryLifepo4": "LiFePO4 (2,6-3,65V)",
"appSettings_batteryLipo": "LiPo (3,0-4,2V)", "appSettings_batteryLipo": "LiPo (3,0-4,2V)",
"appSettings_mapDisplay": "Affichage de la carte", "appSettings_mapDisplay": "Affichage de la carte",
"appSettings_showRepeaters": "Afficher les répétiteurs", "appSettings_showRepeaters": "Afficher les répéteurs",
"appSettings_showRepeatersSubtitle": "Afficher les nœuds répétiteurs sur la carte", "appSettings_showRepeatersSubtitle": "Afficher les nœuds répéteurs sur la carte",
"appSettings_showChatNodes": "Afficher les nœuds de discussion", "appSettings_showChatNodes": "Afficher les nœuds de discussion",
"appSettings_showChatNodesSubtitle": "Afficher les nœuds de chat sur la carte", "appSettings_showChatNodesSubtitle": "Afficher les nœuds de chat sur la carte",
"appSettings_showOtherNodes": "Afficher d'autres nœuds", "appSettings_showOtherNodes": "Afficher d'autres nœuds",
@@ -266,7 +261,7 @@
} }
} }
}, },
"contacts_manageRepeater": "Gérer le répétiteur", "contacts_manageRepeater": "Gérer le répéteur",
"contacts_roomLogin": "Connexion Salle", "contacts_roomLogin": "Connexion Salle",
"contacts_openChat": "Ouverture du Chat", "contacts_openChat": "Ouverture du Chat",
"contacts_editGroup": "Modifier le groupe", "contacts_editGroup": "Modifier le groupe",
@@ -542,9 +537,9 @@
"chat_forceFloodMode": "Mode tout le réseau forcé", "chat_forceFloodMode": "Mode tout le réseau forcé",
"chat_recentAckPaths": "Chemins ACK récents (touchez pour utiliser) :", "chat_recentAckPaths": "Chemins ACK récents (touchez pour utiliser) :",
"chat_pathHistoryFull": "L'historique du chemin est plein. Supprimez les entrées pour en ajouter de nouvelles.", "chat_pathHistoryFull": "L'historique du chemin est plein. Supprimez les entrées pour en ajouter de nouvelles.",
"chat_hopSingular": "Sautez", "chat_hopSingular": "saut",
"chat_hopPlural": "sautez", "chat_hopPlural": "sauts",
"chat_hopsCount": "{count} {count, plural, =1{hop} other{hops}}", "chat_hopsCount": "{count} {count, plural, =1{saut} other{sauts}}",
"@chat_hopsCount": { "@chat_hopsCount": {
"placeholders": { "placeholders": {
"count": { "count": {
@@ -636,7 +631,7 @@
} }
}, },
"map_chat": "Chat", "map_chat": "Chat",
"map_repeater": "Répétiteur", "map_repeater": "Répéteur",
"map_room": "Salle", "map_room": "Salle",
"map_sensor": "Capteur", "map_sensor": "Capteur",
"map_pinDm": "Clé (DM)", "map_pinDm": "Clé (DM)",
@@ -677,7 +672,7 @@
"map_lastSeenTime": "Dernière fois vu", "map_lastSeenTime": "Dernière fois vu",
"map_sharedPin": "Clé partagée", "map_sharedPin": "Clé partagée",
"map_joinRoom": "Rejoindre la salle", "map_joinRoom": "Rejoindre la salle",
"map_manageRepeater": "Gérer le répétiteur", "map_manageRepeater": "Gérer le répéteur",
"mapCache_title": "Cache de Carte Hors Ligne", "mapCache_title": "Cache de Carte Hors Ligne",
"mapCache_selectAreaFirst": "Sélectionner une zone pour la mise en cache en premier", "mapCache_selectAreaFirst": "Sélectionner une zone pour la mise en cache en premier",
"mapCache_noTilesToDownload": "Aucun tuilage à télécharger pour cette zone.", "mapCache_noTilesToDownload": "Aucun tuilage à télécharger pour cette zone.",
@@ -800,13 +795,13 @@
"time_allTime": "Tout le temps", "time_allTime": "Tout le temps",
"dialog_disconnect": "Déconnecter", "dialog_disconnect": "Déconnecter",
"dialog_disconnectConfirm": "Êtes-vous sûr de vouloir vous déconnecter de cet appareil ?", "dialog_disconnectConfirm": "Êtes-vous sûr de vouloir vous déconnecter de cet appareil ?",
"login_repeaterLogin": "Connexion au répétiteur", "login_repeaterLogin": "Connexion au répéteur",
"login_roomLogin": "Connexion Salle", "login_roomLogin": "Connexion Salle",
"login_password": "Mot de passe", "login_password": "Mot de passe",
"login_enterPassword": "Entrez votre mot de passe", "login_enterPassword": "Entrez votre mot de passe",
"login_savePassword": "Sauvegarder le mot de passe", "login_savePassword": "Sauvegarder le mot de passe",
"login_savePasswordSubtitle": "Le mot de passe sera stocké en toute sécurité sur cet appareil.", "login_savePasswordSubtitle": "Le mot de passe sera stocké en toute sécurité sur cet appareil.",
"login_repeaterDescription": "Entrez le mot de passe du répétiteur pour accéder aux paramètres et à l'état.", "login_repeaterDescription": "Entrez le mot de passe du répéteur pour accéder aux paramètres et à l'état.",
"login_roomDescription": "Entrez le mot de passe de la pièce pour accéder aux paramètres et à l'état.", "login_roomDescription": "Entrez le mot de passe de la pièce pour accéder aux paramètres et à l'état.",
"login_routing": "Redirection", "login_routing": "Redirection",
"login_routingMode": "Mode de routage", "login_routingMode": "Mode de routage",
@@ -871,17 +866,17 @@
}, },
"path_tooLong": "Le chemin est trop long. Maximum 64 sauts autorisés.", "path_tooLong": "Le chemin est trop long. Maximum 64 sauts autorisés.",
"path_setPath": "Définir le chemin", "path_setPath": "Définir le chemin",
"repeater_management": "Gestion des répétiteurs", "repeater_management": "Gestion des répéteurs",
"repeater_managementTools": "Outils de Gestion", "repeater_managementTools": "Outils de Gestion",
"repeater_status": "État", "repeater_status": "État",
"repeater_statusSubtitle": "Afficher l'état, les statistiques et les voisins du répétiteur", "repeater_statusSubtitle": "Afficher l'état, les statistiques et les voisins du répéteur",
"repeater_telemetry": "Télémetrie", "repeater_telemetry": "Télémetrie",
"repeater_telemetrySubtitle": "Afficher la télémétrie des capteurs et les statistiques du système", "repeater_telemetrySubtitle": "Afficher la télémétrie des capteurs et les statistiques du système",
"repeater_cli": "CLI", "repeater_cli": "CLI",
"repeater_cliSubtitle": "Envoyer des commandes au répétiteur", "repeater_cliSubtitle": "Envoyer des commandes au répéteur",
"repeater_settings": "Paramètres", "repeater_settings": "Paramètres",
"repeater_settingsSubtitle": "Configurer les paramètres du répétiteur", "repeater_settingsSubtitle": "Configurer les paramètres du répéteur",
"repeater_statusTitle": "État du répétiteur", "repeater_statusTitle": "État du répéteur",
"repeater_routingMode": "Mode de routage", "repeater_routingMode": "Mode de routage",
"repeater_autoUseSavedPath": "Auto (utiliser le chemin sauvegardé)", "repeater_autoUseSavedPath": "Auto (utiliser le chemin sauvegardé)",
"repeater_forceFloodMode": "Mode tout le réseau forcé", "repeater_forceFloodMode": "Mode tout le réseau forcé",
@@ -976,10 +971,10 @@
} }
} }
}, },
"repeater_settingsTitle": "Paramètres du répétiteur", "repeater_settingsTitle": "Paramètres du répéteur",
"repeater_basicSettings": "Paramètres de base", "repeater_basicSettings": "Paramètres de base",
"repeater_repeaterName": "Nom du répétiteur", "repeater_repeaterName": "Nom du répéteur",
"repeater_repeaterNameHelper": "Afficher le nom de ce répétiteur", "repeater_repeaterNameHelper": "Afficher le nom de ce répéteur",
"repeater_adminPassword": "Mot de passe Administrateur", "repeater_adminPassword": "Mot de passe Administrateur",
"repeater_adminPasswordHelper": "Mot de passe d'accès complet", "repeater_adminPasswordHelper": "Mot de passe d'accès complet",
"repeater_guestPassword": "Mot de passe invité", "repeater_guestPassword": "Mot de passe invité",
@@ -999,7 +994,7 @@
"repeater_longitudeHelper": "Degrés décimaux (par exemple, -122,4194)", "repeater_longitudeHelper": "Degrés décimaux (par exemple, -122,4194)",
"repeater_features": "Fonctionnalités", "repeater_features": "Fonctionnalités",
"repeater_packetForwarding": "Transfert de paquets", "repeater_packetForwarding": "Transfert de paquets",
"repeater_packetForwardingSubtitle": "Activer le répétiteur pour transmettre des paquets", "repeater_packetForwardingSubtitle": "Activer le répéteur pour transmettre des paquets",
"repeater_guestAccess": "Accès Invité", "repeater_guestAccess": "Accès Invité",
"repeater_guestAccessSubtitle": "Autoriser l'accès invité en lecture seule", "repeater_guestAccessSubtitle": "Autoriser l'accès invité en lecture seule",
"repeater_privacyMode": "Mode de confidentialité", "repeater_privacyMode": "Mode de confidentialité",
@@ -1026,14 +1021,14 @@
"repeater_encryptedAdvertInterval": "Intervalle d'annonces cryptées", "repeater_encryptedAdvertInterval": "Intervalle d'annonces cryptées",
"repeater_dangerZone": "Zone dangereuse", "repeater_dangerZone": "Zone dangereuse",
"repeater_rebootRepeater": "Redémarrer Répéteur", "repeater_rebootRepeater": "Redémarrer Répéteur",
"repeater_rebootRepeaterSubtitle": "Réinitialiser l'appareil répétiteur", "repeater_rebootRepeaterSubtitle": "Réinitialiser l'appareil répéteur",
"repeater_rebootRepeaterConfirm": "Êtes-vous sûr de vouloir redémarrer ce répétiteur ?", "repeater_rebootRepeaterConfirm": "Êtes-vous sûr de vouloir redémarrer ce répéteur ?",
"repeater_regenerateIdentityKey": "Ré générer la clé d'identité", "repeater_regenerateIdentityKey": "Ré générer la clé d'identité",
"repeater_regenerateIdentityKeySubtitle": "Générer une nouvelle paire de clés publique/privée", "repeater_regenerateIdentityKeySubtitle": "Générer une nouvelle paire de clés publique/privée",
"repeater_regenerateIdentityKeyConfirm": "Cela générera une nouvelle identité pour le répétiteur. Continuer ?", "repeater_regenerateIdentityKeyConfirm": "Cela générera une nouvelle identité pour le répéteur. Continuer ?",
"repeater_eraseFileSystem": "Supprimer le système de fichiers", "repeater_eraseFileSystem": "Supprimer le système de fichiers",
"repeater_eraseFileSystemSubtitle": "Formater le système de fichiers du répétiteur", "repeater_eraseFileSystemSubtitle": "Formater le système de fichiers du répéteur",
"repeater_eraseFileSystemConfirm": "AVERTISSEMENT : Cela effacera toutes les données du répétiteur. Cela ne peut pas être annulé !", "repeater_eraseFileSystemConfirm": "AVERTISSEMENT : Cela effacera toutes les données du répéteur. Cela ne peut pas être annulé !",
"repeater_eraseSerialOnly": "Erase n'est disponible que via la console série.", "repeater_eraseSerialOnly": "Erase n'est disponible que via la console série.",
"repeater_commandSent": "Commande envoyée : {command}", "repeater_commandSent": "Commande envoyée : {command}",
"@repeater_commandSent": { "@repeater_commandSent": {
@@ -1085,7 +1080,7 @@
} }
} }
}, },
"repeater_cliTitle": "Répétiteur CLI", "repeater_cliTitle": "Répéteur CLI",
"repeater_debugNextCommand": "Déboguer Prochaine Commande", "repeater_debugNextCommand": "Déboguer Prochaine Commande",
"repeater_commandHelp": "Aide", "repeater_commandHelp": "Aide",
"repeater_clearHistory": "Effacer l'historique", "repeater_clearHistory": "Effacer l'historique",
@@ -1119,13 +1114,13 @@
"repeater_cliHelpClearStats": "Réinitialise divers compteurs de statistiques à zéro.", "repeater_cliHelpClearStats": "Réinitialise divers compteurs de statistiques à zéro.",
"repeater_cliHelpSetAf": "Définit le facteur de temps d'air.", "repeater_cliHelpSetAf": "Définit le facteur de temps d'air.",
"repeater_cliHelpSetTx": "Définit la puissance de transmission LoRa en dBm (réinitialisation requise pour appliquer).", "repeater_cliHelpSetTx": "Définit la puissance de transmission LoRa en dBm (réinitialisation requise pour appliquer).",
"repeater_cliHelpSetRepeat": "Active ou désactive le rôle du répétiteur pour ce nœud.", "repeater_cliHelpSetRepeat": "Active ou désactive le rôle du répéteur pour ce nœud.",
"repeater_cliHelpSetAllowReadOnly": "(Room server) Si \"activé\", alors un mot de passe vide permettra la connexion, mais ne permettra pas de publier dans la pièce. (lecture seule uniquement)", "repeater_cliHelpSetAllowReadOnly": "(Room server) Si \"activé\", alors un mot de passe vide permettra la connexion, mais ne permettra pas de publier dans la pièce. (lecture seule uniquement)",
"repeater_cliHelpSetFloodMax": "Définit le nombre maximal de sauts pour les paquets de balayage entrants (si >= max, le paquet n'est pas acheminé).", "repeater_cliHelpSetFloodMax": "Définit le nombre maximal de sauts pour les paquets de balayage entrants (si >= max, le paquet n'est pas acheminé).",
"repeater_cliHelpSetIntThresh": "Définit le seuil d'interférence (en dB). La valeur par défaut est de 14. Définir sur 0 désactive la détection des interférences de canal.", "repeater_cliHelpSetIntThresh": "Définit le seuil d'interférence (en dB). La valeur par défaut est de 14. Définir sur 0 désactive la détection des interférences de canal.",
"repeater_cliHelpSetAgcResetInterval": "Définit l'intervalle pour réinitialiser le contrôleur de gain automatique. Mettez à 0 pour désactiver.", "repeater_cliHelpSetAgcResetInterval": "Définit l'intervalle pour réinitialiser le contrôleur de gain automatique. Mettez à 0 pour désactiver.",
"repeater_cliHelpSetMultiAcks": "Active ou désactive la fonctionnalité « double ACKs ».", "repeater_cliHelpSetMultiAcks": "Active ou désactive la fonctionnalité « double ACKs ».",
"repeater_cliHelpSetAdvertInterval": "Définit l'intervalle du minuteur pour envoyer un paquet d'annonce local (sans relais). Définir sur 0 pour désactiver.", "repeater_cliHelpSetAdvertInterval": "Définit l'intervalle entre chaque émission d'une annonce locale (sans relais). Définir sur 0 pour désactiver.",
"repeater_cliHelpSetFloodAdvertInterval": "Définit l'intervalle du minuteur en heures pour envoyer un paquet d'annonce massive. Définir sur 0 pour désactiver.", "repeater_cliHelpSetFloodAdvertInterval": "Définit l'intervalle du minuteur en heures pour envoyer un paquet d'annonce massive. Définir sur 0 pour désactiver.",
"repeater_cliHelpSetGuestPassword": "Définit/met à jour le mot de passe de l'invité. (pour les répéteurs, les connexions d'invités peuvent envoyer la requête \"Get Stats\")", "repeater_cliHelpSetGuestPassword": "Définit/met à jour le mot de passe de l'invité. (pour les répéteurs, les connexions d'invités peuvent envoyer la requête \"Get Stats\")",
"repeater_cliHelpSetName": "Définit le nom de l'annonce.", "repeater_cliHelpSetName": "Définit le nom de l'annonce.",
@@ -1147,7 +1142,7 @@
"repeater_cliHelpLogStart": "Démarre l'enregistrement des paquets dans le système de fichiers.", "repeater_cliHelpLogStart": "Démarre l'enregistrement des paquets dans le système de fichiers.",
"repeater_cliHelpLogStop": "Arrêter de journaliser les paquets vers le système de fichiers.", "repeater_cliHelpLogStop": "Arrêter de journaliser les paquets vers le système de fichiers.",
"repeater_cliHelpLogErase": "Supprime les journaux de paquets du système de fichiers.", "repeater_cliHelpLogErase": "Supprime les journaux de paquets du système de fichiers.",
"repeater_cliHelpNeighbors": "Affiche une liste d'autres nœuds répétiteurs entendus via des annonces sans relais. Chaque ligne est id-préfixe-hexadécimal:timestamp:snr-fois-4", "repeater_cliHelpNeighbors": "Affiche une liste d'autres nœuds répéteurs entendus via des annonces sans relais. Chaque ligne est id-préfixe-hexadécimal:timestamp:snr-fois-4",
"repeater_cliHelpNeighborRemove": "Supprime la première entrée correspondante (par préfixe de clé publique (hexadécimal)) de la liste des voisins.", "repeater_cliHelpNeighborRemove": "Supprime la première entrée correspondante (par préfixe de clé publique (hexadécimal)) de la liste des voisins.",
"repeater_cliHelpRegion": "(série uniquement) Liste toutes les régions définies et les autorisations actuelles d'annonces sur tout le réseau (flood).", "repeater_cliHelpRegion": "(série uniquement) Liste toutes les régions définies et les autorisations actuelles d'annonces sur tout le réseau (flood).",
"repeater_cliHelpRegionLoad": "REMARQUE : il s'agit d'une invocation multi-commande spéciale. Chaque commande subséquente est un nom de région (indenté avec des espaces pour indiquer la hiérarchie parent, avec un minimum d'un espace). Terminé par l'envoi d'une ligne vide/commande.", "repeater_cliHelpRegionLoad": "REMARQUE : il s'agit d'une invocation multi-commande spéciale. Chaque commande subséquente est un nom de région (indenté avec des espaces pour indiquer la hiérarchie parent, avec un minimum d'un espace). Terminé par l'envoi d'une ligne vide/commande.",
@@ -1171,8 +1166,8 @@
"repeater_settingsCategory": "Paramètres", "repeater_settingsCategory": "Paramètres",
"repeater_bridge": "Pont", "repeater_bridge": "Pont",
"repeater_logging": "Journalisation", "repeater_logging": "Journalisation",
"repeater_neighborsRepeaterOnly": "Voisins (Uniquement répétiteur)", "repeater_neighborsRepeaterOnly": "Voisins (Uniquement répéteur)",
"repeater_regionManagementRepeaterOnly": "Gestion des régions (uniquement pour le répétiteur)", "repeater_regionManagementRepeaterOnly": "Gestion des régions (uniquement pour le répéteur)",
"repeater_regionNote": "Les commandes de région ont été introduites pour gérer les définitions et les autorisations des régions.", "repeater_regionNote": "Les commandes de région ont été introduites pour gérer les définitions et les autorisations des régions.",
"repeater_gpsManagement": "Gestion GPS", "repeater_gpsManagement": "Gestion GPS",
"repeater_gpsNote": "La commande GPS a été introduite pour gérer les sujets liés à la localisation.", "repeater_gpsNote": "La commande GPS a été introduite pour gérer les sujets liés à la localisation.",
@@ -1241,7 +1236,7 @@
"channelPath_title": "Chemin de paquet", "channelPath_title": "Chemin de paquet",
"channelPath_viewMap": "Afficher la carte", "channelPath_viewMap": "Afficher la carte",
"channelPath_otherObservedPaths": "Autres chemins observés", "channelPath_otherObservedPaths": "Autres chemins observés",
"channelPath_repeaterHops": "Sauts du répétiteur", "channelPath_repeaterHops": "Sauts du répéteur",
"channelPath_noHopDetails": "Les détails de l'envoi ne sont pas fournis pour ce paquet.", "channelPath_noHopDetails": "Les détails de l'envoi ne sont pas fournis pour ce paquet.",
"channelPath_messageDetails": "Détails du message", "channelPath_messageDetails": "Détails du message",
"channelPath_senderLabel": "Expéditeur", "channelPath_senderLabel": "Expéditeur",
@@ -1306,7 +1301,7 @@
} }
}, },
"channelPath_mapTitle": "Carte du chemin", "channelPath_mapTitle": "Carte du chemin",
"channelPath_noRepeaterLocations": "Aucune position de répétiteur disponible pour ce chemin.", "channelPath_noRepeaterLocations": "Aucune position de répéteur disponible pour ce chemin.",
"channelPath_primaryPath": "Chemin {index} (Principal)", "channelPath_primaryPath": "Chemin {index} (Principal)",
"@channelPath_primaryPath": { "@channelPath_primaryPath": {
"placeholders": { "placeholders": {
@@ -1533,5 +1528,73 @@
"community_secretRegenerated": "Mot de passe secret régénéré pour \"{name}\"", "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_scanToUpdateSecret": "Scanner le nouveau code QR pour mettre à jour le mot de passe pour \"{name}\"",
"community_updateSecret": "Mettre à jour le secret", "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 à tout le réseau",
"contacts_contactImportFailed": "Échec de l'importation du contact.",
"contacts_zeroHopAdvert": "Annonce Zero saut",
"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",
"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": "Un ou plusieurs des sauts manquent d'une localisation !",
"map_tapToAdd": "Appuyez sur les nœuds pour les ajouter au chemin.",
"pathTrace_clearTooltip": "Effacer le chemin",
"map_pathTraceCancelled": "Traçage de chemin annulé",
"map_removeLast": "Supprimer le dernier",
"map_runTrace": "Exécuter la traçage de chemin",
"scanner_bluetoothOffMessage": "Veuillez activer le Bluetooth pour rechercher des appareils.",
"scanner_bluetoothOff": "Le Bluetooth est désactivé.",
"scanner_enableBluetooth": "Activer le Bluetooth",
"settings_clientRepeatFreqWarning": "Pour les transmissions hors réseau, il est nécessaire d'utiliser les fréquences de 433, 869 ou 918 MHz.",
"settings_clientRepeatSubtitle": "Permettez à cet appareil de répéter les paquets de données pour les autres.",
"settings_clientRepeat": "Répétition hors réseau"
} }
+69 -6
View File
@@ -131,9 +131,6 @@
"settings_infoContactsCount": "Numero contatti", "settings_infoContactsCount": "Numero contatti",
"settings_infoChannelCount": "Numero Canale", "settings_infoChannelCount": "Numero Canale",
"settings_presets": "Preset", "settings_presets": "Preset",
"settings_preset915Mhz": "915 MHz",
"settings_preset868Mhz": "868 MHz",
"settings_preset433Mhz": "433 MHz",
"settings_frequency": "Frequenza (MHz)", "settings_frequency": "Frequenza (MHz)",
"settings_frequencyHelper": "300,0 - 2500,0", "settings_frequencyHelper": "300,0 - 2500,0",
"settings_frequencyInvalid": "Frequenza non valida (300-2500 MHz)", "settings_frequencyInvalid": "Frequenza non valida (300-2500 MHz)",
@@ -143,8 +140,6 @@
"settings_txPower": "TX Potenza (dBm)", "settings_txPower": "TX Potenza (dBm)",
"settings_txPowerHelper": "0 - 22", "settings_txPowerHelper": "0 - 22",
"settings_txPowerInvalid": "Potere TX non valido (0-22 dBm)", "settings_txPowerInvalid": "Potere TX non valido (0-22 dBm)",
"settings_longRange": "Lungo Raggio",
"settings_fastSpeed": "Velocità Rapida",
"settings_error": "Errore: {message}", "settings_error": "Errore: {message}",
"@settings_error": { "@settings_error": {
"placeholders": { "placeholders": {
@@ -1533,5 +1528,73 @@
"community_secretRegenerated": "Codice segreto rigenerato per \"{name}\"", "community_secretRegenerated": "Codice segreto rigenerato per \"{name}\"",
"community_updateSecret": "Aggiorna Segreto", "community_updateSecret": "Aggiorna Segreto",
"community_secretUpdated": "Segreto aggiornato per \"{name}\"", "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",
"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!",
"map_removeLast": "Rimuovi ultimo",
"map_pathTraceCancelled": "Tracciamento del percorso annullato.",
"pathTrace_clearTooltip": "Pulisci percorso",
"map_runTrace": "Esegui Path Trace",
"map_tapToAdd": "Tocca i nodi per aggiungerli al percorso.",
"scanner_bluetoothOff": "Il Bluetooth è disattivato.",
"scanner_bluetoothOffMessage": "Si prega di attivare il Bluetooth per effettuare la scansione dei dispositivi.",
"scanner_enableBluetooth": "Abilita il Bluetooth",
"settings_clientRepeat": "Ripetizione \"fuori dalla rete\"",
"settings_clientRepeatFreqWarning": "Per la comunicazione fuori rete, è necessario utilizzare frequenze di 433, 869 o 918 MHz.",
"settings_clientRepeatSubtitle": "Permetti a questo dispositivo di ripetere i pacchetti di rete per gli altri."
} }
+360 -24
View File
@@ -376,6 +376,24 @@ abstract class AppLocalizations {
/// **'Scan'** /// **'Scan'**
String get scanner_scan; String get scanner_scan;
/// No description provided for @scanner_bluetoothOff.
///
/// In en, this message translates to:
/// **'Bluetooth is off'**
String get scanner_bluetoothOff;
/// No description provided for @scanner_bluetoothOffMessage.
///
/// In en, this message translates to:
/// **'Please turn on Bluetooth to scan for devices'**
String get scanner_bluetoothOffMessage;
/// No description provided for @scanner_enableBluetooth.
///
/// In en, this message translates to:
/// **'Enable Bluetooth'**
String get scanner_enableBluetooth;
/// No description provided for @device_quickSwitch. /// No description provided for @device_quickSwitch.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -730,24 +748,6 @@ abstract class AppLocalizations {
/// **'Presets'** /// **'Presets'**
String get settings_presets; String get settings_presets;
/// No description provided for @settings_preset915Mhz.
///
/// In en, this message translates to:
/// **'915 MHz'**
String get settings_preset915Mhz;
/// No description provided for @settings_preset868Mhz.
///
/// In en, this message translates to:
/// **'868 MHz'**
String get settings_preset868Mhz;
/// No description provided for @settings_preset433Mhz.
///
/// In en, this message translates to:
/// **'433 MHz'**
String get settings_preset433Mhz;
/// No description provided for @settings_frequency. /// No description provided for @settings_frequency.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -802,17 +802,23 @@ abstract class AppLocalizations {
/// **'Invalid TX power (0-22 dBm)'** /// **'Invalid TX power (0-22 dBm)'**
String get settings_txPowerInvalid; String get settings_txPowerInvalid;
/// No description provided for @settings_longRange. /// No description provided for @settings_clientRepeat.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Long Range'** /// **'Off-Grid Repeat'**
String get settings_longRange; String get settings_clientRepeat;
/// No description provided for @settings_fastSpeed. /// No description provided for @settings_clientRepeatSubtitle.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Fast Speed'** /// **'Allow this device to repeat mesh packets for others'**
String get settings_fastSpeed; String get settings_clientRepeatSubtitle;
/// No description provided for @settings_clientRepeatFreqWarning.
///
/// In en, this message translates to:
/// **'Off-grid repeat requires 433, 869, or 918 MHz frequency'**
String get settings_clientRepeatFreqWarning;
/// No description provided for @settings_error. /// No description provided for @settings_error.
/// ///
@@ -946,6 +952,18 @@ abstract class AppLocalizations {
/// **'Български'** /// **'Български'**
String get appSettings_languageBg; 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. /// No description provided for @appSettings_notifications.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -2506,6 +2524,30 @@ abstract class AppLocalizations {
/// **'Manage Repeater'** /// **'Manage Repeater'**
String get map_manageRepeater; String get map_manageRepeater;
/// No description provided for @map_tapToAdd.
///
/// In en, this message translates to:
/// **'Tap on nodes to add them to the path.'**
String get map_tapToAdd;
/// No description provided for @map_runTrace.
///
/// In en, this message translates to:
/// **'Run Path Trace'**
String get map_runTrace;
/// No description provided for @map_removeLast.
///
/// In en, this message translates to:
/// **'Remove Last'**
String get map_removeLast;
/// No description provided for @map_pathTraceCancelled.
///
/// In en, this message translates to:
/// **'Path trace cancelled.'**
String get map_pathTraceCancelled;
/// No description provided for @mapCache_title. /// No description provided for @mapCache_title.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -4687,6 +4729,300 @@ abstract class AppLocalizations {
/// In en, this message translates to: /// In en, this message translates to:
/// **'New group'** /// **'New group'**
String get listFilter_newGroup; 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 @pathTrace_clearTooltip.
///
/// In en, this message translates to:
/// **'Clear path.'**
String get pathTrace_clearTooltip;
/// 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 class _AppLocalizationsDelegate
+225 -11
View File
@@ -143,6 +143,16 @@ class AppLocalizationsBg extends AppLocalizations {
@override @override
String get scanner_scan => 'Сканирай'; String get scanner_scan => 'Сканирай';
@override
String get scanner_bluetoothOff => 'Bluetooth е изключен.';
@override
String get scanner_bluetoothOffMessage =>
'Моля, активирайте Bluetooth, за да сканирате за устройства.';
@override
String get scanner_enableBluetooth => 'Активирайте Bluetooth';
@override @override
String get device_quickSwitch => 'Бързо превключване'; String get device_quickSwitch => 'Бързо превключване';
@@ -340,15 +350,6 @@ class AppLocalizationsBg extends AppLocalizations {
@override @override
String get settings_presets => 'Предварителни настройки'; String get settings_presets => 'Предварителни настройки';
@override
String get settings_preset915Mhz => '915 MHz';
@override
String get settings_preset868Mhz => '868 MHz';
@override
String get settings_preset433Mhz => '433 MHz';
@override @override
String get settings_frequency => 'Честота (MHz)'; String get settings_frequency => 'Честота (MHz)';
@@ -377,10 +378,15 @@ class AppLocalizationsBg extends AppLocalizations {
String get settings_txPowerInvalid => 'Невалидна мощност на TX (0-22 dBm)'; String get settings_txPowerInvalid => 'Невалидна мощност на TX (0-22 dBm)';
@override @override
String get settings_longRange => 'Дълъг обхват'; String get settings_clientRepeat => 'Без електричество – повторение';
@override @override
String get settings_fastSpeed => 'Бърза скорост'; String get settings_clientRepeatSubtitle =>
'Позволете на това устройство да предава пакети към мрежата за други устройства.';
@override
String get settings_clientRepeatFreqWarning =>
'За повторение извън мрежата са необходими честоти от 433, 869 или 918 MHz.';
@override @override
String settings_error(String message) { String settings_error(String message) {
@@ -450,6 +456,12 @@ class AppLocalizationsBg extends AppLocalizations {
@override @override
String get appSettings_languageBg => 'Български'; String get appSettings_languageBg => 'Български';
@override
String get appSettings_languageRu => 'Руски';
@override
String get appSettings_languageUk => 'Украински';
@override @override
String get appSettings_notifications => 'Уведомления'; String get appSettings_notifications => 'Уведомления';
@@ -1357,6 +1369,19 @@ class AppLocalizationsBg extends AppLocalizations {
@override @override
String get map_manageRepeater => 'Управление на Повтарящ се Елемент'; String get map_manageRepeater => 'Управление на Повтарящ се Елемент';
@override
String get map_tapToAdd =>
'Натиснете върху възлите, за да ги добавите към пътя.';
@override
String get map_runTrace => 'Изпълни Път на Следване';
@override
String get map_removeLast => 'Премахни Последно';
@override
String get map_pathTraceCancelled => 'Отменен е следването на пътя.';
@override @override
String get mapCache_title => 'Кеш на офлайн карти'; String get mapCache_title => 'Кеш на офлайн карти';
@@ -2676,4 +2701,193 @@ class AppLocalizationsBg extends AppLocalizations {
@override @override
String get listFilter_newGroup => 'Нова група'; 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 pathTrace_clearTooltip => 'Изчисти пътя';
@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';
} }
+264 -47
View File
@@ -143,6 +143,16 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get scanner_scan => 'Scannen'; String get scanner_scan => 'Scannen';
@override
String get scanner_bluetoothOff => 'Bluetooth ist deaktiviert.';
@override
String get scanner_bluetoothOffMessage =>
'Bitte aktivieren Sie Bluetooth, um nach Geräten zu suchen.';
@override
String get scanner_enableBluetooth => 'Bluetooth aktivieren';
@override @override
String get device_quickSwitch => 'Schnelles Umschalten'; String get device_quickSwitch => 'Schnelles Umschalten';
@@ -160,7 +170,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get settings_appSettingsSubtitle => String get settings_appSettingsSubtitle =>
'Benachrichtigungen, Messaging und Kartenwahrnehmungen'; 'Benachrichtigungen, Messaging und Kartenwahrnehmung';
@override @override
String get settings_nodeSettings => 'Knoten-Einstellungen'; String get settings_nodeSettings => 'Knoten-Einstellungen';
@@ -244,10 +254,10 @@ class AppLocalizationsDe extends AppLocalizations {
String get settings_actions => 'Aktionen'; String get settings_actions => 'Aktionen';
@override @override
String get settings_sendAdvertisement => 'Sende eine Ankündigung'; String get settings_sendAdvertisement => 'Sende Ankündigung';
@override @override
String get settings_sendAdvertisementSubtitle => 'Sende Ankündigung'; String get settings_sendAdvertisementSubtitle => 'Sende eine Ankündigung';
@override @override
String get settings_advertisementSent => 'Ankündigung gesendet'; String get settings_advertisementSent => 'Ankündigung gesendet';
@@ -267,7 +277,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get settings_refreshContactsSubtitle => String get settings_refreshContactsSubtitle =>
'Kontakte-Liste vom Gerät neu laden'; 'Kontakt-Liste vom Gerät neu laden';
@override @override
String get settings_rebootDevice => 'Gerät neu starten'; String get settings_rebootDevice => 'Gerät neu starten';
@@ -334,15 +344,6 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get settings_presets => 'Voreinstellungen'; String get settings_presets => 'Voreinstellungen';
@override
String get settings_preset915Mhz => '915 MHz';
@override
String get settings_preset868Mhz => '868 MHz';
@override
String get settings_preset433Mhz => '433 MHz';
@override @override
String get settings_frequency => 'Frequenz (MHz)'; String get settings_frequency => 'Frequenz (MHz)';
@@ -371,10 +372,15 @@ class AppLocalizationsDe extends AppLocalizations {
String get settings_txPowerInvalid => 'Ungültige TX-Leistung (0-22 dBm)'; String get settings_txPowerInvalid => 'Ungültige TX-Leistung (0-22 dBm)';
@override @override
String get settings_longRange => 'Grosse Reichweite'; String get settings_clientRepeat => 'Wiederholung, ohne Stromanschluss';
@override @override
String get settings_fastSpeed => 'Schnelle Geschwindigkeit'; String get settings_clientRepeatSubtitle =>
'Ermöglichen Sie diesem Gerät, Mesh-Pakete für andere zu wiederholen.';
@override
String get settings_clientRepeatFreqWarning =>
'Die Kommunikation ohne Stromversorgung erfordert Frequenzen von 433, 869 oder 918 MHz.';
@override @override
String settings_error(String message) { String settings_error(String message) {
@@ -444,6 +450,12 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get appSettings_languageBg => 'Български'; String get appSettings_languageBg => 'Български';
@override
String get appSettings_languageRu => 'Russisch';
@override
String get appSettings_languageUk => 'Ukrainisch';
@override @override
String get appSettings_notifications => 'Benachrichtigungen'; String get appSettings_notifications => 'Benachrichtigungen';
@@ -662,7 +674,7 @@ class AppLocalizationsDe extends AppLocalizations {
} }
@override @override
String get contacts_manageRepeater => 'Wiederholungen verwalten'; String get contacts_manageRepeater => 'Repeater verwalten';
@override @override
String get contacts_manageRoom => 'Raum-Server verwalten'; String get contacts_manageRoom => 'Raum-Server verwalten';
@@ -796,7 +808,7 @@ class AppLocalizationsDe extends AppLocalizations {
String get channels_usePublicChannel => 'Verwende öffentlichen Kanal'; String get channels_usePublicChannel => 'Verwende öffentlichen Kanal';
@override @override
String get channels_standardPublicPsk => 'Standard-Öffentliche PSK'; String get channels_standardPublicPsk => 'Öffentliche Standard PSK';
@override @override
String get channels_pskHex => 'PSK (Hex)'; String get channels_pskHex => 'PSK (Hex)';
@@ -1029,11 +1041,11 @@ class AppLocalizationsDe extends AppLocalizations {
} }
@override @override
String get debugFrame_textMessageHeader => 'Textnachricht-Frame:'; String get debugFrame_textMessageHeader => 'Textnachrichten Frame:';
@override @override
String debugFrame_destinationPubKey(String pubKey) { String debugFrame_destinationPubKey(String pubKey) {
return '- Ziel-Pub-Schlüssel: $pubKey'; return '- Ziel-Public-Schlüssel: $pubKey';
} }
@override @override
@@ -1080,7 +1092,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get chat_recentAckPaths => String get chat_recentAckPaths =>
'Aktuelle ACK-Pfade (tasten, um zu verwenden):'; 'Aktuelle ACK-Pfade (antippen, um zu verwenden):';
@override @override
String get chat_pathHistoryFull => String get chat_pathHistoryFull =>
@@ -1111,7 +1123,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get chat_noPathHistoryYet => String get chat_noPathHistoryYet =>
'Keine eine Pfadhistorie vorhanden.\nSende eine Nachricht, um Pfade zu entdecken.'; 'Keine Pfadhistorie vorhanden.\nSende eine Nachricht, um Pfade zu entdecken.';
@override @override
String get chat_pathActions => 'Pfadaktionen:'; String get chat_pathActions => 'Pfadaktionen:';
@@ -1356,6 +1368,19 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get map_manageRepeater => 'Repeater verwalten'; String get map_manageRepeater => 'Repeater verwalten';
@override
String get map_tapToAdd =>
'Tippen Sie auf Knoten, um sie zum Pfad hinzuzufügen.';
@override
String get map_runTrace => 'Pfadverlauf ausführen';
@override
String get map_removeLast => 'Letztes Entfernen';
@override
String get map_pathTraceCancelled => 'Pfadverfolgung abgebrochen.';
@override @override
String get mapCache_title => 'Offline-Karten-Cache'; String get mapCache_title => 'Offline-Karten-Cache';
@@ -1412,7 +1437,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String mapCache_estimatedTiles(int count) { String mapCache_estimatedTiles(int count) {
return 'Geschätzte Fliesen: $count'; return 'Geschätzte Kacheln: $count';
} }
@override @override
@@ -1586,7 +1611,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get path_hexPrefixInstructions => String get path_hexPrefixInstructions =>
'Gebe für jeden Hopfen 2-stellige Hex-Präfixe ein, getrennt durch Kommas.'; 'Gebe für jeden Zwischen-Hop das 2-stellige Hex-Präfix ein, getrennt durch Kommas.';
@override @override
String get path_hexPrefixExample => String get path_hexPrefixExample =>
@@ -1683,7 +1708,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get repeater_statusRequestTimeout => String get repeater_statusRequestTimeout =>
'Statusanfrage zeitweise fehlgeschlagen.'; 'Statusanfrage durch Timeout fehlgeschlagen.';
@override @override
String repeater_errorLoadingStatus(String error) { String repeater_errorLoadingStatus(String error) {
@@ -1760,7 +1785,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String repeater_duplicatesFloodDirect(String flood, String direct) { String repeater_duplicatesFloodDirect(String flood, String direct) {
return 'Überflut: $flood, Direkt: $direct'; return 'Flut: $flood, Direkt: $direct';
} }
@override @override
@@ -1791,7 +1816,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get repeater_guestPasswordHelper => String get repeater_guestPasswordHelper =>
'Schreibgeschützter Zugriffspasswort'; 'Schreibgeschütztes Zugriffspasswort';
@override @override
String get repeater_radioSettings => 'Funk Einstellungen'; String get repeater_radioSettings => 'Funk Einstellungen';
@@ -1888,8 +1913,7 @@ class AppLocalizationsDe extends AppLocalizations {
String get repeater_rebootRepeater => 'Neustart Repeater'; String get repeater_rebootRepeater => 'Neustart Repeater';
@override @override
String get repeater_rebootRepeaterSubtitle => String get repeater_rebootRepeaterSubtitle => 'Repeater-Gerät neu starten.';
'Wiederholen Sie das Repeater-Gerät.';
@override @override
String get repeater_rebootRepeaterConfirm => String get repeater_rebootRepeaterConfirm =>
@@ -1987,7 +2011,7 @@ class AppLocalizationsDe extends AppLocalizations {
String get repeater_cliTitle => 'Repeater CLI'; String get repeater_cliTitle => 'Repeater CLI';
@override @override
String get repeater_debugNextCommand => 'Fehlersuche Nächster Befehl'; String get repeater_debugNextCommand => 'Fehlersuche des nächsten Befehls';
@override @override
String get repeater_commandHelp => 'Hilfe'; String get repeater_commandHelp => 'Hilfe';
@@ -2000,7 +2024,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get repeater_typeCommandOrUseQuick => String get repeater_typeCommandOrUseQuick =>
'Geben Sie einen Befehl unten ein oder verwenden Sie Schnellbefehle'; 'Geben Sie unten einen Befehl ein oder verwenden Sie die Schnellbefehle';
@override @override
String get repeater_enterCommandHint => 'Geben Sie den Befehl ein...'; String get repeater_enterCommandHint => 'Geben Sie den Befehl ein...';
@@ -2126,7 +2150,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get repeater_cliHelpSetRxDelay => String get repeater_cliHelpSetRxDelay =>
'Sets (experimentell) als Basis (muss > 1 sein für den Effekt) zur Anwendung einer leichten Verzögerung bei empfangenen Paketen, basierend auf Signalstärke/Punktzahl. Auf 0 setzen, um die Funktion zu deaktivieren.'; 'Fügt eine leichte Verzögerung bei empfangenen Paketen hinzu, basierend auf Signalstärke/Punktzahl. Auf 0 setzen, um die Funktion zu deaktivieren.';
@override @override
String get repeater_cliHelpSetTxDelay => String get repeater_cliHelpSetTxDelay =>
@@ -2170,7 +2194,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get repeater_cliHelpGetBridgeType => String get repeater_cliHelpGetBridgeType =>
'Ruft Brückentyp none, rs232, espnow ab.'; 'Ruft Brückentyp: none, rs232, espnow ab.';
@override @override
String get repeater_cliHelpLogStart => String get repeater_cliHelpLogStart =>
@@ -2197,7 +2221,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get repeater_cliHelpRegionLoad => String get repeater_cliHelpRegionLoad =>
'Hinweis: Dies ist ein spezieller Mehrbefehl-Aufruf. Jeder nachfolgende Befehl ist ein Regionsname (eingedruckt mit Leerzeichen zur Angabe der übergeordneten Hierarchie, mit mindestens einem Leerzeichen). Beendet durch das Senden einer Leerzeile/des Befehls.'; 'Hinweis: Dies ist ein spezieller Mehrbefehl-Aufruf. Jeder nachfolgende Befehl ist ein Regionsname (eingeckt mit Leerzeichen zur Angabe der übergeordneten Hierarchie, mit mindestens einem Leerzeichen). Beendet durch das Senden einer Leerzeile.';
@override @override
String get repeater_cliHelpRegionGet => String get repeater_cliHelpRegionGet =>
@@ -2346,10 +2370,11 @@ class AppLocalizationsDe extends AppLocalizations {
} }
@override @override
String get neighbors_receivedData => 'Empfangene Nachbarendaten'; String get neighbors_receivedData => 'Empfangene Nachbarsdaten';
@override @override
String get neighbors_requestTimedOut => 'Nachbarn melden zeitweise Ausfall.'; String get neighbors_requestTimedOut =>
'Anfrage durch Timeout fehlgeschlagen.';
@override @override
String neighbors_errorLoading(String error) { String neighbors_errorLoading(String error) {
@@ -2357,19 +2382,19 @@ class AppLocalizationsDe extends AppLocalizations {
} }
@override @override
String get neighbors_repeatersNeighbours => 'Wiederholer Nachbarn'; String get neighbors_repeatersNeighbours => 'Nachbarn';
@override @override
String get neighbors_noData => 'Keine Nachbardaten verfügbar.'; String get neighbors_noData => 'Keine Nachbarsdaten verfügbar.';
@override @override
String neighbors_unknownContact(String pubkey) { String neighbors_unknownContact(String pubkey) {
return 'Unbekannte $pubkey'; return 'Unbekannt $pubkey';
} }
@override @override
String neighbors_heardAgo(String time) { String neighbors_heardAgo(String time) {
return 'Hörte: $time vor her.'; return 'Gehört vor: $time';
} }
@override @override
@@ -2389,7 +2414,7 @@ class AppLocalizationsDe extends AppLocalizations {
'Die Detailangaben für dieses Paket sind nicht verfügbar.'; 'Die Detailangaben für dieses Paket sind nicht verfügbar.';
@override @override
String get channelPath_messageDetails => 'Nachrichtsdetails'; String get channelPath_messageDetails => 'Nachrichtendetails';
@override @override
String get channelPath_senderLabel => 'Sender'; String get channelPath_senderLabel => 'Sender';
@@ -2588,7 +2613,7 @@ class AppLocalizationsDe extends AppLocalizations {
} }
@override @override
String get community_regenerateSecret => 'Neu generieren Sie das Geheimnis'; String get community_regenerateSecret => 'Neugenerierung des Schlüssels';
@override @override
String community_regenerateSecretConfirm(String name) { String community_regenerateSecretConfirm(String name) {
@@ -2600,15 +2625,15 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String community_secretRegenerated(String name) { String community_secretRegenerated(String name) {
return 'Geheime Wiederherstellung für \"$name\" erfolgreich'; return 'Wiederherstellung des Schlüssels für \"$name\" erfolgreich';
} }
@override @override
String get community_updateSecret => 'Aktualisieren Sie das Geheimnis'; String get community_updateSecret => 'Aktualisieren Sie den Schlüssel';
@override @override
String community_secretUpdated(String name) { String community_secretUpdated(String name) {
return 'Geheime für \"$name\" aktualisiert'; return 'Schlüssel für \"$name\" aktualisiert';
} }
@override @override
@@ -2625,14 +2650,14 @@ class AppLocalizationsDe extends AppLocalizations {
'Füge einen Hashtag-Kanal für diese Community hinzu'; 'Füge einen Hashtag-Kanal für diese Community hinzu';
@override @override
String get community_selectCommunity => 'Wählen Sie Community'; String get community_selectCommunity => 'Wählen Sie eine Community';
@override @override
String get community_regularHashtag => 'Regulärer Hashtag'; String get community_regularHashtag => 'Regulärer Hashtag';
@override @override
String get community_regularHashtagDesc => String get community_regularHashtagDesc =>
'Öffentliches Hashtag (jeder kann teilnehmen)'; 'Öffentlicher Hashtag (jeder kann teilnehmen)';
@override @override
String get community_communityHashtag => 'Community Hashtag'; String get community_communityHashtag => 'Community Hashtag';
@@ -2677,8 +2702,200 @@ class AppLocalizationsDe extends AppLocalizations {
String get listFilter_roomServers => 'Raumserver'; String get listFilter_roomServers => 'Raumserver';
@override @override
String get listFilter_unreadOnly => 'Nur nicht gelesen'; String get listFilter_unreadOnly => 'Nicht gelesen';
@override @override
String get listFilter_newGroup => 'Neue Gruppe'; 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 =>
'Bei einer oder mehreren Knoten fehlt der Standort!';
@override
String get pathTrace_clearTooltip => 'Pfad löschen';
@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-Ankündigung';
@override
String get contacts_floodAdvert => 'Flut-Ankündigung';
@override
String get contacts_copyAdvertToClipboard =>
'Ankündigung 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 der Ankündigung 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 als GPX exportieren';
@override
String get settings_gpxExportRepeatersSubtitle =>
'Exportiert Repeater und Raumserver mit einem Standort in eine GPX-Datei.';
@override
String get settings_gpxExportContacts => 'Kontakte als GPX exportieren';
@override
String get settings_gpxExportContactsSubtitle =>
'Exportiert Kontakte mit einem Ort in eine GPX-Datei.';
@override
String get settings_gpxExportAll => 'Alle Knoten als GPX exportieren';
@override
String get settings_gpxExportAllSubtitle =>
'Exportiert alle Knoten mit einem Standort in eine GPX-Datei.';
@override
String get settings_gpxExportSuccess => 'GPX-Datei erfolgreich 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 => 'Kontaktstandorte';
@override
String get settings_gpxExportAllContacts => 'Alle Kontaktstandorte';
@override
String get settings_gpxExportShareText =>
'GPX-Kartendaten aus meshcore-open exportiert';
@override
String get settings_gpxExportShareSubject =>
'GPX-Kartendaten aus meshcore-open exportieren';
} }
+220 -11
View File
@@ -142,6 +142,16 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get scanner_scan => 'Scan'; String get scanner_scan => 'Scan';
@override
String get scanner_bluetoothOff => 'Bluetooth is off';
@override
String get scanner_bluetoothOffMessage =>
'Please turn on Bluetooth to scan for devices';
@override
String get scanner_enableBluetooth => 'Enable Bluetooth';
@override @override
String get device_quickSwitch => 'Quick switch'; String get device_quickSwitch => 'Quick switch';
@@ -332,15 +342,6 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get settings_presets => 'Presets'; String get settings_presets => 'Presets';
@override
String get settings_preset915Mhz => '915 MHz';
@override
String get settings_preset868Mhz => '868 MHz';
@override
String get settings_preset433Mhz => '433 MHz';
@override @override
String get settings_frequency => 'Frequency (MHz)'; String get settings_frequency => 'Frequency (MHz)';
@@ -369,10 +370,15 @@ class AppLocalizationsEn extends AppLocalizations {
String get settings_txPowerInvalid => 'Invalid TX power (0-22 dBm)'; String get settings_txPowerInvalid => 'Invalid TX power (0-22 dBm)';
@override @override
String get settings_longRange => 'Long Range'; String get settings_clientRepeat => 'Off-Grid Repeat';
@override @override
String get settings_fastSpeed => 'Fast Speed'; String get settings_clientRepeatSubtitle =>
'Allow this device to repeat mesh packets for others';
@override
String get settings_clientRepeatFreqWarning =>
'Off-grid repeat requires 433, 869, or 918 MHz frequency';
@override @override
String settings_error(String message) { String settings_error(String message) {
@@ -442,6 +448,12 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get appSettings_languageBg => 'Български'; String get appSettings_languageBg => 'Български';
@override
String get appSettings_languageRu => 'Русский';
@override
String get appSettings_languageUk => 'Українська';
@override @override
String get appSettings_notifications => 'Notifications'; String get appSettings_notifications => 'Notifications';
@@ -1336,6 +1348,18 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get map_manageRepeater => 'Manage Repeater'; String get map_manageRepeater => 'Manage Repeater';
@override
String get map_tapToAdd => 'Tap on nodes to add them to the path.';
@override
String get map_runTrace => 'Run Path Trace';
@override
String get map_removeLast => 'Remove Last';
@override
String get map_pathTraceCancelled => 'Path trace cancelled.';
@override @override
String get mapCache_title => 'Offline Map Cache'; String get mapCache_title => 'Offline Map Cache';
@@ -2636,4 +2660,189 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get listFilter_newGroup => 'New group'; 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 pathTrace_clearTooltip => 'Clear path.';
@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';
} }
+225 -11
View File
@@ -143,6 +143,16 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get scanner_scan => 'Escanea'; String get scanner_scan => 'Escanea';
@override
String get scanner_bluetoothOff => 'Bluetooth está desactivado.';
@override
String get scanner_bluetoothOffMessage =>
'Por favor, active el Bluetooth para escanear dispositivos.';
@override
String get scanner_enableBluetooth => 'Habilitar Bluetooth';
@override @override
String get device_quickSwitch => 'Cambiar rápidamente'; String get device_quickSwitch => 'Cambiar rápidamente';
@@ -337,15 +347,6 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get settings_presets => 'Preajustes'; String get settings_presets => 'Preajustes';
@override
String get settings_preset915Mhz => '915 MHz';
@override
String get settings_preset868Mhz => '868 MHz';
@override
String get settings_preset433Mhz => '433 MHz';
@override @override
String get settings_frequency => 'Frecuencia (MHz)'; String get settings_frequency => 'Frecuencia (MHz)';
@@ -374,10 +375,15 @@ class AppLocalizationsEs extends AppLocalizations {
String get settings_txPowerInvalid => 'Potencia de TX inválida (0-22 dBm)'; String get settings_txPowerInvalid => 'Potencia de TX inválida (0-22 dBm)';
@override @override
String get settings_longRange => 'Largo Alcance'; String get settings_clientRepeat => 'Repetir sin conexión';
@override @override
String get settings_fastSpeed => 'Velocidad Rápida'; String get settings_clientRepeatSubtitle =>
'Permita que este dispositivo repita los paquetes de red para otros usuarios.';
@override
String get settings_clientRepeatFreqWarning =>
'Para la comunicación fuera de la red, se requiere una frecuencia de 433, 869 o 918 MHz.';
@override @override
String settings_error(String message) { String settings_error(String message) {
@@ -447,6 +453,12 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get appSettings_languageBg => 'Български'; String get appSettings_languageBg => 'Български';
@override
String get appSettings_languageRu => 'Ruso';
@override
String get appSettings_languageUk => 'Ucraniano';
@override @override
String get appSettings_notifications => 'Notificaciones'; String get appSettings_notifications => 'Notificaciones';
@@ -1354,6 +1366,18 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get map_manageRepeater => 'Gestionar Repetidor'; String get map_manageRepeater => 'Gestionar Repetidor';
@override
String get map_tapToAdd => 'Pulse en los nodos para agregarlos al camino.';
@override
String get map_runTrace => 'Ejecutar Rastreo de Ruta';
@override
String get map_removeLast => 'Eliminar último';
@override
String get map_pathTraceCancelled => 'Rastreo de ruta cancelado.';
@override @override
String get mapCache_title => 'Caché de Mapa Offline'; String get mapCache_title => 'Caché de Mapa Offline';
@@ -2675,4 +2699,194 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get listFilter_newGroup => 'Nuevo grupo'; 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 pathTrace_clearTooltip => 'Borrar ruta';
@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';
} }
+266 -45
View File
@@ -143,6 +143,16 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get scanner_scan => 'Scanner'; String get scanner_scan => 'Scanner';
@override
String get scanner_bluetoothOff => 'Le Bluetooth est désactivé.';
@override
String get scanner_bluetoothOffMessage =>
'Veuillez activer le Bluetooth pour rechercher des appareils.';
@override
String get scanner_enableBluetooth => 'Activer le Bluetooth';
@override @override
String get device_quickSwitch => 'Basculement rapide'; String get device_quickSwitch => 'Basculement rapide';
@@ -338,15 +348,6 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get settings_presets => 'Préréglages'; String get settings_presets => 'Préréglages';
@override
String get settings_preset915Mhz => '915 MHz';
@override
String get settings_preset868Mhz => '868 MHz';
@override
String get settings_preset433Mhz => '433 MHz';
@override @override
String get settings_frequency => 'Fréquence (MHz)'; String get settings_frequency => 'Fréquence (MHz)';
@@ -375,10 +376,15 @@ class AppLocalizationsFr extends AppLocalizations {
String get settings_txPowerInvalid => 'Puissance TX invalide (0-22 dBm)'; String get settings_txPowerInvalid => 'Puissance TX invalide (0-22 dBm)';
@override @override
String get settings_longRange => 'Portée Longue'; String get settings_clientRepeat => 'Répétition hors réseau';
@override @override
String get settings_fastSpeed => 'Vitesse Rapide'; String get settings_clientRepeatSubtitle =>
'Permettez à cet appareil de répéter les paquets de données pour les autres.';
@override
String get settings_clientRepeatFreqWarning =>
'Pour les transmissions hors réseau, il est nécessaire d\'utiliser les fréquences de 433, 869 ou 918 MHz.';
@override @override
String settings_error(String message) { String settings_error(String message) {
@@ -448,6 +454,12 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get appSettings_languageBg => 'Български'; String get appSettings_languageBg => 'Български';
@override
String get appSettings_languageRu => 'Russe';
@override
String get appSettings_languageUk => 'Ukrainien';
@override @override
String get appSettings_notifications => 'Notifications'; String get appSettings_notifications => 'Notifications';
@@ -554,11 +566,11 @@ class AppLocalizationsFr extends AppLocalizations {
String get appSettings_mapDisplay => 'Affichage de la carte'; String get appSettings_mapDisplay => 'Affichage de la carte';
@override @override
String get appSettings_showRepeaters => 'Afficher les répétiteurs'; String get appSettings_showRepeaters => 'Afficher les répéteurs';
@override @override
String get appSettings_showRepeatersSubtitle => String get appSettings_showRepeatersSubtitle =>
'Afficher les nœuds répétiteurs sur la carte'; 'Afficher les nœuds répéteurs sur la carte';
@override @override
String get appSettings_showChatNodes => 'Afficher les nœuds de discussion'; String get appSettings_showChatNodes => 'Afficher les nœuds de discussion';
@@ -665,7 +677,7 @@ class AppLocalizationsFr extends AppLocalizations {
} }
@override @override
String get contacts_manageRepeater => 'Gérer le répétiteur'; String get contacts_manageRepeater => 'Gérer le répéteur';
@override @override
String get contacts_manageRoom => 'Gérer le Room Server'; String get contacts_manageRoom => 'Gérer le Room Server';
@@ -1088,18 +1100,18 @@ class AppLocalizationsFr extends AppLocalizations {
'L\'historique du chemin est plein. Supprimez les entrées pour en ajouter de nouvelles.'; 'L\'historique du chemin est plein. Supprimez les entrées pour en ajouter de nouvelles.';
@override @override
String get chat_hopSingular => 'Sautez'; String get chat_hopSingular => 'saut';
@override @override
String get chat_hopPlural => 'sautez'; String get chat_hopPlural => 'sauts';
@override @override
String chat_hopsCount(int count) { String chat_hopsCount(int count) {
String _temp0 = intl.Intl.pluralLogic( String _temp0 = intl.Intl.pluralLogic(
count, count,
locale: localeName, locale: localeName,
other: 'hops', other: 'sauts',
one: 'hop', one: 'saut',
); );
return '$count $_temp0'; return '$count $_temp0';
} }
@@ -1253,7 +1265,7 @@ class AppLocalizationsFr extends AppLocalizations {
String get map_chat => 'Chat'; String get map_chat => 'Chat';
@override @override
String get map_repeater => 'Répétiteur'; String get map_repeater => 'Répéteur';
@override @override
String get map_room => 'Salle'; String get map_room => 'Salle';
@@ -1359,7 +1371,20 @@ class AppLocalizationsFr extends AppLocalizations {
String get map_joinRoom => 'Rejoindre la salle'; String get map_joinRoom => 'Rejoindre la salle';
@override @override
String get map_manageRepeater => 'Gérer le répétiteur'; String get map_manageRepeater => 'Gérer le répéteur';
@override
String get map_tapToAdd =>
'Appuyez sur les nœuds pour les ajouter au chemin.';
@override
String get map_runTrace => 'Exécuter la traçage de chemin';
@override
String get map_removeLast => 'Supprimer le dernier';
@override
String get map_pathTraceCancelled => 'Traçage de chemin annulé';
@override @override
String get mapCache_title => 'Cache de Carte Hors Ligne'; String get mapCache_title => 'Cache de Carte Hors Ligne';
@@ -1503,7 +1528,7 @@ class AppLocalizationsFr extends AppLocalizations {
'Êtes-vous sûr de vouloir vous déconnecter de cet appareil ?'; 'Êtes-vous sûr de vouloir vous déconnecter de cet appareil ?';
@override @override
String get login_repeaterLogin => 'Connexion au répétiteur'; String get login_repeaterLogin => 'Connexion au répéteur';
@override @override
String get login_roomLogin => 'Connexion Salle'; String get login_roomLogin => 'Connexion Salle';
@@ -1523,7 +1548,7 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get login_repeaterDescription => String get login_repeaterDescription =>
'Entrez le mot de passe du répétiteur pour accéder aux paramètres et à l\'état.'; 'Entrez le mot de passe du répéteur pour accéder aux paramètres et à l\'état.';
@override @override
String get login_roomDescription => String get login_roomDescription =>
@@ -1628,7 +1653,7 @@ class AppLocalizationsFr extends AppLocalizations {
String get path_setPath => 'Définir le chemin'; String get path_setPath => 'Définir le chemin';
@override @override
String get repeater_management => 'Gestion des répétiteurs'; String get repeater_management => 'Gestion des répéteurs';
@override @override
String get room_management => 'Administración del Servidor de Habitación'; String get room_management => 'Administración del Servidor de Habitación';
@@ -1641,7 +1666,7 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get repeater_statusSubtitle => String get repeater_statusSubtitle =>
'Afficher l\'état, les statistiques et les voisins du répétiteur'; 'Afficher l\'état, les statistiques et les voisins du répéteur';
@override @override
String get repeater_telemetry => 'Télémetrie'; String get repeater_telemetry => 'Télémetrie';
@@ -1654,7 +1679,7 @@ class AppLocalizationsFr extends AppLocalizations {
String get repeater_cli => 'CLI'; String get repeater_cli => 'CLI';
@override @override
String get repeater_cliSubtitle => 'Envoyer des commandes au répétiteur'; String get repeater_cliSubtitle => 'Envoyer des commandes au répéteur';
@override @override
String get repeater_neighbours => 'Voisins'; String get repeater_neighbours => 'Voisins';
@@ -1668,10 +1693,10 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get repeater_settingsSubtitle => String get repeater_settingsSubtitle =>
'Configurer les paramètres du répétiteur'; 'Configurer les paramètres du répéteur';
@override @override
String get repeater_statusTitle => 'État du répétiteur'; String get repeater_statusTitle => 'État du répéteur';
@override @override
String get repeater_routingMode => 'Mode de routage'; String get repeater_routingMode => 'Mode de routage';
@@ -1777,16 +1802,16 @@ class AppLocalizationsFr extends AppLocalizations {
} }
@override @override
String get repeater_settingsTitle => 'Paramètres du répétiteur'; String get repeater_settingsTitle => 'Paramètres du répéteur';
@override @override
String get repeater_basicSettings => 'Paramètres de base'; String get repeater_basicSettings => 'Paramètres de base';
@override @override
String get repeater_repeaterName => 'Nom du répétiteur'; String get repeater_repeaterName => 'Nom du répéteur';
@override @override
String get repeater_repeaterNameHelper => 'Afficher le nom de ce répétiteur'; String get repeater_repeaterNameHelper => 'Afficher le nom de ce répéteur';
@override @override
String get repeater_adminPassword => 'Mot de passe Administrateur'; String get repeater_adminPassword => 'Mot de passe Administrateur';
@@ -1850,7 +1875,7 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get repeater_packetForwardingSubtitle => String get repeater_packetForwardingSubtitle =>
'Activer le répétiteur pour transmettre des paquets'; 'Activer le répéteur pour transmettre des paquets';
@override @override
String get repeater_guestAccess => 'Accès Invité'; String get repeater_guestAccess => 'Accès Invité';
@@ -1899,11 +1924,11 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get repeater_rebootRepeaterSubtitle => String get repeater_rebootRepeaterSubtitle =>
'Réinitialiser l\'appareil répétiteur'; 'Réinitialiser l\'appareil répéteur';
@override @override
String get repeater_rebootRepeaterConfirm => String get repeater_rebootRepeaterConfirm =>
'Êtes-vous sûr de vouloir redémarrer ce répétiteur ?'; 'Êtes-vous sûr de vouloir redémarrer ce répéteur ?';
@override @override
String get repeater_regenerateIdentityKey => 'Ré générer la clé d\'identité'; String get repeater_regenerateIdentityKey => 'Ré générer la clé d\'identité';
@@ -1914,18 +1939,18 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get repeater_regenerateIdentityKeyConfirm => String get repeater_regenerateIdentityKeyConfirm =>
'Cela générera une nouvelle identité pour le répétiteur. Continuer ?'; 'Cela générera une nouvelle identité pour le répéteur. Continuer ?';
@override @override
String get repeater_eraseFileSystem => 'Supprimer le système de fichiers'; String get repeater_eraseFileSystem => 'Supprimer le système de fichiers';
@override @override
String get repeater_eraseFileSystemSubtitle => String get repeater_eraseFileSystemSubtitle =>
'Formater le système de fichiers du répétiteur'; 'Formater le système de fichiers du répéteur';
@override @override
String get repeater_eraseFileSystemConfirm => String get repeater_eraseFileSystemConfirm =>
'AVERTISSEMENT : Cela effacera toutes les données du répétiteur. Cela ne peut pas être annulé !'; 'AVERTISSEMENT : Cela effacera toutes les données du répéteur. Cela ne peut pas être annulé !';
@override @override
String get repeater_eraseSerialOnly => String get repeater_eraseSerialOnly =>
@@ -1993,7 +2018,7 @@ class AppLocalizationsFr extends AppLocalizations {
} }
@override @override
String get repeater_cliTitle => 'Répétiteur CLI'; String get repeater_cliTitle => 'Répéteur CLI';
@override @override
String get repeater_debugNextCommand => 'Déboguer Prochaine Commande'; String get repeater_debugNextCommand => 'Déboguer Prochaine Commande';
@@ -2085,7 +2110,7 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get repeater_cliHelpSetRepeat => String get repeater_cliHelpSetRepeat =>
'Active ou désactive le rôle du répétiteur pour ce nœud.'; 'Active ou désactive le rôle du répéteur pour ce nœud.';
@override @override
String get repeater_cliHelpSetAllowReadOnly => String get repeater_cliHelpSetAllowReadOnly =>
@@ -2109,7 +2134,7 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get repeater_cliHelpSetAdvertInterval => String get repeater_cliHelpSetAdvertInterval =>
'Définit l\'intervalle du minuteur pour envoyer un paquet d\'annonce local (sans relais). Définir sur 0 pour désactiver.'; 'Définit l\'intervalle entre chaque émission d\'une annonce locale (sans relais). Définir sur 0 pour désactiver.';
@override @override
String get repeater_cliHelpSetFloodAdvertInterval => String get repeater_cliHelpSetFloodAdvertInterval =>
@@ -2195,7 +2220,7 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get repeater_cliHelpNeighbors => String get repeater_cliHelpNeighbors =>
'Affiche une liste d\'autres nœuds répétiteurs entendus via des annonces sans relais. Chaque ligne est id-préfixe-hexadécimal:timestamp:snr-fois-4'; 'Affiche une liste d\'autres nœuds répéteurs entendus via des annonces sans relais. Chaque ligne est id-préfixe-hexadécimal:timestamp:snr-fois-4';
@override @override
String get repeater_cliHelpNeighborRemove => String get repeater_cliHelpNeighborRemove =>
@@ -2283,12 +2308,11 @@ class AppLocalizationsFr extends AppLocalizations {
String get repeater_logging => 'Journalisation'; String get repeater_logging => 'Journalisation';
@override @override
String get repeater_neighborsRepeaterOnly => String get repeater_neighborsRepeaterOnly => 'Voisins (Uniquement répéteur)';
'Voisins (Uniquement répétiteur)';
@override @override
String get repeater_regionManagementRepeaterOnly => String get repeater_regionManagementRepeaterOnly =>
'Gestion des régions (uniquement pour le répétiteur)'; 'Gestion des régions (uniquement pour le répéteur)';
@override @override
String get repeater_regionNote => String get repeater_regionNote =>
@@ -2393,7 +2417,7 @@ class AppLocalizationsFr extends AppLocalizations {
String get channelPath_otherObservedPaths => 'Autres chemins observés'; String get channelPath_otherObservedPaths => 'Autres chemins observés';
@override @override
String get channelPath_repeaterHops => 'Sauts du répétiteur'; String get channelPath_repeaterHops => 'Sauts du répéteur';
@override @override
String get channelPath_noHopDetails => String get channelPath_noHopDetails =>
@@ -2461,7 +2485,7 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get channelPath_noRepeaterLocations => String get channelPath_noRepeaterLocations =>
'Aucune position de répétiteur disponible pour ce chemin.'; 'Aucune position de répéteur disponible pour ce chemin.';
@override @override
String channelPath_primaryPath(int index) { String channelPath_primaryPath(int index) {
@@ -2692,4 +2716,201 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get listFilter_newGroup => 'Nouveau groupe'; 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 =>
'Un ou plusieurs des sauts manquent d\'une localisation !';
@override
String get pathTrace_clearTooltip => 'Effacer le chemin';
@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 saut';
@override
String get contacts_floodAdvert => 'Annonce à tout le réseau';
@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';
} }
+229 -11
View File
@@ -143,6 +143,16 @@ class AppLocalizationsIt extends AppLocalizations {
@override @override
String get scanner_scan => 'Scansiona'; String get scanner_scan => 'Scansiona';
@override
String get scanner_bluetoothOff => 'Il Bluetooth è disattivato.';
@override
String get scanner_bluetoothOffMessage =>
'Si prega di attivare il Bluetooth per effettuare la scansione dei dispositivi.';
@override
String get scanner_enableBluetooth => 'Abilita il Bluetooth';
@override @override
String get device_quickSwitch => 'Passa velocemente'; String get device_quickSwitch => 'Passa velocemente';
@@ -336,15 +346,6 @@ class AppLocalizationsIt extends AppLocalizations {
@override @override
String get settings_presets => 'Preset'; String get settings_presets => 'Preset';
@override
String get settings_preset915Mhz => '915 MHz';
@override
String get settings_preset868Mhz => '868 MHz';
@override
String get settings_preset433Mhz => '433 MHz';
@override @override
String get settings_frequency => 'Frequenza (MHz)'; String get settings_frequency => 'Frequenza (MHz)';
@@ -373,10 +374,15 @@ class AppLocalizationsIt extends AppLocalizations {
String get settings_txPowerInvalid => 'Potere TX non valido (0-22 dBm)'; String get settings_txPowerInvalid => 'Potere TX non valido (0-22 dBm)';
@override @override
String get settings_longRange => 'Lungo Raggio'; String get settings_clientRepeat => 'Ripetizione \"fuori dalla rete\"';
@override @override
String get settings_fastSpeed => 'Velocità Rapida'; String get settings_clientRepeatSubtitle =>
'Permetti a questo dispositivo di ripetere i pacchetti di rete per gli altri.';
@override
String get settings_clientRepeatFreqWarning =>
'Per la comunicazione fuori rete, è necessario utilizzare frequenze di 433, 869 o 918 MHz.';
@override @override
String settings_error(String message) { String settings_error(String message) {
@@ -446,6 +452,12 @@ class AppLocalizationsIt extends AppLocalizations {
@override @override
String get appSettings_languageBg => 'Български'; String get appSettings_languageBg => 'Български';
@override
String get appSettings_languageRu => 'Russo';
@override
String get appSettings_languageUk => 'Ucraino';
@override @override
String get appSettings_notifications => 'Notifiche'; String get appSettings_notifications => 'Notifiche';
@@ -1353,6 +1365,18 @@ class AppLocalizationsIt extends AppLocalizations {
@override @override
String get map_manageRepeater => 'Gestisci Ripetitore'; String get map_manageRepeater => 'Gestisci Ripetitore';
@override
String get map_tapToAdd => 'Tocca i nodi per aggiungerli al percorso.';
@override
String get map_runTrace => 'Esegui Path Trace';
@override
String get map_removeLast => 'Rimuovi ultimo';
@override
String get map_pathTraceCancelled => 'Tracciamento del percorso annullato.';
@override @override
String get mapCache_title => 'Cache Mappa Offline'; String get mapCache_title => 'Cache Mappa Offline';
@@ -2675,4 +2699,198 @@ class AppLocalizationsIt extends AppLocalizations {
@override @override
String get listFilter_newGroup => 'Nuovo gruppo'; 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 pathTrace_clearTooltip => 'Pulisci percorso';
@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';
} }
+226 -11
View File
@@ -142,6 +142,16 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get scanner_scan => 'Scan'; String get scanner_scan => 'Scan';
@override
String get scanner_bluetoothOff => 'Bluetooth is uitgeschakeld';
@override
String get scanner_bluetoothOffMessage =>
'Zorg ervoor dat Bluetooth is ingeschakeld om naar apparaten te zoeken.';
@override
String get scanner_enableBluetooth => 'Activeer Bluetooth';
@override @override
String get device_quickSwitch => 'Snelle overschakeling'; String get device_quickSwitch => 'Snelle overschakeling';
@@ -334,15 +344,6 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get settings_presets => 'Presets'; String get settings_presets => 'Presets';
@override
String get settings_preset915Mhz => '915 MHz';
@override
String get settings_preset868Mhz => '868 MHz';
@override
String get settings_preset433Mhz => '433 MHz';
@override @override
String get settings_frequency => 'Frequentie (MHz)'; String get settings_frequency => 'Frequentie (MHz)';
@@ -371,10 +372,15 @@ class AppLocalizationsNl extends AppLocalizations {
String get settings_txPowerInvalid => 'Ongeldige TX-vermogen (0-22 dBm)'; String get settings_txPowerInvalid => 'Ongeldige TX-vermogen (0-22 dBm)';
@override @override
String get settings_longRange => 'Lange Afstand'; String get settings_clientRepeat => 'Herhalen: Afgekoppeld';
@override @override
String get settings_fastSpeed => 'Hoge Snelheid'; String get settings_clientRepeatSubtitle =>
'Laat dit apparaat de mesh-pakketten opnieuw verzenden voor andere apparaten.';
@override
String get settings_clientRepeatFreqWarning =>
'Om een signaal buiten het netwerk te versturen, zijn frequenties van 433, 869 of 918 MHz vereist.';
@override @override
String settings_error(String message) { String settings_error(String message) {
@@ -444,6 +450,12 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get appSettings_languageBg => 'Български'; String get appSettings_languageBg => 'Български';
@override
String get appSettings_languageRu => 'Russisch';
@override
String get appSettings_languageUk => 'Oekraïens';
@override @override
String get appSettings_notifications => 'Notificaties'; String get appSettings_notifications => 'Notificaties';
@@ -1349,6 +1361,19 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get map_manageRepeater => 'Beheer Repeater'; String get map_manageRepeater => 'Beheer Repeater';
@override
String get map_tapToAdd =>
'Tik op knooppunten om ze toe te voegen aan het pad';
@override
String get map_runTrace => 'Padeshulp traceren';
@override
String get map_removeLast => 'Verwijder Laatste';
@override
String get map_pathTraceCancelled => 'Pad traceren geannuleerd';
@override @override
String get mapCache_title => 'Offline Kaarten Cache'; String get mapCache_title => 'Offline Kaarten Cache';
@@ -2666,4 +2691,194 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get listFilter_newGroup => 'Nieuwe groep'; 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 pathTrace_clearTooltip => 'Weg wissen';
@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';
} }
+232 -11
View File
@@ -143,6 +143,16 @@ class AppLocalizationsPl extends AppLocalizations {
@override @override
String get scanner_scan => 'Przeskanuj'; String get scanner_scan => 'Przeskanuj';
@override
String get scanner_bluetoothOff => 'Bluetooth jest wyłączony';
@override
String get scanner_bluetoothOffMessage =>
'Prosimy włączyć Bluetooth, aby przeskanować urządzenia.';
@override
String get scanner_enableBluetooth => 'Włącz Bluetooth';
@override @override
String get device_quickSwitch => 'Szybka zmiana'; String get device_quickSwitch => 'Szybka zmiana';
@@ -337,15 +347,6 @@ class AppLocalizationsPl extends AppLocalizations {
@override @override
String get settings_presets => 'Preset'; String get settings_presets => 'Preset';
@override
String get settings_preset915Mhz => '915 MHz';
@override
String get settings_preset868Mhz => '868 MHz';
@override
String get settings_preset433Mhz => '433 MHz';
@override @override
String get settings_frequency => 'Częstotliwość (MHz)'; String get settings_frequency => 'Częstotliwość (MHz)';
@@ -375,10 +376,15 @@ class AppLocalizationsPl extends AppLocalizations {
String get settings_txPowerInvalid => 'Nieprawidłowa moc TX (0-22 dBm)'; String get settings_txPowerInvalid => 'Nieprawidłowa moc TX (0-22 dBm)';
@override @override
String get settings_longRange => 'Długi zasięg'; String get settings_clientRepeat => 'Powtórzenie: Niezależne od sieci';
@override @override
String get settings_fastSpeed => 'Szybka prędkość'; String get settings_clientRepeatSubtitle =>
'Pozwól temu urządzeniu powtarzać pakiety danych dla innych urządzeń.';
@override
String get settings_clientRepeatFreqWarning =>
'Powtórka poza siecią wymaga częstotliwości 433, 869 lub 918 MHz.';
@override @override
String settings_error(String message) { String settings_error(String message) {
@@ -448,6 +454,12 @@ class AppLocalizationsPl extends AppLocalizations {
@override @override
String get appSettings_languageBg => 'Български'; String get appSettings_languageBg => 'Български';
@override
String get appSettings_languageRu => 'Rosyjski';
@override
String get appSettings_languageUk => 'Ukraińska';
@override @override
String get appSettings_notifications => 'Powiadomienia'; String get appSettings_notifications => 'Powiadomienia';
@@ -1355,6 +1367,18 @@ class AppLocalizationsPl extends AppLocalizations {
@override @override
String get map_manageRepeater => 'Zarządzaj Powtórzami'; String get map_manageRepeater => 'Zarządzaj Powtórzami';
@override
String get map_tapToAdd => 'Kliknij na węzły, aby dodać je do ścieżki.';
@override
String get map_runTrace => 'Uruchom ślad ścieżki';
@override
String get map_removeLast => 'Usuń ostatni';
@override
String get map_pathTraceCancelled => 'Śledzenie ścieżki anulowano.';
@override @override
String get mapCache_title => 'Bufor Map Offline'; String get mapCache_title => 'Bufor Map Offline';
@@ -2674,4 +2698,201 @@ class AppLocalizationsPl extends AppLocalizations {
@override @override
String get listFilter_newGroup => 'Nowa grupa'; 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 pathTrace_clearTooltip => 'Wyczyść ścieżkę';
@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';
} }
+224 -11
View File
@@ -143,6 +143,16 @@ class AppLocalizationsPt extends AppLocalizations {
@override @override
String get scanner_scan => 'Digitalizar'; String get scanner_scan => 'Digitalizar';
@override
String get scanner_bluetoothOff => 'Bluetooth está desativado';
@override
String get scanner_bluetoothOffMessage =>
'Por favor, ative o Bluetooth para escanear por dispositivos.';
@override
String get scanner_enableBluetooth => 'Ative o Bluetooth';
@override @override
String get device_quickSwitch => 'Mudar rapidamente'; String get device_quickSwitch => 'Mudar rapidamente';
@@ -338,15 +348,6 @@ class AppLocalizationsPt extends AppLocalizations {
@override @override
String get settings_presets => 'Presets'; String get settings_presets => 'Presets';
@override
String get settings_preset915Mhz => '915 MHz';
@override
String get settings_preset868Mhz => '868 MHz';
@override
String get settings_preset433Mhz => '433 MHz';
@override @override
String get settings_frequency => 'Frequência (MHz)'; String get settings_frequency => 'Frequência (MHz)';
@@ -375,10 +376,15 @@ class AppLocalizationsPt extends AppLocalizations {
String get settings_txPowerInvalid => 'Potência de TX inválida (0-22 dBm)'; String get settings_txPowerInvalid => 'Potência de TX inválida (0-22 dBm)';
@override @override
String get settings_longRange => 'Alcance Longo'; String get settings_clientRepeat => 'Repetição sem rede';
@override @override
String get settings_fastSpeed => 'Velocidade Rápida'; String get settings_clientRepeatSubtitle =>
'Permita que este dispositivo repita pacotes de rede para outros dispositivos.';
@override
String get settings_clientRepeatFreqWarning =>
'A repetição fora da rede requer frequências de 433, 869 ou 918 MHz.';
@override @override
String settings_error(String message) { String settings_error(String message) {
@@ -448,6 +454,12 @@ class AppLocalizationsPt extends AppLocalizations {
@override @override
String get appSettings_languageBg => 'Български'; String get appSettings_languageBg => 'Български';
@override
String get appSettings_languageRu => 'Russo';
@override
String get appSettings_languageUk => 'Ucraniano';
@override @override
String get appSettings_notifications => 'Notificações'; String get appSettings_notifications => 'Notificações';
@@ -1355,6 +1367,18 @@ class AppLocalizationsPt extends AppLocalizations {
@override @override
String get map_manageRepeater => 'Gerenciar Repetidor'; String get map_manageRepeater => 'Gerenciar Repetidor';
@override
String get map_tapToAdd => 'Toque nos nós para adicioná-los ao caminho.';
@override
String get map_runTrace => 'Executar Traçado de Caminho';
@override
String get map_removeLast => 'Remover Último';
@override
String get map_pathTraceCancelled => 'Rastreamento de caminho cancelado.';
@override @override
String get mapCache_title => 'Cache de Mapa Offline'; String get mapCache_title => 'Cache de Mapa Offline';
@@ -2677,4 +2701,193 @@ class AppLocalizationsPt extends AppLocalizations {
@override @override
String get listFilter_newGroup => 'Novo grupo'; 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 pathTrace_clearTooltip => 'Limpar caminho';
@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';
} }
+233 -11
View File
@@ -142,6 +142,16 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get scanner_scan => 'Сканирование'; String get scanner_scan => 'Сканирование';
@override
String get scanner_bluetoothOff => 'Bluetooth выключен';
@override
String get scanner_bluetoothOffMessage =>
'Пожалуйста, включите Bluetooth, чтобы найти устройства.';
@override
String get scanner_enableBluetooth => 'Включите Bluetooth';
@override @override
String get device_quickSwitch => 'Быстрое переключение'; String get device_quickSwitch => 'Быстрое переключение';
@@ -335,15 +345,6 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get settings_presets => 'Пресеты'; String get settings_presets => 'Пресеты';
@override
String get settings_preset915Mhz => '915 МГц';
@override
String get settings_preset868Mhz => '868 МГц';
@override
String get settings_preset433Mhz => '433 МГц';
@override @override
String get settings_frequency => 'Частота (МГц)'; String get settings_frequency => 'Частота (МГц)';
@@ -373,10 +374,15 @@ class AppLocalizationsRu extends AppLocalizations {
'Недопустимая мощность передачи (0–22 дБм)'; 'Недопустимая мощность передачи (0–22 дБм)';
@override @override
String get settings_longRange => 'Дальний радиус'; String get settings_clientRepeat => 'Повторение \"вне сети\"';
@override @override
String get settings_fastSpeed => 'Высокая скорость'; String get settings_clientRepeatSubtitle =>
'Позвольте этому устройству повторять пакеты данных для других устройств.';
@override
String get settings_clientRepeatFreqWarning =>
'Для работы в режиме \"без подключения к сети\" требуется частота 433, 869 или 918 МГц.';
@override @override
String settings_error(String message) { String settings_error(String message) {
@@ -446,6 +452,12 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get appSettings_languageBg => 'Болгарский'; String get appSettings_languageBg => 'Болгарский';
@override
String get appSettings_languageRu => 'Русский';
@override
String get appSettings_languageUk => 'Українська';
@override @override
String get appSettings_notifications => 'Уведомления'; String get appSettings_notifications => 'Уведомления';
@@ -1356,6 +1368,18 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get map_manageRepeater => 'Управление репитером'; String get map_manageRepeater => 'Управление репитером';
@override
String get map_tapToAdd => 'Нажимайте на узлы, чтобы добавить их в путь.';
@override
String get map_runTrace => 'Запустить трассировку пути';
@override
String get map_removeLast => 'Удалить последний';
@override
String get map_pathTraceCancelled => 'Отмена трассировки пути';
@override @override
String get mapCache_title => 'Кэш офлайн-карты'; String get mapCache_title => 'Кэш офлайн-карты';
@@ -2679,4 +2703,202 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get listFilter_newGroup => 'Новая группа'; 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 pathTrace_clearTooltip => 'Очистить путь';
@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';
} }
+226 -11
View File
@@ -143,6 +143,16 @@ class AppLocalizationsSk extends AppLocalizations {
@override @override
String get scanner_scan => 'Skončiť'; String get scanner_scan => 'Skončiť';
@override
String get scanner_bluetoothOff => 'Bluetooth je vypnutý';
@override
String get scanner_bluetoothOffMessage =>
'Prosím, zapnite Bluetooth, aby ste mohli skenovať pre zariadenia.';
@override
String get scanner_enableBluetooth => 'Povolte Bluetooth';
@override @override
String get device_quickSwitch => 'Rýchle prepínač'; String get device_quickSwitch => 'Rýchle prepínač';
@@ -334,15 +344,6 @@ class AppLocalizationsSk extends AppLocalizations {
@override @override
String get settings_presets => 'Prednastavenia'; String get settings_presets => 'Prednastavenia';
@override
String get settings_preset915Mhz => '915 MHz';
@override
String get settings_preset868Mhz => '868 MHz';
@override
String get settings_preset433Mhz => '433 MHz';
@override @override
String get settings_frequency => 'Frekvencia (MHz)'; String get settings_frequency => 'Frekvencia (MHz)';
@@ -371,10 +372,15 @@ class AppLocalizationsSk extends AppLocalizations {
String get settings_txPowerInvalid => 'Neplatná hodnota výkonu TX (0-22 dBm)'; String get settings_txPowerInvalid => 'Neplatná hodnota výkonu TX (0-22 dBm)';
@override @override
String get settings_longRange => 'Dlhý dosah'; String get settings_clientRepeat => 'Opätovné použitie bez elektrickej siete';
@override @override
String get settings_fastSpeed => 'Rýchla rýchlosť'; String get settings_clientRepeatSubtitle =>
'Umožnite, aby toto zariadenie opakovávalo siete pre ostatných.';
@override
String get settings_clientRepeatFreqWarning =>
'Použitie off-grid systému vyžaduje frekvencie 433, 869 alebo 918 MHz.';
@override @override
String settings_error(String message) { String settings_error(String message) {
@@ -444,6 +450,12 @@ class AppLocalizationsSk extends AppLocalizations {
@override @override
String get appSettings_languageBg => 'Български'; String get appSettings_languageBg => 'Български';
@override
String get appSettings_languageRu => 'Ruština';
@override
String get appSettings_languageUk => 'Ukrajinská';
@override @override
String get appSettings_notifications => 'Upozornenia'; String get appSettings_notifications => 'Upozornenia';
@@ -1350,6 +1362,18 @@ class AppLocalizationsSk extends AppLocalizations {
@override @override
String get map_manageRepeater => 'Spravovať Opakovanie'; String get map_manageRepeater => 'Spravovať Opakovanie';
@override
String get map_tapToAdd => 'Kliknite na uzly, aby ste ich pridali k ceste.';
@override
String get map_runTrace => 'Spustiť trasovaním cesty';
@override
String get map_removeLast => 'Odstrániť posledný';
@override
String get map_pathTraceCancelled => 'Zrušenie stopáže cesty bolo zrušené.';
@override @override
String get mapCache_title => 'Offline Mapa Pamäť'; String get mapCache_title => 'Offline Mapa Pamäť';
@@ -2662,4 +2686,195 @@ class AppLocalizationsSk extends AppLocalizations {
@override @override
String get listFilter_newGroup => 'Nová skupina'; 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 pathTrace_clearTooltip => 'Zmazať cestu';
@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';
} }
File diff suppressed because it is too large Load Diff
+223 -11
View File
@@ -142,6 +142,16 @@ class AppLocalizationsSv extends AppLocalizations {
@override @override
String get scanner_scan => 'Skanna'; String get scanner_scan => 'Skanna';
@override
String get scanner_bluetoothOff => 'Bluetooth är avstängt';
@override
String get scanner_bluetoothOffMessage =>
'Vänligen aktivera Bluetooth för att söka efter enheter.';
@override
String get scanner_enableBluetooth => 'Aktivera Bluetooth';
@override @override
String get device_quickSwitch => 'Snabb växling'; String get device_quickSwitch => 'Snabb växling';
@@ -331,15 +341,6 @@ class AppLocalizationsSv extends AppLocalizations {
@override @override
String get settings_presets => 'Fördefinierade inställningar'; String get settings_presets => 'Fördefinierade inställningar';
@override
String get settings_preset915Mhz => '915 MHz';
@override
String get settings_preset868Mhz => '868 MHz';
@override
String get settings_preset433Mhz => '433 MHz';
@override @override
String get settings_frequency => 'Frekvens (MHz)'; String get settings_frequency => 'Frekvens (MHz)';
@@ -368,10 +369,15 @@ class AppLocalizationsSv extends AppLocalizations {
String get settings_txPowerInvalid => 'Ogiltig TX-effekt (0-22 dBm)'; String get settings_txPowerInvalid => 'Ogiltig TX-effekt (0-22 dBm)';
@override @override
String get settings_longRange => 'Lång räckvidd'; String get settings_clientRepeat => 'Upprepa utan elnät';
@override @override
String get settings_fastSpeed => 'Snabb hastighet'; String get settings_clientRepeatSubtitle =>
'Låt enheten repetera nätpaket för andra användare.';
@override
String get settings_clientRepeatFreqWarning =>
'För att kunna kommunicera utanför elnätet krävs frekvenserna 433, 869 eller 918 MHz.';
@override @override
String settings_error(String message) { String settings_error(String message) {
@@ -441,6 +447,12 @@ class AppLocalizationsSv extends AppLocalizations {
@override @override
String get appSettings_languageBg => 'Български'; String get appSettings_languageBg => 'Български';
@override
String get appSettings_languageRu => 'Ryska';
@override
String get appSettings_languageUk => 'Ukrainska';
@override @override
String get appSettings_notifications => 'Meddelanden'; String get appSettings_notifications => 'Meddelanden';
@@ -1342,6 +1354,18 @@ class AppLocalizationsSv extends AppLocalizations {
@override @override
String get map_manageRepeater => 'Hantera Upprepare'; String get map_manageRepeater => 'Hantera Upprepare';
@override
String get map_tapToAdd => 'Tryck på noder för att lägga till dem i banan.';
@override
String get map_runTrace => 'Kör spårsökning';
@override
String get map_removeLast => 'Ta bort sista';
@override
String get map_pathTraceCancelled => 'Sökvägsspårning avbruten.';
@override @override
String get mapCache_title => 'Offline Kartcache'; String get mapCache_title => 'Offline Kartcache';
@@ -2650,4 +2674,192 @@ class AppLocalizationsSv extends AppLocalizations {
@override @override
String get listFilter_newGroup => 'Ny grupp'; 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 pathTrace_clearTooltip => 'Rensa väg';
@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';
} }
+232 -11
View File
@@ -143,6 +143,16 @@ class AppLocalizationsUk extends AppLocalizations {
@override @override
String get scanner_scan => 'Сканувати'; String get scanner_scan => 'Сканувати';
@override
String get scanner_bluetoothOff => 'Bluetooth вимкнено';
@override
String get scanner_bluetoothOffMessage =>
'Будь ласка, увімкніть Bluetooth, щоб сканувати пристрої.';
@override
String get scanner_enableBluetooth => 'Увімкніть Bluetooth';
@override @override
String get device_quickSwitch => 'Швидке перемикання'; String get device_quickSwitch => 'Швидке перемикання';
@@ -336,15 +346,6 @@ class AppLocalizationsUk extends AppLocalizations {
@override @override
String get settings_presets => 'Попередні налаштування'; String get settings_presets => 'Попередні налаштування';
@override
String get settings_preset915Mhz => '915 МГц';
@override
String get settings_preset868Mhz => '868 МГц';
@override
String get settings_preset433Mhz => '433 МГц';
@override @override
String get settings_frequency => 'Частота (МГц)'; String get settings_frequency => 'Частота (МГц)';
@@ -373,10 +374,15 @@ class AppLocalizationsUk extends AppLocalizations {
String get settings_txPowerInvalid => 'Некоректна потужність TX (0-22 дБм)'; String get settings_txPowerInvalid => 'Некоректна потужність TX (0-22 дБм)';
@override @override
String get settings_longRange => 'Дальній діапазон'; String get settings_clientRepeat => 'Автономна система';
@override @override
String get settings_fastSpeed => 'Висока швидкість'; String get settings_clientRepeatSubtitle =>
'Дозвольте цьому пристрою повторювати пакети даних для інших пристроїв.';
@override
String get settings_clientRepeatFreqWarning =>
'Повтор без підключення до мережі вимагає частоти 433, 869 або 918 МГц.';
@override @override
String settings_error(String message) { String settings_error(String message) {
@@ -446,6 +452,12 @@ class AppLocalizationsUk extends AppLocalizations {
@override @override
String get appSettings_languageBg => 'Български'; String get appSettings_languageBg => 'Български';
@override
String get appSettings_languageRu => 'Російська';
@override
String get appSettings_languageUk => 'Українська';
@override @override
String get appSettings_notifications => 'Сповіщення'; String get appSettings_notifications => 'Сповіщення';
@@ -1355,6 +1367,18 @@ class AppLocalizationsUk extends AppLocalizations {
@override @override
String get map_manageRepeater => 'Керувати ретранслятором'; String get map_manageRepeater => 'Керувати ретранслятором';
@override
String get map_tapToAdd => 'Натисніть на вузли, щоб додати їх до шляху';
@override
String get map_runTrace => 'Виконати трасування шляху';
@override
String get map_removeLast => 'Видалити останній';
@override
String get map_pathTraceCancelled => 'Відмінується трасування шляху';
@override @override
String get mapCache_title => 'Офлайн-кеш карти'; String get mapCache_title => 'Офлайн-кеш карти';
@@ -2686,4 +2710,201 @@ class AppLocalizationsUk extends AppLocalizations {
@override @override
String get listFilter_newGroup => 'Нова група'; 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 pathTrace_clearTooltip => 'Очистити шлях';
@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
+69 -6
View File
@@ -131,9 +131,6 @@
"settings_infoContactsCount": "Aantal Contacten", "settings_infoContactsCount": "Aantal Contacten",
"settings_infoChannelCount": "Aantal Kanalen", "settings_infoChannelCount": "Aantal Kanalen",
"settings_presets": "Presets", "settings_presets": "Presets",
"settings_preset915Mhz": "915 MHz",
"settings_preset868Mhz": "868 MHz",
"settings_preset433Mhz": "433 MHz",
"settings_frequency": "Frequentie (MHz)", "settings_frequency": "Frequentie (MHz)",
"settings_frequencyHelper": "300,0 - 2500,0", "settings_frequencyHelper": "300,0 - 2500,0",
"settings_frequencyInvalid": "Ongeldige frequentie (300-2500 MHz)", "settings_frequencyInvalid": "Ongeldige frequentie (300-2500 MHz)",
@@ -143,8 +140,6 @@
"settings_txPower": "TX Vermogen (dBm)", "settings_txPower": "TX Vermogen (dBm)",
"settings_txPowerHelper": "0 - 22", "settings_txPowerHelper": "0 - 22",
"settings_txPowerInvalid": "Ongeldige TX-vermogen (0-22 dBm)", "settings_txPowerInvalid": "Ongeldige TX-vermogen (0-22 dBm)",
"settings_longRange": "Lange Afstand",
"settings_fastSpeed": "Hoge Snelheid",
"settings_error": "Fout: {message}", "settings_error": "Fout: {message}",
"@settings_error": { "@settings_error": {
"placeholders": { "placeholders": {
@@ -1533,5 +1528,73 @@
"community_regenerate": "Regeneer", "community_regenerate": "Regeneer",
"community_updateSecret": "Bijwerken Geheime", "community_updateSecret": "Bijwerken Geheime",
"community_secretUpdated": "Geheim gewijzigd voor \"{name}\"", "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",
"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!",
"map_removeLast": "Verwijder Laatste",
"pathTrace_clearTooltip": "Weg wissen",
"map_pathTraceCancelled": "Pad traceren geannuleerd",
"map_tapToAdd": "Tik op knooppunten om ze toe te voegen aan het pad",
"map_runTrace": "Padeshulp traceren",
"scanner_enableBluetooth": "Activeer Bluetooth",
"scanner_bluetoothOffMessage": "Zorg ervoor dat Bluetooth is ingeschakeld om naar apparaten te zoeken.",
"scanner_bluetoothOff": "Bluetooth is uitgeschakeld",
"settings_clientRepeat": "Herhalen: Afgekoppeld",
"settings_clientRepeatSubtitle": "Laat dit apparaat de mesh-pakketten opnieuw verzenden voor andere apparaten.",
"settings_clientRepeatFreqWarning": "Om een signaal buiten het netwerk te versturen, zijn frequenties van 433, 869 of 918 MHz vereist."
} }
+69 -6
View File
@@ -131,9 +131,6 @@
"settings_infoContactsCount": "Liczba kontaktów", "settings_infoContactsCount": "Liczba kontaktów",
"settings_infoChannelCount": "Liczba kanałów", "settings_infoChannelCount": "Liczba kanałów",
"settings_presets": "Preset", "settings_presets": "Preset",
"settings_preset915Mhz": "915 MHz",
"settings_preset868Mhz": "868 MHz",
"settings_preset433Mhz": "433 MHz",
"settings_frequency": "Częstotliwość (MHz)", "settings_frequency": "Częstotliwość (MHz)",
"settings_frequencyHelper": "300,0 - 2500,0", "settings_frequencyHelper": "300,0 - 2500,0",
"settings_frequencyInvalid": "Nieprawidłowa częstotliwość (300-2500 MHz)", "settings_frequencyInvalid": "Nieprawidłowa częstotliwość (300-2500 MHz)",
@@ -143,8 +140,6 @@
"settings_txPower": "TX Moc (dBm)", "settings_txPower": "TX Moc (dBm)",
"settings_txPowerHelper": "0 - 22", "settings_txPowerHelper": "0 - 22",
"settings_txPowerInvalid": "Nieprawidłowa moc TX (0-22 dBm)", "settings_txPowerInvalid": "Nieprawidłowa moc TX (0-22 dBm)",
"settings_longRange": "Długi zasięg",
"settings_fastSpeed": "Szybka prędkość",
"settings_error": "Błąd: {message}", "settings_error": "Błąd: {message}",
"@settings_error": { "@settings_error": {
"placeholders": { "placeholders": {
@@ -1533,5 +1528,73 @@
"community_regenerateSecretConfirm": "Regeneruj tajny klucz dla \"{name}\"? Wszyscy członkowie będą musieli zeskanować nowy kod QR, aby kontynuować komunikację.", "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_scanToUpdateSecret": "Skanuj nowy kod QR, aby zaktualizować sekret dla \"{name}\"",
"community_secretUpdated": "Hasło zaktualizowane 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ść",
"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!",
"map_pathTraceCancelled": "Śledzenie ścieżki anulowano.",
"map_runTrace": "Uruchom ślad ścieżki",
"pathTrace_clearTooltip": "Wyczyść ścieżkę",
"map_removeLast": "Usuń ostatni",
"map_tapToAdd": "Kliknij na węzły, aby dodać je do ścieżki.",
"scanner_bluetoothOffMessage": "Prosimy włączyć Bluetooth, aby przeskanować urządzenia.",
"scanner_bluetoothOff": "Bluetooth jest wyłączony",
"scanner_enableBluetooth": "Włącz Bluetooth",
"settings_clientRepeatSubtitle": "Pozwól temu urządzeniu powtarzać pakiety danych dla innych urządzeń.",
"settings_clientRepeat": "Powtórzenie: Niezależne od sieci",
"settings_clientRepeatFreqWarning": "Powtórka poza siecią wymaga częstotliwości 433, 869 lub 918 MHz."
} }
+69 -6
View File
@@ -131,9 +131,6 @@
"settings_infoContactsCount": "Número de Contatos", "settings_infoContactsCount": "Número de Contatos",
"settings_infoChannelCount": "Número do Canal", "settings_infoChannelCount": "Número do Canal",
"settings_presets": "Presets", "settings_presets": "Presets",
"settings_preset915Mhz": "915 MHz",
"settings_preset868Mhz": "868 MHz",
"settings_preset433Mhz": "433 MHz",
"settings_frequency": "Frequência (MHz)", "settings_frequency": "Frequência (MHz)",
"settings_frequencyHelper": "300,0 - 2500,0", "settings_frequencyHelper": "300,0 - 2500,0",
"settings_frequencyInvalid": "Frequência inválida (300-2500 MHz)", "settings_frequencyInvalid": "Frequência inválida (300-2500 MHz)",
@@ -143,8 +140,6 @@
"settings_txPower": "TX Potência (dBm)", "settings_txPower": "TX Potência (dBm)",
"settings_txPowerHelper": "0 - 22", "settings_txPowerHelper": "0 - 22",
"settings_txPowerInvalid": "Potência de TX inválida (0-22 dBm)", "settings_txPowerInvalid": "Potência de TX inválida (0-22 dBm)",
"settings_longRange": "Alcance Longo",
"settings_fastSpeed": "Velocidade Rápida",
"settings_error": "Erro: {message}", "settings_error": "Erro: {message}",
"@settings_error": { "@settings_error": {
"placeholders": { "placeholders": {
@@ -1533,5 +1528,73 @@
"community_regenerate": "Regenerar", "community_regenerate": "Regenerar",
"community_secretUpdated": "Segredo atualizado para \"{name}\"", "community_secretUpdated": "Segredo atualizado para \"{name}\"",
"community_scanToUpdateSecret": "Scanar o novo código QR para atualizar o segredo para \"{name}\"\n\n\n+++++", "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",
"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!",
"map_runTrace": "Executar Traçado de Caminho",
"map_pathTraceCancelled": "Rastreamento de caminho cancelado.",
"pathTrace_clearTooltip": "Limpar caminho",
"map_removeLast": "Remover Último",
"map_tapToAdd": "Toque nos nós para adicioná-los ao caminho.",
"scanner_enableBluetooth": "Ative o Bluetooth",
"scanner_bluetoothOff": "Bluetooth está desativado",
"scanner_bluetoothOffMessage": "Por favor, ative o Bluetooth para escanear por dispositivos.",
"settings_clientRepeatFreqWarning": "A repetição fora da rede requer frequências de 433, 869 ou 918 MHz.",
"settings_clientRepeat": "Repetição sem rede",
"settings_clientRepeatSubtitle": "Permita que este dispositivo repita pacotes de rede para outros dispositivos."
} }
+68 -6
View File
@@ -101,9 +101,6 @@
"settings_infoContactsCount": "Количество контактов", "settings_infoContactsCount": "Количество контактов",
"settings_infoChannelCount": "Количество каналов", "settings_infoChannelCount": "Количество каналов",
"settings_presets": "Пресеты", "settings_presets": "Пресеты",
"settings_preset915Mhz": "915 МГц",
"settings_preset868Mhz": "868 МГц",
"settings_preset433Mhz": "433 МГц",
"settings_frequency": "Частота (МГц)", "settings_frequency": "Частота (МГц)",
"settings_frequencyHelper": "300.0 2500.0", "settings_frequencyHelper": "300.0 2500.0",
"settings_frequencyInvalid": "Недопустимая частота (300–2500 МГц)", "settings_frequencyInvalid": "Недопустимая частота (300–2500 МГц)",
@@ -113,8 +110,6 @@
"settings_txPower": "Мощность передачи (дБм)", "settings_txPower": "Мощность передачи (дБм)",
"settings_txPowerHelper": "0 22", "settings_txPowerHelper": "0 22",
"settings_txPowerInvalid": "Недопустимая мощность передачи (0–22 дБм)", "settings_txPowerInvalid": "Недопустимая мощность передачи (0–22 дБм)",
"settings_longRange": "Дальний радиус",
"settings_fastSpeed": "Высокая скорость",
"settings_error": "Ошибка: {message}", "settings_error": "Ошибка: {message}",
"appSettings_title": "Настройки приложения", "appSettings_title": "Настройки приложения",
"appSettings_appearance": "Внешний вид", "appSettings_appearance": "Внешний вид",
@@ -774,5 +769,72 @@
"chat_openLink": "Открыть ссылку?", "chat_openLink": "Открыть ссылку?",
"chat_openLinkConfirmation": "Хотите открыть эту ссылку в вашем браузере?", "chat_openLinkConfirmation": "Хотите открыть эту ссылку в вашем браузере?",
"neighbors_heardAgo": "Слушал(а): {time} назад", "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": "Получено новое сообщение",
"settings_gpxExportRepeaters": "Экспортировать рипитеры / сервер комнаты в GPX",
"settings_gpxExportRepeatersSubtitle": "Экспортирует ретрансляторы / сервер комнат с местоположением в файл GPX.",
"settings_gpxExportContacts": "Экспортировать спутников в GPX",
"settings_gpxExportNotAvailable": "Не поддерживается на вашем устройстве/ОС",
"settings_gpxExportError": "Произошла ошибка при экспорте.",
"settings_gpxExportRepeatersRoom": "Местоположения повторителей и серверов комнат",
"settings_gpxExportChat": "Местоположения спутников",
"settings_gpxExportContactsSubtitle": "Экспортирует спутников с местоположением в файл GPX.",
"settings_gpxExportAll": "Экспортировать все контакты в GPX",
"settings_gpxExportAllSubtitle": "Экспортирует все контакты с местоположением в файл GPX.",
"settings_gpxExportAllContacts": "Все местоположения контактов",
"settings_gpxExportSuccess": "Успешно экспортирован файл GPX.",
"settings_gpxExportNoContacts": "Нет контактов для экспорта.",
"settings_gpxExportShareText": "Данные карты экспортированы из meshcore-open",
"settings_gpxExportShareSubject": "meshcore-open экспорт данных карты GPX",
"pathTrace_someHopsNoLocation": "Одному или нескольким хмелям не указано местоположение!",
"map_tapToAdd": "Нажимайте на узлы, чтобы добавить их в путь.",
"map_removeLast": "Удалить последний",
"map_pathTraceCancelled": "Отмена трассировки пути",
"pathTrace_clearTooltip": "Очистить путь",
"map_runTrace": "Запустить трассировку пути",
"scanner_enableBluetooth": "Включите Bluetooth",
"scanner_bluetoothOff": "Bluetooth выключен",
"scanner_bluetoothOffMessage": "Пожалуйста, включите Bluetooth, чтобы найти устройства.",
"settings_clientRepeatFreqWarning": "Для работы в режиме \"без подключения к сети\" требуется частота 433, 869 или 918 МГц.",
"settings_clientRepeatSubtitle": "Позвольте этому устройству повторять пакеты данных для других устройств.",
"settings_clientRepeat": "Повторение \"вне сети\""
} }
+69 -6
View File
@@ -131,9 +131,6 @@
"settings_infoContactsCount": "Počet kontaktov", "settings_infoContactsCount": "Počet kontaktov",
"settings_infoChannelCount": "Počet kanálov", "settings_infoChannelCount": "Počet kanálov",
"settings_presets": "Prednastavenia", "settings_presets": "Prednastavenia",
"settings_preset915Mhz": "915 MHz",
"settings_preset868Mhz": "868 MHz",
"settings_preset433Mhz": "433 MHz",
"settings_frequency": "Frekvencia (MHz)", "settings_frequency": "Frekvencia (MHz)",
"settings_frequencyHelper": "300,0 2500,0", "settings_frequencyHelper": "300,0 2500,0",
"settings_frequencyInvalid": "Neplatná frekvencia (300-2500 MHz)", "settings_frequencyInvalid": "Neplatná frekvencia (300-2500 MHz)",
@@ -143,8 +140,6 @@
"settings_txPower": "TX Výkon (dBm)", "settings_txPower": "TX Výkon (dBm)",
"settings_txPowerHelper": "0 - 22", "settings_txPowerHelper": "0 - 22",
"settings_txPowerInvalid": "Neplatná hodnota výkonu TX (0-22 dBm)", "settings_txPowerInvalid": "Neplatná hodnota výkonu TX (0-22 dBm)",
"settings_longRange": "Dlhý dosah",
"settings_fastSpeed": "Rýchla rýchlosť",
"settings_error": "Chyba: {message}", "settings_error": "Chyba: {message}",
"@settings_error": { "@settings_error": {
"placeholders": { "placeholders": {
@@ -1533,5 +1528,73 @@
"community_regenerateSecret": "Zobraziť nový tajný kód", "community_regenerateSecret": "Zobraziť nový tajný kód",
"community_scanToUpdateSecret": "Skáňte nový QR kód na aktualizáciu tajného hesla pre \"{name}\"", "community_scanToUpdateSecret": "Skáňte nový QR kód na aktualizáciu tajného hesla pre \"{name}\"",
"community_updateSecret": "Aktualizovať tajné heslo", "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",
"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!",
"pathTrace_clearTooltip": "Zmazať cestu",
"map_tapToAdd": "Kliknite na uzly, aby ste ich pridali k ceste.",
"map_removeLast": "Odstrániť posledný",
"map_runTrace": "Spustiť trasovaním cesty",
"map_pathTraceCancelled": "Zrušenie stopáže cesty bolo zrušené.",
"scanner_bluetoothOffMessage": "Prosím, zapnite Bluetooth, aby ste mohli skenovať pre zariadenia.",
"scanner_bluetoothOff": "Bluetooth je vypnutý",
"scanner_enableBluetooth": "Povolte Bluetooth",
"settings_clientRepeat": "Opätovné použitie bez elektrickej siete",
"settings_clientRepeatFreqWarning": "Použitie off-grid systému vyžaduje frekvencie 433, 869 alebo 918 MHz.",
"settings_clientRepeatSubtitle": "Umožnite, aby toto zariadenie opakovávalo siete pre ostatných."
} }
+209 -146
View File
@@ -1,7 +1,7 @@
{ {
"@@locale": "sl", "@@locale": "sl",
"appTitle": "MeshCore Open", "appTitle": "MeshCore Open",
"nav_contacts": "Kontakti", "nav_contacts": "Stiki",
"nav_channels": "Kanali", "nav_channels": "Kanali",
"nav_map": "Karta", "nav_map": "Karta",
"common_cancel": "Prekliči", "common_cancel": "Prekliči",
@@ -69,49 +69,49 @@
}, },
"scanner_stop": "Prekliči", "scanner_stop": "Prekliči",
"scanner_scan": "Skeniraj", "scanner_scan": "Skeniraj",
"device_quickSwitch": "Hitro preklopiti", "device_quickSwitch": "Hitro preklop",
"device_meshcore": "MeshCore", "device_meshcore": "MeshCore",
"settings_title": "Nastavitve", "settings_title": "Nastavitve",
"settings_deviceInfo": "Informacije o napravei", "settings_deviceInfo": "Informacije o napravei",
"settings_appSettings": "Nastavitve aplikacije", "settings_appSettings": "Nastavitve aplikacije",
"settings_appSettingsSubtitle": "Obveščanja, sporoščanje in zemljevidi.", "settings_appSettingsSubtitle": "Obveščanja, sporoščanje in zemljevidi.",
"settings_nodeSettings": "Nastavitve časa", "settings_nodeSettings": "Nastavitev časa",
"settings_nodeName": "Ime omrežno mesto", "settings_nodeName": "Ime node-a",
"settings_nodeNameNotSet": "Nezavedeno", "settings_nodeNameNotSet": "Ni nastavljeno",
"settings_nodeNameHint": "Vnesite ime časa", "settings_nodeNameHint": "Vnesite ime node-a",
"settings_nodeNameUpdated": "Ime posodobljeno", "settings_nodeNameUpdated": "Ime posodobljeno",
"settings_radioSettings": "Nastavitve radija", "settings_radioSettings": "Nastavitve radija",
"settings_radioSettingsSubtitle": "Frekvenca, moč, razširni faktor", "settings_radioSettingsSubtitle": "Frekvenca, moč, razširitveni faktor",
"settings_radioSettingsUpdated": "Radio nastavitve posodobljene", "settings_radioSettingsUpdated": "Radio nastavitve posodobljene",
"settings_location": "Lokacija", "settings_location": "Lokacija",
"settings_locationSubtitle": "GPS koordinate", "settings_locationSubtitle": "GPS koordinate",
"settings_locationUpdated": "Lokacija posodobljena", "settings_locationUpdated": "Lokacija posodobljena",
"settings_locationBothRequired": "Vnesite širino in dolžino.", "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_latitude": "Širina",
"settings_longitude": "Dolžina", "settings_longitude": "Dolžina",
"settings_privacyMode": "Mod podjetja", "settings_privacyMode": "Zasebnost",
"settings_privacyModeSubtitle": "Skrita imena/lokacije v oglasih", "settings_privacyModeSubtitle": "Skrita imena/lokacije v oglasih",
"settings_privacyModeToggle": "Omogoči način zasebnosti, da skrijemo tvoje ime in lokacijo 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_privacyModeEnabled": "Privatni način je omogočen.",
"settings_privacyModeDisabled": "Privatni režim je onemogočen.", "settings_privacyModeDisabled": "Privatni način je onemogočen.",
"settings_actions": "Akcije", "settings_actions": "Akcije",
"settings_sendAdvertisement": "Pošlji Oglas", "settings_sendAdvertisement": "Pošlji Oglas",
"settings_sendAdvertisementSubtitle": "Trenutna prisotnost v oddajah", "settings_sendAdvertisementSubtitle": "Trenutna prisotnost v oddajah",
"settings_advertisementSent": "Oglas poslan", "settings_advertisementSent": "Oglas poslan",
"settings_syncTime": "Ugasniti čas", "settings_syncTime": "Nastavi uro",
"settings_syncTimeSubtitle": "Nastavi uro naprave v čas telefona", "settings_syncTimeSubtitle": "Nastavi uro naprave na čas telefona",
"settings_timeSynchronized": "Sinhronizirano po času", "settings_timeSynchronized": "Ura sinhronizirana",
"settings_refreshContacts": "Ponovno obišči kontakte", "settings_refreshContacts": "Ponovno obišči kontakte",
"settings_refreshContactsSubtitle": "Ponovno naloži seznam kontaktov iz naprave", "settings_refreshContactsSubtitle": "Ponovno naloži seznam stikov v napravi",
"settings_rebootDevice": "Restart Naprave", "settings_rebootDevice": "Ponovni zagon naprave",
"settings_rebootDeviceSubtitle": "Ponovite zažetek naprave MeshCore", "settings_rebootDeviceSubtitle": "Ponovno zaženi MeshCore napravo",
"settings_rebootDeviceConfirm": "Ste prepričani, da želite ponovno zagon napravke? Boste odvisni od omrežja.", "settings_rebootDeviceConfirm": "Ste prepričani, da želite ponovno zagnati napravo? Povezava bo prekinjena.",
"settings_debug": "Napravi popravek", "settings_debug": "Debug",
"settings_bleDebugLog": "Logarjev zapis BLE", "settings_bleDebugLog": "BLE debug log (razhroščevanje)",
"settings_bleDebugLogSubtitle": "Navodila BLE, odgovori in surovo podatkovno", "settings_bleDebugLogSubtitle": "BLE ukazi, odgovori in surovi podatki",
"settings_appDebugLog": "Log zapiske aplikacije", "settings_appDebugLog": "Logi aplikacije",
"settings_appDebugLogSubtitle": "Prijavni sporočila aplikacije", "settings_appDebugLogSubtitle": "Debug sporočila aplikacije",
"settings_about": "Oglejte si", "settings_about": "Oglejte si",
"settings_aboutVersion": "MeshCore Open v{version}", "settings_aboutVersion": "MeshCore Open v{version}",
"@settings_aboutVersion": { "@settings_aboutVersion": {
@@ -121,30 +121,25 @@
} }
} }
}, },
"settings_aboutLegalese": "MeshCore Odprtokodni Projekt 2024", "settings_aboutLegalese": "Odprtokodni projekt MeshCore 2024",
"settings_aboutDescription": "Odprtokodni Flutter kličnik za naprave za LoRa mrežo MeshCore.", "settings_aboutDescription": "Odprtokodni Flutter klient za naprave za LoRa omrežje MeshCore.",
"settings_infoName": "Ime", "settings_infoName": "Ime",
"settings_infoId": "ID", "settings_infoId": "ID",
"settings_infoStatus": "Status", "settings_infoStatus": "Status",
"settings_infoBattery": "Baterija", "settings_infoBattery": "Baterija",
"settings_infoPublicKey": "Ključ javnega tipa", "settings_infoPublicKey": "Javni ključ",
"settings_infoContactsCount": "Število kontaktov", "settings_infoContactsCount": "Število stikov",
"settings_infoChannelCount": "Število kanalov", "settings_infoChannelCount": "Število kanalov",
"settings_presets": "Prednastavitve", "settings_presets": "Prednastavitve",
"settings_preset915Mhz": "915 MHz",
"settings_preset868Mhz": "868 MHz",
"settings_preset433Mhz": "433 MHz",
"settings_frequency": "Frekvenca (MHz)", "settings_frequency": "Frekvenca (MHz)",
"settings_frequencyHelper": "300,00 - 2500,00", "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_bandwidth": "Pasovna širina",
"settings_spreadingFactor": "Razširitveni faktor", "settings_spreadingFactor": "Razširitveni faktor",
"settings_codingRate": "Programska hitrost", "settings_codingRate": "Programska hitrost",
"settings_txPower": "TX Moč (dBm)", "settings_txPower": "TX Moč (dBm)",
"settings_txPowerHelper": "0 - 22", "settings_txPowerHelper": "0 - 22",
"settings_txPowerInvalid": "Neveljaven TX moč (0-22 dBm)", "settings_txPowerInvalid": "Neveljavna TX moč (0-22 dBm)",
"settings_longRange": "Dolenje območje",
"settings_fastSpeed": "Hitra hitrost",
"settings_error": "Napaka: {message}", "settings_error": "Napaka: {message}",
"@settings_error": { "@settings_error": {
"placeholders": { "placeholders": {
@@ -156,8 +151,8 @@
"appSettings_title": "Nastavitve aplikacije", "appSettings_title": "Nastavitve aplikacije",
"appSettings_appearance": "Prikaži", "appSettings_appearance": "Prikaži",
"appSettings_theme": "Tema", "appSettings_theme": "Tema",
"appSettings_themeSystem": "Predpomnilnik sistema", "appSettings_themeSystem": "Sistemska tema",
"appSettings_themeLight": "Luč", "appSettings_themeLight": "Svetlo",
"appSettings_themeDark": "Temno", "appSettings_themeDark": "Temno",
"appSettings_language": "Jezik", "appSettings_language": "Jezik",
"appSettings_languageSystem": "Sistemska privzeta vrednost", "appSettings_languageSystem": "Sistemska privzeta vrednost",
@@ -174,8 +169,8 @@
"appSettings_languageNl": "Nederlands", "appSettings_languageNl": "Nederlands",
"appSettings_languageSk": "Slovenčina", "appSettings_languageSk": "Slovenčina",
"appSettings_languageBg": "Български", "appSettings_languageBg": "Български",
"appSettings_notifications": "Obveščanja", "appSettings_notifications": "Obvestila",
"appSettings_enableNotifications": "Omogoči obveščanje", "appSettings_enableNotifications": "Omogoči obvestila",
"appSettings_enableNotificationsSubtitle": "Prejmite obvestila o sporočilih in oglasih", "appSettings_enableNotificationsSubtitle": "Prejmite obvestila o sporočilih in oglasih",
"appSettings_notificationPermissionDenied": "Odobritev obvestila zavrnjena", "appSettings_notificationPermissionDenied": "Odobritev obvestila zavrnjena",
"appSettings_notificationsEnabled": "Obvestila omogočena", "appSettings_notificationsEnabled": "Obvestila omogočena",
@@ -185,19 +180,19 @@
"appSettings_channelMessageNotifications": "Obvestila o sporočilih kanala", "appSettings_channelMessageNotifications": "Obvestila o sporočilih kanala",
"appSettings_channelMessageNotificationsSubtitle": "Pokaži obvestilo ob prejemanju sporočil kanala", "appSettings_channelMessageNotificationsSubtitle": "Pokaži obvestilo ob prejemanju sporočil kanala",
"appSettings_advertisementNotifications": "Opozorila o oglasih", "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_messaging": "Komuniciranje",
"appSettings_clearPathOnMaxRetry": "Ponovite pot do cilja na največjem štetju", "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_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_pathsWillBeCleared": "Počisti pot po 5 neuspešnih poskusih.",
"appSettings_pathsWillNotBeCleared": "Potniški poti ne bodo samodejno čiščeni.", "appSettings_pathsWillNotBeCleared": "Poti ne bodo samodejno čiščene.",
"appSettings_autoRouteRotation": "Avtomatsko Občutke in Rotacije", "appSettings_autoRouteRotation": "Avtomatsko rotacija prenosne poti",
"appSettings_autoRouteRotationSubtitle": "Med spreminjanjem med najboljšimi potmi in plovilnim načinom", "appSettings_autoRouteRotationSubtitle": "Menjaj med boljšo potjo in flood načinom",
"appSettings_autoRouteRotationEnabled": "Samodejno krmilno rotiranje omogočeno", "appSettings_autoRouteRotationEnabled": "Samodejno krmilno rotiranje omogočeno",
"appSettings_autoRouteRotationDisabled": "Samodejno krmilno rotiranje je onemogočeno", "appSettings_autoRouteRotationDisabled": "Samodejno krmilno rotiranje je onemogočeno",
"appSettings_battery": "Baterija", "appSettings_battery": "Baterija",
"appSettings_batteryChemistry": "Razem z možnostmi", "appSettings_batteryChemistry": "Kemija baterije",
"appSettings_batteryChemistryPerDevice": "Nastavitve za naprave ({deviceName})", "appSettings_batteryChemistryPerDevice": "Nastavitev za napravo ({deviceName})",
"@appSettings_batteryChemistryPerDevice": { "@appSettings_batteryChemistryPerDevice": {
"placeholders": { "placeholders": {
"deviceName": { "deviceName": {
@@ -205,20 +200,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_batteryNmc": "18650 NMC (3,0-4,2V)",
"appSettings_batteryLifepo4": "LiFePO4 (2,63,65 V)", "appSettings_batteryLifepo4": "LiFePO4 (2,63,65 V)",
"appSettings_batteryLipo": "LiPo (3,0-4,2V)", "appSettings_batteryLipo": "LiPo (3,0-4,2V)",
"appSettings_mapDisplay": "Prikaz zemljevide", "appSettings_mapDisplay": "Prikaz zemljevida",
"appSettings_showRepeaters": "Prikaži ponovitve", "appSettings_showRepeaters": "Prikaži repetitorje",
"appSettings_showRepeatersSubtitle": "Prikaži ponovljalne notranjosti na zemljeploscu", "appSettings_showRepeatersSubtitle": "Prikaži repetitorje na mapi",
"appSettings_showChatNodes": "Prikaži čakalne notranjosti", "appSettings_showChatNodes": "Prikaži naprave za klepet",
"appSettings_showChatNodesSubtitle": "Prikaži pogovorni pike na zemljeploscu", "appSettings_showChatNodesSubtitle": "Prikaži naprave na zemljevidu",
"appSettings_showOtherNodes": "Pokaži druge vozlišča", "appSettings_showOtherNodes": "Pokaži druge naprave",
"appSettings_showOtherNodesSubtitle": "Pokaži druge vrste notranjih elementov na zemljevalu.", "appSettings_showOtherNodesSubtitle": "Pokaži druge vrste naprav na zemljevidu.",
"appSettings_timeFilter": "Filtri po času", "appSettings_timeFilter": "Filter po času",
"appSettings_timeFilterShowAll": "Pokaži vse notranje elemente", "appSettings_timeFilterShowAll": "Pokaži vse naprave",
"appSettings_timeFilterShowLast": "Pokaži notranjosti iz zadnjih {hours} ur", "appSettings_timeFilterShowLast": "Pokaži naprave v zadnjih {hours} urah",
"@appSettings_timeFilterShowLast": { "@appSettings_timeFilterShowLast": {
"placeholders": { "placeholders": {
"hours": { "hours": {
@@ -226,15 +221,15 @@
} }
} }
}, },
"appSettings_mapTimeFilter": "Filtri časa zemljevida", "appSettings_mapTimeFilter": "Filter časa na zemljevidu",
"appSettings_showNodesDiscoveredWithin": "Pokaži notranje čepke, odkrivene v:", "appSettings_showNodesDiscoveredWithin": "Pokaži naprave odkrite v:",
"appSettings_allTime": "Vse čase", "appSettings_allTime": "Brez omejitev",
"appSettings_lastHour": "Minuto nazaj", "appSettings_lastHour": "V zadnji uri",
"appSettings_last6Hours": "Zadnjih 6 ur", "appSettings_last6Hours": "Zadnjih 6 ur",
"appSettings_last24Hours": "Zadnjih 24 ur", "appSettings_last24Hours": "Zadnjih 24 ur",
"appSettings_lastWeek": "Lepošno", "appSettings_lastWeek": "Prejšnji teden",
"appSettings_offlineMapCache": "Omrezni Poudni Arhiv", "appSettings_offlineMapCache": "Shramba zemljevidov brez povezave",
"appSettings_noAreaSelected": "Nizkana označena površina", "appSettings_noAreaSelected": "Območje ni izbrano",
"appSettings_areaSelectedZoom": "Izbrano območje (povečava {minZoom}-{maxZoom})", "appSettings_areaSelectedZoom": "Izbrano območje (povečava {minZoom}-{maxZoom})",
"@appSettings_areaSelectedZoom": { "@appSettings_areaSelectedZoom": {
"placeholders": { "placeholders": {
@@ -246,19 +241,19 @@
} }
} }
}, },
"appSettings_debugCard": "Napravi popravek", "appSettings_debugCard": "Razhroščevanje",
"appSettings_appDebugLogging": "Programski Log", "appSettings_appDebugLogging": "Programski dnevnik",
"appSettings_appDebugLoggingSubtitle": "Log aplikacijske debug sporočila za odpravljanje težav", "appSettings_appDebugLoggingSubtitle": "Dnevnik debug sporočil za odpravljanje težav",
"appSettings_appDebugLoggingEnabled": "Omogočeno zaznamovanje napak v aplikaciji", "appSettings_appDebugLoggingEnabled": "Beleženje napak v aplikaciji omogočeno",
"appSettings_appDebugLoggingDisabled": "Programski logi aplikacije so onemogočeni.", "appSettings_appDebugLoggingDisabled": "Beleženje napak v aplikacije onemogočeno.",
"contacts_title": "Kontakti", "contacts_title": "Stiki",
"contacts_noContacts": "Še ni kontaktov.", "contacts_noContacts": "Ni stikov.",
"contacts_contactsWillAppear": "Kontakti se bodo prikazali, ko naprave oglasijo.", "contacts_contactsWillAppear": "Stiki se bodo prikazali, ko se naprave oglasijo.",
"contacts_searchContacts": "Iskanje kontaktov...", "contacts_searchContacts": "Iskanje stikov...",
"contacts_noUnreadContacts": "Nerešeno kontaktov.", "contacts_noUnreadContacts": "Ne prebrani stiki.",
"contacts_noContactsFound": "Niti ena oseba ali skupine ni najdena.", "contacts_noContactsFound": "Stiki niso najdeni.",
"contacts_deleteContact": "Izbrisati Kontakt", "contacts_deleteContact": "Izbriši stik",
"contacts_removeConfirm": "Izbrisati {contactName} iz kontaktov?", "contacts_removeConfirm": "Izbrišem {contactName} iz stikov?",
"@contacts_removeConfirm": { "@contacts_removeConfirm": {
"placeholders": { "placeholders": {
"contactName": { "contactName": {
@@ -266,12 +261,12 @@
} }
} }
}, },
"contacts_manageRepeater": "Upravljajte Ponovitve", "contacts_manageRepeater": "Upravljaj Ponovitve",
"contacts_roomLogin": "Vnos v sobo", "contacts_roomLogin": "Prijava v sobo",
"contacts_openChat": "Odprta kleta", "contacts_openChat": "Odpri klepet",
"contacts_editGroup": "Uredi Skupino", "contacts_editGroup": "Uredi skupino",
"contacts_deleteGroup": "Izbrisati Skupino", "contacts_deleteGroup": "Izbriši skupino",
"contacts_deleteGroupConfirm": "Odpovedati {groupName}?", "contacts_deleteGroupConfirm": "Izbriši {groupName}?",
"@contacts_deleteGroupConfirm": { "@contacts_deleteGroupConfirm": {
"placeholders": { "placeholders": {
"groupName": { "groupName": {
@@ -279,8 +274,8 @@
} }
} }
}, },
"contacts_newGroup": "Novo skupino", "contacts_newGroup": "Nova skupina",
"contacts_groupName": "Skupina imena", "contacts_groupName": "Ime skupine",
"contacts_groupNameRequired": "Ime skupine je obvezno.", "contacts_groupNameRequired": "Ime skupine je obvezno.",
"contacts_groupAlreadyExists": "Skupina \"{name}\" že obstaja", "contacts_groupAlreadyExists": "Skupina \"{name}\" že obstaja",
"@contacts_groupAlreadyExists": { "@contacts_groupAlreadyExists": {
@@ -290,11 +285,11 @@
} }
} }
}, },
"contacts_filterContacts": "Filtri kontakt\\,...", "contacts_filterContacts": "Filtriraj stik\\,...",
"contacts_noContactsMatchFilter": "Niti ena oseba ne ustreza vašemu kriteriju.", "contacts_noContactsMatchFilter": "Noben stik ne ustreza vašemu kriteriju.",
"contacts_noMembers": "Nič članov.", "contacts_noMembers": "Ni članov.",
"contacts_lastSeenNow": "Datum zadnjega vpisa zdaj", "contacts_lastSeenNow": "Nazadnje viden zdaj",
"contacts_lastSeenMinsAgo": "Zadnjič videti {minutes} minut nazaj", "contacts_lastSeenMinsAgo": "Zadnjič viden pred {minutes} minutami",
"@contacts_lastSeenMinsAgo": { "@contacts_lastSeenMinsAgo": {
"placeholders": { "placeholders": {
"minutes": { "minutes": {
@@ -302,8 +297,8 @@
} }
} }
}, },
"contacts_lastSeenHourAgo": "Zadnjič ogledan pred 1 uro.", "contacts_lastSeenHourAgo": "Zadnjič viden pred 1 uro.",
"contacts_lastSeenHoursAgo": "Zadnjič videti {hours} ur nazaj", "contacts_lastSeenHoursAgo": "Zadnjič viden pred {hours} urami",
"@contacts_lastSeenHoursAgo": { "@contacts_lastSeenHoursAgo": {
"placeholders": { "placeholders": {
"hours": { "hours": {
@@ -311,8 +306,8 @@
} }
} }
}, },
"contacts_lastSeenDayAgo": "Zadnjič ogledan pred 1 dnem", "contacts_lastSeenDayAgo": "Zadnjič viden pred 1 dnem",
"contacts_lastSeenDaysAgo": "Zadnjič videti {days} dni nazaj", "contacts_lastSeenDaysAgo": "Zadnjič viden pred {days} dnem",
"@contacts_lastSeenDaysAgo": { "@contacts_lastSeenDaysAgo": {
"placeholders": { "placeholders": {
"days": { "days": {
@@ -321,10 +316,10 @@
} }
}, },
"channels_title": "Kanali", "channels_title": "Kanali",
"channels_noChannelsConfigured": "Nekonfigurirane kanale", "channels_noChannelsConfigured": "Kanali še niso konfigurirani",
"channels_addPublicChannel": "Dodaj Objavni Kanal", "channels_addPublicChannel": "Dodaj javni kanal",
"channels_searchChannels": "Poišči kanale...", "channels_searchChannels": "Poišči kanale...",
"channels_noChannelsFound": "Niti kanalov najti ni.", "channels_noChannelsFound": "Ne najdem kanalov.",
"channels_channelIndex": "Kanal {index}", "channels_channelIndex": "Kanal {index}",
"@channels_channelIndex": { "@channels_channelIndex": {
"placeholders": { "placeholders": {
@@ -334,13 +329,13 @@
} }
}, },
"channels_hashtagChannel": "Hashtag kanal", "channels_hashtagChannel": "Hashtag kanal",
"channels_public": "javno", "channels_public": "Javni",
"channels_private": "Zasebno", "channels_private": "Zasebni",
"channels_publicChannel": "Ogljišna skupina", "channels_publicChannel": "Javni kanal",
"channels_privateChannel": "Zatemniščen kanal", "channels_privateChannel": "Zasebni kanal",
"channels_editChannel": "Uredi kanal", "channels_editChannel": "Uredi kanal",
"channels_deleteChannel": "Pošlji 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": { "@channels_deleteChannelConfirm": {
"placeholders": { "placeholders": {
"name": { "name": {
@@ -424,8 +419,8 @@
} }
} }
}, },
"chat_typeMessage": "Vnesite sporočilo...", "chat_typeMessage": "Vnesi sporočilo...",
"chat_messageTooLong": "Pošiljanje sporočila je onemogočeno, saj je preveliko (maksimalno {maxBytes} bajt).", "chat_messageTooLong": "Pošiljanje sporočila je onemogočeno, saj je preveliko (maksimalno {maxBytes} byte-ov).",
"@chat_messageTooLong": { "@chat_messageTooLong": {
"placeholders": { "placeholders": {
"maxBytes": { "maxBytes": {
@@ -433,9 +428,9 @@
} }
} }
}, },
"chat_messageCopied": "Pošljeno sporočilo", "chat_messageCopied": "Sporočilo poslano",
"chat_messageDeleted": "Pošiljanje sporočila izbrisano", "chat_messageDeleted": "Sporočilo izbrisano",
"chat_retryingMessage": "Ponovna poskus.", "chat_retryingMessage": "Ponovni poskus.",
"chat_retryCount": "Ponovit {current}/{max}", "chat_retryCount": "Ponovit {current}/{max}",
"@chat_retryCount": { "@chat_retryCount": {
"placeholders": { "placeholders": {
@@ -448,31 +443,31 @@
} }
}, },
"chat_sendGif": "Pošlji GIF", "chat_sendGif": "Pošlji GIF",
"chat_reply": "Odpošlji", "chat_reply": "Odgovori",
"chat_addReaction": "Dodaj Reakcijo", "chat_addReaction": "Dodaj reakcijo",
"chat_me": "jaz", "chat_me": "jaz",
"emojiCategorySmileys": "Emoji", "emojiCategorySmileys": "Emoji",
"emojiCategoryGestures": "Gestikulacije", "emojiCategoryGestures": "Gestikulacije",
"emojiCategoryHearts": "Srce", "emojiCategoryHearts": "Srce",
"emojiCategoryObjects": "Predmeti", "emojiCategoryObjects": "Predmeti",
"gifPicker_title": "Izberi GIF", "gifPicker_title": "Izberi GIF",
"gifPicker_searchHint": "Iskalite GIF-e...", "gifPicker_searchHint": "Išči GIF-e...",
"gifPicker_poweredBy": "Naprodno z GIPHY", "gifPicker_poweredBy": "Napredno z GIPHY",
"gifPicker_noGifsFound": "Niti GIF-jev najti ni.", "gifPicker_noGifsFound": "Ne najdem GIF-ov.",
"gifPicker_failedLoad": "Neuspešno je naložilo GIF-e", "gifPicker_failedLoad": "Neuspešno nalaganje GIF-a",
"gifPicker_failedSearch": "Posodobit neuspešno.", "gifPicker_failedSearch": "Iskanje neuspešno.",
"gifPicker_noInternet": "Ni internetne povezave", "gifPicker_noInternet": "Ni internetne povezave",
"debugLog_appTitle": "Log zapiske aplikacije", "debugLog_appTitle": "Log zapiske aplikacije",
"debugLog_bleTitle": "Logarjev zapis BLE", "debugLog_bleTitle": "Log zapis BLE",
"debugLog_copyLog": "Kopiraj zapiske", "debugLog_copyLog": "Kopiraj dnevnik",
"debugLog_clearLog": "Pasters log", "debugLog_clearLog": "Briši log",
"debugLog_copied": "Kopirana belež poteka.", "debugLog_copied": "Beležka kopirana.",
"debugLog_bleCopied": "Kopirana beležke iz BLE", "debugLog_bleCopied": "Kopirana beležka iz BLE",
"debugLog_noEntries": "Še ni ustvarjenih debug zapisov.", "debugLog_noEntries": "Ni ustvarjenih debug zapisov.",
"debugLog_enableInSettings": "Omogoči beleženje napak v aplikaciji v nastavitvah", "debugLog_enableInSettings": "Omogoči beleženje napak v nastavitvah aplikacije",
"debugLog_frames": "Okna", "debugLog_frames": "Okvirji",
"debugLog_rawLogRx": "Svež Log-RX", "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": "Izhodni rob: {count} bajtov",
"@debugFrame_length": { "@debugFrame_length": {
"placeholders": { "placeholders": {
@@ -542,8 +537,8 @@
"chat_forceFloodMode": "Nasilje obvezati v način", "chat_forceFloodMode": "Nasilje obvezati v način",
"chat_recentAckPaths": "Nedavni poti ACK (tap za uporabo):", "chat_recentAckPaths": "Nedavni poti ACK (tap za uporabo):",
"chat_pathHistoryFull": "Zapiske o poti so popolni. Izbriši vnose, da dodaš nove.", "chat_pathHistoryFull": "Zapiske o poti so popolni. Izbriši vnose, da dodaš nove.",
"chat_hopSingular": "skoč", "chat_hopSingular": "skok",
"chat_hopPlural": "škrabec", "chat_hopPlural": "skokov",
"chat_hopsCount": "{count} {count, plural, =1{hop} other{hops}}", "chat_hopsCount": "{count} {count, plural, =1{hop} other{hops}}",
"@chat_hopsCount": { "@chat_hopsCount": {
"placeholders": { "placeholders": {
@@ -554,16 +549,16 @@
}, },
"chat_successes": "Uspešni", "chat_successes": "Uspešni",
"chat_removePath": "Izbriši pot", "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_pathActions": "Potni ukazi:",
"chat_setCustomPath": "Nastavi Prilozeno Pot", "chat_setCustomPath": "Nastavi Prilozeno Pot",
"chat_setCustomPathSubtitle": "Ročno določite potniško 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_clearPathSubtitle": "Ob naslednji pošiljanju znova zbrati.",
"chat_pathCleared": "Pot je očiščena. Naslednje sporočilo bo ponovno odkril pot.", "chat_pathCleared": "Pot je očiščena. Naslednje sporočilo bo ponovno odkril pot.",
"chat_floodModeSubtitle": "Uporabi tipko usmerjevanja v meniju aplikacije.", "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_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_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": "Pot nastavljen: {hopCount} {hopCount, plural, =1{hop} other{hops}} - {status}",
"@chat_pathSetHops": { "@chat_pathSetHops": {
@@ -1104,13 +1099,13 @@
} }
} }
}, },
"repeater_cliQuickGetName": "Dobiti ime", "repeater_cliQuickGetName": "Pridobi ime",
"repeater_cliQuickGetRadio": "Dobiti Radiopravo", "repeater_cliQuickGetRadio": "Dobiti Radiopravo",
"repeater_cliQuickGetTx": "Dobiti TX", "repeater_cliQuickGetTx": "Pridobi TX",
"repeater_cliQuickNeighbors": "Sosedi", "repeater_cliQuickNeighbors": "Sosedi",
"repeater_cliQuickVersion": "Različica", "repeater_cliQuickVersion": "Različica",
"repeater_cliQuickAdvertise": "Oglasite", "repeater_cliQuickAdvertise": "Oglasite",
"repeater_cliQuickClock": "Urnik", "repeater_cliQuickClock": "Ura",
"repeater_cliHelpAdvert": "Pošlje paket oglasov", "repeater_cliHelpAdvert": "Pošlje paket oglasov",
"repeater_cliHelpReboot": "Ponastavi naprave. (Opomba, lahko pride do 'Timeouta', kar je normalno)", "repeater_cliHelpReboot": "Ponastavi naprave. (Opomba, lahko pride do 'Timeouta', kar je normalno)",
"repeater_cliHelpClock": "Prikaže trenutno uro po uri naprave.", "repeater_cliHelpClock": "Prikaže trenutno uro po uri naprave.",
@@ -1142,7 +1137,7 @@
"repeater_cliHelpSetBridgeSecret": "Nastavi skrivni dostop za mostove ESPNOW.", "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_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_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_cliHelpGetBridgeType": "Dobrodošli pri izbiri vrste mostu: brez, rs232, espnow",
"repeater_cliHelpLogStart": "Začnete beleženje paketov v datotekovni sistem.", "repeater_cliHelpLogStart": "Začnete beleženje paketov v datotekovni sistem.",
"repeater_cliHelpLogStop": "Ustavite beleženje paketov v datotečno sistem.", "repeater_cliHelpLogStop": "Ustavite beleženje paketov v datotečno sistem.",
@@ -1171,8 +1166,8 @@
"repeater_settingsCategory": "Nastavitve", "repeater_settingsCategory": "Nastavitve",
"repeater_bridge": "Most", "repeater_bridge": "Most",
"repeater_logging": "Logiranje", "repeater_logging": "Logiranje",
"repeater_neighborsRepeaterOnly": "Sosedi (le za ponovitelja)", "repeater_neighborsRepeaterOnly": "Sosedi (le za repetitorje)",
"repeater_regionManagementRepeaterOnly": "Upravljanje regij (zgolj za ponovitve)", "repeater_regionManagementRepeaterOnly": "Upravljanje regij (zgolj za repetitorje)",
"repeater_regionNote": "Regionske ukazi so bili uvedeni za upravljanje z regijskimi definicijami in dovolili.", "repeater_regionNote": "Regionske ukazi so bili uvedeni za upravljanje z regijskimi definicijami in dovolili.",
"repeater_gpsManagement": "Upravljanje GPS", "repeater_gpsManagement": "Upravljanje GPS",
"repeater_gpsNote": "GPS ukaz je bil uveden za upravljanje z vprašanji, povezanimi z lokacijo.", "repeater_gpsNote": "GPS ukaz je bil uveden za upravljanje z vprašanji, povezanimi z lokacijo.",
@@ -1244,9 +1239,9 @@
"channelPath_repeaterHops": "Skoki ponovitelja", "channelPath_repeaterHops": "Skoki ponovitelja",
"channelPath_noHopDetails": "Podrobnosti o paketu za dostavo niso navedene.", "channelPath_noHopDetails": "Podrobnosti o paketu za dostavo niso navedene.",
"channelPath_messageDetails": "Podrobnosti sporočila", "channelPath_messageDetails": "Podrobnosti sporočila",
"channelPath_senderLabel": "Pošiljalec", "channelPath_senderLabel": "Pošiljatelj",
"channelPath_timeLabel": "Čas", "channelPath_timeLabel": "Ura",
"channelPath_repeatsLabel": "Ponovi", "channelPath_repeatsLabel": "Ponovitve",
"channelPath_pathLabel": "Pot {index}", "channelPath_pathLabel": "Pot {index}",
"channelPath_observedLabel": "Opazovani", "channelPath_observedLabel": "Opazovani",
"channelPath_observedPathTitle": "Opazovana pot {index} • {hops}", "channelPath_observedPathTitle": "Opazovana pot {index} • {hops}",
@@ -1478,10 +1473,10 @@
"community_addPublicChannel": "Dodaj Objavni Kanal Komunitarja", "community_addPublicChannel": "Dodaj Objavni Kanal Komunitarja",
"community_addPublicChannelHint": "Samodejno dodaj javni kanal za to skupnost.", "community_addPublicChannelHint": "Samodejno dodaj javni kanal za to skupnost.",
"community_noCommunities": "Še nobena skupnost se ni pridružila.", "community_noCommunities": "Še nobena skupnost se ni pridružila.",
"community_scanOrCreate": "Skenirajte QR kodo ali ustvarite skupnost za začetek.", "community_scanOrCreate": "Skeniraj QR kodo ali ustvari skupnost za začetek.",
"community_manageCommunities": "Upravljajte skupnosti", "community_manageCommunities": "Upravljanje skupnosti",
"community_delete": "Opusti skupnost", "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": "To bo izbrisalo tudi {count} kanal/kanalov in njihova sporočila.",
"@community_deleteChannelsWarning": { "@community_deleteChannelsWarning": {
"placeholders": { "placeholders": {
@@ -1491,11 +1486,11 @@
} }
}, },
"community_deleted": "Zapustil skupnost \"{name}\"", "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_addHashtagChannelDesc": "Dodajte hashtag kanal za to skupnost.",
"community_selectCommunity": "Izberi skupnost", "community_selectCommunity": "Izberi skupnost",
"community_regularHashtag": "Oznaka s hashtagom", "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_communityHashtag": "Skupnostni hashtag",
"community_communityHashtagDesc": "Izključeno za uporabnike skupnosti", "community_communityHashtagDesc": "Izključeno za uporabnike skupnosti",
"community_forCommunity": "Za {name}", "community_forCommunity": "Za {name}",
@@ -1527,11 +1522,79 @@
} }
} }
}, },
"community_secretRegenerated": "Tajna za \"{name}\" ponovno ustvarjena", "community_secretRegenerated": "Geslo za \"{name}\" ponovno ustvarjeno",
"community_regenerateSecret": "Preberi nov tajni kôd", "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_regenerateSecretConfirm": "Preberite novo tajno geslo za \"{name}\"? Vsi članici morajo prebrati novo QR kodo, da lahko nadaljujejo s komunikacijo.",
"community_regenerate": "Preberi znova", "community_regenerate": "Preberi znova",
"community_scanToUpdateSecret": "Skeniraj nov kôd QR za posodabljanje tajne za {name}", "community_scanToUpdateSecret": "Skeniraj novo QR kodo za posodabljanje ključa za {name}",
"community_updateSecret": "Ažurniraj tajno", "community_updateSecret": "Ažuriraj ključ",
"community_secretUpdated": "Skrivnostno spremembo za \"{name}\"" "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",
"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!",
"map_tapToAdd": "Pritisnite na vozlišča, da jih dodate poti.",
"map_removeLast": "Odstrani Zadnji",
"map_runTrace": "Zaženi sledenje poti",
"pathTrace_clearTooltip": "Počisti pot",
"map_pathTraceCancelled": "Spremljanje poti je prekinjeno.",
"scanner_enableBluetooth": "Omogočite Bluetooth",
"scanner_bluetoothOffMessage": "Prosimo, vklopite Bluetooth, da lahko poiščete naprave.",
"scanner_bluetoothOff": "Bluetooth je izklopljen",
"settings_clientRepeatFreqWarning": "Za ponovni prenos na brezžični način so potrebne frekvence 433, 869 ali 918 MHz.",
"settings_clientRepeatSubtitle": "Omogočite temu naprave, da ponavlja paketne sporočila za druge.",
"settings_clientRepeat": "Neovadno ponavljanje"
} }
+69 -6
View File
@@ -131,9 +131,6 @@
"settings_infoContactsCount": "Kontakterantal", "settings_infoContactsCount": "Kontakterantal",
"settings_infoChannelCount": "Kanalantal", "settings_infoChannelCount": "Kanalantal",
"settings_presets": "Fördefinierade inställningar", "settings_presets": "Fördefinierade inställningar",
"settings_preset915Mhz": "915 MHz",
"settings_preset868Mhz": "868 MHz",
"settings_preset433Mhz": "433 MHz",
"settings_frequency": "Frekvens (MHz)", "settings_frequency": "Frekvens (MHz)",
"settings_frequencyHelper": "300,0 - 2500,0", "settings_frequencyHelper": "300,0 - 2500,0",
"settings_frequencyInvalid": "Ogiltig frekvens (300-2500 MHz)", "settings_frequencyInvalid": "Ogiltig frekvens (300-2500 MHz)",
@@ -143,8 +140,6 @@
"settings_txPower": "TX-effekt (dBm)", "settings_txPower": "TX-effekt (dBm)",
"settings_txPowerHelper": "0 - 22", "settings_txPowerHelper": "0 - 22",
"settings_txPowerInvalid": "Ogiltig TX-effekt (0-22 dBm)", "settings_txPowerInvalid": "Ogiltig TX-effekt (0-22 dBm)",
"settings_longRange": "Lång räckvidd",
"settings_fastSpeed": "Snabb hastighet",
"settings_error": "Fel: {message}", "settings_error": "Fel: {message}",
"@settings_error": { "@settings_error": {
"placeholders": { "placeholders": {
@@ -1533,5 +1528,73 @@
"community_regenerateSecret": "Regenerera hemlig kod", "community_regenerateSecret": "Regenerera hemlig kod",
"community_scanToUpdateSecret": "Skanna den nya QR-koden för att uppdatera hemligheten för \"{name}\"", "community_scanToUpdateSecret": "Skanna den nya QR-koden för att uppdatera hemligheten för \"{name}\"",
"community_secretUpdated": "Hemlighet uppdaterad 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",
"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!",
"pathTrace_clearTooltip": "Rensa väg",
"map_pathTraceCancelled": "Sökvägsspårning avbruten.",
"map_runTrace": "Kör spårsökning",
"map_tapToAdd": "Tryck på noder för att lägga till dem i banan.",
"map_removeLast": "Ta bort sista",
"scanner_enableBluetooth": "Aktivera Bluetooth",
"scanner_bluetoothOffMessage": "Vänligen aktivera Bluetooth för att söka efter enheter.",
"scanner_bluetoothOff": "Bluetooth är avstängt",
"settings_clientRepeatSubtitle": "Låt enheten repetera nätpaket för andra användare.",
"settings_clientRepeat": "Upprepa utan elnät",
"settings_clientRepeatFreqWarning": "För att kunna kommunicera utanför elnätet krävs frekvenserna 433, 869 eller 918 MHz."
} }
+69 -7
View File
@@ -131,9 +131,6 @@
"settings_infoContactsCount": "Кількість контактів", "settings_infoContactsCount": "Кількість контактів",
"settings_infoChannelCount": "Кількість каналів", "settings_infoChannelCount": "Кількість каналів",
"settings_presets": "Попередні налаштування", "settings_presets": "Попередні налаштування",
"settings_preset915Mhz": "915 МГц",
"settings_preset868Mhz": "868 МГц",
"settings_preset433Mhz": "433 МГц",
"settings_frequency": "Частота (МГц)", "settings_frequency": "Частота (МГц)",
"settings_frequencyHelper": "300.0 - 2500.0", "settings_frequencyHelper": "300.0 - 2500.0",
"settings_frequencyInvalid": "Некоректна частота (300-2500 МГц)", "settings_frequencyInvalid": "Некоректна частота (300-2500 МГц)",
@@ -143,8 +140,6 @@
"settings_txPower": "Потужність TX (дБм)", "settings_txPower": "Потужність TX (дБм)",
"settings_txPowerHelper": "0 - 22", "settings_txPowerHelper": "0 - 22",
"settings_txPowerInvalid": "Некоректна потужність TX (0-22 дБм)", "settings_txPowerInvalid": "Некоректна потужність TX (0-22 дБм)",
"settings_longRange": "Дальній діапазон",
"settings_fastSpeed": "Висока швидкість",
"settings_error": "Помилка: {message}", "settings_error": "Помилка: {message}",
"@settings_error": { "@settings_error": {
"placeholders": { "placeholders": {
@@ -1534,5 +1529,72 @@
"community_secretRegenerated": "Секретний пароль для «{name}» перегенеровано", "community_secretRegenerated": "Секретний пароль для «{name}» перегенеровано",
"community_scanToUpdateSecret": "Відскануйте новий QR-код, щоб оновити пароль для «{name}»", "community_scanToUpdateSecret": "Відскануйте новий QR-код, щоб оновити пароль для «{name}»",
"community_updateSecret": "Оновити секрет", "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": "Отримано нове повідомлення",
"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": "Одне або більше хмелів відсутнє місце розташування!",
"map_tapToAdd": "Натисніть на вузли, щоб додати їх до шляху",
"map_runTrace": "Виконати трасування шляху",
"pathTrace_clearTooltip": "Очистити шлях",
"map_removeLast": "Видалити останній",
"map_pathTraceCancelled": "Відмінується трасування шляху",
"scanner_enableBluetooth": "Увімкніть Bluetooth",
"scanner_bluetoothOffMessage": "Будь ласка, увімкніть Bluetooth, щоб сканувати пристрої.",
"scanner_bluetoothOff": "Bluetooth вимкнено",
"settings_clientRepeatFreqWarning": "Повтор без підключення до мережі вимагає частоти 433, 869 або 918 МГц.",
"settings_clientRepeatSubtitle": "Дозвольте цьому пристрою повторювати пакети даних для інших пристроїв.",
"settings_clientRepeat": "Автономна система"
}
+576 -513
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.loadContactCache();
await connector.loadChannelSettings(); await connector.loadChannelSettings();
await connector.loadCachedChannels();
// Load persisted channel messages // Load persisted channel messages
await connector.loadAllChannelMessages(); await connector.loadAllChannelMessages();
await connector.loadUnreadState(); await connector.loadUnreadState();
runApp(MeshCoreApp( runApp(
connector: connector, MeshCoreApp(
retryService: retryService, connector: connector,
pathHistoryService: pathHistoryService, retryService: retryService,
storage: storage, pathHistoryService: pathHistoryService,
appSettingsService: appSettingsService, storage: storage,
bleDebugLogService: bleDebugLogService, appSettingsService: appSettingsService,
appDebugLogService: appDebugLogService, bleDebugLogService: bleDebugLogService,
mapTileCacheService: mapTileCacheService, appDebugLogService: appDebugLogService,
)); mapTileCacheService: mapTileCacheService,
),
);
} }
class MeshCoreApp extends StatelessWidget { class MeshCoreApp extends StatelessWidget {
@@ -124,10 +127,15 @@ class MeshCoreApp extends StatelessWidget {
GlobalCupertinoLocalizations.delegate, GlobalCupertinoLocalizations.delegate,
], ],
supportedLocales: AppLocalizations.supportedLocales, supportedLocales: AppLocalizations.supportedLocales,
locale: _localeFromSetting(settingsService.settings.languageOverride), locale: _localeFromSetting(
settingsService.settings.languageOverride,
),
theme: ThemeData( theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue), colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
useMaterial3: true, useMaterial3: true,
snackBarTheme: const SnackBarThemeData(
behavior: SnackBarBehavior.floating,
),
), ),
darkTheme: ThemeData( darkTheme: ThemeData(
colorScheme: ColorScheme.fromSeed( colorScheme: ColorScheme.fromSeed(
@@ -135,8 +143,19 @@ class MeshCoreApp extends StatelessWidget {
brightness: Brightness.dark, brightness: Brightness.dark,
), ),
useMaterial3: true, 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(), home: const ScannerScreen(),
); );
}, },
+18 -11
View File
@@ -76,13 +76,14 @@ class AppSettings {
mapShowRepeaters: json['map_show_repeaters'] as bool? ?? true, mapShowRepeaters: json['map_show_repeaters'] as bool? ?? true,
mapShowChatNodes: json['map_show_chat_nodes'] as bool? ?? true, mapShowChatNodes: json['map_show_chat_nodes'] as bool? ?? true,
mapShowOtherNodes: json['map_show_other_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, mapKeyPrefixEnabled: json['map_key_prefix_enabled'] as bool? ?? false,
mapKeyPrefix: json['map_key_prefix'] as String? ?? '', mapKeyPrefix: json['map_key_prefix'] as String? ?? '',
mapShowMarkers: json['map_show_markers'] as bool? ?? true, mapShowMarkers: json['map_show_markers'] as bool? ?? true,
mapCacheBounds: (json['map_cache_bounds'] as Map?)?.map( 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, mapCacheMinZoom: json['map_cache_min_zoom'] as int? ?? 10,
mapCacheMaxZoom: json['map_cache_max_zoom'] as int? ?? 15, mapCacheMaxZoom: json['map_cache_max_zoom'] as int? ?? 15,
notificationsEnabled: json['notifications_enabled'] as bool? ?? true, notificationsEnabled: json['notifications_enabled'] as bool? ?? true,
@@ -90,11 +91,13 @@ class AppSettings {
notifyOnNewChannelMessage: notifyOnNewChannelMessage:
json['notify_on_new_channel_message'] as bool? ?? true, json['notify_on_new_channel_message'] as bool? ?? true,
notifyOnNewAdvert: json['notify_on_new_advert'] as bool? ?? true, notifyOnNewAdvert: json['notify_on_new_advert'] as bool? ?? true,
autoRouteRotationEnabled: json['auto_route_rotation_enabled'] as bool? ?? false, autoRouteRotationEnabled:
json['auto_route_rotation_enabled'] as bool? ?? false,
themeMode: json['theme_mode'] as String? ?? 'system', themeMode: json['theme_mode'] as String? ?? 'system',
languageOverride: json['language_override'] as String?, languageOverride: json['language_override'] as String?,
appDebugLogEnabled: json['app_debug_log_enabled'] as bool? ?? false, appDebugLogEnabled: json['app_debug_log_enabled'] as bool? ?? false,
batteryChemistryByDeviceId: (json['battery_chemistry_by_device_id'] as Map?)?.map( batteryChemistryByDeviceId:
(json['battery_chemistry_by_device_id'] as Map?)?.map(
(key, value) => MapEntry(key.toString(), value.toString()), (key, value) => MapEntry(key.toString(), value.toString()),
) ?? ) ??
{}, {},
@@ -132,8 +135,9 @@ class AppSettings {
mapKeyPrefixEnabled: mapKeyPrefixEnabled ?? this.mapKeyPrefixEnabled, mapKeyPrefixEnabled: mapKeyPrefixEnabled ?? this.mapKeyPrefixEnabled,
mapKeyPrefix: mapKeyPrefix ?? this.mapKeyPrefix, mapKeyPrefix: mapKeyPrefix ?? this.mapKeyPrefix,
mapShowMarkers: mapShowMarkers ?? this.mapShowMarkers, mapShowMarkers: mapShowMarkers ?? this.mapShowMarkers,
mapCacheBounds: mapCacheBounds: mapCacheBounds == _unset
mapCacheBounds == _unset ? this.mapCacheBounds : mapCacheBounds as Map<String, double>?, ? this.mapCacheBounds
: mapCacheBounds as Map<String, double>?,
mapCacheMinZoom: mapCacheMinZoom ?? this.mapCacheMinZoom, mapCacheMinZoom: mapCacheMinZoom ?? this.mapCacheMinZoom,
mapCacheMaxZoom: mapCacheMaxZoom ?? this.mapCacheMaxZoom, mapCacheMaxZoom: mapCacheMaxZoom ?? this.mapCacheMaxZoom,
notificationsEnabled: notificationsEnabled ?? this.notificationsEnabled, notificationsEnabled: notificationsEnabled ?? this.notificationsEnabled,
@@ -141,12 +145,15 @@ class AppSettings {
notifyOnNewChannelMessage: notifyOnNewChannelMessage:
notifyOnNewChannelMessage ?? this.notifyOnNewChannelMessage, notifyOnNewChannelMessage ?? this.notifyOnNewChannelMessage,
notifyOnNewAdvert: notifyOnNewAdvert ?? this.notifyOnNewAdvert, notifyOnNewAdvert: notifyOnNewAdvert ?? this.notifyOnNewAdvert,
autoRouteRotationEnabled: autoRouteRotationEnabled ?? this.autoRouteRotationEnabled, autoRouteRotationEnabled:
autoRouteRotationEnabled ?? this.autoRouteRotationEnabled,
themeMode: themeMode ?? this.themeMode, themeMode: themeMode ?? this.themeMode,
languageOverride: languageOverride: languageOverride == _unset
languageOverride == _unset ? this.languageOverride : languageOverride as String?, ? this.languageOverride
: languageOverride as String?,
appDebugLogEnabled: appDebugLogEnabled ?? this.appDebugLogEnabled, 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 int index;
final String name; final String name;
final Uint8List psk; // 16 bytes final Uint8List psk; // 16 bytes
int unreadCount;
Channel({ Channel({
required this.index, required this.index,
required this.name, required this.name,
required this.psk, required this.psk,
this.unreadCount = 0,
}); });
String get pskHex => _bytesToHex(psk); String get pskHex => _bytesToHex(psk);
@@ -39,11 +41,7 @@ class Channel {
} }
static Channel empty(int index) { static Channel empty(int index) {
return Channel( return Channel(index: index, name: '', psk: Uint8List(16));
index: index,
name: '',
psk: Uint8List(16),
);
} }
static Channel fromHex(int index, String name, String pskHex) { static Channel fromHex(int index, String name, String pskHex) {
+23 -16
View File
@@ -59,15 +59,18 @@ class ChannelMessage {
this.replyToSenderName, this.replyToSenderName,
this.replyToText, this.replyToText,
Map<String, int>? reactions, Map<String, int>? reactions,
}) : messageId = messageId ?? '${timestamp.millisecondsSinceEpoch}_${senderName.hashCode}_${text.hashCode}', }) : messageId =
reactions = reactions ?? {}, messageId ??
pathBytes = pathBytes ?? Uint8List(0), '${timestamp.millisecondsSinceEpoch}_${senderName.hashCode}_${text.hashCode}',
pathVariants = _mergePathVariants( reactions = reactions ?? {},
pathBytes ?? Uint8List(0), pathBytes = pathBytes ?? Uint8List(0),
pathVariants, pathVariants = _mergePathVariants(
); pathBytes ?? Uint8List(0),
pathVariants,
);
String? get senderKeyHex => senderKey != null ? pubKeyToHex(senderKey!) : null; String? get senderKeyHex =>
senderKey != null ? pubKeyToHex(senderKey!) : null;
ChannelMessage copyWith({ ChannelMessage copyWith({
ChannelMessageStatus? status, ChannelMessageStatus? status,
@@ -125,8 +128,10 @@ class ChannelMessage {
final hasPathBytesFlag = (data[2] & 0x01) != 0; final hasPathBytesFlag = (data[2] & 0x01) != 0;
final canFitPath = pathLen > 0 && data.length >= cursor + pathLen + 5; final canFitPath = pathLen > 0 && data.length >= cursor + pathLen + 5;
final hasValidTxtType = final hasValidTxtType =
cursor < data.length && (data[cursor] == txtTypePlain || data[cursor] == txtTypeCliData); cursor < data.length &&
if ((hasPathBytesFlag || (canFitPath && !hasValidTxtType)) && canFitPath) { (data[cursor] == txtTypePlain || data[cursor] == txtTypeCliData);
if ((hasPathBytesFlag || (canFitPath && !hasValidTxtType)) &&
canFitPath) {
pathBytes = Uint8List.fromList(data.sublist(cursor, cursor + pathLen)); pathBytes = Uint8List.fromList(data.sublist(cursor, cursor + pathLen));
cursor += pathLen; cursor += pathLen;
} }
@@ -162,7 +167,8 @@ class ChannelMessage {
final potentialSender = text.substring(0, colonIndex); final potentialSender = text.substring(0, colonIndex);
if (!RegExp(r'[:\[\]]').hasMatch(potentialSender)) { if (!RegExp(r'[:\[\]]').hasMatch(potentialSender)) {
senderName = potentialSender; senderName = potentialSender;
final offset = (colonIndex + 1 < text.length && text[colonIndex + 1] == ' ') final offset =
(colonIndex + 1 < text.length && text[colonIndex + 1] == ' ')
? colonIndex + 2 ? colonIndex + 2
: colonIndex + 1; : colonIndex + 1;
actualText = text.substring(offset); 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( return ChannelMessage(
senderKey: null, senderKey: null,
senderName: senderName, senderName: senderName,
@@ -249,8 +259,5 @@ class ReplyInfo {
final String mentionedNode; final String mentionedNode;
final String actualMessage; final String actualMessage;
ReplyInfo({ ReplyInfo({required this.mentionedNode, required this.actualMessage});
required this.mentionedNode,
required this.actualMessage,
});
} }
+4 -8
View File
@@ -34,10 +34,7 @@ class Community {
}) : hashtagChannels = hashtagChannels ?? []; }) : hashtagChannels = hashtagChannels ?? [];
/// Generate a new community with a random 32-byte secret /// Generate a new community with a random 32-byte secret
factory Community.create({ factory Community.create({required String id, required String name}) {
required String id,
required String name,
}) {
final random = Random.secure(); final random = Random.secure();
final secret = Uint8List(32); final secret = Uint8List(32);
for (int i = 0; i < 32; i++) { for (int i = 0; i < 32; i++) {
@@ -84,7 +81,8 @@ class Community {
name: json['name'] as String, name: json['name'] as String,
secret: base64Decode(json['secret'] as String), secret: base64Decode(json['secret'] as String),
createdAt: DateTime.fromMillisecondsSinceEpoch(json['created_at'] as int), 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) ?.map((e) => e as String)
.toList() ?? .toList() ??
[], [],
@@ -234,9 +232,7 @@ class Community {
@override @override
bool operator ==(Object other) => bool operator ==(Object other) =>
identical(this, other) || identical(this, other) ||
other is Community && other is Community && runtimeType == other.runtimeType && id == other.id;
runtimeType == other.runtimeType &&
id == other.id;
@override @override
int get hashCode => id.hashCode; int get hashCode => id.hashCode;
+54 -5
View File
@@ -7,7 +7,8 @@ class Contact {
final int type; final int type;
final int pathLength; // -1 = flood, 0+ = direct hops (from device) final int pathLength; // -1 = flood, 0+ = direct hops (from device)
final Uint8List path; // Path bytes 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 Uint8List? pathOverrideBytes; // User's path override bytes
final double? latitude; final double? latitude;
final double? longitude; final double? longitude;
@@ -78,8 +79,12 @@ class Contact {
type: type ?? this.type, type: type ?? this.type,
pathLength: pathLength ?? this.pathLength, pathLength: pathLength ?? this.pathLength,
path: path ?? this.path, path: path ?? this.path,
pathOverride: clearPathOverride ? null : (pathOverride ?? this.pathOverride), pathOverride: clearPathOverride
pathOverrideBytes: clearPathOverride ? null : (pathOverrideBytes ?? this.pathOverrideBytes), ? null
: (pathOverride ?? this.pathOverride),
pathOverrideBytes: clearPathOverride
? null
: (pathOverrideBytes ?? this.pathOverrideBytes),
latitude: latitude ?? this.latitude, latitude: latitude ?? this.latitude,
longitude: longitude ?? this.longitude, longitude: longitude ?? this.longitude,
lastSeen: lastSeen ?? this.lastSeen, lastSeen: lastSeen ?? this.lastSeen,
@@ -93,15 +98,59 @@ class Contact {
final parts = <String>[]; final parts = <String>[];
final groupSize = pathHashSize; final groupSize = pathHashSize;
for (int i = 0; i < pathBytes.length; i += groupSize) { 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); final chunk = pathBytes.sublist(i, end);
parts.add( 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(','); 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 { Uint8List get _pathBytesForDisplay {
if (pathOverride != null) { if (pathOverride != null) {
if (pathOverride! < 0) return Uint8List(0); if (pathOverride! < 0) return Uint8List(0);
+5 -15
View File
@@ -2,15 +2,9 @@ class ContactGroup {
final String name; final String name;
final List<String> memberKeys; final List<String> memberKeys;
const ContactGroup({ const ContactGroup({required this.name, required this.memberKeys});
required this.name,
required this.memberKeys,
});
ContactGroup copyWith({ ContactGroup copyWith({String? name, List<String>? memberKeys}) {
String? name,
List<String>? memberKeys,
}) {
return ContactGroup( return ContactGroup(
name: name ?? this.name, name: name ?? this.name,
memberKeys: memberKeys ?? List<String>.from(this.memberKeys), memberKeys: memberKeys ?? List<String>.from(this.memberKeys),
@@ -18,16 +12,12 @@ class ContactGroup {
} }
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
return { return {'name': name, 'members': memberKeys};
'name': name,
'members': memberKeys,
};
} }
factory ContactGroup.fromJson(Map<String, dynamic> json) { factory ContactGroup.fromJson(Map<String, dynamic> json) {
final members = (json['members'] as List?) final members =
?.map((value) => value.toString()) (json['members'] as List?)?.map((value) => value.toString()).toList() ??
.toList() ??
<String>[]; <String>[];
return ContactGroup( return ContactGroup(
name: json['name'] as String? ?? '', name: json['name'] as String? ?? '',
+5 -4
View File
@@ -43,9 +43,9 @@ class Message {
Uint8List? pathBytes, Uint8List? pathBytes,
Uint8List? fourByteRoomContactKey, Uint8List? fourByteRoomContactKey,
Map<String, int>? reactions, Map<String, int>? reactions,
}) : pathBytes = pathBytes ?? Uint8List(0), }) : pathBytes = pathBytes ?? Uint8List(0),
fourByteRoomContactKey = fourByteRoomContactKey ?? Uint8List(0), fourByteRoomContactKey = fourByteRoomContactKey ?? Uint8List(0),
reactions = reactions ?? {}; reactions = reactions ?? {};
String get senderKeyHex => pubKeyToHex(senderKey); String get senderKeyHex => pubKeyToHex(senderKey);
@@ -80,7 +80,8 @@ class Message {
pathLength: pathLength ?? this.pathLength, pathLength: pathLength ?? this.pathLength,
pathBytes: pathBytes ?? this.pathBytes, pathBytes: pathBytes ?? this.pathBytes,
reactions: reactions ?? this.reactions, reactions: reactions ?? this.reactions,
fourByteRoomContactKey: fourByteRoomContactKey ?? this.fourByteRoomContactKey, fourByteRoomContactKey:
fourByteRoomContactKey ?? this.fourByteRoomContactKey,
); );
} }
+8 -6
View File
@@ -38,7 +38,8 @@ class PathRecord {
tripTimeMs: json['trip_time_ms'] as int, tripTimeMs: json['trip_time_ms'] as int,
timestamp: DateTime.parse(json['timestamp'] as String), timestamp: DateTime.parse(json['timestamp'] as String),
wasFloodDiscovery: json['was_flood'] as bool, 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, successCount: json['success_count'] as int? ?? 0,
failureCount: json['failure_count'] as int? ?? 0, failureCount: json['failure_count'] as int? ?? 0,
); );
@@ -65,14 +66,15 @@ class ContactPathHistory {
} }
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
return { return {'recent_paths': recentPaths.map((p) => p.toJson()).toList()};
'recent_paths': recentPaths.map((p) => p.toJson()).toList(),
};
} }
factory ContactPathHistory.fromJson( factory ContactPathHistory.fromJson(
String contactPubKeyHex, Map<String, dynamic> json) { String contactPubKeyHex,
final pathsList = (json['recent_paths'] as List?) Map<String, dynamic> json,
) {
final pathsList =
(json['recent_paths'] as List?)
?.map((p) => PathRecord.fromJson(p as Map<String, dynamic>)) ?.map((p) => PathRecord.fromJson(p as Map<String, dynamic>))
.toList() ?? .toList() ??
[]; [];
+183 -29
View File
@@ -59,46 +59,200 @@ class RadioSettings {
required this.txPowerDbm, required this.txPowerDbm,
}); });
// Preset configurations // Regional preset configurations
static RadioSettings get preset915MHz => RadioSettings( static final List<(String, RadioSettings)> presets = [
frequencyMHz: 915.0, (
bandwidth: LoRaBandwidth.bw125, 'Australia',
RadioSettings(
frequencyMHz: 915.8,
bandwidth: LoRaBandwidth.bw250,
spreadingFactor: LoRaSpreadingFactor.sf10,
codingRate: LoRaCodingRate.cr4_5,
txPowerDbm: 20,
),
),
(
'Australia (Narrow)',
RadioSettings(
frequencyMHz: 916.575,
bandwidth: LoRaBandwidth.bw62_5,
spreadingFactor: LoRaSpreadingFactor.sf7, spreadingFactor: LoRaSpreadingFactor.sf7,
codingRate: LoRaCodingRate.cr4_5, codingRate: LoRaCodingRate.cr4_5,
txPowerDbm: 20, txPowerDbm: 20,
); ),
),
static RadioSettings get preset868MHz => RadioSettings( (
frequencyMHz: 868.0, 'Australia SA, WA, QLD',
bandwidth: LoRaBandwidth.bw125, RadioSettings(
frequencyMHz: 923.125,
bandwidth: LoRaBandwidth.bw62_5,
spreadingFactor: LoRaSpreadingFactor.sf8,
codingRate: LoRaCodingRate.cr4_5,
txPowerDbm: 20,
),
),
(
'Czech Republic',
RadioSettings(
frequencyMHz: 869.432,
bandwidth: LoRaBandwidth.bw62_5,
spreadingFactor: LoRaSpreadingFactor.sf7, spreadingFactor: LoRaSpreadingFactor.sf7,
codingRate: LoRaCodingRate.cr4_5, codingRate: LoRaCodingRate.cr4_5,
txPowerDbm: 14, txPowerDbm: 14,
); ),
),
static RadioSettings get preset433MHz => RadioSettings( (
'EU 433MHz',
RadioSettings(
frequencyMHz: 433.650,
bandwidth: LoRaBandwidth.bw250,
spreadingFactor: LoRaSpreadingFactor.sf11,
codingRate: LoRaCodingRate.cr4_5,
txPowerDbm: 20,
),
),
(
'EU/UK (Long Range)',
RadioSettings(
frequencyMHz: 869.525,
bandwidth: LoRaBandwidth.bw250,
spreadingFactor: LoRaSpreadingFactor.sf11,
codingRate: LoRaCodingRate.cr4_5,
txPowerDbm: 14,
),
),
(
'EU/UK (Medium Range)',
RadioSettings(
frequencyMHz: 869.525,
bandwidth: LoRaBandwidth.bw250,
spreadingFactor: LoRaSpreadingFactor.sf10,
codingRate: LoRaCodingRate.cr4_5,
txPowerDbm: 14,
),
),
(
'EU/UK (Narrow)',
RadioSettings(
frequencyMHz: 869.618,
bandwidth: LoRaBandwidth.bw62_5,
spreadingFactor: LoRaSpreadingFactor.sf8,
codingRate: LoRaCodingRate.cr4_5,
txPowerDbm: 14,
),
),
(
'New Zealand',
RadioSettings(
frequencyMHz: 917.375,
bandwidth: LoRaBandwidth.bw250,
spreadingFactor: LoRaSpreadingFactor.sf11,
codingRate: LoRaCodingRate.cr4_5,
txPowerDbm: 20,
),
),
(
'New Zealand (Narrow)',
RadioSettings(
frequencyMHz: 917.375,
bandwidth: LoRaBandwidth.bw62_5,
spreadingFactor: LoRaSpreadingFactor.sf7,
codingRate: LoRaCodingRate.cr4_5,
txPowerDbm: 20,
),
),
(
'Portugal 433',
RadioSettings(
frequencyMHz: 433.375,
bandwidth: LoRaBandwidth.bw62_5,
spreadingFactor: LoRaSpreadingFactor.sf9,
codingRate: LoRaCodingRate.cr4_5,
txPowerDbm: 20,
),
),
(
'Portugal 869',
RadioSettings(
frequencyMHz: 869.618,
bandwidth: LoRaBandwidth.bw62_5,
spreadingFactor: LoRaSpreadingFactor.sf7,
codingRate: LoRaCodingRate.cr4_5,
txPowerDbm: 14,
),
),
(
'Switzerland',
RadioSettings(
frequencyMHz: 869.618,
bandwidth: LoRaBandwidth.bw62_5,
spreadingFactor: LoRaSpreadingFactor.sf8,
codingRate: LoRaCodingRate.cr4_5,
txPowerDbm: 14,
),
),
(
'USA Arizona',
RadioSettings(
frequencyMHz: 908.205,
bandwidth: LoRaBandwidth.bw62_5,
spreadingFactor: LoRaSpreadingFactor.sf10,
codingRate: LoRaCodingRate.cr4_5,
txPowerDbm: 20,
),
),
(
'USA/Canada',
RadioSettings(
frequencyMHz: 910.525,
bandwidth: LoRaBandwidth.bw62_5,
spreadingFactor: LoRaSpreadingFactor.sf7,
codingRate: LoRaCodingRate.cr4_5,
txPowerDbm: 20,
),
),
(
'Vietnam',
RadioSettings(
frequencyMHz: 920.250,
bandwidth: LoRaBandwidth.bw250,
spreadingFactor: LoRaSpreadingFactor.sf11,
codingRate: LoRaCodingRate.cr4_5,
txPowerDbm: 20,
),
),
// Off-grid repeat presets (valid client_repeat frequencies)
(
'Off-Grid 433',
RadioSettings(
frequencyMHz: 433.0, frequencyMHz: 433.0,
bandwidth: LoRaBandwidth.bw125, bandwidth: LoRaBandwidth.bw250,
spreadingFactor: LoRaSpreadingFactor.sf7, spreadingFactor: LoRaSpreadingFactor.sf11,
codingRate: LoRaCodingRate.cr4_5, codingRate: LoRaCodingRate.cr4_5,
txPowerDbm: 20, txPowerDbm: 20,
); ),
),
static RadioSettings get presetLongRange => RadioSettings( (
frequencyMHz: 915.0, 'Off-Grid 869',
bandwidth: LoRaBandwidth.bw125, RadioSettings(
spreadingFactor: LoRaSpreadingFactor.sf12, frequencyMHz: 869.0,
codingRate: LoRaCodingRate.cr4_8, bandwidth: LoRaBandwidth.bw250,
txPowerDbm: 20, spreadingFactor: LoRaSpreadingFactor.sf11,
); codingRate: LoRaCodingRate.cr4_5,
txPowerDbm: 14,
static RadioSettings get presetFastSpeed => RadioSettings( ),
frequencyMHz: 915.0, ),
bandwidth: LoRaBandwidth.bw500, (
spreadingFactor: LoRaSpreadingFactor.sf7, 'Off-Grid 918',
RadioSettings(
frequencyMHz: 918.0,
bandwidth: LoRaBandwidth.bw250,
spreadingFactor: LoRaSpreadingFactor.sf11,
codingRate: LoRaCodingRate.cr4_5, codingRate: LoRaCodingRate.cr4_5,
txPowerDbm: 20, txPowerDbm: 20,
); ),
),
];
int get frequencyHz => (frequencyMHz * 1000).round(); int get frequencyHz => (frequencyMHz * 1000).round();
int get bandwidthHz => bandwidth.hz; int get bandwidthHz => bandwidth.hz;
+31 -9
View File
@@ -26,8 +26,10 @@ class AppDebugLogScreen extends StatelessWidget {
onPressed: hasEntries onPressed: hasEntries
? () async { ? () async {
final text = entries final text = entries
.map((entry) => .map(
'[${entry.formattedTime}] [${entry.levelLabel}] [${entry.tag}] ${entry.message}') (entry) =>
'[${entry.formattedTime}] [${entry.levelLabel}] [${entry.tag}] ${entry.message}',
)
.join('\n'); .join('\n');
await Clipboard.setData(ClipboardData(text: text)); await Clipboard.setData(ClipboardData(text: text));
if (!context.mounted) return; if (!context.mounted) return;
@@ -53,7 +55,7 @@ class AppDebugLogScreen extends StatelessWidget {
child: hasEntries child: hasEntries
? ListView.separated( ? ListView.separated(
itemCount: entries.length, itemCount: entries.length,
separatorBuilder: (_, __) => const Divider(height: 1), separatorBuilder: (_, _) => const Divider(height: 1),
itemBuilder: (context, index) { itemBuilder: (context, index) {
final entry = entries[index]; final entry = entries[index];
return ListTile( return ListTile(
@@ -61,11 +63,17 @@ class AppDebugLogScreen extends StatelessWidget {
leading: _buildLevelIcon(entry.level), leading: _buildLevelIcon(entry.level),
title: Text( title: Text(
'[${entry.tag}] ${entry.message}', '[${entry.tag}] ${entry.message}',
style: const TextStyle(fontSize: 12, fontFamily: 'monospace'), style: const TextStyle(
fontSize: 12,
fontFamily: 'monospace',
),
), ),
subtitle: Text( subtitle: Text(
entry.formattedTime, 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( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ 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), const SizedBox(height: 16),
Text( Text(
context.l10n.debugLog_noEntries, context.l10n.debugLog_noEntries,
style: TextStyle(fontSize: 16, color: Colors.grey[600]), style: TextStyle(
fontSize: 16,
color: Colors.grey[600],
),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
context.l10n.debugLog_enableInSettings, 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: case AppDebugLogLevel.info:
return const Icon(Icons.info_outline, size: 18, color: Colors.blue); return const Icon(Icons.info_outline, size: 18, color: Colors.blue);
case AppDebugLogLevel.warning: 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: case AppDebugLogLevel.error:
return const Icon(Icons.error_outline, size: 18, color: Colors.red); return const Icon(Icons.error_outline, size: 18, color: Colors.red);
} }
+146 -57
View File
@@ -43,7 +43,10 @@ class AppSettingsScreen extends StatelessWidget {
); );
} }
Widget _buildAppearanceCard(BuildContext context, AppSettingsService settingsService) { Widget _buildAppearanceCard(
BuildContext context,
AppSettingsService settingsService,
) {
return Card( return Card(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -58,7 +61,9 @@ class AppSettingsScreen extends StatelessWidget {
ListTile( ListTile(
leading: const Icon(Icons.brightness_6_outlined), leading: const Icon(Icons.brightness_6_outlined),
title: Text(context.l10n.appSettings_theme), 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), trailing: const Icon(Icons.chevron_right),
onTap: () => _showThemeModeDialog(context, settingsService), onTap: () => _showThemeModeDialog(context, settingsService),
), ),
@@ -66,7 +71,12 @@ class AppSettingsScreen extends StatelessWidget {
ListTile( ListTile(
leading: const Icon(Icons.language_outlined), leading: const Icon(Icons.language_outlined),
title: Text(context.l10n.appSettings_language), 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), trailing: const Icon(Icons.chevron_right),
onTap: () => _showLanguageDialog(context, settingsService), 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( return Card(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -90,17 +103,22 @@ class AppSettingsScreen extends StatelessWidget {
SwitchListTile( SwitchListTile(
secondary: const Icon(Icons.notifications_outlined), secondary: const Icon(Icons.notifications_outlined),
title: Text(context.l10n.appSettings_enableNotifications), title: Text(context.l10n.appSettings_enableNotifications),
subtitle: Text(context.l10n.appSettings_enableNotificationsSubtitle), subtitle: Text(
context.l10n.appSettings_enableNotificationsSubtitle,
),
value: settingsService.settings.notificationsEnabled, value: settingsService.settings.notificationsEnabled,
onChanged: (value) async { onChanged: (value) async {
if (value) { if (value) {
// Request permission when enabling // Request permission when enabling
final granted = await NotificationService().requestPermissions(); final granted = await NotificationService()
.requestPermissions();
if (!granted) { if (!granted) {
if (context.mounted) { if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text(context.l10n.appSettings_notificationPermissionDenied), content: Text(
context.l10n.appSettings_notificationPermissionDenied,
),
duration: const Duration(seconds: 2), duration: const Duration(seconds: 2),
), ),
); );
@@ -113,9 +131,11 @@ class AppSettingsScreen extends StatelessWidget {
if (context.mounted) { if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text(value content: Text(
? context.l10n.appSettings_notificationsEnabled value
: context.l10n.appSettings_notificationsDisabled), ? context.l10n.appSettings_notificationsEnabled
: context.l10n.appSettings_notificationsDisabled,
),
duration: const Duration(seconds: 2), duration: const Duration(seconds: 2),
), ),
); );
@@ -126,18 +146,24 @@ class AppSettingsScreen extends StatelessWidget {
SwitchListTile( SwitchListTile(
secondary: Icon( secondary: Icon(
Icons.message_outlined, Icons.message_outlined,
color: settingsService.settings.notificationsEnabled ? null : Colors.grey, color: settingsService.settings.notificationsEnabled
? null
: Colors.grey,
), ),
title: Text( title: Text(
context.l10n.appSettings_messageNotifications, context.l10n.appSettings_messageNotifications,
style: TextStyle( style: TextStyle(
color: settingsService.settings.notificationsEnabled ? null : Colors.grey, color: settingsService.settings.notificationsEnabled
? null
: Colors.grey,
), ),
), ),
subtitle: Text( subtitle: Text(
context.l10n.appSettings_messageNotificationsSubtitle, context.l10n.appSettings_messageNotificationsSubtitle,
style: TextStyle( style: TextStyle(
color: settingsService.settings.notificationsEnabled ? null : Colors.grey, color: settingsService.settings.notificationsEnabled
? null
: Colors.grey,
), ),
), ),
value: settingsService.settings.notifyOnNewMessage, value: settingsService.settings.notifyOnNewMessage,
@@ -151,18 +177,24 @@ class AppSettingsScreen extends StatelessWidget {
SwitchListTile( SwitchListTile(
secondary: Icon( secondary: Icon(
Icons.forum_outlined, Icons.forum_outlined,
color: settingsService.settings.notificationsEnabled ? null : Colors.grey, color: settingsService.settings.notificationsEnabled
? null
: Colors.grey,
), ),
title: Text( title: Text(
context.l10n.appSettings_channelMessageNotifications, context.l10n.appSettings_channelMessageNotifications,
style: TextStyle( style: TextStyle(
color: settingsService.settings.notificationsEnabled ? null : Colors.grey, color: settingsService.settings.notificationsEnabled
? null
: Colors.grey,
), ),
), ),
subtitle: Text( subtitle: Text(
context.l10n.appSettings_channelMessageNotificationsSubtitle, context.l10n.appSettings_channelMessageNotificationsSubtitle,
style: TextStyle( style: TextStyle(
color: settingsService.settings.notificationsEnabled ? null : Colors.grey, color: settingsService.settings.notificationsEnabled
? null
: Colors.grey,
), ),
), ),
value: settingsService.settings.notifyOnNewChannelMessage, value: settingsService.settings.notifyOnNewChannelMessage,
@@ -176,18 +208,24 @@ class AppSettingsScreen extends StatelessWidget {
SwitchListTile( SwitchListTile(
secondary: Icon( secondary: Icon(
Icons.cell_tower, Icons.cell_tower,
color: settingsService.settings.notificationsEnabled ? null : Colors.grey, color: settingsService.settings.notificationsEnabled
? null
: Colors.grey,
), ),
title: Text( title: Text(
context.l10n.appSettings_advertisementNotifications, context.l10n.appSettings_advertisementNotifications,
style: TextStyle( style: TextStyle(
color: settingsService.settings.notificationsEnabled ? null : Colors.grey, color: settingsService.settings.notificationsEnabled
? null
: Colors.grey,
), ),
), ),
subtitle: Text( subtitle: Text(
context.l10n.appSettings_advertisementNotificationsSubtitle, context.l10n.appSettings_advertisementNotificationsSubtitle,
style: TextStyle( style: TextStyle(
color: settingsService.settings.notificationsEnabled ? null : Colors.grey, color: settingsService.settings.notificationsEnabled
? null
: Colors.grey,
), ),
), ),
value: settingsService.settings.notifyOnNewAdvert, 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( return Card(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -217,15 +258,19 @@ class AppSettingsScreen extends StatelessWidget {
SwitchListTile( SwitchListTile(
secondary: const Icon(Icons.refresh_outlined), secondary: const Icon(Icons.refresh_outlined),
title: Text(context.l10n.appSettings_clearPathOnMaxRetry), title: Text(context.l10n.appSettings_clearPathOnMaxRetry),
subtitle: Text(context.l10n.appSettings_clearPathOnMaxRetrySubtitle), subtitle: Text(
context.l10n.appSettings_clearPathOnMaxRetrySubtitle,
),
value: settingsService.settings.clearPathOnMaxRetry, value: settingsService.settings.clearPathOnMaxRetry,
onChanged: (value) { onChanged: (value) {
settingsService.setClearPathOnMaxRetry(value); settingsService.setClearPathOnMaxRetry(value);
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text(value content: Text(
? context.l10n.appSettings_pathsWillBeCleared value
: context.l10n.appSettings_pathsWillNotBeCleared), ? context.l10n.appSettings_pathsWillBeCleared
: context.l10n.appSettings_pathsWillNotBeCleared,
),
duration: const Duration(seconds: 2), duration: const Duration(seconds: 2),
), ),
); );
@@ -241,9 +286,11 @@ class AppSettingsScreen extends StatelessWidget {
settingsService.setAutoRouteRotationEnabled(value); settingsService.setAutoRouteRotationEnabled(value);
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text(value content: Text(
? context.l10n.appSettings_autoRouteRotationEnabled value
: context.l10n.appSettings_autoRouteRotationDisabled), ? context.l10n.appSettings_autoRouteRotationEnabled
: context.l10n.appSettings_autoRouteRotationDisabled,
),
duration: const Duration(seconds: 2), 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( return Card(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -302,7 +352,9 @@ class AppSettingsScreen extends StatelessWidget {
subtitle: Text( subtitle: Text(
settingsService.settings.mapTimeFilterHours == 0 settingsService.settings.mapTimeFilterHours == 0
? context.l10n.appSettings_timeFilterShowAll ? 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), trailing: const Icon(Icons.chevron_right),
onTap: () => _showTimeFilterDialog(context, settingsService), onTap: () => _showTimeFilterDialog(context, settingsService),
@@ -332,6 +384,7 @@ class AppSettingsScreen extends StatelessWidget {
); );
} }
// Fixed rendering issues
Widget _buildBatteryCard( Widget _buildBatteryCard(
BuildContext context, BuildContext context,
AppSettingsService settingsService, AppSettingsService settingsService,
@@ -339,13 +392,15 @@ class AppSettingsScreen extends StatelessWidget {
) { ) {
final deviceId = connector.deviceId; final deviceId = connector.deviceId;
final isConnected = connector.isConnected && deviceId != null; final isConnected = connector.isConnected && deviceId != null;
final selection = final selection = isConnected
isConnected ? settingsService.batteryChemistryForDevice(deviceId) : 'nmc'; ? settingsService.batteryChemistryForDevice(deviceId)
: 'nmc';
return Card( return Card(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const SizedBox(height: 4),
Padding( Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text( child: Text(
@@ -353,20 +408,38 @@ class AppSettingsScreen extends StatelessWidget {
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
), ),
), ),
// Main tile (icon + text only)
ListTile( ListTile(
leading: const Icon(Icons.battery_full), leading: const Icon(Icons.battery_full),
title: Text(context.l10n.appSettings_batteryChemistry), title: Text(context.l10n.appSettings_batteryChemistry),
subtitle: Text( subtitle: Text(
isConnected isConnected
? context.l10n.appSettings_batteryChemistryPerDevice(connector.deviceDisplayName) ? context.l10n.appSettings_batteryChemistryPerDevice(
connector.deviceDisplayName,
)
: context.l10n.appSettings_batteryChemistryConnectFirst, : context.l10n.appSettings_batteryChemistryConnectFirst,
), ),
trailing: DropdownButton<String>( contentPadding: const EdgeInsets.symmetric(horizontal: 16),
value: selection, ),
// Dropdown (separate full-width row)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: DropdownButtonFormField<String>(
initialValue: selection,
isExpanded: true,
decoration: const InputDecoration(
border: UnderlineInputBorder(),
isDense: true,
),
onChanged: isConnected onChanged: isConnected
? (value) { ? (value) {
if (value != null) { if (value != null) {
settingsService.setBatteryChemistryForDevice(deviceId, value); settingsService.setBatteryChemistryForDevice(
deviceId,
value,
);
} }
} }
: null, : null,
@@ -391,7 +464,10 @@ class AppSettingsScreen extends StatelessWidget {
); );
} }
void _showThemeModeDialog(BuildContext context, AppSettingsService settingsService) { void _showThemeModeDialog(
BuildContext context,
AppSettingsService settingsService,
) {
showDialog( showDialog(
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
@@ -471,12 +547,19 @@ class AppSettingsScreen extends StatelessWidget {
return context.l10n.appSettings_languageSk; return context.l10n.appSettings_languageSk;
case 'bg': case 'bg':
return context.l10n.appSettings_languageBg; return context.l10n.appSettings_languageBg;
case 'ru':
return context.l10n.appSettings_languageRu;
case 'uk':
return context.l10n.appSettings_languageUk;
default: default:
return context.l10n.appSettings_languageSystem; return context.l10n.appSettings_languageSystem;
} }
} }
void _showLanguageDialog(BuildContext context, AppSettingsService settingsService) { void _showLanguageDialog(
BuildContext context,
AppSettingsService settingsService,
) {
showDialog( showDialog(
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
@@ -547,6 +630,14 @@ class AppSettingsScreen extends StatelessWidget {
title: Text(context.l10n.appSettings_languageBg), title: Text(context.l10n.appSettings_languageBg),
value: 'bg', value: 'bg',
), ),
RadioListTile<String?>(
title: Text(context.l10n.appSettings_languageRu),
value: 'ru',
),
RadioListTile<String?>(
title: Text(context.l10n.appSettings_languageUk),
value: 'uk',
),
], ],
), ),
), ),
@@ -561,7 +652,10 @@ class AppSettingsScreen extends StatelessWidget {
); );
} }
void _showTimeFilterDialog(BuildContext context, AppSettingsService settingsService) { void _showTimeFilterDialog(
BuildContext context,
AppSettingsService settingsService,
) {
showDialog( showDialog(
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
@@ -581,33 +675,23 @@ class AppSettingsScreen extends StatelessWidget {
const SizedBox(height: 16), const SizedBox(height: 16),
ListTile( ListTile(
title: Text(context.l10n.appSettings_allTime), title: Text(context.l10n.appSettings_allTime),
leading: Radio<double>( leading: Radio<double>(value: 0),
value: 0,
),
), ),
ListTile( ListTile(
title: Text(context.l10n.appSettings_lastHour), title: Text(context.l10n.appSettings_lastHour),
leading: Radio<double>( leading: Radio<double>(value: 1),
value: 1,
),
), ),
ListTile( ListTile(
title: Text(context.l10n.appSettings_last6Hours), title: Text(context.l10n.appSettings_last6Hours),
leading: Radio<double>( leading: Radio<double>(value: 6),
value: 6,
),
), ),
ListTile( ListTile(
title: Text(context.l10n.appSettings_last24Hours), title: Text(context.l10n.appSettings_last24Hours),
leading: Radio<double>( leading: Radio<double>(value: 24),
value: 24,
),
), ),
ListTile( ListTile(
title: Text(context.l10n.appSettings_lastWeek), title: Text(context.l10n.appSettings_lastWeek),
leading: Radio<double>( leading: Radio<double>(value: 168),
value: 168,
),
), ),
], ],
), ),
@@ -622,7 +706,10 @@ class AppSettingsScreen extends StatelessWidget {
); );
} }
Widget _buildDebugCard(BuildContext context, AppSettingsService settingsService) { Widget _buildDebugCard(
BuildContext context,
AppSettingsService settingsService,
) {
return Card( return Card(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -644,9 +731,11 @@ class AppSettingsScreen extends StatelessWidget {
if (!context.mounted) return; if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text(value content: Text(
? context.l10n.appSettings_appDebugLoggingEnabled value
: context.l10n.appSettings_appDebugLoggingDisabled), ? context.l10n.appSettings_appDebugLoggingEnabled
: context.l10n.appSettings_appDebugLoggingDisabled,
),
duration: const Duration(seconds: 2), duration: const Duration(seconds: 2),
), ),
); );
+46 -18
View File
@@ -24,7 +24,9 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
final entries = logService.entries.reversed.toList(); final entries = logService.entries.reversed.toList();
final rawEntries = logService.rawLogRxEntries.reversed.toList(); final rawEntries = logService.rawLogRxEntries.reversed.toList();
final showingFrames = _view == _BleLogView.frames; final showingFrames = _view == _BleLogView.frames;
final hasEntries = showingFrames ? entries.isNotEmpty : rawEntries.isNotEmpty; final hasEntries = showingFrames
? entries.isNotEmpty
: rawEntries.isNotEmpty;
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text(context.l10n.debugLog_bleTitle), title: Text(context.l10n.debugLog_bleTitle),
@@ -36,15 +38,23 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
? () async { ? () async {
final text = showingFrames final text = showingFrames
? entries ? entries
.map((entry) => '${entry.description}\n${entry.hexPreview}\n') .map(
.join('\n') (entry) =>
'${entry.description}\n${entry.hexPreview}\n',
)
.join('\n')
: rawEntries : rawEntries
.map((entry) => 'RX RAW_LOG_RX_DATA\n${entry.hexPreview}\n') .map(
.join('\n'); (entry) =>
'RX RAW_LOG_RX_DATA\n${entry.hexPreview}\n',
)
.join('\n');
await Clipboard.setData(ClipboardData(text: text)); await Clipboard.setData(ClipboardData(text: text));
if (!context.mounted) return; if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.debugLog_bleCopied)), SnackBar(
content: Text(context.l10n.debugLog_bleCopied),
),
); );
} }
: null, : null,
@@ -68,8 +78,14 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
child: SegmentedButton<_BleLogView>( child: SegmentedButton<_BleLogView>(
segments: [ segments: [
ButtonSegment(value: _BleLogView.frames, label: Text(context.l10n.debugLog_frames)), ButtonSegment(
ButtonSegment(value: _BleLogView.rawLogRx, label: Text(context.l10n.debugLog_rawLogRx)), value: _BleLogView.frames,
label: Text(context.l10n.debugLog_frames),
),
ButtonSegment(
value: _BleLogView.rawLogRx,
label: Text(context.l10n.debugLog_rawLogRx),
),
], ],
selected: {_view}, selected: {_view},
onSelectionChanged: (selection) { onSelectionChanged: (selection) {
@@ -81,8 +97,10 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
Expanded( Expanded(
child: hasEntries child: hasEntries
? ListView.separated( ? ListView.separated(
itemCount: showingFrames ? entries.length : rawEntries.length, itemCount: showingFrames
separatorBuilder: (_, __) => const Divider(height: 1), ? entries.length
: rawEntries.length,
separatorBuilder: (_, _) => const Divider(height: 1),
itemBuilder: (context, index) { itemBuilder: (context, index) {
if (showingFrames) { if (showingFrames) {
final entry = entries[index]; final entry = entries[index];
@@ -94,7 +112,9 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
subtitle: Text('${entry.hexPreview}\n$time'), subtitle: Text('${entry.hexPreview}\n$time'),
isThreeLine: true, isThreeLine: true,
leading: Icon( leading: Icon(
entry.outgoing ? Icons.upload : Icons.download, entry.outgoing
? Icons.upload
: Icons.download,
size: 18, size: 18,
), ),
); );
@@ -131,9 +151,7 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
title: Text(info.title), title: Text(info.title),
content: SingleChildScrollView( content: SingleChildScrollView(child: SelectableText(info.rawHex)),
child: SelectableText(info.rawHex),
),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
@@ -195,11 +213,18 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
} }
final payload = raw.sublist(index); 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 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}'; 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) { String _decodePayloadSummary(int payloadType, Uint8List payload) {
@@ -245,7 +270,10 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
return 'ADVERT (short)'; return 'ADVERT (short)';
} }
var offset = 0; 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; offset += 32;
final timestamp = readUint32LE(payload, offset); final timestamp = readUint32LE(payload, offset);
offset += 4; offset += 4;
+292 -244
View File
@@ -11,6 +11,7 @@ import '../connector/meshcore_connector.dart';
import '../helpers/chat_scroll_controller.dart'; import '../helpers/chat_scroll_controller.dart';
import '../connector/meshcore_protocol.dart'; import '../connector/meshcore_protocol.dart';
import '../helpers/link_handler.dart'; import '../helpers/link_handler.dart';
import '../helpers/reaction_helper.dart';
import '../helpers/utf8_length_limiter.dart'; import '../helpers/utf8_length_limiter.dart';
import '../l10n/l10n.dart'; import '../l10n/l10n.dart';
import '../models/channel.dart'; import '../models/channel.dart';
@@ -26,10 +27,7 @@ import 'map_screen.dart';
class ChannelChatScreen extends StatefulWidget { class ChannelChatScreen extends StatefulWidget {
final Channel channel; final Channel channel;
const ChannelChatScreen({ const ChannelChatScreen({super.key, required this.channel});
super.key,
required this.channel,
});
@override @override
State<ChannelChatScreen> createState() => _ChannelChatScreenState(); State<ChannelChatScreen> createState() => _ChannelChatScreenState();
@@ -43,6 +41,8 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
final Map<String, GlobalKey> _messageKeys = {}; final Map<String, GlobalKey> _messageKeys = {};
bool _isLoadingOlder = false; bool _isLoadingOlder = false;
MeshCoreConnector? _connector;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -50,7 +50,8 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
_scrollController.onScrollNearTop = _loadOlderMessages; _scrollController.onScrollNearTop = _loadOlderMessages;
SchedulerBinding.instance.addPostFrameCallback((_) { SchedulerBinding.instance.addPostFrameCallback((_) {
if (!mounted) return; 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 @override
void dispose() { void dispose() {
context.read<MeshCoreConnector>().setActiveChannel(null); _connector?.setActiveChannel(null);
_textFieldFocusNode.removeListener(_onTextFieldFocusChange); _textFieldFocusNode.removeListener(_onTextFieldFocusChange);
_textFieldFocusNode.dispose(); _textFieldFocusNode.dispose();
_textController.dispose(); _textController.dispose();
@@ -134,15 +135,19 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
children: [ children: [
Text( Text(
widget.channel.name.isEmpty widget.channel.name.isEmpty
? context.l10n.channels_channelIndex(widget.channel.index) ? context.l10n.channels_channelIndex(
widget.channel.index,
)
: widget.channel.name, : widget.channel.name,
style: const TextStyle(fontSize: 16), style: const TextStyle(fontSize: 16),
), ),
Consumer<MeshCoreConnector>( Consumer<MeshCoreConnector>(
builder: (context, connector, _) { builder: (context, connector, _) {
final unreadCount = final unreadCount = connector
connector.getUnreadCountForChannelIndex(widget.channel.index); .getUnreadCountForChannelIndex(widget.channel.index);
final privacy = widget.channel.isPublicChannel ? context.l10n.channels_public : context.l10n.channels_private; final privacy = widget.channel.isPublicChannel
? context.l10n.channels_public
: context.l10n.channels_private;
return Text( return Text(
'$privacy${context.l10n.chat_unread(unreadCount)}', '$privacy${context.l10n.chat_unread(unreadCount)}',
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
@@ -201,7 +206,8 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
// Reverse messages so newest appear at bottom with reverse: true // Reverse messages so newest appear at bottom with reverse: true
final reversedMessages = messages.reversed.toList(); 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 // Auto-scroll to bottom if user is already at bottom
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
@@ -224,7 +230,9 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
child: SizedBox( child: SizedBox(
width: 20, width: 20,
height: 20, height: 20,
child: CircularProgressIndicator(strokeWidth: 2), child: CircularProgressIndicator(
strokeWidth: 2,
),
), ),
), ),
); );
@@ -240,9 +248,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
); );
}, },
), ),
JumpToBottomButton( JumpToBottomButton(scrollController: _scrollController),
scrollController: _scrollController,
),
], ],
); );
}, },
@@ -261,15 +267,21 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
final poi = _parsePoiMessage(message.text); final poi = _parsePoiMessage(message.text);
final displayPath = message.pathBytes.isNotEmpty final displayPath = message.pathBytes.isNotEmpty
? message.pathBytes ? message.pathBytes
: (message.pathVariants.isNotEmpty ? message.pathVariants.first : Uint8List(0)); : (message.pathVariants.isNotEmpty
? message.pathVariants.first
: Uint8List(0));
return Padding( return Padding(
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8), padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
child: Column( child: Column(
crossAxisAlignment: isOutgoing ? CrossAxisAlignment.end : CrossAxisAlignment.start, crossAxisAlignment: isOutgoing
? CrossAxisAlignment.end
: CrossAxisAlignment.start,
children: [ children: [
Row( Row(
mainAxisAlignment: isOutgoing ? MainAxisAlignment.end : MainAxisAlignment.start, mainAxisAlignment: isOutgoing
? MainAxisAlignment.end
: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
if (!isOutgoing) ...[ if (!isOutgoing) ...[
@@ -281,128 +293,160 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
onTap: () => _showMessagePathInfo(message), onTap: () => _showMessagePathInfo(message),
onLongPress: () => _showMessageActions(message), onLongPress: () => _showMessageActions(message),
child: Container( child: Container(
padding: gifId != null padding: gifId != null
? const EdgeInsets.all(4) ? const EdgeInsets.all(4)
: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), : const EdgeInsets.symmetric(
constraints: BoxConstraints( horizontal: 12,
maxWidth: MediaQuery.of(context).size.width * 0.65, vertical: 8,
),
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,
), ),
), constraints: BoxConstraints(
), maxWidth: MediaQuery.of(context).size.width * 0.65,
if (gifId == null) const SizedBox(height: 4), ),
], decoration: BoxDecoration(
if (message.replyToMessageId != null) ...[ color: isOutgoing
_buildReplyPreview(message), ? Theme.of(context).colorScheme.primaryContainer
const SizedBox(height: 8), : Theme.of(
], context,
if (poi != null) ).colorScheme.surfaceContainerHighest,
_buildPoiMessage(context, poi, isOutgoing) borderRadius: BorderRadius.circular(12),
else if (gifId != null) ),
ClipRRect( child: Column(
borderRadius: BorderRadius.circular(8), crossAxisAlignment: CrossAxisAlignment.start,
child: GifMessage( children: [
url: 'https://media.giphy.com/media/$gifId/giphy.gif', if (!isOutgoing) ...[
backgroundColor: Colors.transparent, Padding(
fallbackTextColor: isOutgoing padding: gifId != null
? Theme.of(context).colorScheme.onPrimaryContainer.withValues(alpha: 0.7) ? const EdgeInsets.only(
: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6), left: 8,
), top: 4,
) bottom: 4,
else )
Linkify( : EdgeInsets.zero,
text: message.text, child: Text(
style: const TextStyle(fontSize: 14), message.senderName,
linkStyle: const TextStyle( style: TextStyle(
fontSize: 14, fontSize: 12,
color: Colors.green, fontWeight: FontWeight.bold,
decoration: TextDecoration.underline, color: Theme.of(context).colorScheme.primary,
), ),
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) ...[ if (gifId == null) const SizedBox(height: 4),
const SizedBox(width: 6), ],
Icon(Icons.repeat, size: 12, color: Colors.grey[600]), if (message.replyToMessageId != null) ...[
const SizedBox(width: 2), _buildReplyPreview(message),
Text( const SizedBox(height: 8),
'${message.repeatCount}', ],
style: TextStyle(fontSize: 11, color: Colors.grey[600]), 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) ...[ else
const SizedBox(width: 4), Linkify(
Icon( text: message.text,
message.status == ChannelMessageStatus.sent style: const TextStyle(fontSize: 14),
? Icons.check linkStyle: const TextStyle(
: message.status == ChannelMessageStatus.pending 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.schedule
: Icons.error_outline, : Icons.error_outline,
size: 14, size: 14,
color: message.status == ChannelMessageStatus.failed color:
? Colors.red message.status ==
: Colors.grey[600], ChannelMessageStatus.failed
), ? Colors.red
], : Colors.grey[600],
], ),
), ],
],
),
),
],
), ),
], ),
), ),
), ),
),
),
], ],
), ),
if (message.reactions.isNotEmpty) ...[ if (message.reactions.isNotEmpty) ...[
@@ -443,7 +487,10 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
children: [ children: [
Icon(Icons.location_on_outlined, size: 14, color: previewTextColor), Icon(Icons.location_on_outlined, size: 14, color: previewTextColor),
const SizedBox(width: 4), const SizedBox(width: 4),
Text(context.l10n.chat_location, style: TextStyle(fontSize: 12, color: previewTextColor)), Text(
context.l10n.chat_location,
style: TextStyle(fontSize: 12, color: previewTextColor),
),
], ],
); );
} else { } else {
@@ -467,10 +514,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
border: Border( border: Border(
left: BorderSide( left: BorderSide(color: colorScheme.primary, width: 3),
color: colorScheme.primary,
width: 3,
),
), ),
), ),
child: Column( child: Column(
@@ -508,17 +552,16 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
color: Theme.of(context).colorScheme.secondaryContainer, color: Theme.of(context).colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
border: Border.all( 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, width: 1,
), ),
), ),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Text( Text(emoji, style: const TextStyle(fontSize: 16)),
emoji,
style: const TextStyle(fontSize: 16),
),
if (count > 1) ...[ if (count > 1) ...[
const SizedBox(width: 4), const SizedBox(width: 4),
Text( Text(
@@ -545,7 +588,9 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
_PoiInfo? _parsePoiMessage(String text) { _PoiInfo? _parsePoiMessage(String text) {
final trimmed = text.trim(); 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; if (match == null) return null;
final lat = double.tryParse(match.group(1) ?? ''); final lat = double.tryParse(match.group(1) ?? '');
final lon = double.tryParse(match.group(2) ?? ''); final lon = double.tryParse(match.group(2) ?? '');
@@ -556,10 +601,13 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
Widget _buildPoiMessage(BuildContext context, _PoiInfo poi, bool isOutgoing) { Widget _buildPoiMessage(BuildContext context, _PoiInfo poi, bool isOutgoing) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final textColor = final textColor = isOutgoing
isOutgoing ? colorScheme.onPrimaryContainer : colorScheme.onSurface; ? colorScheme.onPrimaryContainer
: colorScheme.onSurface;
final metaColor = textColor.withValues(alpha: 0.7); 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( return Row(
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
@@ -587,18 +635,12 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
children: [ children: [
Text( Text(
context.l10n.chat_poiShared, context.l10n.chat_poiShared,
style: TextStyle( style: TextStyle(color: textColor, fontWeight: FontWeight.w600),
color: textColor,
fontWeight: FontWeight.w600,
),
), ),
if (poi.label.isNotEmpty) if (poi.label.isNotEmpty)
Text( Text(
poi.label, poi.label,
style: TextStyle( style: TextStyle(color: metaColor, fontSize: 12),
color: metaColor,
fontSize: 12,
),
), ),
], ],
), ),
@@ -675,10 +717,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
decoration: BoxDecoration( decoration: BoxDecoration(
color: Theme.of(context).colorScheme.secondaryContainer, color: Theme.of(context).colorScheme.secondaryContainer,
border: Border( border: Border(
bottom: BorderSide( bottom: BorderSide(color: Theme.of(context).dividerColor, width: 1),
color: Theme.of(context).dividerColor,
width: 1,
),
), ),
), ),
child: Row( child: Row(
@@ -707,7 +746,9 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: TextStyle( style: TextStyle(
fontSize: 11, 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( child: Row(
children: [ children: [
IconButton( IconButton(
icon: const Icon(Icons.gif_box), icon: const Icon(Icons.gif_box),
onPressed: () => _showGifPicker(context), onPressed: () => _showGifPicker(context),
tooltip: context.l10n.chat_sendGif, tooltip: context.l10n.chat_sendGif,
), ),
Expanded( Expanded(
child: ValueListenableBuilder<TextEditingValue>( child: ValueListenableBuilder<TextEditingValue>(
valueListenable: _textController, valueListenable: _textController,
builder: (context, value, child) { builder: (context, value, child) {
final gifId = _parseGifId(value.text); final gifId = _parseGifId(value.text);
if (gifId != null) { if (gifId != null) {
return Row( return Row(
children: [ children: [
Expanded( Expanded(
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
child: GifMessage( child: GifMessage(
url: 'https://media.giphy.com/media/$gifId/giphy.gif', url:
backgroundColor: 'https://media.giphy.com/media/$gifId/giphy.gif',
Theme.of(context).colorScheme.surfaceContainerHighest, backgroundColor: Theme.of(
fallbackTextColor: context,
Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6), ).colorScheme.surfaceContainerHighest,
maxSize: 160, 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), maxLines: null,
IconButton( textInputAction: TextInputAction.send,
icon: const Icon(Icons.close), onSubmitted: (_) => _sendMessage(),
onPressed: () => _textController.clear(), );
), },
], ),
); ),
} const SizedBox(width: 8),
IconButton(
return TextField( icon: const Icon(Icons.send),
controller: _textController, onPressed: _sendMessage,
focusNode: _textFieldFocusNode, color: Theme.of(context).colorScheme.primary,
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,
),
],
), ),
), ),
], ],
@@ -877,14 +921,16 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
_setReplyingTo(message); _setReplyingTo(message);
}, },
), ),
ListTile( // Can't react to your own messages
leading: const Icon(Icons.add_reaction_outlined), if (!message.isOutgoing)
title: Text(context.l10n.chat_addReaction), ListTile(
onTap: () { leading: const Icon(Icons.add_reaction_outlined),
Navigator.pop(sheetContext); title: Text(context.l10n.chat_addReaction),
_showEmojiPicker(message); onTap: () {
}, Navigator.pop(sheetContext);
), _showEmojiPicker(message);
},
),
ListTile( ListTile(
leading: const Icon(Icons.copy), leading: const Icon(Icons.copy),
title: Text(context.l10n.common_copy), title: Text(context.l10n.common_copy),
@@ -926,25 +972,31 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
void _sendReaction(ChannelMessage message, String emoji) { void _sendReaction(ChannelMessage message, String emoji) {
final connector = context.read<MeshCoreConnector>(); final connector = context.read<MeshCoreConnector>();
// Send reaction with full messageId to find target, but parser will extract final emojiIndex = ReactionHelper.emojiToIndex(emoji);
// lightweight reactionKey (timestamp_senderPrefix) for deduplication if (emojiIndex == null) return; // Unknown emoji, skip
final reactionText = 'r:${message.messageId}:$emoji'; 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); connector.sendChannelMessage(widget.channel, reactionText);
} }
void _copyMessageText(String text) { void _copyMessageText(String text) {
Clipboard.setData(ClipboardData(text: text)); Clipboard.setData(ClipboardData(text: text));
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(
SnackBar(content: Text(context.l10n.chat_messageCopied)), context,
); ).showSnackBar(SnackBar(content: Text(context.l10n.chat_messageCopied)));
} }
Future<void> _deleteMessage(ChannelMessage message) async { Future<void> _deleteMessage(ChannelMessage message) async {
await context.read<MeshCoreConnector>().deleteChannelMessage(message); await context.read<MeshCoreConnector>().deleteChannelMessage(message);
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(
SnackBar(content: Text(context.l10n.chat_messageDeleted)), context,
); ).showSnackBar(SnackBar(content: Text(context.l10n.chat_messageDeleted)));
} }
String _formatPathPrefixes(Uint8List pathBytes) { String _formatPathPrefixes(Uint8List pathBytes) {
@@ -959,9 +1011,5 @@ class _PoiInfo {
final double lon; final double lon;
final String label; final String label;
const _PoiInfo({ const _PoiInfo({required this.lat, required this.lon, required this.label});
required this.lat,
required this.lon,
required this.label,
});
} }
+150 -66
View File
@@ -4,6 +4,7 @@ import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
import 'package:meshcore_open/screens/path_trace_map.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart'; import '../connector/meshcore_connector.dart';
@@ -17,17 +18,17 @@ import '../models/contact.dart';
class ChannelMessagePathScreen extends StatelessWidget { class ChannelMessagePathScreen extends StatelessWidget {
final ChannelMessage message; final ChannelMessage message;
const ChannelMessagePathScreen({ const ChannelMessagePathScreen({super.key, required this.message});
super.key,
required this.message,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Consumer<MeshCoreConnector>( return Consumer<MeshCoreConnector>(
builder: (context, connector, _) { builder: (context, connector, _) {
final l10n = context.l10n; final 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 hops = _buildPathHops(primaryPath, connector.contacts, l10n);
final hasHopDetails = primaryPath.isNotEmpty; final hasHopDetails = primaryPath.isNotEmpty;
final observedLabel = _formatObservedHops( final observedLabel = _formatObservedHops(
@@ -41,6 +42,21 @@ class ChannelMessagePathScreen extends StatelessWidget {
appBar: AppBar( appBar: AppBar(
title: Text(l10n.channelPath_title), title: Text(l10n.channelPath_title),
actions: [ 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( IconButton(
icon: const Icon(Icons.map_outlined), icon: const Icon(Icons.map_outlined),
tooltip: l10n.channelPath_viewMap, tooltip: l10n.channelPath_viewMap,
@@ -88,10 +104,7 @@ class ChannelMessagePathScreen extends StatelessWidget {
); );
} }
Widget _buildSummaryCard( Widget _buildSummaryCard(BuildContext context, {String? observedLabel}) {
BuildContext context, {
String? observedLabel,
}) {
final l10n = context.l10n; final l10n = context.l10n;
return Card( return Card(
child: Padding( child: Padding(
@@ -105,21 +118,28 @@ class ChannelMessagePathScreen extends StatelessWidget {
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
_buildDetailRow(l10n.channelPath_senderLabel, message.senderName), _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) if (message.repeatCount > 0)
_buildDetailRow(l10n.channelPath_repeatsLabel, message.repeatCount.toString()), _buildDetailRow(
_buildDetailRow(l10n.channelPath_pathLabelTitle, _formatPathLabel(message.pathLength, l10n)), l10n.channelPath_repeatsLabel,
if (observedLabel != null) _buildDetailRow(l10n.channelPath_observedLabel, observedLabel), message.repeatCount.toString(),
),
_buildDetailRow(
l10n.channelPath_pathLabelTitle,
_formatPathLabel(message.pathLength, l10n),
),
if (observedLabel != null)
_buildDetailRow(l10n.channelPath_observedLabel, observedLabel),
], ],
), ),
), ),
); );
} }
Widget _buildPathVariants( Widget _buildPathVariants(BuildContext context, List<Uint8List> variants) {
BuildContext context,
List<Uint8List> variants,
) {
final l10n = context.l10n; final l10n = context.l10n;
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -163,7 +183,7 @@ class ChannelMessagePathScreen extends StatelessWidget {
subtitle: Text( subtitle: Text(
hop.hasLocation hop.hasLocation
? '${hop.position!.latitude.toStringAsFixed(5)}, ' ? '${hop.position!.latitude.toStringAsFixed(5)}, '
'${hop.position!.longitude.toStringAsFixed(5)}' '${hop.position!.longitude.toStringAsFixed(5)}'
: l10n.channelPath_noLocationData, : l10n.channelPath_noLocationData,
), ),
), ),
@@ -239,7 +259,6 @@ class ChannelMessagePathScreen extends StatelessWidget {
), ),
); );
} }
} }
class ChannelMessagePathMapScreen extends StatefulWidget { class ChannelMessagePathMapScreen extends StatefulWidget {
@@ -257,8 +276,10 @@ class ChannelMessagePathMapScreen extends StatefulWidget {
_ChannelMessagePathMapScreenState(); _ChannelMessagePathMapScreenState();
} }
class _ChannelMessagePathMapScreenState extends State<ChannelMessagePathMapScreen> { class _ChannelMessagePathMapScreenState
extends State<ChannelMessagePathMapScreen> {
Uint8List? _selectedPath; Uint8List? _selectedPath;
double _pathDistance = 0.0;
@override @override
void initState() { void initState() {
@@ -270,32 +291,58 @@ class _ChannelMessagePathMapScreenState extends State<ChannelMessagePathMapScree
void didUpdateWidget(ChannelMessagePathMapScreen oldWidget) { void didUpdateWidget(ChannelMessagePathMapScreen oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
if (oldWidget.message != widget.message || if (oldWidget.message != widget.message ||
!_pathsEqual(oldWidget.initialPath ?? Uint8List(0), !_pathsEqual(
widget.initialPath ?? Uint8List(0))) { oldWidget.initialPath ?? Uint8List(0),
widget.initialPath ?? Uint8List(0),
)) {
_selectedPath = widget.initialPath; _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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Consumer<MeshCoreConnector>( return Consumer<MeshCoreConnector>(
builder: (context, connector, _) { builder: (context, connector, _) {
final tileCache = context.read<MapTileCacheService>(); final tileCache = context.read<MapTileCacheService>();
final primaryPath = final primaryPath = _selectPrimaryPath(
_selectPrimaryPath(widget.message.pathBytes, widget.message.pathVariants); widget.message.pathBytes,
final observedPaths = widget.message.pathVariants,
_buildObservedPaths(primaryPath, widget.message.pathVariants); );
final observedPaths = _buildObservedPaths(
primaryPath,
widget.message.pathVariants,
);
final selectedPath = _resolveSelectedPath( final selectedPath = _resolveSelectedPath(
_selectedPath, _selectedPath,
observedPaths, observedPaths,
primaryPath, primaryPath,
); );
final selectedIndex = _indexForPath(selectedPath, observedPaths); final selectedIndex = _indexForPath(selectedPath, observedPaths);
final hops = _buildPathHops(selectedPath, connector.contacts, context.l10n); final hops = _buildPathHops(
final points = hops selectedPath,
.where((hop) => hop.hasLocation) connector.contacts,
.map((hop) => hop.position!) context.l10n,
.toList(); );
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 final polylines = points.length > 1
? [ ? [
Polyline( Polyline(
@@ -306,16 +353,20 @@ class _ChannelMessagePathMapScreenState extends State<ChannelMessagePathMapScree
] ]
: <Polyline>[]; : <Polyline>[];
final initialCenter = final initialCenter = points.isNotEmpty
points.isNotEmpty ? points.first : const LatLng(0, 0); ? points.first
: const LatLng(0, 0);
final initialZoom = points.isNotEmpty ? 13.0 : 2.0; final initialZoom = points.isNotEmpty ? 13.0 : 2.0;
final bounds = points.length > 1 ? LatLngBounds.fromPoints(points) : null; final bounds = points.length > 1
final mapKey = ValueKey(_formatPathPrefixes(selectedPath)); ? LatLngBounds.fromPoints(points)
: null;
final mapKey = ValueKey(
'${_formatPathPrefixes(selectedPath)},${context.l10n.pathTrace_you}',
);
_pathDistance = _getPathDistance(points);
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(title: Text(context.l10n.channelPath_mapTitle)),
title: Text(context.l10n.channelPath_mapTitle),
),
body: SafeArea( body: SafeArea(
top: false, top: false,
child: Stack( child: Stack(
@@ -334,6 +385,9 @@ class _ChannelMessagePathMapScreenState extends State<ChannelMessagePathMapScree
), ),
minZoom: 2.0, minZoom: 2.0,
maxZoom: 18.0, maxZoom: 18.0,
interactionOptions: InteractionOptions(
flags: ~InteractiveFlag.rotate,
),
), ),
children: [ children: [
TileLayer( TileLayer(
@@ -343,30 +397,28 @@ class _ChannelMessagePathMapScreenState extends State<ChannelMessagePathMapScree
MapTileCacheService.userAgentPackageName, MapTileCacheService.userAgentPackageName,
maxZoom: 19, maxZoom: 19,
), ),
if (polylines.isNotEmpty) PolylineLayer(polylines: polylines), if (polylines.isNotEmpty)
MarkerLayer( PolylineLayer(polylines: polylines),
markers: _buildHopMarkers(hops), MarkerLayer(markers: _buildHopMarkers(hops)),
),
], ],
), ),
if (observedPaths.length > 1) if (observedPaths.length > 1)
_buildPathSelector( _buildPathSelector(context, observedPaths, selectedIndex, (
context, index,
observedPaths, ) {
selectedIndex, setState(() {
(index) { _selectedPath = observedPaths[index].pathBytes;
setState(() { });
_selectedPath = observedPaths[index].pathBytes; }),
});
},
),
if (points.isEmpty) if (points.isEmpty)
Center( Center(
child: Card( child: Card(
color: Colors.white.withValues(alpha: 0.9), color: Colors.white.withValues(alpha: 0.9),
child: Padding( child: Padding(
padding: EdgeInsets.all(12), 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) if (hop.hasLocation)
Marker( Marker(
point: hop.position!, point: hop.position!,
width: 40, width: 35,
height: 40, height: 35,
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.green, 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(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
child: Text( 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), style: const TextStyle(fontWeight: FontWeight.w600),
), ),
), ),
@@ -509,7 +594,7 @@ class _ChannelMessagePathMapScreenState extends State<ChannelMessagePathMapScree
: ListView.separated( : ListView.separated(
padding: const EdgeInsets.symmetric(vertical: 4), padding: const EdgeInsets.symmetric(vertical: 4),
itemCount: hops.length, itemCount: hops.length,
separatorBuilder: (_, __) => const Divider(height: 1), separatorBuilder: (_, _) => const Divider(height: 1),
itemBuilder: (context, index) { itemBuilder: (context, index) {
final hop = hops[index]; final hop = hops[index];
return ListTile( return ListTile(
@@ -525,7 +610,7 @@ class _ChannelMessagePathMapScreenState extends State<ChannelMessagePathMapScree
subtitle: Text( subtitle: Text(
hop.hasLocation hop.hasLocation
? '${hop.position!.latitude.toStringAsFixed(5)}, ' ? '${hop.position!.latitude.toStringAsFixed(5)}, '
'${hop.position!.longitude.toStringAsFixed(5)}' '${hop.position!.longitude.toStringAsFixed(5)}'
: l10n.channelPath_noLocationData, : l10n.channelPath_noLocationData,
), ),
); );
@@ -567,10 +652,7 @@ class _ObservedPath {
final Uint8List pathBytes; final Uint8List pathBytes;
final bool isPrimary; final bool isPrimary;
const _ObservedPath({ const _ObservedPath({required this.pathBytes, required this.isPrimary});
required this.pathBytes,
required this.isPrimary,
});
} }
List<_PathHop> _buildPathHops( List<_PathHop> _buildPathHops(
@@ -597,10 +679,12 @@ List<_PathHop> _buildPathHops(
Contact? _matchContactForPrefix(List<Contact> contacts, int prefix) { Contact? _matchContactForPrefix(List<Contact> contacts, int prefix) {
final matches = contacts final matches = contacts
.where((contact) => .where(
(contact.type == advTypeRepeater || contact.type == advTypeRoom) && (contact) =>
contact.publicKey.isNotEmpty && (contact.type == advTypeRepeater || contact.type == advTypeRoom) &&
contact.publicKey[0] == prefix) contact.publicKey.isNotEmpty &&
contact.publicKey[0] == prefix,
)
.toList(); .toList();
if (matches.isEmpty) return null; if (matches.isEmpty) return null;
+48 -21
View File
@@ -121,30 +121,52 @@ class _ChannelsScreenState extends State<ChannelsScreen>
centerTitle: true, centerTitle: true,
automaticallyImplyLeading: false, automaticallyImplyLeading: false,
actions: [ actions: [
if (_communities.isNotEmpty) PopupMenuButton(
IconButton( itemBuilder: (context) => [
icon: const Icon(Icons.groups), PopupMenuItem(
tooltip: context.l10n.community_manageCommunities, child: Row(
onPressed: () => _showManageCommunitiesDialog(context), children: [
), const Icon(Icons.logout, color: Colors.red),
IconButton( const SizedBox(width: 8),
icon: const Icon(Icons.bluetooth_disabled), Text(context.l10n.common_disconnect),
tooltip: context.l10n.common_disconnect, ],
onPressed: () => _disconnect(context), ),
), onTap: () => _disconnect(context),
IconButton( ),
icon: const Icon(Icons.tune), if (_communities.isNotEmpty)
tooltip: context.l10n.common_settings, PopupMenuItem(
onPressed: () => Navigator.push( child: Row(
context, children: [
MaterialPageRoute(builder: (context) => const SettingsScreen()), 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( body: RefreshIndicator(
onRefresh: () async { onRefresh: () async {
await context.read<MeshCoreConnector>().getChannels(); await context.read<MeshCoreConnector>().getChannels(force: true);
}, },
child: () { child: () {
if (connector.isLoadingChannels) { if (connector.isLoadingChannels) {
@@ -931,7 +953,9 @@ class _ChannelsScreenState extends State<ChannelsScreen>
dialogContext.l10n.community_communityHashtag, dialogContext.l10n.community_communityHashtag,
), ),
subtitle: Text( subtitle: Text(
dialogContext.l10n.community_communityHashtagDesc, dialogContext
.l10n
.community_communityHashtagDesc,
), ),
dense: true, dense: true,
), ),
@@ -1026,10 +1050,11 @@ class _ChannelsScreenState extends State<ChannelsScreen>
if (hashtag.startsWith('#')) { if (hashtag.startsWith('#')) {
hashtag = hashtag.substring(1); hashtag = hashtag.substring(1);
} }
final channelName = '#$hashtag'; final String channelName;
final Uint8List psk; final Uint8List psk;
if (isRegularHashtag) { if (isRegularHashtag) {
channelName = '#$hashtag';
// Regular hashtag - public derivation using SHA256 // Regular hashtag - public derivation using SHA256
psk = Channel.derivePskFromHashtag(hashtag); psk = Channel.derivePskFromHashtag(hashtag);
} else { } else {
@@ -1048,6 +1073,8 @@ class _ChannelsScreenState extends State<ChannelsScreen>
); );
return; return;
} }
channelName =
'${selectedCommunity!.name} #$hashtag';
psk = selectedCommunity! psk = selectedCommunity!
.deriveCommunityHashtagPsk(hashtag); .deriveCommunityHashtagPsk(hashtag);
// Track in community's hashtag list // 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/scheduler.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_linkify/flutter_linkify.dart'; import 'package:flutter_linkify/flutter_linkify.dart';
import 'package:meshcore_open/screens/path_trace_map.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
import '../connector/meshcore_connector.dart'; import '../connector/meshcore_connector.dart';
import '../connector/meshcore_protocol.dart'; import '../connector/meshcore_protocol.dart';
import '../helpers/reaction_helper.dart';
import '../helpers/chat_scroll_controller.dart'; import '../helpers/chat_scroll_controller.dart';
import '../helpers/link_handler.dart'; import '../helpers/link_handler.dart';
import '../helpers/utf8_length_limiter.dart'; import '../helpers/utf8_length_limiter.dart';
@@ -43,6 +45,7 @@ class _ChatScreenState extends State<ChatScreen> {
final _scrollController = ChatScrollController(); final _scrollController = ChatScrollController();
final _textFieldFocusNode = FocusNode(); final _textFieldFocusNode = FocusNode();
bool _isLoadingOlder = false; bool _isLoadingOlder = false;
MeshCoreConnector? _connector;
@override @override
void initState() { void initState() {
@@ -51,7 +54,8 @@ class _ChatScreenState extends State<ChatScreen> {
_scrollController.onScrollNearTop = _loadOlderMessages; _scrollController.onScrollNearTop = _loadOlderMessages;
SchedulerBinding.instance.addPostFrameCallback((_) { SchedulerBinding.instance.addPostFrameCallback((_) {
if (!mounted) return; 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 @override
void dispose() { void dispose() {
context.read<MeshCoreConnector>().setActiveContact(null); _connector?.setActiveContact(null);
_textFieldFocusNode.removeListener(_onTextFieldFocusChange); _textFieldFocusNode.removeListener(_onTextFieldFocusChange);
_textFieldFocusNode.dispose(); _textFieldFocusNode.dispose();
_textController.dispose(); _textController.dispose();
@@ -90,12 +94,15 @@ class _ChatScreenState extends State<ChatScreen> {
title: Consumer2<PathHistoryService, MeshCoreConnector>( title: Consumer2<PathHistoryService, MeshCoreConnector>(
builder: (context, pathService, connector, _) { builder: (context, pathService, connector, _) {
final contact = _resolveContact(connector); final contact = _resolveContact(connector);
final unreadCount = connector.getUnreadCountForContactKey(widget.contact.publicKeyHex); final unreadCount = connector.getUnreadCountForContactKey(
widget.contact.publicKeyHex,
);
final unreadLabel = context.l10n.chat_unread(unreadCount); final unreadLabel = context.l10n.chat_unread(unreadCount);
final pathLabel = _currentPathLabel(contact); final pathLabel = _currentPathLabel(contact);
// Show path details if we have path data (from device or override) // Show path details if we have path data (from device or override)
final hasPathData = contact.path.isNotEmpty || contact.pathOverrideBytes != null; final hasPathData =
contact.path.isNotEmpty || contact.pathOverrideBytes != null;
final effectivePath = contact.pathOverrideBytes ?? contact.path; final effectivePath = contact.pathOverrideBytes ?? contact.path;
return Column( return Column(
@@ -105,7 +112,9 @@ class _ChatScreenState extends State<ChatScreen> {
Text(contact.name), Text(contact.name),
GestureDetector( GestureDetector(
behavior: HitTestBehavior.opaque, behavior: HitTestBehavior.opaque,
onTap: hasPathData ? () => _showFullPathDialog(context, effectivePath) : null, onTap: hasPathData
? () => _showFullPathDialog(context, effectivePath)
: null,
child: Text( child: Text(
'$pathLabel$unreadLabel', '$pathLabel$unreadLabel',
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
@@ -143,12 +152,20 @@ class _ChatScreenState extends State<ChatScreen> {
value: 'auto', value: 'auto',
child: Row( child: Row(
children: [ 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), const SizedBox(width: 8),
Text( Text(
context.l10n.chat_autoUseSavedPath, context.l10n.chat_autoUseSavedPath,
style: TextStyle( 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', value: 'flood',
child: Row( child: Row(
children: [ 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), const SizedBox(width: 8),
Text( Text(
context.l10n.chat_forceFloodMode, context.l10n.chat_forceFloodMode,
style: TextStyle( 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 messages.isEmpty
? _buildEmptyState() ? _buildEmptyState()
: _buildMessageList(messages, connector), : _buildMessageList(messages, connector),
JumpToBottomButton( JumpToBottomButton(scrollController: _scrollController),
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 // Reverse messages so newest appear at bottom with reverse: true
final reversedMessages = messages.reversed.toList(); final reversedMessages = messages.reversed.toList();
final itemCount = reversedMessages.length + (_isLoadingOlder ? 1 : 0); final itemCount = reversedMessages.length + (_isLoadingOlder ? 1 : 0);
@@ -266,14 +292,21 @@ class _ChatScreenState extends State<ChatScreen> {
if (widget.contact.type == advTypeRoom) { if (widget.contact.type == advTypeRoom) {
contact = _resolveContactFrom4Bytes( contact = _resolveContactFrom4Bytes(
connector, 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( return _MessageBubble(
message: message, 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, isRoomServer: widget.contact.type == advTypeRoom,
onTap: () => _openMessagePath(message, contact), onTap: () => _openMessagePath(message, contact),
onLongPress: () => _showMessageActions(message, contact), onLongPress: () => _showMessageActions(message, contact),
@@ -289,9 +322,7 @@ class _ChatScreenState extends State<ChatScreen> {
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
decoration: BoxDecoration( decoration: BoxDecoration(
color: colorScheme.surface, color: colorScheme.surface,
border: Border( border: Border(top: BorderSide(color: Theme.of(context).dividerColor)),
top: BorderSide(color: Theme.of(context).dividerColor),
),
), ),
child: SafeArea( child: SafeArea(
child: Row( child: Row(
@@ -313,10 +344,12 @@ class _ChatScreenState extends State<ChatScreen> {
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
child: GifMessage( child: GifMessage(
url: 'https://media.giphy.com/media/$gifId/giphy.gif', url:
backgroundColor: colorScheme.surfaceContainerHighest, 'https://media.giphy.com/media/$gifId/giphy.gif',
fallbackTextColor: backgroundColor:
colorScheme.onSurface.withValues(alpha: 0.6), colorScheme.surfaceContainerHighest,
fallbackTextColor: colorScheme.onSurface
.withValues(alpha: 0.6),
maxSize: 160, maxSize: 160,
), ),
), ),
@@ -340,7 +373,10 @@ class _ChatScreenState extends State<ChatScreen> {
decoration: InputDecoration( decoration: InputDecoration(
hintText: context.l10n.chat_typeMessage, hintText: context.l10n.chat_typeMessage,
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
), ),
textInputAction: TextInputAction.send, textInputAction: TextInputAction.send,
onSubmitted: (_) => _sendMessage(connector), onSubmitted: (_) => _sendMessage(connector),
@@ -389,14 +425,10 @@ class _ChatScreenState extends State<ChatScreen> {
return; return;
} }
connector.sendMessage( connector.sendMessage(widget.contact, text);
widget.contact,
text,
);
_textController.clear(); _textController.clear();
} }
void _showPathHistory(BuildContext context) { void _showPathHistory(BuildContext context) {
final connector = Provider.of<MeshCoreConnector>(context, listen: false); final connector = Provider.of<MeshCoreConnector>(context, listen: false);
@@ -421,13 +453,19 @@ class _ChatScreenState extends State<ChatScreen> {
if (paths.isNotEmpty) ...[ if (paths.isNotEmpty) ...[
Text( Text(
context.l10n.chat_recentAckPaths, context.l10n.chat_recentAckPaths,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 12), style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 12,
),
), ),
if (paths.length >= 100) ...[ if (paths.length >= 100) ...[
const SizedBox(height: 8), const SizedBox(height: 8),
Container( Container(
width: double.infinity, width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.amber[100], color: Colors.amber[100],
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
@@ -446,7 +484,9 @@ class _ChatScreenState extends State<ChatScreen> {
dense: true, dense: true,
leading: CircleAvatar( leading: CircleAvatar(
radius: 16, radius: 16,
backgroundColor: path.wasFloodDiscovery ? Colors.blue : Colors.green, backgroundColor: path.wasFloodDiscovery
? Colors.blue
: Colors.green,
child: Text( child: Text(
'${path.hopCount}', '${path.hopCount}',
style: const TextStyle(fontSize: 12), style: const TextStyle(fontSize: 12),
@@ -474,23 +514,36 @@ class _ChatScreenState extends State<ChatScreen> {
}, },
), ),
path.wasFloodDiscovery path.wasFloodDiscovery
? const Icon(Icons.waves, size: 16, color: Colors.grey) ? const Icon(
: const Icon(Icons.route, size: 16, color: Colors.grey), 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 { onTap: () async {
if (path.pathBytes.isEmpty) { if (path.pathBytes.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text(context.l10n.chat_pathDetailsNotAvailable), content: Text(
context.l10n.chat_pathDetailsNotAvailable,
),
duration: const Duration(seconds: 2), duration: const Duration(seconds: 2),
), ),
); );
return; return;
} }
final pathBytes = Uint8List.fromList(path.pathBytes); final pathBytes = Uint8List.fromList(
path.pathBytes,
);
final pathLength = path.pathBytes.length; final pathLength = path.pathBytes.length;
// Set the path override to persist user's choice // Set the path override to persist user's choice
@@ -520,7 +573,10 @@ class _ChatScreenState extends State<ChatScreen> {
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
context.l10n.chat_pathActions, context.l10n.chat_pathActions,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 12), style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 12,
),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
ListTile( ListTile(
@@ -530,8 +586,14 @@ class _ChatScreenState extends State<ChatScreen> {
backgroundColor: Colors.purple, backgroundColor: Colors.purple,
child: Icon(Icons.edit_road, size: 16), child: Icon(Icons.edit_road, size: 16),
), ),
title: Text(context.l10n.chat_setCustomPath, style: const TextStyle(fontSize: 14)), title: Text(
subtitle: Text(context.l10n.chat_setCustomPathSubtitle, style: const TextStyle(fontSize: 11)), context.l10n.chat_setCustomPath,
style: const TextStyle(fontSize: 14),
),
subtitle: Text(
context.l10n.chat_setCustomPathSubtitle,
style: const TextStyle(fontSize: 11),
),
onTap: () { onTap: () {
Navigator.pop(context); Navigator.pop(context);
_showCustomPathDialog(context); _showCustomPathDialog(context);
@@ -544,8 +606,14 @@ class _ChatScreenState extends State<ChatScreen> {
backgroundColor: Colors.orange, backgroundColor: Colors.orange,
child: Icon(Icons.clear_all, size: 16), child: Icon(Icons.clear_all, size: 16),
), ),
title: Text(context.l10n.chat_clearPath, style: const TextStyle(fontSize: 14)), title: Text(
subtitle: Text(context.l10n.chat_clearPathSubtitle, style: const TextStyle(fontSize: 11)), context.l10n.chat_clearPath,
style: const TextStyle(fontSize: 14),
),
subtitle: Text(
context.l10n.chat_clearPathSubtitle,
style: const TextStyle(fontSize: 11),
),
onTap: () async { onTap: () async {
await connector.clearContactPath(widget.contact); await connector.clearContactPath(widget.contact);
if (!context.mounted) return; if (!context.mounted) return;
@@ -565,10 +633,19 @@ class _ChatScreenState extends State<ChatScreen> {
backgroundColor: Colors.blue, backgroundColor: Colors.blue,
child: Icon(Icons.waves, size: 16), child: Icon(Icons.waves, size: 16),
), ),
title: Text(context.l10n.chat_forceFloodMode, style: const TextStyle(fontSize: 14)), title: Text(
subtitle: Text(context.l10n.chat_floodModeSubtitle, style: const TextStyle(fontSize: 11)), context.l10n.chat_forceFloodMode,
style: const TextStyle(fontSize: 14),
),
subtitle: Text(
context.l10n.chat_floodModeSubtitle,
style: const TextStyle(fontSize: 11),
),
onTap: () async { onTap: () async {
await connector.setPathOverride(widget.contact, pathLen: -1); await connector.setPathOverride(
widget.contact,
pathLen: -1,
);
if (!context.mounted) return; if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
@@ -597,7 +674,9 @@ class _ChatScreenState extends State<ChatScreen> {
String _formatRelativeTime(DateTime time) { String _formatRelativeTime(DateTime time) {
final diff = DateTime.now().difference(time); final diff = DateTime.now().difference(time);
if (diff.inSeconds < 60) return context.l10n.time_justNow; 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); if (diff.inHours < 24) return context.l10n.time_hoursAgo(diff.inHours);
return context.l10n.time_daysAgo(diff.inDays); return context.l10n.time_daysAgo(diff.inDays);
} }
@@ -623,6 +702,19 @@ class _ChatScreenState extends State<ChatScreen> {
title: Text(context.l10n.chat_fullPath), title: Text(context.l10n.chat_fullPath),
content: SelectableText(formattedPath), content: SelectableText(formattedPath),
actions: [ 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( TextButton(
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
child: Text(context.l10n.common_close), 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( return connector.contacts.firstWhere(
(c) => listEquals(c.publicKey.sublist(0, 4), key4Bytes.sublist(0, 4)), (c) => listEquals(c.publicKey.sublist(0, 4), key4Bytes.sublist(0, 4)),
orElse: () => widget.contact, orElse: () => widget.contact,
@@ -673,12 +768,12 @@ class _ChatScreenState extends State<ChatScreen> {
final status = !connector.isConnected final status = !connector.isConnected
? context.l10n.chat_pathSavedLocally ? 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( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text( content: Text(context.l10n.chat_pathSetHops(hopCount, status)),
context.l10n.chat_pathSetHops(hopCount, status),
),
duration: const Duration(seconds: 3), duration: const Duration(seconds: 3),
), ),
); );
@@ -693,7 +788,9 @@ class _ChatScreenState extends State<ChatScreen> {
builder: (context) => Consumer<MeshCoreConnector>( builder: (context) => Consumer<MeshCoreConnector>(
builder: (context, connector, _) { builder: (context, connector, _) {
final contact = _resolveContact(connector); final contact = _resolveContact(connector);
final smazEnabled = connector.isContactSmazEnabled(contact.publicKeyHex); final smazEnabled = connector.isContactSmazEnabled(
contact.publicKeyHex,
);
return AlertDialog( return AlertDialog(
title: Text(contact.name), title: Text(contact.name),
@@ -709,7 +806,10 @@ class _ChatScreenState extends State<ChatScreen> {
context.l10n.chat_location, context.l10n.chat_location,
'${contact.latitude?.toStringAsFixed(4)}, ${contact.longitude?.toStringAsFixed(4)}', '${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(), const Divider(),
SwitchListTile( SwitchListTile(
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
@@ -717,7 +817,10 @@ class _ChatScreenState extends State<ChatScreen> {
subtitle: Text(context.l10n.chat_compressOutgoingMessages), subtitle: Text(context.l10n.chat_compressOutgoingMessages),
value: smazEnabled, value: smazEnabled,
onChanged: (value) { 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 connector = Provider.of<MeshCoreConnector>(context, listen: false);
final currentContact = _resolveContact(connector); final currentContact = _resolveContact(connector);
if (currentContact.pathLength > 0 && currentContact.path.isEmpty && connector.isConnected) { if (currentContact.pathLength > 0 &&
currentContact.path.isEmpty &&
connector.isConnected) {
connector.getContacts(); connector.getContacts();
} }
@@ -785,19 +890,31 @@ class _ChatScreenState extends State<ChatScreen> {
onRefresh: connector.isConnected ? connector.getContacts : null, 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) { if (result == null) {
appLogger.info('PathSelectionDialog was cancelled or returned null', tag: 'ChatScreen'); appLogger.info(
'PathSelectionDialog was cancelled or returned null',
tag: 'ChatScreen',
);
return; return;
} }
if (!mounted) { 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; return;
} }
appLogger.info('Calling setPathOverride for ${widget.contact.name}', tag: 'ChatScreen'); appLogger.info(
'Calling setPathOverride for ${widget.contact.name}',
tag: 'ChatScreen',
);
await connector.setPathOverride( await connector.setPathOverride(
widget.contact, widget.contact,
pathLen: result.length, pathLen: result.length,
@@ -809,7 +926,6 @@ class _ChatScreenState extends State<ChatScreen> {
await _notifyPathSet(connector, widget.contact, result, result.length); await _notifyPathSet(connector, widget.contact, result, result.length);
} }
void _openMessagePath(Message message, Contact contact) { void _openMessagePath(Message message, Contact contact) {
final connector = context.read<MeshCoreConnector>(); final connector = context.read<MeshCoreConnector>();
final fourByteHex = message.fourByteRoomContactKey final fourByteHex = message.fourByteRoomContactKey
@@ -850,14 +966,16 @@ class _ChatScreenState extends State<ChatScreen> {
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
ListTile( // Can't react to your own messages
leading: const Icon(Icons.add_reaction_outlined), if (!message.isOutgoing)
title: Text(context.l10n.chat_addReaction), ListTile(
onTap: () { leading: const Icon(Icons.add_reaction_outlined),
Navigator.pop(sheetContext); title: Text(context.l10n.chat_addReaction),
_showEmojiPicker(message); onTap: () {
}, Navigator.pop(sheetContext);
), _showEmojiPicker(message, contact);
},
),
ListTile( ListTile(
leading: const Icon(Icons.copy), leading: const Icon(Icons.copy),
title: Text(context.l10n.common_copy), title: Text(context.l10n.common_copy),
@@ -874,8 +992,7 @@ class _ChatScreenState extends State<ChatScreen> {
await _deleteMessage(message); await _deleteMessage(message);
}, },
), ),
if (message.isOutgoing && if (message.isOutgoing && message.status == MessageStatus.failed)
message.status == MessageStatus.failed)
ListTile( ListTile(
leading: const Icon(Icons.refresh), leading: const Icon(Icons.refresh),
title: Text(context.l10n.common_retry), title: Text(context.l10n.common_retry),
@@ -906,50 +1023,57 @@ class _ChatScreenState extends State<ChatScreen> {
void _copyMessageText(String text) { void _copyMessageText(String text) {
Clipboard.setData(ClipboardData(text: text)); Clipboard.setData(ClipboardData(text: text));
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(
SnackBar(content: Text(context.l10n.chat_messageCopied)), context,
); ).showSnackBar(SnackBar(content: Text(context.l10n.chat_messageCopied)));
} }
Future<void> _deleteMessage(Message message) async { Future<void> _deleteMessage(Message message) async {
await context.read<MeshCoreConnector>().deleteMessage(message); await context.read<MeshCoreConnector>().deleteMessage(message);
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(
SnackBar(content: Text(context.l10n.chat_messageDeleted)), context,
); ).showSnackBar(SnackBar(content: Text(context.l10n.chat_messageDeleted)));
} }
void _retryMessage(Message message) { void _retryMessage(Message message) {
final connector = Provider.of<MeshCoreConnector>(context, listen: false); final connector = Provider.of<MeshCoreConnector>(context, listen: false);
// Retry using the contact's current path override setting // Retry using the contact's current path override setting
connector.sendMessage( connector.sendMessage(widget.contact, message.text);
widget.contact, ScaffoldMessenger.of(
message.text, context,
); ).showSnackBar(SnackBar(content: Text(context.l10n.chat_retryingMessage)));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.chat_retryingMessage)),
);
} }
void _showEmojiPicker(Message message) { void _showEmojiPicker(Message message, Contact senderContact) {
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
builder: (context) => EmojiPicker( builder: (context) => EmojiPicker(
onEmojiSelected: (emoji) { 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>(); final connector = context.read<MeshCoreConnector>();
// Send reaction with messageId if available, otherwise use lightweight format final emojiIndex = ReactionHelper.emojiToIndex(emoji);
// Parser will extract reactionKey (timestamp_senderPrefix) for deduplication if (emojiIndex == null) return; // Unknown emoji, skip
final messageId = message.messageId ?? final timestampSecs = message.timestamp.millisecondsSinceEpoch ~/ 1000;
'${message.timestamp.millisecondsSinceEpoch}_${message.senderKeyHex.substring(0, 8)}';
final reactionText = 'r:$messageId:$emoji'; // 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); connector.sendMessage(widget.contact, reactionText);
} }
} }
@@ -978,7 +1102,9 @@ class _MessageBubble extends StatelessWidget {
final isFailed = message.status == MessageStatus.failed; final isFailed = message.status == MessageStatus.failed;
final bubbleColor = isFailed final bubbleColor = isFailed
? colorScheme.errorContainer ? colorScheme.errorContainer
: (isOutgoing ? colorScheme.primary : colorScheme.surfaceContainerHighest); : (isOutgoing
? colorScheme.primary
: colorScheme.surfaceContainerHighest);
final textColor = isFailed final textColor = isFailed
? colorScheme.onErrorContainer ? colorScheme.onErrorContainer
: (isOutgoing ? colorScheme.onPrimary : colorScheme.onSurface); : (isOutgoing ? colorScheme.onPrimary : colorScheme.onSurface);
@@ -990,13 +1116,17 @@ class _MessageBubble extends StatelessWidget {
return Padding( return Padding(
padding: const EdgeInsets.symmetric(vertical: 4), padding: const EdgeInsets.symmetric(vertical: 4),
child: Column( child: Column(
crossAxisAlignment: isOutgoing ? CrossAxisAlignment.end : CrossAxisAlignment.start, crossAxisAlignment: isOutgoing
? CrossAxisAlignment.end
: CrossAxisAlignment.start,
children: [ children: [
GestureDetector( GestureDetector(
onTap: onTap, onTap: onTap,
onLongPress: onLongPress, onLongPress: onLongPress,
child: Row( child: Row(
mainAxisAlignment: isOutgoing ? MainAxisAlignment.end : MainAxisAlignment.start, mainAxisAlignment: isOutgoing
? MainAxisAlignment.end
: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
if (!isOutgoing) ...[ if (!isOutgoing) ...[
@@ -1005,133 +1135,154 @@ class _MessageBubble extends StatelessWidget {
], ],
Flexible( Flexible(
child: Container( child: Container(
padding: gifId != null padding: gifId != null
? const EdgeInsets.all(4) ? const EdgeInsets.all(4)
: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), : const EdgeInsets.symmetric(
constraints: BoxConstraints( horizontal: 12,
maxWidth: MediaQuery.of(context).size.width * 0.65, vertical: 8,
),
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,
), ),
), constraints: BoxConstraints(
), maxWidth: MediaQuery.of(context).size.width * 0.65,
if (gifId == null) const SizedBox(height: 4), ),
], decoration: BoxDecoration(
if (poi != null) color: bubbleColor,
_buildPoiMessage(context, poi, textColor, metaColor) borderRadius: BorderRadius.circular(16),
else if (gifId != null) ),
ClipRRect( child: Column(
borderRadius: BorderRadius.circular(12), crossAxisAlignment: CrossAxisAlignment.start,
child: GifMessage( children: [
url: 'https://media.giphy.com/media/$gifId/giphy.gif', if (!isOutgoing) ...[
backgroundColor: Colors.transparent, Padding(
fallbackTextColor: textColor.withValues(alpha: 0.7), padding: gifId != null
), ? const EdgeInsets.only(
) left: 8,
else top: 4,
Linkify( bottom: 4,
text: messageText, )
style: TextStyle( : EdgeInsets.zero,
color: textColor, child: Text(
), senderName,
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( style: TextStyle(
fontSize: 9, fontSize: 12,
color: isOutgoing ? metaColor : Colors.green[700], 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) { _PoiInfo? _parsePoiMessage(String text) {
final trimmed = text.trim(); final trimmed = text.trim();
final match = RegExp(r'^m:([\-0-9.]+),([\-0-9.]+)\|([^|]*)\|.*$') final match = RegExp(
.firstMatch(trimmed); r'^m:([\-0-9.]+),([\-0-9.]+)\|([^|]*)\|.*$',
).firstMatch(trimmed);
if (match == null) return null; if (match == null) return null;
final lat = double.tryParse(match.group(1) ?? ''); final lat = double.tryParse(match.group(1) ?? '');
final lon = double.tryParse(match.group(2) ?? ''); final lon = double.tryParse(match.group(2) ?? '');
@@ -1186,18 +1338,12 @@ class _MessageBubble extends StatelessWidget {
children: [ children: [
Text( Text(
context.l10n.chat_poiShared, context.l10n.chat_poiShared,
style: TextStyle( style: TextStyle(color: textColor, fontWeight: FontWeight.w600),
color: textColor,
fontWeight: FontWeight.w600,
),
), ),
if (poi.label.isNotEmpty) if (poi.label.isNotEmpty)
Text( Text(
poi.label, poi.label,
style: TextStyle( style: TextStyle(color: metaColor, fontSize: 12),
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( return Wrap(
spacing: 6, spacing: 6,
runSpacing: 6, runSpacing: 6,
@@ -1227,10 +1377,7 @@ class _MessageBubble extends StatelessWidget {
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Text( Text(emoji, style: const TextStyle(fontSize: 16)),
emoji,
style: const TextStyle(fontSize: 16),
),
if (count > 1) ...[ if (count > 1) ...[
const SizedBox(width: 4), const SizedBox(width: 4),
Text( Text(
@@ -1314,11 +1461,7 @@ class _MessageBubble extends StatelessWidget {
break; break;
} }
return Icon( return Icon(icon, size: 12, color: color);
icon,
size: 12,
color: color,
);
} }
String _formatTime(DateTime time) { String _formatTime(DateTime time) {
@@ -1333,9 +1476,5 @@ class _PoiInfo {
final double lon; final double lon;
final String label; final String label;
const _PoiInfo({ const _PoiInfo({required this.lat, required this.lon, required this.label});
required this.lat,
required this.lon,
required this.label,
});
} }
+330 -28
View File
@@ -1,6 +1,8 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/material.dart'; 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 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart'; import '../connector/meshcore_connector.dart';
@@ -27,10 +29,9 @@ import 'map_screen.dart';
import 'repeater_hub_screen.dart'; import 'repeater_hub_screen.dart';
import 'settings_screen.dart'; import 'settings_screen.dart';
enum RoomLoginDestination { enum RoomLoginDestination { chat, management }
chat,
management, enum ContactOperationType { import, export, zeroHopShare }
}
class ContactsScreen extends StatefulWidget { class ContactsScreen extends StatefulWidget {
final bool hideBackButton; final bool hideBackButton;
@@ -52,16 +53,22 @@ class _ContactsScreenState extends State<ContactsScreen>
List<ContactGroup> _groups = []; List<ContactGroup> _groups = [];
Timer? _searchDebounce; Timer? _searchDebounce;
final Set<ContactOperationType> _pendingOperations = {};
StreamSubscription<Uint8List>? _frameSubscription;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_loadGroups(); _loadGroups();
_setupFrameListener();
} }
@override @override
void dispose() { void dispose() {
_searchDebounce?.cancel(); _searchDebounce?.cancel();
_searchController.dispose(); _searchController.dispose();
_frameSubscription?.cancel();
super.dispose(); super.dispose();
} }
@@ -77,6 +84,137 @@ class _ContactsScreenState extends State<ContactsScreen>
await _groupStore.saveGroups(_groups); 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final connector = context.watch<MeshCoreConnector>(); final connector = context.watch<MeshCoreConnector>();
@@ -96,18 +234,94 @@ class _ContactsScreenState extends State<ContactsScreen>
centerTitle: true, centerTitle: true,
automaticallyImplyLeading: false, automaticallyImplyLeading: false,
actions: [ actions: [
IconButton( PopupMenuButton(
icon: const Icon(Icons.bluetooth_disabled), itemBuilder: (context) => [
tooltip: context.l10n.common_disconnect, PopupMenuItem(
onPressed: () => _disconnect(context, connector), 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( PopupMenuButton(
icon: const Icon(Icons.tune), itemBuilder: (context) => [
tooltip: context.l10n.common_settings, PopupMenuItem(
onPressed: () => Navigator.push( child: Row(
context, children: [
MaterialPageRoute(builder: (context) => const SettingsScreen()), 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( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
builder: (context) => destination == RoomLoginDestination.management builder: (context) =>
destination == RoomLoginDestination.management
? RepeaterHubScreen(repeater: room, password: password) ? RepeaterHubScreen(repeater: room, password: password)
: ChatScreen(contact: room), : ChatScreen(contact: room),
), ),
@@ -760,7 +975,26 @@ class _ContactsScreenState extends State<ContactsScreen>
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ 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( ListTile(
leading: const Icon(Icons.cell_tower, color: Colors.orange), leading: const Icon(Icons.cell_tower, color: Colors.orange),
title: Text(context.l10n.contacts_manageRepeater), title: Text(context.l10n.contacts_manageRepeater),
@@ -768,8 +1002,27 @@ class _ContactsScreenState extends State<ContactsScreen>
Navigator.pop(sheetContext); Navigator.pop(sheetContext);
_showRepeaterLogin(context, contact); _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( ListTile(
leading: const Icon(Icons.room, color: Colors.blue), leading: const Icon(Icons.room, color: Colors.blue),
title: Text(context.l10n.contacts_roomLogin), title: Text(context.l10n.contacts_roomLogin),
@@ -779,14 +1032,39 @@ class _ContactsScreenState extends State<ContactsScreen>
}, },
), ),
ListTile( 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), title: Text(context.l10n.room_management),
onTap: () { onTap: () {
Navigator.pop(sheetContext); 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( ListTile(
leading: const Icon(Icons.chat), leading: const Icon(Icons.chat),
title: Text(context.l10n.contacts_openChat), title: Text(context.l10n.contacts_openChat),
@@ -795,6 +1073,23 @@ class _ContactsScreenState extends State<ContactsScreen>
_openChat(context, contact); _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( ListTile(
leading: const Icon(Icons.delete, color: Colors.red), leading: const Icon(Icons.delete, color: Colors.red),
title: Text( title: Text(
@@ -860,16 +1155,18 @@ class _ContactTile extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final shotPublicKey =
"<${contact.publicKeyHex.substring(0, 8)}...${contact.publicKeyHex.substring(contact.publicKeyHex.length - 8)}>";
return ListTile( return ListTile(
leading: CircleAvatar( leading: CircleAvatar(
backgroundColor: _getTypeColor(contact.type), backgroundColor: _getTypeColor(contact.type),
child: _buildContactAvatar(contact), child: _buildContactAvatar(contact),
), ),
title: Text(contact.name), title: Text(contact.name),
subtitle: Text( subtitle: Column(
'${contact.typeLabel}${contact.pathLabel} $shotPublicKey', crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(contact.pathLabel),
Text(contact.shortPubKeyHex, style: TextStyle(fontSize: 12)),
],
), ),
// Clamp text scaling in trailing section to prevent overflow while // Clamp text scaling in trailing section to prevent overflow while
// maintaining accessibility. Primary content (title/subtitle) scales normally. // maintaining accessibility. Primary content (title/subtitle) scales normally.
@@ -891,8 +1188,13 @@ class _ContactTile extends StatelessWidget {
_formatLastSeen(context, lastSeen), _formatLastSeen(context, lastSeen),
style: TextStyle(fontSize: 12, color: Colors.grey[600]), style: TextStyle(fontSize: 12, color: Colors.grey[600]),
), ),
if (contact.hasLocation) Row(
Icon(Icons.location_on, size: 14, color: Colors.grey[400]), 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( return Card(
elevation: 0, elevation: 0,
color: colorScheme.surfaceContainerHighest, color: colorScheme.surfaceContainerHighest,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
borderRadius: BorderRadius.circular(24),
),
child: Padding( child: Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Column( child: Column(
@@ -207,7 +205,6 @@ class _DeviceScreenState extends State<DeviceScreen>
); );
} }
Widget _buildBatteryIndicator( Widget _buildBatteryIndicator(
MeshCoreConnector connector, MeshCoreConnector connector,
BuildContext context, BuildContext context,
@@ -224,11 +221,7 @@ class _DeviceScreenState extends State<DeviceScreen>
final icon = _batteryIcon(percent); final icon = _batteryIcon(percent);
return ActionChip( return ActionChip(
avatar: Icon( avatar: Icon(icon, size: 16, color: colorScheme.onSecondaryContainer),
icon,
size: 16,
color: colorScheme.onSecondaryContainer,
),
label: Text(displayLabel), label: Text(displayLabel),
labelStyle: theme.textTheme.labelMedium?.copyWith( labelStyle: theme.textTheme.labelMedium?.copyWith(
color: colorScheme.onSecondaryContainer, color: colorScheme.onSecondaryContainer,
@@ -260,25 +253,19 @@ class _DeviceScreenState extends State<DeviceScreen>
case 0: case 0:
Navigator.pushReplacement( Navigator.pushReplacement(
context, context,
buildQuickSwitchRoute( buildQuickSwitchRoute(const ContactsScreen(hideBackButton: true)),
const ContactsScreen(hideBackButton: true),
),
); );
break; break;
case 1: case 1:
Navigator.pushReplacement( Navigator.pushReplacement(
context, context,
buildQuickSwitchRoute( buildQuickSwitchRoute(const ChannelsScreen(hideBackButton: true)),
const ChannelsScreen(hideBackButton: true),
),
); );
break; break;
case 2: case 2:
Navigator.pushReplacement( Navigator.pushReplacement(
context, context,
buildQuickSwitchRoute( buildQuickSwitchRoute(const MapScreen(hideBackButton: true)),
const MapScreen(hideBackButton: true),
),
); );
break; break;
} }
+31 -23
View File
@@ -56,10 +56,7 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
_updateEstimate(); _updateEstimate();
if (bounds != null) { if (bounds != null) {
_mapController.fitCamera( _mapController.fitCamera(
CameraFit.bounds( CameraFit.bounds(bounds: bounds, padding: const EdgeInsets.all(48)),
bounds: bounds,
padding: const EdgeInsets.all(48),
),
); );
} }
} }
@@ -72,8 +69,11 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
return; return;
} }
final cacheService = context.read<MapTileCacheService>(); final cacheService = context.read<MapTileCacheService>();
final count = final count = cacheService.estimateTileCount(
cacheService.estimateTileCount(_selectedBounds!, _minZoom, _maxZoom); _selectedBounds!,
_minZoom,
_maxZoom,
);
setState(() { setState(() {
_estimatedTiles = count; _estimatedTiles = count;
}); });
@@ -181,9 +181,9 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
result.failed, result.failed,
) )
: context.l10n.mapCache_cachedTiles(result.downloaded); : context.l10n.mapCache_cachedTiles(result.downloaded);
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(
SnackBar(content: Text(message)), context,
); ).showSnackBar(SnackBar(content: Text(message)));
} }
Future<void> _clearCache() async { Future<void> _clearCache() async {
@@ -224,10 +224,7 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
: (_completedTiles / _estimatedTiles).clamp(0.0, 1.0).toDouble(); : (_completedTiles / _estimatedTiles).clamp(0.0, 1.0).toDouble();
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(title: Text(l10n.mapCache_title), centerTitle: true),
title: Text(l10n.mapCache_title),
centerTitle: true,
),
body: Column( body: Column(
children: [ children: [
Expanded( Expanded(
@@ -290,7 +287,10 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
children: [ children: [
Text( Text(
l10n.mapCache_cacheArea, l10n.mapCache_cacheArea,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16), style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Row( Row(
@@ -304,8 +304,9 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
TextButton( TextButton(
onPressed: onPressed: _isDownloading || selectedBounds == null
_isDownloading || selectedBounds == null ? null : _clearBounds, ? null
: _clearBounds,
child: Text(l10n.common_clear), child: Text(l10n.common_clear),
), ),
], ],
@@ -313,11 +314,16 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
const SizedBox(height: 12), const SizedBox(height: 12),
Text( Text(
l10n.mapCache_zoomRange, l10n.mapCache_zoomRange,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16), style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
), ),
RangeSlider( RangeSlider(
values: values: RangeValues(
RangeValues(_minZoom.toDouble(), _maxZoom.toDouble()), _minZoom.toDouble(),
_maxZoom.toDouble(),
),
min: 3, min: 3,
max: 18, max: 18,
divisions: 15, divisions: 15,
@@ -341,10 +347,12 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
const SizedBox(height: 8), const SizedBox(height: 8),
LinearProgressIndicator(value: progressValue), LinearProgressIndicator(value: progressValue),
const SizedBox(height: 4), const SizedBox(height: 4),
Text(l10n.mapCache_downloadedTiles( Text(
_completedTiles, l10n.mapCache_downloadedTiles(
_estimatedTiles, _completedTiles,
)), _estimatedTiles,
),
),
], ],
const SizedBox(height: 12), const SizedBox(height: 12),
Row( Row(
+392 -178
View File
@@ -1,8 +1,10 @@
import 'dart:math'; import 'dart:math';
import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
import 'package:meshcore_open/screens/path_trace_map.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart'; import '../connector/meshcore_connector.dart';
@@ -48,9 +50,14 @@ class _MapScreenState extends State<MapScreen> {
final MapMarkerService _markerService = MapMarkerService(); final MapMarkerService _markerService = MapMarkerService();
final Set<String> _hiddenMarkerIds = {}; final Set<String> _hiddenMarkerIds = {};
Set<String> _removedMarkerIds = {}; Set<String> _removedMarkerIds = {};
bool _isBuildingPathTrace = false;
bool _isSelectingPoi = false; bool _isSelectingPoi = false;
bool _hasInitializedMap = false; bool _hasInitializedMap = false;
bool _removedMarkersLoaded = false; bool _removedMarkersLoaded = false;
final List<int> _pathTrace = [];
final List<LatLng> _points = [];
final List<Polyline> _polylines = [];
bool _legendExpanded = false;
@override @override
void initState() { void initState() {
@@ -147,6 +154,19 @@ class _MapScreenState extends State<MapScreen> {
.where((c) => c.hasLocation) .where((c) => c.hasLocation)
.toList(); .toList();
_polylines.clear();
_polylines.addAll(
_points.length > 1
? [
Polyline(
points: _points,
strokeWidth: 4,
color: Colors.blueAccent,
),
]
: <Polyline>[],
);
// Calculate center and zoom of all nodes, or default to (0, 0) // Calculate center and zoom of all nodes, or default to (0, 0)
LatLng center = const LatLng(0, 0); LatLng center = const LatLng(0, 0);
double initialZoom = 10.0; double initialZoom = 10.0;
@@ -225,13 +245,15 @@ class _MapScreenState extends State<MapScreen> {
} }
// Re center map after removed markers have loaded // Re center map after removed markers have loaded
if (!_hasInitializedMap && _removedMarkersLoaded && hasMapContent) { if (!_hasInitializedMap && _removedMarkersLoaded) {
_hasInitializedMap = true; _hasInitializedMap = true;
WidgetsBinding.instance.addPostFrameCallback((_) { if (hasMapContent) {
if (mounted) { WidgetsBinding.instance.addPostFrameCallback((_) {
_mapController.move(center, initialZoom); if (mounted) {
} _mapController.move(center, initialZoom);
}); }
});
}
} }
final allowBack = !connector.isConnected; final allowBack = !connector.isConnected;
@@ -245,105 +267,165 @@ class _MapScreenState extends State<MapScreen> {
centerTitle: true, centerTitle: true,
automaticallyImplyLeading: false, automaticallyImplyLeading: false,
actions: [ actions: [
IconButton( if (!_isBuildingPathTrace)
icon: const Icon(Icons.bluetooth_disabled), IconButton(
tooltip: context.l10n.common_disconnect, icon: const Icon(Icons.radar),
onPressed: () => _disconnect(context, connector), onPressed: () => _startPath(),
), tooltip: context.l10n.contacts_pathTrace,
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 body: Stack(
? _buildEmptyState() children: [
: Stack( FlutterMap(
children: [ mapController: _mapController,
FlutterMap( options: MapOptions(
mapController: _mapController, initialCenter: center,
options: MapOptions( initialZoom: initialZoom,
initialCenter: center, minZoom: 2.0,
initialZoom: initialZoom, maxZoom: 18.0,
minZoom: 2.0, interactionOptions: InteractionOptions(
maxZoom: 18.0, flags: ~InteractiveFlag.rotate,
interactionOptions: InteractionOptions( ),
flags: ~InteractiveFlag.rotate onTap: (_, latLng) {
), if (_isSelectingPoi) {
onTap: (_, latLng) { setState(() {
if (_isSelectingPoi) { _isSelectingPoi = false;
setState(() { });
_isSelectingPoi = false; _shareMarker(
}); context: context,
_shareMarker( connector: connector,
context: context, position: latLng,
connector: connector, defaultLabel: context.l10n.map_pointOfInterest,
position: latLng, flags: 'poi',
defaultLabel: context.l10n.map_pointOfInterest, );
flags: 'poi', }
); },
} onLongPress: (_, latLng) {
}, if (_isSelectingPoi) {
onLongPress: (_, latLng) { setState(() {
if (_isSelectingPoi) { _isSelectingPoi = false;
setState(() { });
_isSelectingPoi = false; _shareMarker(
}); context: context,
_shareMarker( connector: connector,
context: context, position: latLng,
connector: connector, defaultLabel: context.l10n.map_pointOfInterest,
position: latLng, flags: 'poi',
defaultLabel: context.l10n.map_pointOfInterest, );
flags: 'poi', return;
); }
return; _showShareMarkerAtPositionSheet(
} context: context,
_showShareMarkerAtPositionSheet( connector: connector,
context: context, position: latLng,
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,
),
],
), ),
children: [
TileLayer(
urlTemplate: kMapTileUrlTemplate,
tileProvider: tileCache.tileProvider,
userAgentPackageName:
MapTileCacheService.userAgentPackageName,
maxZoom: 19,
),
if (_polylines.isNotEmpty && _isBuildingPathTrace)
PolylineLayer(polylines: _polylines),
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,
),
),
),
),
],
),
],
),
if (!_isBuildingPathTrace)
_buildLegend(
contactsWithLocation.length,
sharedMarkers.length,
),
if (_isBuildingPathTrace) _buildPathTraceOverlay(),
],
),
bottomNavigationBar: SafeArea( bottomNavigationBar: SafeArea(
top: false, top: false,
child: QuickSwitchBar( child: QuickSwitchBar(
@@ -363,28 +445,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) { List<Marker> _buildMarkers(List<Contact> contacts, settings) {
final markers = <Marker>[]; final markers = <Marker>[];
@@ -404,14 +464,18 @@ class _MapScreenState extends State<MapScreen> {
final marker = Marker( final marker = Marker(
point: LatLng(contact.latitude!, contact.longitude!), point: LatLng(contact.latitude!, contact.longitude!),
width: 80, width: 35,
height: 80, height: 35,
child: GestureDetector( child: GestureDetector(
onTap: () => _showNodeInfo(context, contact), onLongPress: () =>
_isBuildingPathTrace ? _showNodeInfo(context, contact) : null,
onTap: () => _isBuildingPathTrace
? _addToPath(context, contact)
: _showNodeInfo(context, contact),
child: Column( child: Column(
children: [ children: [
Container( Container(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(4),
decoration: BoxDecoration( decoration: BoxDecoration(
color: _getNodeColor(contact.type), color: _getNodeColor(contact.type),
shape: BoxShape.circle, shape: BoxShape.circle,
@@ -427,7 +491,7 @@ class _MapScreenState extends State<MapScreen> {
child: Icon( child: Icon(
_getNodeIcon(contact.type), _getNodeIcon(contact.type),
color: Colors.white, color: Colors.white,
size: 24, size: 20,
), ),
), ),
], ],
@@ -476,60 +540,102 @@ class _MapScreenState extends State<MapScreen> {
top: 16, top: 16,
right: 16, right: 16,
child: Card( child: Card(
child: Padding( child: Column(
padding: const EdgeInsets.all(12.0), mainAxisSize: MainAxisSize.min,
child: Column( children: [
crossAxisAlignment: CrossAxisAlignment.start, InkWell(
mainAxisSize: MainAxisSize.min, borderRadius: BorderRadius.circular(12),
children: [ onTap: () {
Text( setState(() {
context.l10n.map_nodesCount(nodeCount), _legendExpanded = !_legendExpanded;
style: const TextStyle( });
fontWeight: FontWeight.bold, },
fontSize: 14, child: Padding(
padding: const EdgeInsets.fromLTRB(12, 10, 12, 10),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.l10n.map_nodesCount(nodeCount),
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
Text(
context.l10n.map_pinsCount(markerCount),
style: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
],
),
const SizedBox(width: 8),
AnimatedRotation(
turns: _legendExpanded ? 0.5 : 0,
duration: const Duration(milliseconds: 200),
child: const Icon(Icons.expand_more, size: 20),
),
],
), ),
), ),
Text( ),
context.l10n.map_pinsCount(markerCount), AnimatedCrossFade(
style: const TextStyle( firstChild: const SizedBox.shrink(),
fontWeight: FontWeight.bold, secondChild: Padding(
fontSize: 12, padding: const EdgeInsets.fromLTRB(12, 0, 12, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 6),
_buildLegendItem(
Icons.person,
context.l10n.map_chat,
Colors.blue,
),
_buildLegendItem(
Icons.router,
context.l10n.map_repeater,
Colors.green,
),
_buildLegendItem(
Icons.meeting_room,
context.l10n.map_room,
Colors.purple,
),
_buildLegendItem(
Icons.sensors,
context.l10n.map_sensor,
Colors.orange,
),
_buildLegendItem(
Icons.flag,
context.l10n.map_pinDm,
Colors.blue,
),
_buildLegendItem(
Icons.flag,
context.l10n.map_pinPrivate,
Colors.purple,
),
_buildLegendItem(
Icons.flag,
context.l10n.map_pinPublic,
Colors.orange,
),
],
), ),
), ),
const SizedBox(height: 8), crossFadeState: _legendExpanded
_buildLegendItem( ? CrossFadeState.showSecond
Icons.person, : CrossFadeState.showFirst,
context.l10n.map_chat, duration: const Duration(milliseconds: 200),
Colors.blue, ),
), ],
_buildLegendItem(
Icons.router,
context.l10n.map_repeater,
Colors.green,
),
_buildLegendItem(
Icons.meeting_room,
context.l10n.map_room,
Colors.purple,
),
_buildLegendItem(
Icons.sensors,
context.l10n.map_sensor,
Colors.orange,
),
_buildLegendItem(Icons.flag, context.l10n.map_pinDm, Colors.blue),
_buildLegendItem(
Icons.flag,
context.l10n.map_pinPrivate,
Colors.purple,
),
_buildLegendItem(
Icons.flag,
context.l10n.map_pinPublic,
Colors.orange,
),
],
),
), ),
), ),
); );
@@ -537,7 +643,7 @@ class _MapScreenState extends State<MapScreen> {
Widget _buildLegendItem(IconData icon, String label, Color color) { Widget _buildLegendItem(IconData icon, String label, Color color) {
return Padding( return Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0), padding: const EdgeInsets.symmetric(vertical: 1.0),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
@@ -1385,6 +1491,114 @@ class _MapScreenState extends State<MapScreen> {
return context.l10n.time_allTime; return context.l10n.time_allTime;
} }
} }
void _addToPath(BuildContext context, Contact contact) {
setState(() {
_pathTrace.add(
contact.publicKey[0],
); // Add first 16 bytes of public key to path trace
_points.add(LatLng(contact.latitude!, contact.longitude!));
});
}
void _startPath() {
setState(() {
_isBuildingPathTrace = true;
_pathTrace.clear();
_points.clear();
_polylines.clear();
});
}
void _removePath() {
setState(() {
_pathTrace.removeLast(); // Remove last node from path trace
_points.removeLast(); // Remove last point from points list
_polylines.clear(); // Clear polylines
});
}
Widget _buildPathTraceOverlay() {
final l10n = context.l10n;
return Positioned(
top: 16,
left: 16,
right: 16,
child: Card(
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
l10n.contacts_pathTrace,
style: TextStyle(fontWeight: FontWeight.bold),
),
if (_pathTrace.isEmpty) const SizedBox(height: 8),
if (_pathTrace.isEmpty)
Text(l10n.map_tapToAdd, style: TextStyle(fontSize: 12)),
const SizedBox(height: 6),
if (_pathTrace.isNotEmpty)
Text(
"${l10n.path_currentPathLabel} ${formatDistance(getPathDistanceMeters(_points))}",
style: TextStyle(fontSize: 12, color: Colors.grey[700]),
),
SelectableText(
_pathTrace
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join(','),
style: TextStyle(fontSize: 18),
),
const SizedBox(height: 6),
Row(
mainAxisSize: MainAxisSize.min,
children: [
if (_pathTrace.isNotEmpty)
ElevatedButton(
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => PathTraceMapScreen(
title: l10n.contacts_pathTrace,
path: Uint8List.fromList(_pathTrace),
),
),
);
setState(() {
_isBuildingPathTrace = false;
});
},
child: Text(l10n.map_runTrace),
),
if (_pathTrace.isNotEmpty)
ElevatedButton(
onPressed: _removePath,
child: Text(l10n.map_removeLast),
),
if (_pathTrace.isEmpty)
ElevatedButton(
onPressed: () {
setState(() {
_isBuildingPathTrace = false;
_pathTrace.clear();
_points.clear();
_polylines.clear();
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.map_pathTraceCancelled)),
);
},
child: Text(l10n.common_cancel),
),
],
),
],
),
),
),
);
}
} }
class _MarkerPayload { class _MarkerPayload {
+576
View File
@@ -0,0 +1,576 @@
import 'dart:async';
import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
import 'package:meshcore_open/connector/meshcore_connector.dart';
import 'package:meshcore_open/connector/meshcore_protocol.dart';
import 'package:meshcore_open/l10n/l10n.dart';
import 'package:meshcore_open/models/contact.dart';
import 'package:meshcore_open/services/map_tile_cache_service.dart';
import 'package:meshcore_open/widgets/snr_indicator.dart';
import 'package:provider/provider.dart';
double getPathDistanceMeters(List<LatLng> points) {
if (points.length <= 1) return 0.0;
double distanceMeters = 0.0;
final distanceCalculator = Distance();
for (int i = 0; i < points.length - 1; i++) {
distanceMeters += distanceCalculator(points[i], points[i + 1]);
}
return distanceMeters;
}
String formatDistance(double distanceMeters) {
return '(${(distanceMeters / 1609.34).toStringAsFixed(2)} Miles / ${(distanceMeters / 1000).toStringAsFixed(2)} Km)';
}
class PathTraceData {
final Uint8List pathData;
final Uint8List snrData;
final Map<int, Contact> pathContacts;
PathTraceData({
required this.pathData,
required this.snrData,
required this.pathContacts,
});
}
class PathTraceMapScreen extends StatefulWidget {
final String title;
final Uint8List path;
final bool flipPathRound;
final bool reversePathRound;
const PathTraceMapScreen({
super.key,
required this.title,
required this.path,
this.flipPathRound = false,
this.reversePathRound = false,
});
@override
State<PathTraceMapScreen> createState() => _PathTraceMapScreenState();
}
class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
StreamSubscription<Uint8List>? _frameSubscription;
Timer? _timeoutTimer;
bool _isLoading = false;
bool _failed2Loaded = false;
bool _hasData = false;
PathTraceData? _traceData;
List<LatLng> _points = <LatLng>[];
List<Polyline> _polylines = [];
LatLng? _initialCenter = LatLng(0, 0);
double _initialZoom = 2.0;
LatLngBounds? _bounds;
ValueKey<String> _mapKey = const ValueKey('initial');
double _pathDistanceMeters = 0.0;
String _formatPathPrefixes(Uint8List pathBytes) {
return pathBytes
.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase())
.join(',');
}
@override
void initState() {
super.initState();
_setupFrameListener();
_doPathTrace();
}
@override
void dispose() {
_frameSubscription?.cancel();
_timeoutTimer?.cancel();
super.dispose();
}
Uint8List addReturnpath(Uint8List pathBytes) {
Uint8List? traceBytes;
final len = (pathBytes.length + pathBytes.length - 1);
traceBytes = Uint8List(len);
for (int i = 0; i < pathBytes.length; i++) {
traceBytes[i] = pathBytes[i];
if (i < pathBytes.length - 1) {
traceBytes[len - 1 - i] = pathBytes[i];
}
}
return traceBytes;
}
Future<void> _doPathTrace() async {
if (mounted) {
setState(() {
_isLoading = true;
_failed2Loaded = false;
});
}
final Uint8List path;
Uint8List pathTmp = widget.reversePathRound
? Uint8List.fromList(widget.path.reversed.toList())
: widget.path;
if (widget.flipPathRound) {
path = addReturnpath(pathTmp);
} else {
path = pathTmp;
}
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
final frame = buildTraceReq(
DateTime.now().millisecondsSinceEpoch ~/ 1000,
0, //flags
0, //auth
payload: path,
);
connector.sendFrame(frame);
}
void _setupFrameListener() {
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
Uint8List tagData = Uint8List(4);
// Listen for incoming text messages from the repeater
_frameSubscription = connector.receivedFrames.listen((frame) {
if (frame.isEmpty) return;
final frameBuffer = BufferReader(frame);
final code = frameBuffer.readUInt8();
if (code == respCodeSent) {
frameBuffer.skipBytes(1); //reserved
tagData = frameBuffer.readBytes(4);
final timeoutSeconds = frameBuffer.readUInt32LE();
// Start timeout timer for trace response
_timeoutTimer?.cancel();
_timeoutTimer = Timer(Duration(milliseconds: timeoutSeconds), () {
if (!mounted) return;
setState(() {
_isLoading = false;
_failed2Loaded = true;
});
});
}
if (code == respCodeErr) {
_timeoutTimer?.cancel();
if (!mounted) return;
setState(() {
_isLoading = false;
_failed2Loaded = true;
});
}
// Check if it's a binary response
if (frame.length > 8 &&
code == pushCodeTraceData &&
listEquals(frame.sublist(4, 8), tagData)) {
_timeoutTimer?.cancel();
if (!mounted) return;
frameBuffer.skipBytes(3); //reserved + path length + flag
if (listEquals(frameBuffer.readBytes(4), tagData)) {
_handleTraceResponse(frame);
}
}
});
}
Future<void> _handleTraceResponse(Uint8List frame) async {
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
final buffer = BufferReader(frame);
buffer.skipBytes(2); // Skip push code and reserved byte
int pathLength = buffer.readUInt8();
buffer.skipBytes(5); // Skip Flag byte and tag data
buffer.skipBytes(4); // Skip auth code
Uint8List pathData = buffer.readBytes(pathLength);
Uint8List snrData = buffer.readRemainingBytes();
Map<int, Contact> pathContacts = {};
connector.contacts.where((c) => c.type != advTypeChat).forEach((repeater) {
for (var repeaterData in pathData) {
if (listEquals(
repeater.publicKey.sublist(0, 1),
Uint8List.fromList([repeaterData]),
)) {
pathContacts[repeaterData] = repeater;
}
}
});
setState(() {
_isLoading = false;
_hasData = true;
_traceData = PathTraceData(
pathData: pathData,
snrData: snrData,
pathContacts: pathContacts,
);
_points = <LatLng>[];
_points.add(LatLng(connector.selfLatitude!, connector.selfLongitude!));
for (final hop in _traceData!.pathData) {
final contact = _traceData!.pathContacts[hop];
if (contact != null &&
contact.hasLocation &&
contact.latitude != null &&
contact.longitude != null) {
_points.add(LatLng(contact.latitude!, contact.longitude!));
}
}
_polylines = _points.length > 1
? [
Polyline(
points: _points,
strokeWidth: 4,
color: Colors.blueAccent,
),
]
: <Polyline>[];
_initialCenter = _points.isNotEmpty ? _points.first : const LatLng(0, 0);
_initialZoom = _points.isNotEmpty ? 13.0 : 2.0;
_bounds = _points.length > 1 ? LatLngBounds.fromPoints(_points) : null;
_mapKey = ValueKey(
'${context.l10n.pathTrace_you},${_formatPathPrefixes(_traceData!.pathData)}',
);
_pathDistanceMeters = getPathDistanceMeters(_points);
});
}
@override
Widget build(BuildContext context) {
return Consumer<MeshCoreConnector>(
builder: (context, connector, _) {
final tileCache = context.read<MapTileCacheService>();
return Scaffold(
appBar: AppBar(
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
FittedBox(
fit: BoxFit.scaleDown,
child: Text(
widget.title,
style: const TextStyle(fontSize: 24),
),
),
],
),
centerTitle: false,
actions: [
IconButton(
icon: _isLoading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.refresh),
onPressed: _isLoading ? null : _doPathTrace,
tooltip: context.l10n.pathTrace_refreshTooltip,
),
],
),
body: SafeArea(
top: false,
child: Stack(
children: [
if (!_hasData)
Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (_isLoading) const CircularProgressIndicator(),
const SizedBox(height: 16),
if (!_isLoading && _failed2Loaded)
Text(context.l10n.pathTrace_notAvailable),
],
),
),
if (_hasData) _buildMapPathTrace(context, tileCache),
if (_points.isEmpty &&
!_hasData &&
!_isLoading &&
!_failed2Loaded)
Center(
child: Card(
color: Colors.white.withValues(alpha: 0.9),
child: Padding(
padding: EdgeInsets.all(12),
child: Text(
context.l10n.channelPath_noRepeaterLocations,
),
),
),
),
if (_hasData) _buildLegendCard(context, _traceData!),
],
),
),
);
},
);
}
List<Marker> _buildHopMarkers(List<int> pathData) {
return [
for (final hop in pathData)
if (_traceData!.pathContacts[hop] != null &&
_traceData!.pathContacts[hop]!.hasLocation)
Marker(
point: LatLng(
_traceData!.pathContacts[hop]!.latitude!,
_traceData!.pathContacts[hop]!.longitude!,
),
width: 35,
height: 35,
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: Colors.green,
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.3),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
alignment: Alignment.center,
child: Text(
_traceData!.pathContacts[hop]!.publicKey
.sublist(0, 1)
.map(
(b) => b.toRadixString(16).padLeft(2, '0').toUpperCase(),
)
.join(),
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 12,
),
),
),
),
if (context.read<MeshCoreConnector>().selfLatitude != null &&
context.read<MeshCoreConnector>().selfLongitude != null)
Marker(
point: LatLng(
context.read<MeshCoreConnector>().selfLatitude!,
context.read<MeshCoreConnector>().selfLongitude!,
),
width: 35,
height: 35,
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: Colors.blue,
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.3),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
alignment: Alignment.center,
child: Text(
context.l10n.pathTrace_you,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 12,
),
),
),
),
];
}
String formatDirectionText(PathTraceData pathTraceData, int index) {
if (index == 0 || index == pathTraceData.snrData.length - 1) {
if (index == 0) {
return context.l10n.pathTrace_you;
} else {
final contactName = pathTraceData
.pathContacts[pathTraceData.pathData[pathTraceData.pathData.length -
1]]
?.name;
final hex = pathTraceData.pathData[pathTraceData.pathData.length - 1]
.toRadixString(16)
.padLeft(2, '0')
.toUpperCase();
return contactName != null
? "$hex: $contactName"
: "$hex: ${context.l10n.channelPath_unknownRepeater}";
}
} else {
final contactName =
pathTraceData.pathContacts[pathTraceData.pathData[index - 1]]?.name;
final hex = pathTraceData.pathData[index - 1]
.toRadixString(16)
.padLeft(2, '0')
.toUpperCase();
return contactName != null
? "$hex: $contactName"
: "$hex: ${context.l10n.channelPath_unknownRepeater}";
}
}
String formatDirectionSubText(PathTraceData pathTraceData, int index) {
if (index == 0 || index == pathTraceData.snrData.length - 1) {
if (index == 0) {
final contactName =
pathTraceData.pathContacts[pathTraceData.pathData[0]]?.name;
final hex = pathTraceData.pathData[0]
.toRadixString(16)
.padLeft(2, '0')
.toUpperCase();
return contactName != null
? "$hex: $contactName"
: "$hex: ${context.l10n.channelPath_unknownRepeater}";
} else {
return context.l10n.pathTrace_you;
}
} else {
final contactName =
pathTraceData.pathContacts[pathTraceData.pathData[index]]?.name;
final hex = pathTraceData.pathData[index]
.toRadixString(16)
.padLeft(2, '0')
.toUpperCase();
return contactName != null
? "$hex: $contactName"
: "$hex: ${context.l10n.channelPath_unknownRepeater}";
}
}
Widget _buildMapPathTrace(
BuildContext context,
MapTileCacheService tileCache,
) {
return FlutterMap(
key: _mapKey,
options: MapOptions(
interactionOptions: InteractionOptions(flags: ~InteractiveFlag.rotate),
initialCenter: _initialCenter!,
initialZoom: _initialZoom,
initialCameraFit: _bounds == null
? null
: CameraFit.bounds(
bounds: _bounds!,
padding: const EdgeInsets.all(64),
maxZoom: 16,
),
minZoom: 2.0,
maxZoom: 18.0,
),
children: [
TileLayer(
urlTemplate: kMapTileUrlTemplate,
tileProvider: tileCache.tileProvider,
userAgentPackageName: MapTileCacheService.userAgentPackageName,
maxZoom: 19,
),
if (_polylines.isNotEmpty) PolylineLayer(polylines: _polylines),
if (_traceData!.pathData.isNotEmpty)
MarkerLayer(markers: _buildHopMarkers(_traceData!.pathData)),
],
);
}
Widget _buildLegendCard(BuildContext context, PathTraceData pathTraceData) {
final l10n = context.l10n;
final maxHeight = MediaQuery.of(context).size.height * 0.35;
final estimatedHeight = 72.0 + (pathTraceData.pathData.length * 56.0);
final cardHeight = max(96.0, min(maxHeight, estimatedHeight));
return Positioned(
left: 16,
right: 16,
bottom: 16,
child: SizedBox(
height: cardHeight,
child: Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.all(12),
child: Text(
'${l10n.channelPath_repeaterHops} ${formatDistance(_pathDistanceMeters)}',
style: const TextStyle(fontWeight: FontWeight.w600),
),
),
const Divider(height: 1),
Expanded(
child: pathTraceData.pathData.isEmpty
? Center(
child: Text(l10n.channelPath_noHopDetailsAvailable),
)
: Scrollbar(
child: ListView.separated(
padding: const EdgeInsets.symmetric(vertical: 4),
itemCount: pathTraceData.pathData.length + 1,
separatorBuilder: (_, _) => const Divider(height: 1),
itemBuilder: (context, index) {
return Column(
children: [
ListTile(
leading:
index >= pathTraceData.snrData.length / 2
? Icon(Icons.call_received)
: Icon(Icons.call_made),
title: Text(
formatDirectionText(pathTraceData, index),
style: const TextStyle(fontSize: 14),
),
subtitle: Text(
formatDirectionSubText(
pathTraceData,
index,
),
style: const TextStyle(fontSize: 14),
),
trailing: SNRIcon(
snr:
pathTraceData.snrData[index].toSigned(
8,
) /
4.0,
),
onTap: () {
// Handle item tap
},
),
],
);
},
),
),
),
],
),
),
),
);
}
}
+61 -25
View File
@@ -119,14 +119,24 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
// Show debug info if requested // Show debug info if requested
if (showDebug && mounted) { if (showDebug && mounted) {
final frame = buildSendCliCommandFrame(widget.repeater.publicKey, command); final frame = buildSendCliCommandFrame(
DebugFrameViewer.showFrameDebug(context, frame, context.l10n.repeater_cliCommandFrameTitle); widget.repeater.publicKey,
command,
);
DebugFrameViewer.showFrameDebug(
context,
frame,
context.l10n.repeater_cliCommandFrameTitle,
);
} }
// Send CLI command to repeater with retry // Send CLI command to repeater with retry
try { try {
if (_commandService != null) { if (_commandService != null) {
final connector = Provider.of<MeshCoreConnector>(context, listen: false); final connector = Provider.of<MeshCoreConnector>(
context,
listen: false,
);
final repeater = _resolveRepeater(connector); final repeater = _resolveRepeater(connector);
final response = await _commandService!.sendCommand( final response = await _commandService!.sendCommand(
repeater, repeater,
@@ -230,7 +240,10 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
Text(l10n.repeater_cliTitle), Text(l10n.repeater_cliTitle),
Text( Text(
repeater.name, repeater.name,
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.normal), style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.normal,
),
), ),
], ],
), ),
@@ -251,12 +264,20 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
value: 'auto', value: 'auto',
child: Row( child: Row(
children: [ 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), const SizedBox(width: 8),
Text( Text(
l10n.repeater_autoUseSavedPath, l10n.repeater_autoUseSavedPath,
style: TextStyle( 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', value: 'flood',
child: Row( child: Row(
children: [ 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), const SizedBox(width: 8),
Text( Text(
l10n.repeater_forceFloodMode, l10n.repeater_forceFloodMode,
style: TextStyle( style: TextStyle(
fontWeight: isFloodMode ? FontWeight.bold : FontWeight.normal, fontWeight: isFloodMode
? FontWeight.bold
: FontWeight.normal,
), ),
), ),
], ],
@@ -282,7 +311,8 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
IconButton( IconButton(
icon: const Icon(Icons.timeline), icon: const Icon(Icons.timeline),
tooltip: l10n.repeater_pathManagement, tooltip: l10n.repeater_pathManagement,
onPressed: () => PathManagementDialog.show(context, contact: repeater), onPressed: () =>
PathManagementDialog.show(context, contact: repeater),
), ),
IconButton( IconButton(
icon: const Icon(Icons.bug_report), icon: const Icon(Icons.bug_report),
@@ -473,7 +503,10 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
decoration: InputDecoration( decoration: InputDecoration(
hintText: l10n.repeater_enterCommandHint, hintText: l10n.repeater_enterCommandHint,
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
prefixText: '> ', prefixText: '> ',
), ),
style: const TextStyle(fontFamily: 'monospace'), style: const TextStyle(fontFamily: 'monospace'),
@@ -718,10 +751,7 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
]; ];
final gpsCommands = [ final gpsCommands = [
_CommandHelpEntry( _CommandHelpEntry(command: 'gps', description: l10n.repeater_cliHelpGps),
command: 'gps',
description: l10n.repeater_cliHelpGps,
),
_CommandHelpEntry( _CommandHelpEntry(
command: 'gps {on|off}', command: 'gps {on|off}',
description: l10n.repeater_cliHelpGpsOnOff, description: l10n.repeater_cliHelpGpsOnOff,
@@ -758,13 +788,25 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
style: const TextStyle(fontSize: 13), style: const TextStyle(fontSize: 13),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
_buildHelpSection(context, l10n.repeater_general, generalCommands), _buildHelpSection(
context,
l10n.repeater_general,
generalCommands,
),
const SizedBox(height: 16), const SizedBox(height: 16),
_buildHelpSection(context, l10n.repeater_settingsCategory, settingsCommands), _buildHelpSection(
context,
l10n.repeater_settingsCategory,
settingsCommands,
),
const SizedBox(height: 16), const SizedBox(height: 16),
_buildHelpSection(context, l10n.repeater_bridge, bridgeCommands), _buildHelpSection(context, l10n.repeater_bridge, bridgeCommands),
const SizedBox(height: 16), const SizedBox(height: 16),
_buildHelpSection(context, l10n.repeater_logging, loggingCommands), _buildHelpSection(
context,
l10n.repeater_logging,
loggingCommands,
),
const SizedBox(height: 16), const SizedBox(height: 16),
_buildHelpSection( _buildHelpSection(
context, context,
@@ -813,10 +855,7 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
), ),
if (note != null) ...[ if (note != null) ...[
const SizedBox(height: 6), const SizedBox(height: 6),
Text( Text(note, style: const TextStyle(fontSize: 12)),
note,
style: const TextStyle(fontSize: 12),
),
], ],
const SizedBox(height: 8), const SizedBox(height: 8),
...commands.map((entry) => _buildHelpCommandCard(context, entry)), ...commands.map((entry) => _buildHelpCommandCard(context, entry)),
@@ -871,8 +910,5 @@ class _CommandHelpEntry {
final String command; final String command;
final String description; final String description;
const _CommandHelpEntry({ const _CommandHelpEntry({required this.command, required this.description});
required this.command,
required this.description,
});
} }
+1 -1
View File
@@ -73,7 +73,7 @@ class RepeaterHubScreen extends StatelessWidget {
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
'<${repeater.publicKeyHex.substring(0, 8)}...${repeater.publicKeyHex.substring(repeater.publicKeyHex.length - 8)}>', repeater.shortPubKeyHex,
style: TextStyle(fontSize: 14, color: Colors.grey[600]), style: TextStyle(fontSize: 14, color: Colors.grey[600]),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
+183 -65
View File
@@ -41,7 +41,8 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
// Basic settings // Basic settings
final TextEditingController _nameController = TextEditingController(); final TextEditingController _nameController = TextEditingController();
final TextEditingController _passwordController = TextEditingController(); final TextEditingController _passwordController = TextEditingController();
final TextEditingController _guestPasswordController = TextEditingController(); final TextEditingController _guestPasswordController =
TextEditingController();
// Radio settings // Radio settings
final TextEditingController _freqController = TextEditingController(); final TextEditingController _freqController = TextEditingController();
@@ -60,7 +61,9 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
bool _privacyMode = false; bool _privacyMode = false;
// Advertisement settings // Advertisement settings
bool _advertEnable = true;
int _advertInterval = 120; // minutes/2 int _advertInterval = 120; // minutes/2
bool _floodAdvertEnable = true;
int _floodAdvertInterval = 12; // hours int _floodAdvertInterval = 12; // hours
int _privAdvertInterval = 60; // minutes int _privAdvertInterval = 60; // minutes
@@ -146,7 +149,10 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
if (_fetchedSettings.isEmpty) return; if (_fetchedSettings.isEmpty) return;
final appLog = Provider.of<AppDebugLogService>(context, listen: false); 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(() { setState(() {
// Update name // Update name
@@ -161,7 +167,10 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
final radioStr = _fetchedSettings['radio']!; final radioStr = _fetchedSettings['radio']!;
appLog.info('Raw radio string: "$radioStr"', tag: 'RadioSettings'); appLog.info('Raw radio string: "$radioStr"', tag: 'RadioSettings');
final parts = radioStr.split(','); 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) { if (parts.isNotEmpty) {
final freqText = parts[0].replaceAll(RegExp(r'[^0-9.]'), '').trim(); 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'); appLog.info('CR text: "$crText"', tag: 'RadioSettings');
_codingRate = int.tryParse(crText) ?? _codingRate; _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')) { if (_fetchedSettings.containsKey('tx')) {
@@ -207,11 +219,17 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
} }
if (_fetchedSettings.containsKey('lat')) { 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']!; _latController.text = _fetchedSettings['lat']!;
} }
if (_fetchedSettings.containsKey('lon')) { 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']!; _lonController.text = _fetchedSettings['lon']!;
} }
@@ -230,12 +248,14 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
_fetchedSettings['advert.interval']!, _fetchedSettings['advert.interval']!,
_advertInterval, _advertInterval,
); );
_advertEnable = _advertInterval > 0;
} }
if (_fetchedSettings.containsKey('flood.advert.interval')) { if (_fetchedSettings.containsKey('flood.advert.interval')) {
_floodAdvertInterval = _parseIntWithFallback( _floodAdvertInterval = _parseIntWithFallback(
_fetchedSettings['flood.advert.interval']!, _fetchedSettings['flood.advert.interval']!,
_floodAdvertInterval, _floodAdvertInterval,
); );
_floodAdvertEnable = _floodAdvertInterval > 0;
} }
if (_fetchedSettings.containsKey('priv.advert.interval')) { if (_fetchedSettings.containsKey('priv.advert.interval')) {
_privAdvertInterval = _parseIntWithFallback( _privAdvertInterval = _parseIntWithFallback(
@@ -268,7 +288,10 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
void _applySettingResponse(String command, String response) { void _applySettingResponse(String command, String response) {
final appLog = Provider.of<AppDebugLogService>(context, listen: false); 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); final value = _extractCliValue(response);
appLog.info('Extracted value: "$value"', tag: 'RadioSettings'); appLog.info('Extracted value: "$value"', tag: 'RadioSettings');
if (value == null) return; if (value == null) return;
@@ -280,7 +303,10 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
// Validate response content matches expected format for the command // Validate response content matches expected format for the command
// This prevents mismatched responses over LoRa where order isn't guaranteed // This prevents mismatched responses over LoRa where order isn't guaranteed
if (!_validateResponseForCommand(key, value)) { 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; return;
} }
@@ -311,7 +337,9 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
// Must have at least 3 commas and start with a frequency-like number // Must have at least 3 commas and start with a frequency-like number
final parts = value.split(','); final parts = value.split(',');
if (parts.length < 4) return false; 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) // Frequency should be in reasonable LoRa range (300-2500 MHz)
return freq != null && freq >= 300 && freq <= 2500; return freq != null && freq >= 300 && freq <= 2500;
@@ -339,22 +367,33 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
case 'privacy': case 'privacy':
// Boolean values: on/off/true/false/1/0/enabled/disabled // Boolean values: on/off/true/false/1/0/enabled/disabled
final lower = value.toLowerCase().trim(); 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 'advert.interval':
case 'flood.advert.interval': case 'flood.advert.interval':
case 'priv.advert.interval': case 'priv.advert.interval':
// Interval: positive integer // Interval: non-negative integer (0 means disabled)
if (value.contains(',')) return false; if (value.contains(',')) return false;
final interval = int.tryParse(value.replaceAll(RegExp(r'[^0-9]'), '')); final interval = int.tryParse(value.replaceAll(RegExp(r'[^0-9]'), ''));
return interval != null && interval > 0; return interval != null && interval >= 0;
case 'name': case 'name':
// Name: any non-empty string, but should NOT look like radio settings // Name: any non-empty string, but should NOT look like radio settings
if (value.isEmpty) return false; if (value.isEmpty) return false;
// If it has 3+ commas and looks like numbers, probably radio data // If it has 3+ commas and looks like numbers, probably radio data
final commaCount = ','.allMatches(value).length; 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; return true;
default: default:
@@ -551,7 +590,9 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
final freqMHz = double.tryParse(_freqController.text); final freqMHz = double.tryParse(_freqController.text);
if (freqMHz != null) { if (freqMHz != null) {
final bwKHz = _bandwidth! / 1000; 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, timestampSeconds: timestampSeconds,
); );
await connector.sendFrame(frame); 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(() { setState(() {
@@ -614,7 +657,9 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text(context.l10n.repeater_errorSavingSettings(e.toString())), content: Text(
context.l10n.repeater_errorSavingSettings(e.toString()),
),
backgroundColor: Colors.red, backgroundColor: Colors.red,
), ),
); );
@@ -699,7 +744,10 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
Text(l10n.repeater_settingsTitle), Text(l10n.repeater_settingsTitle),
Text( Text(
repeater.name, 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', value: 'auto',
child: Row( child: Row(
children: [ 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), const SizedBox(width: 8),
Text( Text(
l10n.repeater_autoUseSavedPath, l10n.repeater_autoUseSavedPath,
style: TextStyle( 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', value: 'flood',
child: Row( child: Row(
children: [ 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), const SizedBox(width: 8),
Text( Text(
l10n.repeater_forceFloodMode, l10n.repeater_forceFloodMode,
style: TextStyle( style: TextStyle(
fontWeight: isFloodMode ? FontWeight.bold : FontWeight.normal, fontWeight: isFloodMode
? FontWeight.bold
: FontWeight.normal,
), ),
), ),
], ],
@@ -754,7 +818,8 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
IconButton( IconButton(
icon: const Icon(Icons.timeline), icon: const Icon(Icons.timeline),
tooltip: l10n.repeater_pathManagement, tooltip: l10n.repeater_pathManagement,
onPressed: () => PathManagementDialog.show(context, contact: repeater), onPressed: () =>
PathManagementDialog.show(context, contact: repeater),
), ),
if (_hasChanges) if (_hasChanges)
TextButton.icon( TextButton.icon(
@@ -865,7 +930,9 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
suffixText: 'MHz', suffixText: 'MHz',
), ),
keyboardType: const TextInputType.numberWithOptions(decimal: true), keyboardType: const TextInputType.numberWithOptions(
decimal: true,
),
onChanged: (_) => _markChanged(), onChanged: (_) => _markChanged(),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
@@ -923,10 +990,7 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
), ),
items: _spreadingFactorOptions.map((sf) { items: _spreadingFactorOptions.map((sf) {
return DropdownMenuItem( return DropdownMenuItem(value: sf, child: Text('SF$sf'));
value: sf,
child: Text('SF$sf'),
);
}).toList(), }).toList(),
onChanged: (value) { onChanged: (value) {
if (value != null) { if (value != null) {
@@ -945,10 +1009,7 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
), ),
items: _codingRateOptions.map((cr) { items: _codingRateOptions.map((cr) {
return DropdownMenuItem( return DropdownMenuItem(value: cr, child: Text('4/$cr'));
value: cr,
child: Text('4/$cr'),
);
}).toList(), }).toList(),
onChanged: (value) { onChanged: (value) {
if (value != null) { if (value != null) {
@@ -988,7 +1049,10 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
helperText: l10n.repeater_latitudeHelper, helperText: l10n.repeater_latitudeHelper,
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
), ),
keyboardType: const TextInputType.numberWithOptions(decimal: true, signed: true), keyboardType: const TextInputType.numberWithOptions(
decimal: true,
signed: true,
),
onChanged: (_) => _markChanged(), onChanged: (_) => _markChanged(),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
@@ -999,7 +1063,10 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
helperText: l10n.repeater_longitudeHelper, helperText: l10n.repeater_longitudeHelper,
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
), ),
keyboardType: const TextInputType.numberWithOptions(decimal: true, signed: true), keyboardType: const TextInputType.numberWithOptions(
decimal: true,
signed: true,
),
onChanged: (_) => _markChanged(), onChanged: (_) => _markChanged(),
), ),
], ],
@@ -1018,11 +1085,17 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
children: [ children: [
Row( Row(
children: [ 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), const SizedBox(width: 8),
Text( Text(
l10n.repeater_features, 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, width: 18,
height: 18, height: 18,
child: CircularProgressIndicator(strokeWidth: 2), child: CircularProgressIndicator(strokeWidth: 2),
) )
: const Icon(Icons.refresh, size: 20), : const Icon(Icons.refresh, size: 20),
onPressed: isRefreshing ? null : onRefresh, onPressed: isRefreshing ? null : onRefresh,
tooltip: refreshTooltip, tooltip: refreshTooltip,
@@ -1130,40 +1203,72 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
const Divider(), const Divider(),
ListTile( ListTile(
title: Text(l10n.repeater_localAdvertInterval), title: Text(l10n.repeater_localAdvertInterval),
subtitle: Text(l10n.repeater_localAdvertIntervalMinutes(_advertInterval)), subtitle: Text(
trailing: Text(l10n.repeater_localAdvertIntervalMinutes(_advertInterval)), l10n.repeater_localAdvertIntervalMinutes(_advertInterval),
),
trailing: Switch(
value: _advertEnable,
onChanged: (value) {
setState(() {
_advertInterval = value ? 60 : 0;
_advertEnable = value;
});
_markChanged();
},
),
), ),
Slider( Slider(
value: _advertInterval.toDouble(), value: _advertInterval == 0
? 60.toDouble()
: _advertInterval.toDouble(),
min: 60, min: 60,
max: 240, max: 240,
divisions: 18, divisions: 18,
label: l10n.repeater_localAdvertIntervalMinutes(_advertInterval), label: l10n.repeater_localAdvertIntervalMinutes(_advertInterval),
onChanged: (value) { onChanged: _advertEnable
setState(() { ? (value) {
_advertInterval = value.toInt(); setState(() {
}); _advertInterval = value.toInt();
_markChanged(); });
}, _markChanged();
}
: null,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
ListTile( ListTile(
title: Text(l10n.repeater_floodAdvertInterval), title: Text(l10n.repeater_floodAdvertInterval),
subtitle: Text(l10n.repeater_floodAdvertIntervalHours(_floodAdvertInterval)), subtitle: Text(
trailing: Text(l10n.repeater_floodAdvertIntervalHours(_floodAdvertInterval)), l10n.repeater_floodAdvertIntervalHours(_floodAdvertInterval),
),
trailing: Switch(
value: _floodAdvertEnable,
onChanged: (value) {
setState(() {
_floodAdvertInterval = value ? 3 : 0;
_floodAdvertEnable = value;
});
_markChanged();
},
),
), ),
Slider( Slider(
value: _floodAdvertInterval.toDouble(), value: _floodAdvertInterval == 0
? 3.toDouble()
: _floodAdvertInterval.toDouble(),
min: 3, min: 3,
max: 48, max: 168,
divisions: 45, divisions: 165,
label: l10n.repeater_floodAdvertIntervalHours(_floodAdvertInterval), label: l10n.repeater_floodAdvertIntervalHours(
onChanged: (value) { _floodAdvertInterval,
setState(() { ),
_floodAdvertInterval = value.toInt(); onChanged: _floodAdvertEnable
}); ? (value) {
_markChanged(); setState(() {
}, _floodAdvertInterval = value.toInt();
});
_markChanged();
}
: null,
), ),
// Encrypted advertisement interval - hidden until privacy mode is implemented // Encrypted advertisement interval - hidden until privacy mode is implemented
// if (_privacyMode) ...[ // if (_privacyMode) ...[
@@ -1220,10 +1325,15 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
const Divider(), const Divider(),
ListTile( ListTile(
leading: Icon(Icons.refresh, color: colorScheme.onErrorContainer), 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( subtitle: Text(
l10n.repeater_rebootRepeaterSubtitle, l10n.repeater_rebootRepeaterSubtitle,
style: TextStyle(color: colorScheme.onErrorContainer.withValues(alpha: 0.8)), style: TextStyle(
color: colorScheme.onErrorContainer.withValues(alpha: 0.8),
),
), ),
onTap: () => _confirmAction( onTap: () => _confirmAction(
l10n.repeater_rebootRepeater, l10n.repeater_rebootRepeater,
@@ -1246,11 +1356,19 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
// ), // ),
// ), // ),
ListTile( ListTile(
leading: Icon(Icons.delete_forever, color: colorScheme.onErrorContainer), leading: Icon(
title: Text(l10n.repeater_eraseFileSystem, style: TextStyle(color: colorScheme.onErrorContainer)), Icons.delete_forever,
color: colorScheme.onErrorContainer,
),
title: Text(
l10n.repeater_eraseFileSystem,
style: TextStyle(color: colorScheme.onErrorContainer),
),
subtitle: Text( subtitle: Text(
l10n.repeater_eraseFileSystemSubtitle, l10n.repeater_eraseFileSystemSubtitle,
style: TextStyle(color: colorScheme.onErrorContainer.withValues(alpha: 0.8)), style: TextStyle(
color: colorScheme.onErrorContainer.withValues(alpha: 0.8),
),
), ),
onTap: () => _confirmAction( onTap: () => _confirmAction(
l10n.repeater_eraseFileSystem, l10n.repeater_eraseFileSystem,
@@ -1272,9 +1390,9 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
if (command == 'erase') { if (command == 'erase') {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(
SnackBar(content: Text(l10n.repeater_eraseSerialOnly)), context,
); ).showSnackBar(SnackBar(content: Text(l10n.repeater_eraseSerialOnly)));
} }
return; return;
} }
+68 -18
View File
@@ -28,7 +28,8 @@ class RepeaterStatusScreen extends StatefulWidget {
class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> { class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
static const int _statusPayloadOffset = 8; static const int _statusPayloadOffset = 8;
static const int _statusStatsSize = 52; static const int _statusStatsSize = 52;
static const int _statusResponseBytes = _statusPayloadOffset + _statusStatsSize; static const int _statusResponseBytes =
_statusPayloadOffset + _statusStatsSize;
bool _isLoading = false; bool _isLoading = false;
StreamSubscription<Uint8List>? _frameSubscription; StreamSubscription<Uint8List>? _frameSubscription;
@@ -293,7 +294,9 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text(context.l10n.repeater_errorLoadingStatus(e.toString())), content: Text(
context.l10n.repeater_errorLoadingStatus(e.toString()),
),
backgroundColor: Colors.red, backgroundColor: Colors.red,
), ),
); );
@@ -327,7 +330,10 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
Text(l10n.repeater_statusTitle), Text(l10n.repeater_statusTitle),
Text( Text(
repeater.name, repeater.name,
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.normal), style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.normal,
),
), ),
], ],
), ),
@@ -348,12 +354,20 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
value: 'auto', value: 'auto',
child: Row( child: Row(
children: [ 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), const SizedBox(width: 8),
Text( Text(
l10n.repeater_autoUseSavedPath, l10n.repeater_autoUseSavedPath,
style: TextStyle( 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', value: 'flood',
child: Row( child: Row(
children: [ 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), const SizedBox(width: 8),
Text( Text(
l10n.repeater_forceFloodMode, l10n.repeater_forceFloodMode,
style: TextStyle( style: TextStyle(
fontWeight: isFloodMode ? FontWeight.bold : FontWeight.normal, fontWeight: isFloodMode
? FontWeight.bold
: FontWeight.normal,
), ),
), ),
], ],
@@ -379,7 +401,8 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
IconButton( IconButton(
icon: const Icon(Icons.timeline), icon: const Icon(Icons.timeline),
tooltip: l10n.repeater_pathManagement, tooltip: l10n.repeater_pathManagement,
onPressed: () => PathManagementDialog.show(context, contact: repeater), onPressed: () =>
PathManagementDialog.show(context, contact: repeater),
), ),
IconButton( IconButton(
icon: _isLoading icon: _isLoading
@@ -423,11 +446,17 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
children: [ children: [
Row( Row(
children: [ 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), const SizedBox(width: 8),
Text( Text(
l10n.repeater_systemInformation, 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: [ children: [
Row( Row(
children: [ 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), const SizedBox(width: 8),
Text( Text(
l10n.repeater_radioStatistics, l10n.repeater_radioStatistics,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
), ),
], ],
), ),
const Divider(), 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_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_txAirtime, _formatDuration(_txAirSecs)),
_buildInfoRow(l10n.repeater_rxAirtime, _formatDuration(_rxAirSecs)), _buildInfoRow(l10n.repeater_rxAirtime, _formatDuration(_rxAirSecs)),
], ],
@@ -483,11 +524,17 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
children: [ children: [
Row( Row(
children: [ 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), const SizedBox(width: 8),
Text( Text(
l10n.repeater_packetStatistics, 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 ''; if (_statusRequestedAt == null) return '';
final dt = _statusRequestedAt!; final dt = _statusRequestedAt!;
final date = '${dt.day}/${dt.month}/${dt.year}'; 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'; return '$date $time';
} }
@@ -598,7 +646,9 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
final direct = _formatValue(_dupDirect); final direct = _formatValue(_dupDirect);
return l10n.repeater_duplicatesFloodDirect(flood, direct); return l10n.repeater_duplicatesFloodDirect(flood, direct);
} }
if (_packetsRecv == null || _floodRx == null || _directRx == null) return ''; if (_packetsRecv == null || _floodRx == null || _directRx == null) {
return '';
}
final dupTotal = _packetsRecv! - _floodRx! - _directRx!; final dupTotal = _packetsRecv! - _floodRx! - _directRx!;
if (dupTotal < 0) return ''; if (dupTotal < 0) return '';
return l10n.repeater_duplicatesTotal(dupTotal); return l10n.repeater_duplicatesTotal(dupTotal);
+126 -34
View File
@@ -1,3 +1,6 @@
import 'dart:async';
import 'dart:io' show Platform;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_blue_plus/flutter_blue_plus.dart'; import 'package:flutter_blue_plus/flutter_blue_plus.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@@ -8,9 +11,61 @@ import '../widgets/device_tile.dart';
import 'contacts_screen.dart'; import 'contacts_screen.dart';
/// Screen for scanning and connecting to MeshCore devices /// Screen for scanning and connecting to MeshCore devices
class ScannerScreen extends StatelessWidget { class ScannerScreen extends StatefulWidget {
const ScannerScreen({super.key}); const ScannerScreen({super.key});
@override
State<ScannerScreen> createState() => _ScannerScreenState();
}
class _ScannerScreenState extends State<ScannerScreen> {
bool _changedNavigation = false;
late final VoidCallback _connectionListener;
BluetoothAdapterState _bluetoothState = BluetoothAdapterState.unknown;
late StreamSubscription<BluetoothAdapterState> _bluetoothStateSubscription;
@override
void initState() {
super.initState();
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
_connectionListener = () {
if (connector.state == MeshCoreConnectionState.disconnected) {
_changedNavigation = false;
} else if (connector.state == MeshCoreConnectionState.connected &&
!_changedNavigation) {
_changedNavigation = true;
if (mounted) {
Navigator.of(context).push(
MaterialPageRoute(builder: (context) => const ContactsScreen()),
);
}
}
};
connector.addListener(_connectionListener);
_bluetoothStateSubscription = FlutterBluePlus.adapterState.listen((state) {
if (mounted) {
setState(() {
_bluetoothState = state;
});
// Cancel scan if Bluetooth turns off while scanning
if (state != BluetoothAdapterState.on) {
unawaited(connector.stopScan());
}
}
});
}
@override
void dispose() {
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
connector.removeListener(_connectionListener);
unawaited(_bluetoothStateSubscription.cancel());
super.dispose();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
@@ -25,13 +80,15 @@ class ScannerScreen extends StatelessWidget {
builder: (context, connector, child) { builder: (context, connector, child) {
return Column( return Column(
children: [ children: [
// Bluetooth off warning
if (_bluetoothState == BluetoothAdapterState.off)
_bluetoothOffWarning(context),
// Status bar // Status bar
_buildStatusBar(context, connector), _buildStatusBar(context, connector),
// Device list // Device list
Expanded( Expanded(child: _buildDeviceList(context, connector)),
child: _buildDeviceList(context, connector),
),
], ],
); );
}, },
@@ -39,17 +96,21 @@ class ScannerScreen extends StatelessWidget {
), ),
floatingActionButton: Consumer<MeshCoreConnector>( floatingActionButton: Consumer<MeshCoreConnector>(
builder: (context, connector, child) { builder: (context, connector, child) {
final isScanning = connector.state == MeshCoreConnectionState.scanning; final isScanning =
connector.state == MeshCoreConnectionState.scanning;
final isBluetoothOff = _bluetoothState == BluetoothAdapterState.off;
return FloatingActionButton.extended( return FloatingActionButton.extended(
onPressed: () { onPressed: isBluetoothOff
if (isScanning) { ? null
connector.stopScan(); : () {
} else { if (isScanning) {
connector.startScan(); connector.stopScan();
} } else {
}, connector.startScan();
icon: isScanning }
},
icon: isScanning
? const SizedBox( ? const SizedBox(
width: 20, width: 20,
height: 20, height: 20,
@@ -59,7 +120,11 @@ class ScannerScreen extends StatelessWidget {
), ),
) )
: const Icon(Icons.bluetooth_searching), : 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,
),
); );
}, },
), ),
@@ -70,7 +135,7 @@ class ScannerScreen extends StatelessWidget {
String statusText; String statusText;
Color statusColor; Color statusColor;
final l10n = context.l10n; final l10n = context.l10n;
switch (connector.state) { switch (connector.state) {
case MeshCoreConnectionState.scanning: case MeshCoreConnectionState.scanning:
statusText = l10n.scanner_scanning; statusText = l10n.scanner_scanning;
@@ -117,20 +182,13 @@ final l10n = context.l10n;
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon( Icon(Icons.bluetooth, size: 64, color: Colors.grey[400]),
Icons.bluetooth,
size: 64,
color: Colors.grey[400],
),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
connector.state == MeshCoreConnectionState.scanning connector.state == MeshCoreConnectionState.scanning
? context.l10n.scanner_searchingDevices ? context.l10n.scanner_searchingDevices
: context.l10n.scanner_tapToScan, : context.l10n.scanner_tapToScan,
style: TextStyle( style: TextStyle(fontSize: 16, color: Colors.grey[600]),
fontSize: 16,
color: Colors.grey[600],
),
), ),
], ],
), ),
@@ -161,15 +219,6 @@ final l10n = context.l10n;
? result.device.platformName ? result.device.platformName
: result.advertisementData.advName; : result.advertisementData.advName;
await connector.connect(result.device, displayName: name); await connector.connect(result.device, displayName: name);
if (context.mounted && connector.isConnected) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const ContactsScreen(),
),
);
}
} catch (e) { } catch (e) {
if (context.mounted) { if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
@@ -181,4 +230,47 @@ final l10n = context.l10n;
} }
} }
} }
Widget _bluetoothOffWarning(BuildContext context) {
final errorColor = Theme.of(context).colorScheme.error;
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
color: errorColor.withValues(alpha: 0.15),
child: Row(
children: [
Icon(Icons.bluetooth_disabled, size: 24, color: errorColor),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.l10n.scanner_bluetoothOff,
style: TextStyle(
color: errorColor,
fontWeight: FontWeight.w600,
fontSize: 14,
),
),
const SizedBox(height: 4),
Text(
context.l10n.scanner_bluetoothOffMessage,
style: TextStyle(
color: errorColor.withValues(alpha: 0.85),
fontSize: 12,
),
),
],
),
),
if (Platform.isAndroid)
TextButton(
onPressed: () => FlutterBluePlus.turnOn(),
child: Text(context.l10n.scanner_enableBluetooth),
),
],
),
);
}
} }
+267 -91
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:meshcore_open/utils/gpx_export.dart';
import 'package:meshcore_open/widgets/elements_ui.dart'; import 'package:meshcore_open/widgets/elements_ui.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:package_info_plus/package_info_plus.dart'; import 'package:package_info_plus/package_info_plus.dart';
@@ -20,6 +21,7 @@ class SettingsScreen extends StatefulWidget {
class _SettingsScreenState extends State<SettingsScreen> { class _SettingsScreenState extends State<SettingsScreen> {
bool _showBatteryVoltage = false; bool _showBatteryVoltage = false;
bool _deviceInfoExpanded = false;
String _appVersion = ''; String _appVersion = '';
@override @override
@@ -57,6 +59,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
const SizedBox(height: 16), const SizedBox(height: 16),
_buildDebugCard(context), _buildDebugCard(context),
const SizedBox(height: 16), const SizedBox(height: 16),
_buildExportCard(connector),
const SizedBox(height: 16),
_buildAboutCard(context), _buildAboutCard(context),
], ],
); );
@@ -71,43 +75,84 @@ class _SettingsScreenState extends State<SettingsScreen> {
MeshCoreConnector connector, MeshCoreConnector connector,
) { ) {
final l10n = context.l10n; final l10n = context.l10n;
return Card( return Card(
child: Padding( child: Column(
padding: const EdgeInsets.all(16), crossAxisAlignment: CrossAxisAlignment.start,
child: Column( children: [
crossAxisAlignment: CrossAxisAlignment.start, InkWell(
children: [ borderRadius: BorderRadius.circular(12),
Text( onTap: () {
l10n.settings_deviceInfo, setState(() {
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), _deviceInfoExpanded = !_deviceInfoExpanded;
), });
const SizedBox(height: 16), },
_buildInfoRow(l10n.settings_infoName, connector.deviceDisplayName), child: Padding(
_buildInfoRow(l10n.settings_infoId, connector.deviceIdLabel), padding: const EdgeInsets.fromLTRB(16, 16, 16, 16),
_buildInfoRow( child: Row(
l10n.settings_infoStatus, children: [
connector.isConnected Expanded(
? l10n.common_connected child: Text(
: l10n.common_disconnected, l10n.settings_deviceInfo,
), style: const TextStyle(
_buildBatteryInfoRow(context, connector), fontSize: 18,
if (connector.selfName != null) fontWeight: FontWeight.bold,
_buildInfoRow(l10n.settings_nodeName, connector.selfName!), ),
if (connector.selfPublicKey != null) ),
_buildInfoRow( ),
l10n.settings_infoPublicKey, AnimatedRotation(
'${pubKeyToHex(connector.selfPublicKey!).substring(0, 16)}...', turns: _deviceInfoExpanded ? 0.5 : 0,
duration: const Duration(milliseconds: 200),
child: const Icon(Icons.expand_more),
),
],
), ),
_buildInfoRow(
l10n.settings_infoContactsCount,
'${connector.contacts.length}',
), ),
_buildInfoRow( ),
l10n.settings_infoChannelCount,
'${connector.channels.length}', AnimatedCrossFade(
firstChild: const SizedBox.shrink(),
secondChild: Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildInfoRow(
l10n.settings_infoName,
connector.deviceDisplayName,
),
_buildInfoRow(l10n.settings_infoId, connector.deviceIdLabel),
_buildInfoRow(
l10n.settings_infoStatus,
connector.isConnected
? l10n.common_connected
: l10n.common_disconnected,
),
_buildBatteryInfoRow(context, connector),
if (connector.selfName != null)
_buildInfoRow(l10n.settings_nodeName, connector.selfName!),
if (connector.selfPublicKey != null)
_buildInfoRow(
l10n.settings_infoPublicKey,
'${pubKeyToHex(connector.selfPublicKey!).substring(0, 16)}...',
),
_buildInfoRow(
l10n.settings_infoContactsCount,
'${connector.contacts.length}',
),
_buildInfoRow(
l10n.settings_infoChannelCount,
'${connector.channels.length}',
),
],
),
), ),
], crossFadeState: _deviceInfoExpanded
), ? CrossFadeState.showSecond
: CrossFadeState.showFirst,
duration: const Duration(milliseconds: 200),
),
],
), ),
); );
} }
@@ -352,22 +397,33 @@ class _SettingsScreenState extends State<SettingsScreen> {
Color? valueColor, Color? valueColor,
VoidCallback? onTap, VoidCallback? onTap,
}) { }) {
final theme = Theme.of(context);
final row = Padding( final row = Padding(
padding: const EdgeInsets.symmetric(vertical: 4), padding: const EdgeInsets.symmetric(vertical: 10),
child: Row( child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Row( Row(
children: [ children: [
if (leading != null) ...[leading, const SizedBox(width: 8)], if (leading != null) ...[leading, const SizedBox(width: 8)],
Text(label, style: TextStyle(color: Colors.grey[600])), Expanded(
child: Text(
label,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w500,
),
),
),
], ],
), ),
Flexible( const SizedBox(height: 4),
child: Text( Text(
value, value,
style: TextStyle(fontWeight: FontWeight.w500, color: valueColor), style: theme.textTheme.bodyLarge?.copyWith(
overflow: TextOverflow.ellipsis, color: valueColor,
fontWeight: FontWeight.w500,
), ),
), ),
], ],
@@ -376,11 +432,12 @@ class _SettingsScreenState extends State<SettingsScreen> {
if (onTap != null) { if (onTap != null) {
return InkWell( return InkWell(
borderRadius: BorderRadius.circular(6),
onTap: onTap, onTap: onTap,
borderRadius: BorderRadius.circular(4),
child: row, child: row,
); );
} }
return row; return row;
} }
@@ -442,7 +499,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
bool isGPSEnabled = customVars["gps"] == "1"; bool isGPSEnabled = customVars["gps"] == "1";
// Read current interval or default to 900 (15 minutes) // 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(); intervalController.text = currentInterval.toString();
showDialog( showDialog(
@@ -683,6 +741,110 @@ class _SettingsScreenState extends State<SettingsScreen> {
], ],
); );
} }
Future<void> _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;
}
}
Widget _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 { class _RadioSettingsDialog extends StatefulWidget {
@@ -700,6 +862,7 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> {
LoRaSpreadingFactor _spreadingFactor = LoRaSpreadingFactor.sf7; LoRaSpreadingFactor _spreadingFactor = LoRaSpreadingFactor.sf7;
LoRaCodingRate _codingRate = LoRaCodingRate.cr4_5; LoRaCodingRate _codingRate = LoRaCodingRate.cr4_5;
final _txPowerController = TextEditingController(text: '20'); final _txPowerController = TextEditingController(text: '20');
bool _clientRepeat = false;
@override @override
void initState() { void initState() {
@@ -749,6 +912,8 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> {
if (widget.connector.currentTxPower != null) { if (widget.connector.currentTxPower != null) {
_txPowerController.text = widget.connector.currentTxPower.toString(); _txPowerController.text = widget.connector.currentTxPower.toString();
} }
_clientRepeat = widget.connector.clientRepeat ?? false;
} }
@override @override
@@ -780,10 +945,13 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> {
return; return;
} }
if (txPower == null || txPower < 0 || txPower > 22) { final maxTxPower = widget.connector.maxTxPower ?? 22;
ScaffoldMessenger.of( if (txPower == null || txPower < 0 || txPower > maxTxPower) {
context, ScaffoldMessenger.of(context).showSnackBar(
).showSnackBar(SnackBar(content: Text(l10n.settings_txPowerInvalid))); SnackBar(
content: Text('${l10n.settings_txPowerInvalid} (0-$maxTxPower dBm)'),
),
);
return; return;
} }
@@ -795,9 +963,29 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> {
widget.connector.currentCr, widget.connector.currentCr,
); );
// if the client repeat isnt null then we know its supported
//otherwise we leave it out of the frame to avoid accidentally enabling
final knownRepeat = widget.connector.clientRepeat != null;
if (knownRepeat) {
const validRepeatFreqsKHz = {433000, 869000, 918000};
if (_clientRepeat && !validRepeatFreqsKHz.contains(freqHz)) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.settings_clientRepeatFreqWarning)),
);
return;
}
}
try { try {
await widget.connector.sendFrame( await widget.connector.sendFrame(
buildSetRadioParamsFrame(freqHz, bwHz, sf, cr), buildSetRadioParamsFrame(
freqHz,
bwHz,
sf,
cr,
clientRepeat: knownRepeat ? _clientRepeat : null,
),
); );
await widget.connector.sendFrame(buildSetRadioTxPowerFrame(txPower)); await widget.connector.sendFrame(buildSetRadioTxPowerFrame(txPower));
await widget.connector.refreshDeviceInfo(); await widget.connector.refreshDeviceInfo();
@@ -836,37 +1024,25 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( DropdownButtonFormField<int>(
l10n.settings_presets, decoration: InputDecoration(
style: const TextStyle(fontWeight: FontWeight.bold), labelText: l10n.settings_presets,
), border: const OutlineInputBorder(),
const SizedBox(height: 8), ),
Wrap( items: [
spacing: 8, for (var i = 0; i < RadioSettings.presets.length; i++)
children: [ DropdownMenuItem(
_PresetChip( value: i,
label: l10n.settings_preset915Mhz, child: Text(RadioSettings.presets[i].$1),
onTap: () => _applyPreset(RadioSettings.preset915MHz), ),
),
_PresetChip(
label: l10n.settings_preset868Mhz,
onTap: () => _applyPreset(RadioSettings.preset868MHz),
),
_PresetChip(
label: l10n.settings_preset433Mhz,
onTap: () => _applyPreset(RadioSettings.preset433MHz),
),
_PresetChip(
label: l10n.settings_longRange,
onTap: () => _applyPreset(RadioSettings.presetLongRange),
),
_PresetChip(
label: l10n.settings_fastSpeed,
onTap: () => _applyPreset(RadioSettings.presetFastSpeed),
),
], ],
onChanged: (index) {
if (index != null) {
_applyPreset(RadioSettings.presets[index].$2);
}
},
), ),
const SizedBox(height: 24), const SizedBox(height: 16),
TextField( TextField(
controller: _frequencyController, controller: _frequencyController,
decoration: InputDecoration( decoration: InputDecoration(
@@ -932,10 +1108,22 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> {
decoration: InputDecoration( decoration: InputDecoration(
labelText: l10n.settings_txPower, labelText: l10n.settings_txPower,
border: const OutlineInputBorder(), 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, keyboardType: TextInputType.number,
), ),
if (widget.connector.clientRepeat != null) ...[
const SizedBox(height: 16),
SwitchListTile(
title: Text(l10n.settings_clientRepeat),
subtitle: Text(l10n.settings_clientRepeatSubtitle),
value: _clientRepeat,
onChanged: (value) => setState(() => _clientRepeat = value),
contentPadding: EdgeInsets.zero,
),
],
], ],
), ),
), ),
@@ -949,15 +1137,3 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> {
); );
} }
} }
class _PresetChip extends StatelessWidget {
final String label;
final VoidCallback onTap;
const _PresetChip({required this.label, required this.onTap});
@override
Widget build(BuildContext context) {
return ActionChip(label: Text(label), onPressed: onTap);
}
}
+6 -6
View File
@@ -1,10 +1,6 @@
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
enum AppDebugLogLevel { enum AppDebugLogLevel { info, warning, error }
info,
warning,
error,
}
class AppDebugLogEntry { class AppDebugLogEntry {
final DateTime timestamp; final DateTime timestamp;
@@ -51,7 +47,11 @@ class AppDebugLogService extends ChangeNotifier {
notifyListeners(); 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; if (!_enabled) return;
_entries.add( _entries.add(
+11 -7
View File
@@ -82,10 +82,7 @@ class AppSettingsService extends ChangeNotifier {
final safeMin = minZoom <= maxZoom ? minZoom : maxZoom; final safeMin = minZoom <= maxZoom ? minZoom : maxZoom;
final safeMax = minZoom <= maxZoom ? maxZoom : minZoom; final safeMax = minZoom <= maxZoom ? maxZoom : minZoom;
await updateSettings( await updateSettings(
_settings.copyWith( _settings.copyWith(mapCacheMinZoom: safeMin, mapCacheMaxZoom: safeMax),
mapCacheMinZoom: safeMin,
mapCacheMaxZoom: safeMax,
),
); );
} }
@@ -123,9 +120,16 @@ class AppSettingsService extends ChangeNotifier {
appLogger.setEnabled(value); appLogger.setEnabled(value);
} }
Future<void> setBatteryChemistryForDevice(String deviceId, String chemistry) async { Future<void> setBatteryChemistryForDevice(
final updated = Map<String, String>.from(_settings.batteryChemistryByDeviceId); String deviceId,
String chemistry,
) async {
final updated = Map<String, String>.from(
_settings.batteryChemistryByDeviceId,
);
updated[deviceId] = chemistry; updated[deviceId] = chemistry;
await updateSettings(_settings.copyWith(batteryChemistryByDeviceId: updated)); await updateSettings(
_settings.copyWith(batteryChemistryByDeviceId: updated),
);
} }
} }
+5 -12
View File
@@ -1,4 +1,3 @@
import 'dart:isolate';
import 'dart:io'; import 'dart:io';
import 'package:flutter_foreground_task/flutter_foreground_task.dart'; import 'package:flutter_foreground_task/flutter_foreground_task.dart';
@@ -15,20 +14,14 @@ class BackgroundService {
channelDescription: 'Keeps MeshCore running in the background.', channelDescription: 'Keeps MeshCore running in the background.',
channelImportance: NotificationChannelImportance.LOW, channelImportance: NotificationChannelImportance.LOW,
priority: NotificationPriority.LOW, priority: NotificationPriority.LOW,
iconData: const NotificationIconData(
resType: ResourceType.mipmap,
resPrefix: ResourcePrefix.ic,
name: 'launcher',
),
), ),
iosNotificationOptions: const IOSNotificationOptions( iosNotificationOptions: const IOSNotificationOptions(
showNotification: false, showNotification: false,
playSound: false, playSound: false,
), ),
foregroundTaskOptions: const ForegroundTaskOptions( foregroundTaskOptions: ForegroundTaskOptions(
interval: 5000, eventAction: ForegroundTaskEventAction.repeat(5000),
autoRunOnBoot: false, autoRunOnBoot: false,
allowWakeLock: true,
allowWifiLock: false, allowWifiLock: false,
), ),
); );
@@ -64,13 +57,13 @@ void startCallback() {
class _MeshCoreTaskHandler extends TaskHandler { class _MeshCoreTaskHandler extends TaskHandler {
@override @override
void onStart(DateTime timestamp, SendPort? sendPort) {} Future<void> onStart(DateTime timestamp, TaskStarter starter) async {}
@override @override
void onRepeatEvent(DateTime timestamp, SendPort? sendPort) {} void onRepeatEvent(DateTime timestamp) {}
@override @override
void onDestroy(DateTime timestamp, SendPort? sendPort) {} Future<void> onDestroy(DateTime timestamp, bool isTimeout) async {}
@override @override
void onNotificationButtonPressed(String id) {} void onNotificationButtonPressed(String id) {}
+4
View File
@@ -156,6 +156,8 @@ class BleDebugLogService extends ChangeNotifier {
return 'CMD_GET_RADIO_SETTINGS'; return 'CMD_GET_RADIO_SETTINGS';
case cmdSetCustomVar: case cmdSetCustomVar:
return 'CMD_SET_CUSTOM_VAR'; return 'CMD_SET_CUSTOM_VAR';
case cmdSendTracePath:
return 'CMD_SEND_TRACE_PATH';
default: default:
return null; return null;
} }
@@ -195,6 +197,8 @@ class BleDebugLogService extends ChangeNotifier {
return 'RESP_CODE_CHANNEL_INFO'; return 'RESP_CODE_CHANNEL_INFO';
case respCodeRadioSettings: case respCodeRadioSettings:
return 'RESP_CODE_RADIO_SETTINGS'; return 'RESP_CODE_RADIO_SETTINGS';
case pushCodeTraceData:
return 'PUSH_CODE_TRACE_DATA';
default: default:
return null; return null;
} }
+29 -26
View File
@@ -42,20 +42,21 @@ class MapTileCacheService {
late final TileProvider tileProvider; late final TileProvider tileProvider;
MapTileCacheService({BaseCacheManager? cacheManager}) MapTileCacheService({BaseCacheManager? cacheManager})
: cacheManager = cacheManager ?? : cacheManager =
CacheManager( cacheManager ??
Config( CacheManager(
cacheKey, Config(
stalePeriod: const Duration(days: 365), cacheKey,
maxNrOfCacheObjects: 200000, stalePeriod: const Duration(days: 365),
), maxNrOfCacheObjects: 200000,
) { ),
) {
tileProvider = CachedNetworkTileProvider(cacheManager: this.cacheManager); tileProvider = CachedNetworkTileProvider(cacheManager: this.cacheManager);
} }
Map<String, String> get defaultHeaders => { Map<String, String> get defaultHeaders => {
'User-Agent': 'flutter_map ($userAgentPackageName)', 'User-Agent': 'flutter_map ($userAgentPackageName)',
}; };
Future<void> clearCache() async { Future<void> clearCache() async {
await cacheManager.emptyCache(); await cacheManager.emptyCache();
@@ -96,17 +97,21 @@ class MapTileCacheService {
final future = cacheManager final future = cacheManager
.downloadFile(url, key: url, authHeaders: authHeaders) .downloadFile(url, key: url, authHeaders: authHeaders)
.then((_) { .then((_) {
completed += 1; completed += 1;
}).catchError((_) { })
completed += 1; .catchError((_) {
failed += 1; completed += 1;
}).whenComplete(() { failed += 1;
onProgress?.call(MapTileCacheProgress( })
completed: completed, .whenComplete(() {
total: total, onProgress?.call(
failed: failed, MapTileCacheProgress(
)); completed: completed,
}); total: total,
failed: failed,
),
);
});
pending.add(future); pending.add(future);
if (pending.length >= safeConcurrency) { if (pending.length >= safeConcurrency) {
@@ -189,11 +194,9 @@ class MapTileCacheService {
int _latToTileY(double lat, int zoom, int maxIndex) { int _latToTileY(double lat, int zoom, int maxIndex) {
final n = 1 << zoom; final n = 1 << zoom;
final rad = lat * math.pi / 180.0; final rad = lat * math.pi / 180.0;
final value = ((1 - final value =
math.log(math.tan(rad) + 1 / math.cos(rad)) / math.pi) / ((1 - math.log(math.tan(rad) + 1 / math.cos(rad)) / math.pi) / 2 * n)
2 * .floor();
n)
.floor();
return value.clamp(0, maxIndex); return value.clamp(0, maxIndex);
} }
+140 -57
View File
@@ -25,10 +25,7 @@ class _AckHashMapping {
final String messageId; final String messageId;
final DateTime timestamp; final DateTime timestamp;
_AckHashMapping({ _AckHashMapping({required this.messageId, required this.timestamp});
required this.messageId,
required this.timestamp,
});
} }
class MessageRetryService extends ChangeNotifier { class MessageRetryService extends ChangeNotifier {
@@ -39,11 +36,16 @@ class MessageRetryService extends ChangeNotifier {
final Map<String, Message> _pendingMessages = {}; final Map<String, Message> _pendingMessages = {};
final Map<String, Contact> _pendingContacts = {}; final Map<String, Contact> _pendingContacts = {};
final Map<String, PathSelection> _pendingPathSelections = {}; final Map<String, PathSelection> _pendingPathSelections = {};
final Map<String, _AckHashMapping> _ackHashToMessageId = {}; // ackHashHex messageId + timestamp for O(1) lookup final Map<String, _AckHashMapping> _ackHashToMessageId =
final Map<String, List<Uint8List>> _expectedAckHashes = {}; // Track all expected ACKs for retries (for history) {}; // ackHashHex messageId + timestamp for O(1) lookup
final List<_AckHistoryEntry> _ackHistory = []; // Rolling buffer of recent ACK hashes final Map<String, List<Uint8List>> _expectedAckHashes =
final Map<String, List<String>> _pendingMessageQueuePerContact = {}; // contactPubKeyHex FIFO queue of messageIds (DEPRECATED - will be removed) {}; // Track all expected ACKs for retries (for history)
final Map<String, String> _expectedHashToMessageId = {}; // expectedAckHashHex messageId (for matching RESP_CODE_SENT by hash) 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(Contact, String, int, int)? _sendMessageCallback;
Function(String, Message)? _addMessageCallback; Function(String, Message)? _addMessageCallback;
@@ -130,7 +132,8 @@ class MessageRetryService extends ChangeNotifier {
final messagePathBytes = final messagePathBytes =
pathBytes ?? _resolveMessagePathBytes(contact, useFlood, pathSelection); pathBytes ?? _resolveMessagePathBytes(contact, useFlood, pathSelection);
final messagePathLength = final messagePathLength =
pathLength ?? _resolveMessagePathLength(contact, useFlood, pathSelection); pathLength ??
_resolveMessagePathLength(contact, useFlood, pathSelection);
final message = Message( final message = Message(
senderKey: contact.publicKey, senderKey: contact.publicKey,
text: text, text: text,
@@ -167,15 +170,25 @@ class MessageRetryService extends ChangeNotifier {
if (_setContactPathCallback != null && _clearContactPathCallback != null) { if (_setContactPathCallback != null && _clearContactPathCallback != null) {
if (message.pathLength != null && message.pathLength! < 0) { if (message.pathLength != null && message.pathLength! < 0) {
// Flood mode - clear the path // 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); _clearContactPathCallback!(contact);
} else if (message.pathLength != null && message.pathLength! >= 0) { } else if (message.pathLength != null && message.pathLength! >= 0) {
// Specific path (including direct neighbor with pathLength=0) // Specific path (including direct neighbor with pathLength=0)
final pathStr = message.pathBytes.isEmpty final pathStr = message.pathBytes.isEmpty
? 'direct' ? 'direct'
: message.pathBytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(','); : message.pathBytes
debugPrint('Setting path [$pathStr] (${message.pathLength} hops) for retry attempt ${message.retryCount}'); .map((b) => b.toRadixString(16).padLeft(2, '0'))
await _setContactPathCallback!(contact, message.pathBytes, message.pathLength!); .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 // IMPORTANT: Use the transformed text (with SMAZ encoding if enabled) to match device's hash
final selfPubKey = _getSelfPublicKeyCallback?.call(); final selfPubKey = _getSelfPublicKeyCallback?.call();
if (selfPubKey != null) { 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( final expectedHash = MessageRetryService.computeExpectedAckHash(
timestampSeconds, timestampSeconds,
attempt, attempt,
outboundText, outboundText,
selfPubKey, 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; _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( _debugLogService?.info(
'Sent "$shortText" to ${contact.name} → expect ACK hash $expectedHashHex (attempt $attempt)', 'Sent "$shortText" to ${contact.name} → expect ACK hash $expectedHashHex (attempt $attempt)',
tag: 'AckHash', 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) // DEPRECATED: Old queue-based matching (kept for fallback)
@@ -209,17 +230,14 @@ class MessageRetryService extends ChangeNotifier {
_pendingMessageQueuePerContact[contact.publicKeyHex]!.add(messageId); _pendingMessageQueuePerContact[contact.publicKeyHex]!.add(messageId);
if (_sendMessageCallback != null) { if (_sendMessageCallback != null) {
_sendMessageCallback!( _sendMessageCallback!(contact, message.text, attempt, timestampSeconds);
contact,
message.text,
attempt,
timestampSeconds,
);
} }
} }
void updateMessageFromSent(Uint8List ackHash, int timeoutMs) { 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) // NEW: Try hash-based matching first (fixes LoRa message drops causing mismatches)
String? messageId = _expectedHashToMessageId.remove(ackHashHex); String? messageId = _expectedHashToMessageId.remove(ackHashHex);
@@ -230,16 +248,21 @@ class MessageRetryService extends ChangeNotifier {
final message = _pendingMessages[messageId]; final message = _pendingMessages[messageId];
if (contact != null && message != null) { 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( _debugLogService?.info(
'RESP_CODE_SENT received: ACK hash $ackHashHex ✓ matched "$shortText" to ${contact.name}', 'RESP_CODE_SENT received: ACK hash $ackHashHex ✓ matched "$shortText" to ${contact.name}',
tag: 'AckHash', 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 // Remove from old queue since we matched
_pendingMessageQueuePerContact[contact.publicKeyHex]?.remove(messageId); _pendingMessageQueuePerContact[contact.publicKeyHex]?.remove(messageId);
if (_pendingMessageQueuePerContact[contact.publicKeyHex]?.isEmpty ?? false) { if (_pendingMessageQueuePerContact[contact.publicKeyHex]?.isEmpty ??
false) {
_pendingMessageQueuePerContact.remove(contact.publicKeyHex); _pendingMessageQueuePerContact.remove(contact.publicKeyHex);
} }
} else { } else {
@@ -259,7 +282,9 @@ class MessageRetryService extends ChangeNotifier {
'RESP_CODE_SENT: ACK hash $ackHashHex not found in hash table, falling back to queue', 'RESP_CODE_SENT: ACK hash $ackHashHex not found in hash table, falling back to queue',
tag: 'AckHash', 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) { for (var entry in _pendingMessageQueuePerContact.entries) {
final contactKey = entry.key; final contactKey = entry.key;
@@ -271,7 +296,9 @@ class MessageRetryService extends ChangeNotifier {
if (_pendingMessages.containsKey(candidateMessageId)) { if (_pendingMessages.containsKey(candidateMessageId)) {
messageId = candidateMessageId; messageId = candidateMessageId;
contact = _pendingContacts[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; break;
} else { } else {
debugPrint('Dequeued stale message $candidateMessageId - skipping'); debugPrint('Dequeued stale message $candidateMessageId - skipping');
@@ -280,7 +307,9 @@ class MessageRetryService extends ChangeNotifier {
if (_pendingMessages.containsKey(nextMessageId)) { if (_pendingMessages.containsKey(nextMessageId)) {
messageId = nextMessageId; messageId = nextMessageId;
contact = _pendingContacts[nextMessageId]; contact = _pendingContacts[nextMessageId];
debugPrint('Queue-based match (fallback): $ackHashHex → message $messageId'); debugPrint(
'Queue-based match (fallback): $ackHashHex → message $messageId',
);
break; break;
} }
} }
@@ -306,16 +335,22 @@ class MessageRetryService extends ChangeNotifier {
final selection = _pendingPathSelections[messageId]; final selection = _pendingPathSelections[messageId];
if (message == null) { 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); _ackHashToMessageId.remove(ackHashHex);
return; return;
} }
// Add this ACK hash to the list of expected ACKs for this message (for history) // Add this ACK hash to the list of expected ACKs for this message (for history)
_expectedAckHashes[messageId] ??= []; _expectedAckHashes[messageId] ??= [];
if (!_expectedAckHashes[messageId]!.any((hash) => listEquals(hash, ackHash))) { if (!_expectedAckHashes[messageId]!.any(
(hash) => listEquals(hash, ackHash),
)) {
_expectedAckHashes[messageId]!.add(Uint8List.fromList(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 // Use device-provided timeout, or calculate from radio settings if timeout is 0 or invalid
@@ -330,8 +365,13 @@ class MessageRetryService extends ChangeNotifier {
} else { } else {
pathLengthValue = contact.pathLength; pathLengthValue = contact.pathLength;
} }
actualTimeout = _calculateTimeoutCallback!(pathLengthValue, message.text.length); actualTimeout = _calculateTimeoutCallback!(
debugPrint('Using calculated timeout: ${actualTimeout}ms for path length $pathLengthValue'); pathLengthValue,
message.text.length,
);
debugPrint(
'Using calculated timeout: ${actualTimeout}ms for path length $pathLengthValue',
);
} }
final updatedMessage = message.copyWith( final updatedMessage = message.copyWith(
@@ -364,16 +404,22 @@ class MessageRetryService extends ChangeNotifier {
final selection = _pendingPathSelections[messageId]; final selection = _pendingPathSelections[messageId];
if (message == null || contact == null) { 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; 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( _debugLogService?.warn(
'Timeout: No ACK received for "$shortText" to ${contact.name} (attempt ${message.retryCount}) → retrying', 'Timeout: No ACK received for "$shortText" to ${contact.name} (attempt ${message.retryCount}) → retrying',
tag: 'AckHash', 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) { if (message.retryCount < maxRetries - 1) {
final backoffMs = 1000 * (1 << message.retryCount); final backoffMs = 1000 * (1 << message.retryCount);
@@ -402,7 +448,9 @@ class MessageRetryService extends ChangeNotifier {
if (_pendingMessages.containsKey(messageId)) { if (_pendingMessages.containsKey(messageId)) {
_attemptSend(messageId); _attemptSend(messageId);
} else { } else {
debugPrint('Retry cancelled: message $messageId was delivered while waiting'); debugPrint(
'Retry cancelled: message $messageId was delivered while waiting',
);
} }
}); });
} else { } else {
@@ -420,7 +468,8 @@ class MessageRetryService extends ChangeNotifier {
// Clean up the queue entry for this contact // Clean up the queue entry for this contact
_pendingMessageQueuePerContact[contact.publicKeyHex]?.remove(messageId); _pendingMessageQueuePerContact[contact.publicKeyHex]?.remove(messageId);
if (_pendingMessageQueuePerContact[contact.publicKeyHex]?.isEmpty ?? false) { if (_pendingMessageQueuePerContact[contact.publicKeyHex]?.isEmpty ??
false) {
_pendingMessageQueuePerContact.remove(contact.publicKeyHex); _pendingMessageQueuePerContact.remove(contact.publicKeyHex);
} }
@@ -430,7 +479,13 @@ class MessageRetryService extends ChangeNotifier {
_clearContactPathCallback!(contact); _clearContactPathCallback!(contact);
} }
_recordPathResultFromMessage(contact.publicKeyHex, message, selection, false, null); _recordPathResultFromMessage(
contact.publicKeyHex,
message,
selection,
false,
null,
);
if (_updateMessageCallback != null) { if (_updateMessageCallback != null) {
_updateMessageCallback!(failedMessage); _updateMessageCallback!(failedMessage);
@@ -443,18 +498,22 @@ class MessageRetryService extends ChangeNotifier {
void _moveAckHashesToHistory(String messageId) { void _moveAckHashesToHistory(String messageId) {
final ackHashes = _expectedAckHashes.remove(messageId); final ackHashes = _expectedAckHashes.remove(messageId);
if (ackHashes != null && ackHashes.isNotEmpty) { if (ackHashes != null && ackHashes.isNotEmpty) {
_ackHistory.add(_AckHistoryEntry( _ackHistory.add(
messageId: messageId, _AckHistoryEntry(
ackHashes: ackHashes, messageId: messageId,
timestamp: DateTime.now(), ackHashes: ackHashes,
)); timestamp: DateTime.now(),
),
);
// Trim history to max size (rolling buffer) // Trim history to max size (rolling buffer)
while (_ackHistory.length > maxAckHistorySize) { while (_ackHistory.length > maxAckHistorySize) {
_ackHistory.removeAt(0); _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 entry in _ackHistory) {
for (final expectedHash in entry.ackHashes) { for (final expectedHash in entry.ackHashes) {
if (listEquals(expectedHash, ackHash)) { 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; return true;
} }
} }
@@ -472,7 +533,9 @@ class MessageRetryService extends ChangeNotifier {
void handleAckReceived(Uint8List ackHash, int tripTimeMs) { void handleAckReceived(Uint8List ackHash, int tripTimeMs) {
String? matchedMessageId; 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'); debugPrint('ACK received: $ackHashHex, trip time: ${tripTimeMs}ms');
@@ -502,7 +565,9 @@ class MessageRetryService extends ChangeNotifier {
tag: 'AckHash', tag: 'AckHash',
); );
// Fallback: Check against ALL expected ACK hashes (from all retry attempts) // 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) { for (var entry in _expectedAckHashes.entries) {
final messageId = entry.key; final messageId = entry.key;
final expectedHashes = entry.value; final expectedHashes = entry.value;
@@ -510,7 +575,9 @@ class MessageRetryService extends ChangeNotifier {
for (final expectedHash in expectedHashes) { for (final expectedHash in expectedHashes) {
if (listEquals(expectedHash, ackHash)) { if (listEquals(expectedHash, ackHash)) {
matchedMessageId = messageId; 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; break;
} }
} }
@@ -524,7 +591,9 @@ class MessageRetryService extends ChangeNotifier {
final contact = _pendingContacts[matchedMessageId]; final contact = _pendingContacts[matchedMessageId];
final selection = _pendingPathSelections[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( _debugLogService?.info(
'PUSH_CODE_SEND_CONFIRMED: ACK hash $ackHashHex ✓ "$shortText" delivered to ${contact?.name ?? "unknown"} in ${tripTimeMs}ms', 'PUSH_CODE_SEND_CONFIRMED: ACK hash $ackHashHex ✓ "$shortText" delivered to ${contact?.name ?? "unknown"} in ${tripTimeMs}ms',
tag: 'AckHash', 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) // Clean up the queue entry for this contact (remove any remaining references to this message)
if (contact != null) { if (contact != null) {
_pendingMessageQueuePerContact[contact.publicKeyHex]?.remove(matchedMessageId); _pendingMessageQueuePerContact[contact.publicKeyHex]?.remove(
if (_pendingMessageQueuePerContact[contact.publicKeyHex]?.isEmpty ?? false) { matchedMessageId,
);
if (_pendingMessageQueuePerContact[contact.publicKeyHex]?.isEmpty ??
false) {
_pendingMessageQueuePerContact.remove(contact.publicKeyHex); _pendingMessageQueuePerContact.remove(contact.publicKeyHex);
} }
} }
@@ -560,7 +632,13 @@ class MessageRetryService extends ChangeNotifier {
} }
if (contact != null) { if (contact != null) {
_recordPathResultFromMessage(contact.publicKeyHex, message, selection, true, tripTimeMs); _recordPathResultFromMessage(
contact.publicKeyHex,
message,
selection,
true,
tripTimeMs,
);
} }
notifyListeners(); notifyListeners();
@@ -663,7 +741,12 @@ class MessageRetryService extends ChangeNotifier {
if (_recordPathResultCallback == null) return; if (_recordPathResultCallback == null) return;
final recordSelection = selection ?? _selectionFromMessage(message); final recordSelection = selection ?? _selectionFromMessage(message);
if (recordSelection == null) return; if (recordSelection == null) return;
_recordPathResultCallback!(contactKey, recordSelection, success, tripTimeMs); _recordPathResultCallback!(
contactKey,
recordSelection,
success,
tripTimeMs,
);
} }
PathSelection? _selectionFromMessage(Message message) { PathSelection? _selectionFromMessage(Message message) {
+285 -29
View File
@@ -1,18 +1,53 @@
import 'dart:ui';
import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import '../l10n/app_localizations.dart';
class NotificationService { class NotificationService {
static final NotificationService _instance = NotificationService._internal(); static final NotificationService _instance = NotificationService._internal();
factory NotificationService() => _instance; factory NotificationService() => _instance;
NotificationService._internal(); NotificationService._internal();
final FlutterLocalNotificationsPlugin _notifications = FlutterLocalNotificationsPlugin(); final FlutterLocalNotificationsPlugin _notifications =
FlutterLocalNotificationsPlugin();
bool _isInitialized = false; 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 { Future<void> initialize() async {
if (_isInitialized) return; if (_isInitialized) return;
const androidSettings = AndroidInitializationSettings('@mipmap/ic_launcher'); const androidSettings = AndroidInitializationSettings(
'@mipmap/ic_launcher',
);
const iosSettings = DarwinInitializationSettings( const iosSettings = DarwinInitializationSettings(
requestAlertPermission: true, requestAlertPermission: true,
requestBadgePermission: true, requestBadgePermission: true,
@@ -32,7 +67,7 @@ class NotificationService {
try { try {
await _notifications.initialize( await _notifications.initialize(
initSettings, settings: initSettings,
onDidReceiveNotificationResponse: _onNotificationTapped, onDidReceiveNotificationResponse: _onNotificationTapped,
); );
_isInitialized = true; _isInitialized = true;
@@ -47,16 +82,20 @@ class NotificationService {
} }
// Request Android 13+ notification permission // Request Android 13+ notification permission
final androidPlugin = _notifications.resolvePlatformSpecificImplementation< final androidPlugin = _notifications
AndroidFlutterLocalNotificationsPlugin>(); .resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin
>();
if (androidPlugin != null) { if (androidPlugin != null) {
final granted = await androidPlugin.requestNotificationsPermission(); final granted = await androidPlugin.requestNotificationsPermission();
return granted ?? false; return granted ?? false;
} }
// iOS permissions are requested during initialization // iOS permissions are requested during initialization
final iosPlugin = _notifications.resolvePlatformSpecificImplementation< final iosPlugin = _notifications
IOSFlutterLocalNotificationsPlugin>(); .resolvePlatformSpecificImplementation<
IOSFlutterLocalNotificationsPlugin
>();
if (iosPlugin != null) { if (iosPlugin != null) {
final granted = await iosPlugin.requestPermissions( final granted = await iosPlugin.requestPermissions(
alert: true, alert: true,
@@ -69,7 +108,7 @@ class NotificationService {
return true; return true;
} }
Future<void> showMessageNotification({ Future<void> _showMessageNotificationImpl({
required String contactName, required String contactName,
required String message, required String message,
String? contactId, String? contactId,
@@ -110,15 +149,15 @@ class NotificationService {
); );
await _notifications.show( await _notifications.show(
contactId?.hashCode ?? 0, id: contactId?.hashCode ?? 0,
'New message from $contactName', title: contactName,
message.length > 100 ? '${message.substring(0, 100)}...' : message, body: message,
notificationDetails, notificationDetails: notificationDetails,
payload: 'message:$contactId', payload: 'message:$contactId',
); );
} }
Future<void> showAdvertNotification({ Future<void> _showAdvertNotificationImpl({
required String contactName, required String contactName,
required String contactType, required String contactType,
String? contactId, String? contactId,
@@ -155,15 +194,15 @@ class NotificationService {
); );
await _notifications.show( await _notifications.show(
contactId?.hashCode ?? DateTime.now().millisecondsSinceEpoch, id: contactId?.hashCode ?? DateTime.now().millisecondsSinceEpoch,
'New $contactType discovered', title: _l10n.notification_newTypeDiscovered(contactType),
contactName, body: contactName,
notificationDetails, notificationDetails: notificationDetails,
payload: 'advert:$contactId', payload: 'advert:$contactId',
); );
} }
Future<void> showChannelMessageNotification({ Future<void> _showChannelMessageNotificationImpl({
required String channelName, required String channelName,
required String message, required String message,
int? channelIndex, int? channelIndex,
@@ -203,24 +242,33 @@ class NotificationService {
macOS: macDetails, macOS: macDetails,
); );
final preview = _truncateMessage(message, 30); final preview = message.trim();
final body = preview.isEmpty final body = preview.isEmpty
? 'Received new message' ? _l10n.notification_receivedNewMessage
: preview; : preview;
await _notifications.show( await _notifications.show(
channelIndex?.hashCode ?? DateTime.now().millisecondsSinceEpoch, id: channelIndex?.hashCode ?? DateTime.now().millisecondsSinceEpoch,
channelName, title: channelName,
body, body: body,
notificationDetails, notificationDetails: notificationDetails,
payload: 'channel:$channelIndex', payload: 'channel:$channelIndex',
); );
} }
String _truncateMessage(String message, int maxLength) { /// Returns a privacy-safe identifier for debug logging.
final trimmed = message.trim(); /// - advert: shows device name (body contains contactName)
if (trimmed.length <= maxLength) return trimmed; /// - message: shows "from: sender" (avoids logging message content)
return '${trimmed.substring(0, maxLength)}...'; /// - 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) { void _onNotificationTapped(NotificationResponse response) {
@@ -237,6 +285,214 @@ class NotificationService {
} }
Future<void> cancel(int id) async { Future<void> cancel(int id) async {
await _notifications.cancel(id); await _notifications.cancel(id: id);
}
//
// Public notification methods (rate limiting is enforced automatically)
//
Future<void> showMessageNotification({
required String contactName,
required String message,
String? contactId,
int? badgeCount,
}) async {
if (_suppressNotifications) return;
_queueNotification(
_PendingNotification(
type: _NotificationType.message,
title: contactName,
body: message,
id: contactId,
badgeCount: badgeCount,
),
);
}
Future<void> showAdvertNotification({
required String contactName,
required String contactType,
String? contactId,
}) async {
if (_suppressNotifications) return;
_queueNotification(
_PendingNotification(
type: _NotificationType.advert,
title: contactType,
body: contactName,
id: contactId,
),
);
}
Future<void> showChannelMessageNotification({
required String channelName,
required String message,
int? channelIndex,
int? badgeCount,
}) async {
if (_suppressNotifications) return;
_queueNotification(
_PendingNotification(
type: _NotificationType.channelMessage,
title: channelName,
body: message,
id: channelIndex?.toString(),
badgeCount: badgeCount,
),
);
}
void _queueNotification(_PendingNotification notification) {
final now = DateTime.now();
// If we recently showed a notification, start batching
if (_lastNotificationTime != null &&
now.difference(_lastNotificationTime!) < _minNotificationInterval) {
_pendingNotifications.add(notification);
debugPrint(
'[Notification] queued: ${notification.type.name} (${_getNotificationIdentifier(notification)})',
);
// Start batch timer if not already running
if (!_isBatchingActive) {
_isBatchingActive = true;
Future.delayed(_batchWindow, _processBatch);
}
return;
}
// Show immediately if enough time has passed
debugPrint(
'[Notification] sent immediately: ${notification.type.name} (${_getNotificationIdentifier(notification)})',
);
_showNotificationImmediately(notification);
_lastNotificationTime = now;
}
Future<void> _processBatch() async {
_isBatchingActive = false;
if (_pendingNotifications.isEmpty) return;
final batch = List<_PendingNotification>.from(_pendingNotifications);
_pendingNotifications.clear();
if (batch.length == 1) {
// Single notification, show normally
_showNotificationImmediately(batch.first);
} else {
// Multiple notifications, show summary
await _showBatchSummary(batch);
}
_lastNotificationTime = DateTime.now();
}
Future<void> _showNotificationImmediately(
_PendingNotification notification,
) async {
switch (notification.type) {
case _NotificationType.message:
await _showMessageNotificationImpl(
contactName: notification.title,
message: notification.body,
contactId: notification.id,
badgeCount: notification.badgeCount,
);
break;
case _NotificationType.advert:
await _showAdvertNotificationImpl(
contactName: notification.body,
contactType: notification.title,
contactId: notification.id,
);
break;
case _NotificationType.channelMessage:
await _showChannelMessageNotificationImpl(
channelName: notification.title,
message: notification.body,
channelIndex: int.tryParse(notification.id ?? ''),
badgeCount: notification.badgeCount,
);
break;
}
}
Future<void> _showBatchSummary(List<_PendingNotification> batch) async {
if (!_isInitialized) await initialize();
// Group by type
final messages = batch
.where((n) => n.type == _NotificationType.message)
.toList();
final adverts = batch
.where((n) => n.type == _NotificationType.advert)
.toList();
final channelMsgs = batch
.where((n) => n.type == _NotificationType.channelMessage)
.toList();
// Build summary text using localized plurals
final parts = <String>[];
if (messages.isNotEmpty) {
parts.add(_l10n.notification_messagesCount(messages.length));
}
if (channelMsgs.isNotEmpty) {
parts.add(_l10n.notification_channelMessagesCount(channelMsgs.length));
}
if (adverts.isNotEmpty) {
parts.add(_l10n.notification_newNodesCount(adverts.length));
}
if (parts.isEmpty) return;
// Show first few device names in batch summary for debugging (only if adverts exist)
final deviceInfo = adverts.isNotEmpty
? ' (${adverts.take(5).map((n) => n.body).join(', ')}${adverts.length > 5 ? ', ...' : ''})'
: '';
debugPrint('[Notification] batch summary: ${parts.join(", ")}$deviceInfo');
const androidDetails = AndroidNotificationDetails(
'batch_summary',
'Activity Summary',
channelDescription: 'Batched notification summaries',
importance: Importance.defaultImportance,
priority: Priority.defaultPriority,
icon: '@mipmap/ic_launcher',
);
const notificationDetails = NotificationDetails(android: androidDetails);
await _notifications.show(
id: 'batch_summary'.hashCode,
title: _l10n.notification_activityTitle,
body: parts.join(', '),
notificationDetails: notificationDetails,
payload: 'batch',
);
} }
} }
// Helper class for pending notifications
enum _NotificationType { message, advert, channelMessage }
class _PendingNotification {
final _NotificationType type;
final String title;
final String body;
final String? id;
final int? badgeCount;
_PendingNotification({
required this.type,
required this.title,
required this.body,
this.id,
this.badgeCount,
});
}
+30 -16
View File
@@ -61,7 +61,10 @@ class PathHistoryService extends ChangeNotifier {
int? tripTimeMs, int? tripTimeMs,
}) { }) {
if (selection.useFlood) { if (selection.useFlood) {
final stats = _floodStats.putIfAbsent(contactPubKeyHex, () => _FloodStats()); final stats = _floodStats.putIfAbsent(
contactPubKeyHex,
() => _FloodStats(),
);
if (success) { if (success) {
stats.successCount += 1; stats.successCount += 1;
if (tripTimeMs != null) stats.lastTripTimeMs = tripTimeMs; if (tripTimeMs != null) stats.lastTripTimeMs = tripTimeMs;
@@ -88,23 +91,28 @@ class PathHistoryService extends ChangeNotifier {
} }
PathSelection getNextAutoPathSelection(String contactPubKeyHex) { PathSelection getNextAutoPathSelection(String contactPubKeyHex) {
final ranked = _getRankedPaths(contactPubKeyHex) final ranked = _getRankedPaths(
.take(_autoRotationTopCount) contactPubKeyHex,
.toList(); ).take(_autoRotationTopCount).toList();
if (ranked.isEmpty) { if (ranked.isEmpty) {
return const PathSelection(pathBytes: [], hopCount: -1, useFlood: true); return const PathSelection(pathBytes: [], hopCount: -1, useFlood: true);
} }
_trackAccess(contactPubKeyHex); _trackAccess(contactPubKeyHex);
final selections = ranked final selections =
.map((path) => PathSelection( ranked
pathBytes: path.pathBytes, .map(
hopCount: path.hopCount, (path) => PathSelection(
useFlood: false, pathBytes: path.pathBytes,
)) hopCount: path.hopCount,
.toList() useFlood: false,
..add(const PathSelection(pathBytes: [], hopCount: -1, useFlood: true)); ),
)
.toList()
..add(
const PathSelection(pathBytes: [], hopCount: -1, useFlood: true),
);
final currentIndex = _autoRotationIndex[contactPubKeyHex] ?? 0; final currentIndex = _autoRotationIndex[contactPubKeyHex] ?? 0;
final selection = selections[currentIndex % selections.length]; final selection = selections[currentIndex % selections.length];
@@ -241,7 +249,8 @@ class PathHistoryService extends ChangeNotifier {
} }
Future<ContactPathHistory?> _loadHistoryFromStorage( Future<ContactPathHistory?> _loadHistoryFromStorage(
String contactPubKeyHex) async { String contactPubKeyHex,
) async {
return await _storage.loadPathHistory(contactPubKeyHex); return await _storage.loadPathHistory(contactPubKeyHex);
} }
@@ -308,8 +317,10 @@ class PathHistoryService extends ChangeNotifier {
..removeWhere((p) => p.pathBytes.isEmpty); ..removeWhere((p) => p.pathBytes.isEmpty);
ranked.sort((a, b) { ranked.sort((a, b) {
final aRate = (a.successCount + 1) / (a.successCount + a.failureCount + 2); final aRate =
final bRate = (b.successCount + 1) / (b.successCount + b.failureCount + 2); (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 (aRate != bRate) return bRate.compareTo(aRate);
if (a.successCount != b.successCount) { if (a.successCount != b.successCount) {
return b.successCount.compareTo(a.successCount); return b.successCount.compareTo(a.successCount);
@@ -329,7 +340,10 @@ class PathHistoryService extends ChangeNotifier {
} }
void _updateFloodStats(String contactPubKeyHex) { void _updateFloodStats(String contactPubKeyHex) {
final stats = _floodStats.putIfAbsent(contactPubKeyHex, () => _FloodStats()); final stats = _floodStats.putIfAbsent(
contactPubKeyHex,
() => _FloodStats(),
);
stats.lastUsed = DateTime.now(); stats.lastUsed = DateTime.now();
} }
+9 -3
View File
@@ -26,7 +26,9 @@ class RepeaterCommandService {
int retries = maxRetries, int retries = maxRetries,
}) async { }) async {
final repeaterKey = repeater.publicKeyHex; final repeaterKey = repeater.publicKeyHex;
final hasPending = _pendingCommands.keys.any((id) => id.startsWith(repeaterKey)); final hasPending = _pendingCommands.keys.any(
(id) => id.startsWith(repeaterKey),
);
if (hasPending) { if (hasPending) {
throw Exception('Another command is still awaiting a response.'); throw Exception('Another command is still awaiting a response.');
} }
@@ -84,7 +86,9 @@ class RepeaterCommandService {
attempt: attempt, attempt: attempt,
timestampSeconds: timestampSeconds, timestampSeconds: timestampSeconds,
); );
final responseBytes = frame.length > maxFrameSize ? frame.length : maxFrameSize; final responseBytes = frame.length > maxFrameSize
? frame.length
: maxFrameSize;
final timeoutMs = _connector.calculateTimeout( final timeoutMs = _connector.calculateTimeout(
pathLength: pathLengthValue, pathLength: pathLengthValue,
messageBytes: responseBytes, messageBytes: responseBytes,
@@ -97,7 +101,9 @@ class RepeaterCommandService {
() { () {
final completer = _pendingCommands[commandId]; final completer = _pendingCommands[commandId];
if (completer != null && !completer.isCompleted) { if (completer != null && !completer.isCompleted) {
completer.completeError('Command timeout after $timeoutSeconds seconds'); completer.completeError(
'Command timeout after $timeoutSeconds seconds',
);
_cleanup(commandId); _cleanup(commandId);
} }
}, },
+9 -4
View File
@@ -8,7 +8,9 @@ class StorageService {
static const String _repeaterPasswordsKey = 'repeater_passwords'; static const String _repeaterPasswordsKey = 'repeater_passwords';
Future<void> savePathHistory( Future<void> savePathHistory(
String contactPubKeyHex, ContactPathHistory history) async { String contactPubKeyHex,
ContactPathHistory history,
) async {
final prefs = PrefsManager.instance; final prefs = PrefsManager.instance;
final key = '$_pathHistoryPrefix$contactPubKeyHex'; final key = '$_pathHistoryPrefix$contactPubKeyHex';
final jsonStr = jsonEncode(history.toJson()); final jsonStr = jsonEncode(history.toJson());
@@ -39,8 +41,9 @@ class StorageService {
Future<void> clearAllPathHistories() async { Future<void> clearAllPathHistories() async {
final prefs = PrefsManager.instance; final prefs = PrefsManager.instance;
final keys = prefs.getKeys(); final keys = prefs.getKeys();
final pathHistoryKeys = final pathHistoryKeys = keys.where(
keys.where((key) => key.startsWith(_pathHistoryPrefix)); (key) => key.startsWith(_pathHistoryPrefix),
);
for (final key in pathHistoryKeys) { for (final key in pathHistoryKeys) {
await prefs.remove(key); await prefs.remove(key);
@@ -74,7 +77,9 @@ class StorageService {
/// Save a repeater password by public key hex /// Save a repeater password by public key hex
Future<void> saveRepeaterPassword( Future<void> saveRepeaterPassword(
String repeaterPubKeyHex, String password) async { String repeaterPubKeyHex,
String password,
) async {
final prefs = PrefsManager.instance; final prefs = PrefsManager.instance;
final passwords = await loadRepeaterPasswords(); final passwords = await loadRepeaterPasswords();
passwords[repeaterPubKeyHex] = password; passwords[repeaterPubKeyHex] = password;
+14 -6
View File
@@ -8,7 +8,10 @@ class ChannelMessageStore {
static const String _keyPrefix = 'channel_messages_'; static const String _keyPrefix = 'channel_messages_';
/// Save messages for a specific channel /// 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 prefs = PrefsManager.instance;
final key = '$_keyPrefix$channelIndex'; final key = '$_keyPrefix$channelIndex';
@@ -96,7 +99,8 @@ class ChannelMessageStore {
pathVariants: (json['pathVariants'] as List<dynamic>?) pathVariants: (json['pathVariants'] as List<dynamic>?)
?.map((entry) => Uint8List.fromList(base64Decode(entry as String))) ?.map((entry) => Uint8List.fromList(base64Decode(entry as String)))
.toList(), .toList(),
repeats: (json['repeats'] as List<dynamic>?) repeats:
(json['repeats'] as List<dynamic>?)
?.map((entry) => _repeatFromJson(entry as Map<String, dynamic>)) ?.map((entry) => _repeatFromJson(entry as Map<String, dynamic>))
.toList() ?? .toList() ??
const [], const [],
@@ -105,15 +109,19 @@ class ChannelMessageStore {
replyToMessageId: json['replyToMessageId'] as String?, replyToMessageId: json['replyToMessageId'] as String?,
replyToSenderName: json['replyToSenderName'] as String?, replyToSenderName: json['replyToSenderName'] as String?,
replyToText: json['replyToText'] as String?, replyToText: json['replyToText'] as String?,
reactions: (json['reactions'] as Map<String, dynamic>?)?.map( reactions:
(key, value) => MapEntry(key, value as int), (json['reactions'] as Map<String, dynamic>?)?.map(
) ?? {}, (key, value) => MapEntry(key, value as int),
) ??
{},
); );
} }
Map<String, dynamic> _repeatToJson(Repeat repeat) { Map<String, dynamic> _repeatToJson(Repeat repeat) {
return { return {
'repeaterKey': repeat.repeaterKey != null ? base64Encode(repeat.repeaterKey!) : null, 'repeaterKey': repeat.repeaterKey != null
? base64Encode(repeat.repeaterKey!)
: null,
'repeaterName': repeat.repeaterName, 'repeaterName': repeat.repeaterName,
'tripTimeMs': repeat.tripTimeMs, 'tripTimeMs': repeat.tripTimeMs,
'path': repeat.path?.map((bytes) => base64Encode(bytes)).toList() ?? [], 'path': repeat.path?.map((bytes) => base64Encode(bytes)).toList() ?? [],
+4 -1
View File
@@ -16,7 +16,10 @@ class ChannelOrderStore {
try { try {
final decoded = jsonDecode(raw); final decoded = jsonDecode(raw);
if (decoded is List) { 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 (_) { } catch (_) {
// fall through to legacy parse // 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 /// Add a new community
Future<void> addCommunity(Community community) async { Future<void> addCommunity(Community community) async {
final communities = await loadCommunities(); final communities = await loadCommunities();
// Check if community with same ID already exists // Check if community with same ID already exists
final existingIndex = communities.indexWhere((c) => c.id == community.id); final existingIndex = communities.indexWhere((c) => c.id == community.id);
if (existingIndex >= 0) { if (existingIndex >= 0) {
@@ -49,7 +49,7 @@ class CommunityStore {
} else { } else {
communities.add(community); communities.add(community);
} }
await saveCommunities(communities); await saveCommunities(communities);
} }
@@ -92,10 +92,7 @@ class CommunityStore {
} }
/// Add a hashtag channel to a community /// Add a hashtag channel to a community
Future<void> addHashtagChannel( Future<void> addHashtagChannel(String communityId, String hashtag) async {
String communityId,
String hashtag,
) async {
final community = await getCommunity(communityId); final community = await getCommunity(communityId);
if (community != null) { if (community != null) {
final updated = community.addHashtagChannel(hashtag); final updated = community.addHashtagChannel(hashtag);
@@ -104,10 +101,7 @@ class CommunityStore {
} }
/// Remove a hashtag channel from a community /// Remove a hashtag channel from a community
Future<void> removeHashtagChannel( Future<void> removeHashtagChannel(String communityId, String hashtag) async {
String communityId,
String hashtag,
) async {
final community = await getCommunity(communityId); final community = await getCommunity(communityId);
if (community != null) { if (community != null) {
final updated = community.removeHashtagChannel(hashtag); final updated = community.removeHashtagChannel(hashtag);
+9 -3
View File
@@ -14,7 +14,9 @@ class ContactStore {
try { try {
final jsonList = jsonDecode(jsonStr) as List<dynamic>; 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 (_) { } catch (_) {
return []; return [];
} }
@@ -57,12 +59,16 @@ class ContactStore {
: Uint8List(0), : Uint8List(0),
pathOverride: json['pathOverride'] as int?, pathOverride: json['pathOverride'] as int?,
pathOverrideBytes: json['pathOverrideBytes'] != null pathOverrideBytes: json['pathOverrideBytes'] != null
? Uint8List.fromList(base64Decode(json['pathOverrideBytes'] as String)) ? Uint8List.fromList(
base64Decode(json['pathOverrideBytes'] as String),
)
: null, : null,
latitude: (json['latitude'] as num?)?.toDouble(), latitude: (json['latitude'] as num?)?.toDouble(),
longitude: (json['longitude'] as num?)?.toDouble(), longitude: (json['longitude'] as num?)?.toDouble(),
lastSeen: DateTime.fromMillisecondsSinceEpoch(lastSeenMs), 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 { class MessageStore {
static const String _keyPrefix = 'messages_'; 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 prefs = PrefsManager.instance;
final key = '$_keyPrefix$contactKeyHex'; final key = '$_keyPrefix$contactKeyHex';
final jsonList = messages.map(_messageToJson).toList(); final jsonList = messages.map(_messageToJson).toList();
@@ -45,12 +48,16 @@ class MessageStore {
'messageId': msg.messageId, 'messageId': msg.messageId,
'retryCount': msg.retryCount, 'retryCount': msg.retryCount,
'estimatedTimeoutMs': msg.estimatedTimeoutMs, 'estimatedTimeoutMs': msg.estimatedTimeoutMs,
'expectedAckHash': msg.expectedAckHash != null ? base64Encode(msg.expectedAckHash!) : null, 'expectedAckHash': msg.expectedAckHash != null
? base64Encode(msg.expectedAckHash!)
: null,
'sentAt': msg.sentAt?.millisecondsSinceEpoch, 'sentAt': msg.sentAt?.millisecondsSinceEpoch,
'deliveredAt': msg.deliveredAt?.millisecondsSinceEpoch, 'deliveredAt': msg.deliveredAt?.millisecondsSinceEpoch,
'tripTimeMs': msg.tripTimeMs, 'tripTimeMs': msg.tripTimeMs,
'pathLength': msg.pathLength, 'pathLength': msg.pathLength,
'pathBytes': msg.pathBytes.isNotEmpty ? base64Encode(msg.pathBytes) : null, 'pathBytes': msg.pathBytes.isNotEmpty
? base64Encode(msg.pathBytes)
: null,
'reactions': msg.reactions, 'reactions': msg.reactions,
'fourByteRoomContactKey': base64Encode(msg.fourByteRoomContactKey), 'fourByteRoomContactKey': base64Encode(msg.fourByteRoomContactKey),
}; };
@@ -59,7 +66,9 @@ class MessageStore {
Message _messageFromJson(Map<String, dynamic> json) { Message _messageFromJson(Map<String, dynamic> json) {
final rawText = json['text'] as String; final rawText = json['text'] as String;
final isCli = json['isCli'] as bool? ?? false; 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( return Message(
senderKey: Uint8List.fromList(base64Decode(json['senderKey'] as String)), senderKey: Uint8List.fromList(base64Decode(json['senderKey'] as String)),
text: decodedText, text: decodedText,
@@ -84,11 +93,15 @@ class MessageStore {
pathBytes: json['pathBytes'] != null pathBytes: json['pathBytes'] != null
? Uint8List.fromList(base64Decode(json['pathBytes'] as String)) ? Uint8List.fromList(base64Decode(json['pathBytes'] as String))
: Uint8List(0), : Uint8List(0),
reactions: (json['reactions'] as Map<String, dynamic>?)?.map( reactions:
(key, value) => MapEntry(key, value as int), (json['reactions'] as Map<String, dynamic>?)?.map(
) ?? {}, (key, value) => MapEntry(key, value as int),
) ??
{},
fourByteRoomContactKey: json['fourByteRoomContactKey'] != null fourByteRoomContactKey: json['fourByteRoomContactKey'] != null
? Uint8List.fromList(base64Decode(json['fourByteRoomContactKey'] as String)) ? Uint8List.fromList(
base64Decode(json['fourByteRoomContactKey'] as String),
)
: null, : null,
); );
} }
+2 -1
View File
@@ -21,7 +21,8 @@ class PrefsManager {
static SharedPreferences get instance { static SharedPreferences get instance {
if (_instance == null) { if (_instance == null) {
throw StateError( throw StateError(
'PrefsManager not initialized. Call PrefsManager.initialize() in main() before use.'); 'PrefsManager not initialized. Call PrefsManager.initialize() in main() before use.',
);
} }
return _instance!; 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. /// Storage for unread message tracking with debounced writes to reduce I/O.
class UnreadStore { class UnreadStore {
static const String _contactLastReadKey = 'contact_last_read'; static const String _contactUnreadCountKey = 'contact_unread_count';
static const String _channelLastReadKey = 'channel_last_read';
// Debounce timers to batch rapid writes // Debounce timers to batch rapid writes
Timer? _contactSaveTimer; Timer? _contactUnreadSaveTimer;
Timer? _channelSaveTimer;
static const Duration _saveDebounceDuration = Duration(milliseconds: 500); static const Duration _saveDebounceDuration = Duration(milliseconds: 500);
// Pending write data // Pending write data
Map<String, int>? _pendingContactLastRead; Map<String, int>? _pendingContactUnreadCount;
Map<int, int>? _pendingChannelLastRead;
/// Dispose timers when no longer needed /// Dispose timers when no longer needed
void dispose() { void dispose() {
_contactSaveTimer?.cancel(); _contactUnreadSaveTimer?.cancel();
_channelSaveTimer?.cancel();
} }
Future<Map<String, int>> loadContactLastRead() async { Future<Map<String, int>> loadContactUnreadCount() async {
final prefs = PrefsManager.instance; final prefs = PrefsManager.instance;
final jsonStr = prefs.getString(_contactLastReadKey); final jsonStr = prefs.getString(_contactUnreadCountKey);
if (jsonStr == null) return {}; if (jsonStr == null) return {};
try { try {
@@ -36,77 +32,30 @@ class UnreadStore {
} }
} }
/// Save contact last read timestamps with debouncing. void saveContactUnreadCount(Map<String, int> counts) {
/// Writes are delayed by 500ms and batched to reduce I/O operations. _pendingContactUnreadCount = counts;
void saveContactLastRead(Map<String, int> lastReadMs) {
_pendingContactLastRead = lastReadMs;
// Cancel existing timer _contactUnreadSaveTimer?.cancel();
_contactSaveTimer?.cancel();
// Schedule new write _contactUnreadSaveTimer = Timer(_saveDebounceDuration, () async {
_contactSaveTimer = Timer(_saveDebounceDuration, () async { if (_pendingContactUnreadCount != null) {
if (_pendingContactLastRead != null) { await _flushContactUnreadCount();
await _flushContactLastRead();
} }
}); });
} }
Future<Map<int, int>> loadChannelLastRead() async { Future<void> _flushContactUnreadCount() async {
final prefs = PrefsManager.instance; if (_pendingContactUnreadCount == null) return;
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;
final prefs = PrefsManager.instance; final prefs = PrefsManager.instance;
final jsonStr = jsonEncode(_pendingContactLastRead); final jsonStr = jsonEncode(_pendingContactUnreadCount);
await prefs.setString(_contactLastReadKey, jsonStr); await prefs.setString(_contactUnreadCountKey, jsonStr);
_pendingContactLastRead = null; _pendingContactUnreadCount = 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;
} }
/// Immediately flush pending writes (call before app termination or disposal) /// Immediately flush pending writes (call before app termination or disposal)
Future<void> flush() async { Future<void> flush() async {
_contactSaveTimer?.cancel(); _contactUnreadSaveTimer?.cancel();
_channelSaveTimer?.cancel(); await _flushContactUnreadCount();
await Future.wait([
_flushContactLastRead(),
_flushChannelLastRead(),
]);
} }
} }
+5 -1
View File
@@ -44,7 +44,11 @@ class AppLogger {
} }
/// Log a message with custom level /// 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) { if (_enabled && _service != null) {
_service!.log(message, tag: tag, level: level); _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 { class BatteryIndicator extends StatefulWidget {
final MeshCoreConnector connector; final MeshCoreConnector connector;
const BatteryIndicator({ const BatteryIndicator({super.key, required this.connector});
super.key,
required this.connector,
});
@override @override
State<BatteryIndicator> createState() => _BatteryIndicatorState(); 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 /// Debug widget to show the hex dump of a frame
class DebugFrameViewer { class DebugFrameViewer {
static void showFrameDebug(BuildContext context, Uint8List frame, String title) { static void showFrameDebug(
BuildContext context,
Uint8List frame,
String title,
) {
final hexString = frame final hexString = frame
.map((b) => b.toRadixString(16).padLeft(2, '0')) .map((b) => b.toRadixString(16).padLeft(2, '0'))
.join(' '); .join(' ');
@@ -14,16 +18,26 @@ class DebugFrameViewer {
details.writeln(context.l10n.debugFrame_length(frame.length)); details.writeln(context.l10n.debugFrame_length(frame.length));
details.writeln(''); details.writeln('');
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) { if (frame[0] == cmdSendTxtMsg && frame.length > 37) {
details.writeln(''); details.writeln('');
details.writeln(context.l10n.debugFrame_textMessageHeader); 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( 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 txtType = (frame[37] >> 2) & 0x03;
final typeLabel = txtType == txtTypeCliData final typeLabel = txtType == txtTypeCliData
@@ -34,7 +48,7 @@ class DebugFrameViewer {
final textBytes = frame.sublist(38); final textBytes = frame.sublist(38);
final nullIdx = textBytes.indexOf(0); final nullIdx = textBytes.indexOf(0);
final text = String.fromCharCodes( final text = String.fromCharCodes(
nullIdx >= 0 ? textBytes.sublist(0, nullIdx) : textBytes nullIdx >= 0 ? textBytes.sublist(0, nullIdx) : textBytes,
); );
details.writeln(context.l10n.debugFrame_text(text)); details.writeln(context.l10n.debugFrame_text(text));
} }
+4 -12
View File
@@ -7,18 +7,14 @@ class DeviceTile extends StatelessWidget {
final ScanResult scanResult; final ScanResult scanResult;
final VoidCallback onTap; final VoidCallback onTap;
const DeviceTile({ const DeviceTile({super.key, required this.scanResult, required this.onTap});
super.key,
required this.scanResult,
required this.onTap,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final device = scanResult.device; final device = scanResult.device;
final rssi = scanResult.rssi; final rssi = scanResult.rssi;
final name = device.platformName.isNotEmpty final name = device.platformName.isNotEmpty
? device.platformName ? device.platformName
: scanResult.advertisementData.advName; : scanResult.advertisementData.advName;
return ListTile( return ListTile(
@@ -58,12 +54,8 @@ class DeviceTile extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon(icon, color: color), Icon(icon, color: color),
Text( Text('$rssi dBm', style: TextStyle(fontSize: 10, color: color)),
'$rssi dBm',
style: TextStyle(fontSize: 10, color: color),
),
], ],
); );
} }
} }
+204 -34
View File
@@ -5,39 +5,203 @@ import '../l10n/l10n.dart';
class EmojiPicker extends StatelessWidget { class EmojiPicker extends StatelessWidget {
final Function(String) onEmojiSelected; final Function(String) onEmojiSelected;
const EmojiPicker({ const EmojiPicker({super.key, required this.onEmojiSelected});
super.key,
required this.onEmojiSelected,
});
static const List<String> quickEmojis = ['👍', '❤️', '😂', '🎉', '👏', '🔥']; static const List<String> quickEmojis = ['👍', '❤️', '😂', '🎉', '👏', '🔥'];
static const List<String> _smileys = [ static const List<String> smileys = [
'😀', '😃', '😄', '😁', '😅', '😂', '🤣', '😊', '😇', '🙂', '🙃', '😉', '😌', '😍', '🥰', '😘', '😀',
'😗', '😙', '😚', '😋', '😛', '😝', '😜', '🤪', '🤨', '🧐', '🤓', '😎', '🥸', '🤩', '🥳', '😏', '😃',
'😒', '😞', '😔', '😟', '😕', '🙁', '😣', '😖', '😫', '😩', '🥺', '😢', '😭', '😤', '😠', '😡', '😄',
'🤬', '🤯', '😳', '🥵', '🥶', '😱', '😨', '😰', '😥', '😓', '🤗', '🤔', '🤭', '🤫', '🤥', '😶', '😁',
]; '😅',
static const List<String> _gestures = [ '😂',
'👍', '👎', '👊', '', '🤛', '🤜', '🤞', '✌️', '🤟', '🤘', '👌', '🤌', '🤏', '👈', '👉', '👆', '🤣',
'👇', '☝️', '👋', '🤚', '🖐️', '', '🖖', '👏', '🙌', '👐', '🤲', '🤝', '🙏', '✍️', '💅', '🤳', '😊',
]; '😇',
static const List<String> _hearts = [ '🙂',
'❤️', '🧡', '💛', '💚', '💙', '💜', '🖤', '🤍', '🤎', '💔', '❤️‍🔥', '❤️‍🩹', '💕', '💞', '💓', '💗', '🙃',
'💖', '💘', '💝', '💟', '💌', '💢', '💥', '💫', '💦', '💨', '🕳️', '💬', '👁️‍🗨️', '🗨️', '🗯️', '💭', '😉',
]; '😌',
static const List<String> _objects = [ '😍',
'🎉', '🎊', '🎈', '🎁', '🎀', '🪅', '🪆', '🏆', '🥇', '🥈', '🥉', '', '', '🥎', '🏀', '🏐', '🥰',
'🏈', '🏉', '🎾', '🥏', '🎳', '🏏', '🏑', '🏒', '🥍', '🏓', '🏸', '🥊', '🥋', '🥅', '', '🔥', '😘',
'', '🌟', '', '', '💡', '🔦', '🏮', '🪔', '📱', '💻', '', '📷', '📺', '📻', '🎵', '🎶', '😗',
]; '😙',
'😚',
'😋',
'😛',
'😝',
'😜',
'🤪',
'🤨',
'🧐',
'🤓',
'😎',
'🥸',
'🤩',
'🥳',
'😏',
'😒',
'😞',
'😔',
'😟',
'😕',
'🙁',
'😣',
'😖',
'😫',
'😩',
'🥺',
'😢',
'😭',
'😤',
'😠',
'😡',
'🤬',
'🤯',
'😳',
'🥵',
'🥶',
'😱',
'😨',
'😰',
'😥',
'😓',
'🤗',
'🤔',
'🤭',
'🤫',
'🤥',
'😶',
];
static const List<String> gestures = [
'👍',
'👎',
'👊',
'',
'🤛',
'🤜',
'🤞',
'✌️',
'🤟',
'🤘',
'👌',
'🤌',
'🤏',
'👈',
'👉',
'👆',
'👇',
'☝️',
'👋',
'🤚',
'🖐️',
'',
'🖖',
'👏',
'🙌',
'👐',
'🤲',
'🤝',
'🙏',
'✍️',
'💅',
'🤳',
'💪',
];
static const List<String> hearts = [
'❤️',
'🧡',
'💛',
'💚',
'💙',
'💜',
'🖤',
'🤍',
'🤎',
'💔',
'❤️‍🔥',
'❤️‍🩹',
'💕',
'💞',
'💓',
'💗',
'💖',
'💘',
'💝',
'💟',
'💌',
'💢',
'💥',
'💫',
'💦',
'💨',
'🕳️',
'💬',
'👁️‍🗨️',
'🗨️',
'🗯️',
'💭',
];
static const List<String> objects = [
'🎉',
'🎊',
'🎈',
'🎁',
'🎀',
'🪅',
'🪆',
'🏆',
'🥇',
'🥈',
'🥉',
'',
'',
'🥎',
'🏀',
'🏐',
'🏈',
'🏉',
'🎾',
'🥏',
'🎳',
'🏏',
'🏑',
'🏒',
'🥍',
'🏓',
'🏸',
'🥊',
'🥋',
'🥅',
'',
'🔥',
'',
'🌟',
'',
'',
'💡',
'🔦',
'🏮',
'🪔',
'📱',
'💻',
'',
'📷',
'📺',
'📻',
'🎵',
'🎶',
'🚀',
];
Map<String, List<String>> _emojiCategories(AppLocalizations l10n) { Map<String, List<String>> _emojiCategories(AppLocalizations l10n) {
return { return {
l10n.emojiCategorySmileys: _smileys, l10n.emojiCategorySmileys: smileys,
l10n.emojiCategoryGestures: _gestures, l10n.emojiCategoryGestures: gestures,
l10n.emojiCategoryHearts: _hearts, l10n.emojiCategoryHearts: hearts,
l10n.emojiCategoryObjects: _objects, l10n.emojiCategoryObjects: objects,
}; };
} }
@@ -60,7 +224,10 @@ class EmojiPicker extends StatelessWidget {
children: [ children: [
Text( Text(
l10n.chat_addReaction, l10n.chat_addReaction,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
), ),
IconButton( IconButton(
icon: const Icon(Icons.close), icon: const Icon(Icons.close),
@@ -83,7 +250,9 @@ class EmojiPicker extends StatelessWidget {
child: Container( child: Container(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Theme.of(context).colorScheme.secondaryContainer, color: Theme.of(
context,
).colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
), ),
child: Text( child: Text(
@@ -114,11 +283,12 @@ class EmojiPicker extends StatelessWidget {
.map( .map(
(emojis) => GridView.builder( (emojis) => GridView.builder(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( gridDelegate:
crossAxisCount: 8, const SliverGridDelegateWithFixedCrossAxisCount(
mainAxisSpacing: 8, crossAxisCount: 8,
crossAxisSpacing: 8, mainAxisSpacing: 8,
), crossAxisSpacing: 8,
),
itemCount: emojis.length, itemCount: emojis.length,
itemBuilder: (context, index) => InkWell( itemBuilder: (context, index) => InkWell(
onTap: () { onTap: () {

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