merge zjs81/dev: resolve conflicting implementations

This commit is contained in:
ericz
2026-06-13 18:06:31 +02:00
134 changed files with 40142 additions and 18078 deletions
+3
View File
@@ -91,3 +91,6 @@ keystore.properties
# Cloudflare Wrangler # Cloudflare Wrangler
.wrangler .wrangler
# Claude Code local working dir (worktrees, jobs, settings)
.claude/
+10 -9
View File
@@ -41,7 +41,7 @@ lib/
├── models/ # Plain data classes (Contact, Channel, Message, Community, …) ├── models/ # Plain data classes (Contact, Channel, Message, Community, …)
├── services/ # ChangeNotifier services + IO services (retry, translation, ML, …) ├── services/ # ChangeNotifier services + IO services (retry, translation, ML, …)
├── storage/ # SharedPreferences-backed stores, scoped per device key ├── storage/ # SharedPreferences-backed stores, scoped per device key
├── helpers/ # Pure utilities (Smaz compression, GIF parsing, scroll helpers) ├── helpers/ # Pure utilities (Smaz compression, GIF parsing, scroll helpers, path hop resolution)
├── utils/ # Platform / IO / UX utilities (logger, GPX export, dialogs) ├── utils/ # Platform / IO / UX utilities (logger, GPX export, dialogs)
├── theme/ # MeshPalette (defined, not yet wired in main.dart) ├── theme/ # MeshPalette (defined, not yet wired in main.dart)
├── l10n/ # ARB localization for 18 locales ├── l10n/ # ARB localization for 18 locales
@@ -194,14 +194,14 @@ enum MeshCoreConnectionState {
## Dependencies ## Dependencies
App version: `8.0.0+11` — Dart SDK constraint: `^3.9.2` App version: `9.5.0+13` — Dart SDK constraint: `^3.9.2`
**Connectivity** **Connectivity**
| Package | Version | Purpose | | Package | Version | Purpose |
|---------|---------|---------| |---------|---------|---------|
| flutter_blue_plus | ^2.1.0 | BLE scanning, connecting, and UART data transfer | | flutter_blue_plus | ^2.1.0 | BLE scanning, connecting, and UART data transfer |
| flutter_blue_plus_platform_interface | ^8.2.1 | Platform-interface layer required by flutter_blue_plus | | flutter_blue_plus_platform_interface | ^9.0.2 | Platform-interface layer required by flutter_blue_plus |
| flserial | git (MeshEnvy fork) | USB serial transport for wired device connections (TODO: upstream pending) | | flserial | git (MeshEnvy fork) | USB serial transport for wired device connections (TODO: upstream pending) |
**State / Storage** **State / Storage**
@@ -232,7 +232,7 @@ App version: `8.0.0+11` — Dart SDK constraint: `^3.9.2`
| Package | Version | Purpose | | Package | Version | Purpose |
|---------|---------|---------| |---------|---------|---------|
| material_symbols_icons | ^4.2906.0 | Extended Material Symbols icon set (line-of-sight, etc.) | | material_symbols_icons | ^4.2928.1 | Extended Material Symbols icon set (line-of-sight, etc.) |
| flutter_svg | ^2.0.10+1 | Renders SVG assets (custom icons such as LoS indicator) | | flutter_svg | ^2.0.10+1 | Renders SVG assets (custom icons such as LoS indicator) |
| cached_network_image | ^3.4.1 | Caches map tile images downloaded over the network | | cached_network_image | ^3.4.1 | Caches map tile images downloaded over the network |
| flutter_cache_manager | ^3.4.1 | Underlying cache manager used by cached_network_image | | flutter_cache_manager | ^3.4.1 | Underlying cache manager used by cached_network_image |
@@ -246,7 +246,7 @@ App version: `8.0.0+11` — Dart SDK constraint: `^3.9.2`
| Package | Version | Purpose | | Package | Version | Purpose |
|---------|---------|---------| |---------|---------|---------|
| flutter_local_notifications | ^20.1.0 | Shows local push notifications for incoming messages | | flutter_local_notifications | ^22.0.0 | Shows local push notifications for incoming messages |
| flutter_foreground_task | ^9.2.0 | Keeps the app alive in background to maintain BLE/USB connection | | flutter_foreground_task | ^9.2.0 | Keeps the app alive in background to maintain BLE/USB connection |
**ML / AI** **ML / AI**
@@ -255,7 +255,8 @@ App version: `8.0.0+11` — Dart SDK constraint: `^3.9.2`
|---------|---------|---------| |---------|---------|---------|
| ml_algo | ^16.0.0 | OLS regression used in `timeout_prediction_service.dart` to predict message ACK timeouts | | ml_algo | ^16.0.0 | OLS regression used in `timeout_prediction_service.dart` to predict message ACK timeouts |
| ml_dataframe | ^1.0.0 | DataFrame input format required by ml_algo | | ml_dataframe | ^1.0.0 | DataFrame input format required by ml_algo |
| llamadart | >=0.6.8 <0.7.0 | On-device LLM inference used in `translation_service.dart` for message translation | | llamadart | ^0.8.0 | On-device LLM inference used in `translation_service.dart` for message translation |
| flutter_langdetect | ^0.0.1 | Detects a message's source language in `translation_service.dart` before translating |
**Misc** **Misc**
@@ -263,8 +264,8 @@ App version: `8.0.0+11` — Dart SDK constraint: `^3.9.2`
|---------|---------|---------| |---------|---------|---------|
| http | ^1.2.0 | Fetches tile URLs and any remote API calls | | http | ^1.2.0 | Fetches tile URLs and any remote API calls |
| url_launcher | ^6.3.0 | Opens URLs in the system browser from linkified chat text | | url_launcher | ^6.3.0 | Opens URLs in the system browser from linkified chat text |
| share_plus | ^12.0.1 | Shares files (e.g. exported GPX tracks) via the system share sheet | | share_plus | ^13.1.0 | Shares files (e.g. exported GPX tracks) via the system share sheet |
| package_info_plus | ^9.0.0 | Reads app version/build number displayed in settings | | package_info_plus | ^10.1.0 | Reads app version/build number displayed in settings |
| web | ^1.1.1 | Web-platform APIs for USB serial and browser detection on Flutter Web | | web | ^1.1.1 | Web-platform APIs for USB serial and browser detection on Flutter Web |
| intl | any | Internationalization and locale formatting (required by flutter_localizations) | | intl | any | Internationalization and locale formatting (required by flutter_localizations) |
| build_pipe | ^0.3.1 | CI/CD build pipeline configuration (web release builds with versioned assets) | | build_pipe | ^0.3.1 | CI/CD build pipeline configuration (web release builds with versioned assets) |
@@ -333,4 +334,4 @@ PWA scaffold present but boilerplate (`manifest.json` and `index.html` are unmod
| `lib/services/translation_service.dart` | On-device LLM translation (llamadart) | | `lib/services/translation_service.dart` | On-device LLM translation (llamadart) |
| `lib/storage/prefs_manager.dart` | SharedPreferences singleton initialized in `main()` | | `lib/storage/prefs_manager.dart` | SharedPreferences singleton initialized in `main()` |
| `lib/screens/scanner_screen.dart` | Home screen — BLE scan and connect | | `lib/screens/scanner_screen.dart` | Home screen — BLE scan and connect |
| `pubspec.yaml` | Dependencies and project metadata (current version `8.0.0+11`) | | `pubspec.yaml` | Dependencies and project metadata (current version `9.5.0+13`) |
+2 -2
View File
@@ -94,12 +94,12 @@ MeshCore Open is a cross-platform mobile application for communicating with Mesh
|---------|---------| |---------|---------|
| flutter_blue_plus | Bluetooth Low Energy communication | | flutter_blue_plus | Bluetooth Low Energy communication |
| provider | State management | | provider | State management |
| sqflite | Local database storage | | shared_preferences | Local key-value storage (scoped per device) |
| flutter_map | Interactive map display | | flutter_map | Interactive map display |
| latlong2 | Geographic coordinate handling | | latlong2 | Geographic coordinate handling |
| flutter_local_notifications | Background notification support | | flutter_local_notifications | Background notification support |
| smaz | Message compression |
| pointycastle | Cryptographic operations | | pointycastle | Cryptographic operations |
| llamadart | On-device LLM message translation |
| intl | Internationalization and date formatting | | intl | Internationalization and date formatting |
## Getting Started ## Getting Started
+4
View File
@@ -1,3 +1,7 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true android.useAndroidX=true
android.enableJetifier=true android.enableJetifier=true
# This builtInKotlin flag was added automatically by Flutter migrator
android.builtInKotlin=false
# This newDsl flag was added automatically by Flutter migrator
android.newDsl=false
+1
View File
@@ -198,6 +198,7 @@ Tap the translate button on any received message. On first use, the GGUF model f
### How It Works ### How It Works
- Model files are managed by `TranslationFileStore`; download progress is shown in-place - Model files are managed by `TranslationFileStore`; download progress is shown in-place
- Before translating, the source language is automatically detected using the `flutter_langdetect` package. If the detected language already matches the target language, translation is skipped
- Translation runs via `TranslationService` using the llamadart CPU backend (arm64 and x64 on Android) - Translation runs via `TranslationService` using the llamadart CPU backend (arm64 and x64 on Android)
- Translated text is shown in `TranslatedMessageContent` as an inline overlay on the original message bubble - Translated text is shown in `TranslatedMessageContent` as an inline overlay on the original message bubble
- Each translation is cached; re-tapping shows the cached result without re-running inference - Each translation is cached; re-tapping shows the cached result without re-running inference
+5 -1
View File
@@ -56,6 +56,7 @@ enum MeshCoreConnectionState {
- `Lilygo` - `Lilygo`
- `HT-` - `HT-`
- `LowMesh_MC_` - `LowMesh_MC_`
- `NRF52`
2. **Connect** with 15-second timeout (6 seconds on Linux) 2. **Connect** with 15-second timeout (6 seconds on Linux)
3. **Request MTU** 185 bytes (non-web only) 3. **Request MTU** 185 bytes (non-web only)
4. **Discover services** and locate NUS 4. **Discover services** and locate NUS
@@ -115,13 +116,15 @@ On unexpected disconnection, auto-reconnect with exponential backoff:
| 32 | CMD_SET_CHANNEL | Set channel name and PSK | | 32 | CMD_SET_CHANNEL | Set channel name and PSK |
| 36 | CMD_SEND_TRACE_PATH | Request path trace | | 36 | CMD_SEND_TRACE_PATH | Request path trace |
| 38 | CMD_SET_OTHER_PARAMS | Set misc parameters | | 38 | CMD_SET_OTHER_PARAMS | Set misc parameters |
| 39 | CMD_GET_TELEMETRY_REQ | Request sensor telemetry | | 39 | CMD_SEND_TELEMETRY_REQ | Request sensor telemetry |
| 40 | CMD_GET_CUSTOM_VAR | Get custom variables | | 40 | CMD_GET_CUSTOM_VAR | Get custom variables |
| 41 | CMD_SET_CUSTOM_VAR | Set a custom variable | | 41 | CMD_SET_CUSTOM_VAR | Set a custom variable |
| 50 | CMD_SEND_BINARY_REQ | Send binary request | | 50 | CMD_SEND_BINARY_REQ | Send binary request |
| 56 | CMD_GET_STATS | Request companion radio stats |
| 57 | CMD_SEND_ANON_REQ | Send anonymous request | | 57 | CMD_SEND_ANON_REQ | Send anonymous request |
| 58 | CMD_SET_AUTO_ADD_CONFIG | Set auto-add configuration | | 58 | CMD_SET_AUTO_ADD_CONFIG | Set auto-add configuration |
| 59 | CMD_GET_AUTO_ADD_CONFIG | Get auto-add configuration | | 59 | CMD_GET_AUTO_ADD_CONFIG | Get auto-add configuration |
| 61 | CMD_SET_PATH_HASH_MODE | Set path hash width (bytes per hop) |
## Response / Push Codes (Device → App) ## Response / Push Codes (Device → App)
@@ -145,6 +148,7 @@ On unexpected disconnection, auto-reconnect with exponential backoff:
| 17 | RESP_CODE_CHANNEL_MSG_RECV_V3 | Incoming channel message (v3) | | 17 | RESP_CODE_CHANNEL_MSG_RECV_V3 | Incoming channel message (v3) |
| 18 | RESP_CODE_CHANNEL_INFO | Channel definition | | 18 | RESP_CODE_CHANNEL_INFO | Channel definition |
| 21 | RESP_CODE_CUSTOM_VARS | Custom variables | | 21 | RESP_CODE_CUSTOM_VARS | Custom variables |
| 24 | RESP_CODE_STATS | Companion radio stats |
| 25 | RESP_CODE_AUTO_ADD_CONFIG | Auto-add flags | | 25 | RESP_CODE_AUTO_ADD_CONFIG | Auto-add flags |
| 0x80 | PUSH_CODE_ADVERT | Known contact re-seen | | 0x80 | PUSH_CODE_ADVERT | Known contact re-seen |
| 0x81 | PUSH_CODE_PATH_UPDATED | Better path found; carries the 32-byte public key of the updated contact | | 0x81 | PUSH_CODE_PATH_UPDATED | Better path found; carries the 32-byte public key of the updated contact |
+8 -9
View File
@@ -4,7 +4,7 @@
Channels are broadcast group-chat spaces secured by a 16-byte pre-shared key (PSK). Any device with the same channel index and PSK will receive and decrypt channel messages. Unlike direct messages, channel messages are broadcast to the entire mesh. Channels are broadcast group-chat spaces secured by a 16-byte pre-shared key (PSK). Any device with the same channel index and PSK will receive and decrypt channel messages. Unlike direct messages, channel messages are broadcast to the entire mesh.
Up to 8 channels (indices 07) can be active simultaneously on one device. The number of active channels is determined by the firmware (default 40); the device reports its actual limit at login.
## How to Access ## How to Access
@@ -17,7 +17,7 @@ QuickSwitchBar tab 1 (middle) from any main screen.
| Public | Globe | Green | Fixed well-known PSK; any device can join | | Public | Globe | Green | Fixed well-known PSK; any device can join |
| Hashtag | Hash tag | Blue | PSK derived from the hashtag name via SHA-256; discoverable by convention | | Hashtag | Hash tag | Blue | PSK derived from the hashtag name via SHA-256; discoverable by convention |
| Private | Lock | Blue | Random PSK; requires out-of-band sharing of the 32-hex key | | Private | Lock | Blue | Random PSK; requires out-of-band sharing of the 32-hex key |
| Community | Groups/Tag | Purple | PSK derived via HMAC-SHA256 from a community's shared secret | | Community | Groups/Tag | Magenta | PSK derived via HMAC-SHA256 from a community's shared secret |
## Channels List Screen ## Channels List Screen
@@ -26,12 +26,12 @@ QuickSwitchBar tab 1 (middle) from any main screen.
- **Search bar** with live text filtering (300ms debounce) - **Search bar** with live text filtering (300ms debounce)
- **Sort/filter button** - **Sort/filter button**
- **Scrollable list of channel cards**, each showing: - **Scrollable list of channel cards**, each showing:
- Type icon with color coding (purple badge overlay for community channels) - Type icon with color coding (magenta badge overlay for community channels)
- Channel name (or "Channel N" if unnamed) - Channel name (or "Channel N" if unnamed)
- Unread badge (if messages are unread) - Unread badge (if messages are unread)
- Drag handle (when manual sort is active) - Drag handle (when manual sort is active)
- **"+" FAB** to add a new channel - **"+" FAB** to add a new channel
- **Overflow menu**: Disconnect, Manage Communities (only shown when at least one community exists), Settings - **Overflow menu**: Disconnect, Manage Communities, Settings
If no channels exist, an empty state with an "Add Public Channel" shortcut is shown. If a search produces no results, a separate "no results" empty state with a search-off icon is shown. If no channels exist, an empty state with an "Add Public Channel" shortcut is shown. If a search produces no results, a separate "no results" empty state with a search-off icon is shown.
@@ -59,7 +59,7 @@ Tap the "+" FAB to open a dialog with six options:
| Action | Description | | Action | Description |
|---|---| |---|---|
| Edit | Change name, PSK (with a dice icon to generate a random PSK), or SMAZ compression toggle (compresses outgoing messages to allow longer text within the byte limit) | | Edit | Change name, PSK (with a dice icon to generate a random PSK), SMAZ compression toggle (compresses outgoing messages to allow longer text within the byte limit), or Cyr2Lat encoding toggle (transliterates Cyrillic to Latin for compatibility) |
| Mute / Unmute | Toggle push notification suppression for this channel | | Mute / Unmute | Toggle push notification suppression for this channel |
| Delete | Remove the channel from the device (confirmation required) | | Delete | Remove the channel from the device (confirmation required) |
@@ -100,8 +100,7 @@ Tap a channel card to open the channel chat screen.
### Message Path Viewing ### Message Path Viewing
- **Mobile**: Tap a message bubble to view its routing path - **All platforms**: Long-press (or right-click on desktop) a message bubble → "Path"
- **Desktop**: Long-press/right-click → "Path" (tapping the bubble does nothing on desktop)
- Opens the Channel Message Path Screen (see [Additional Features](additional-features.md)) - Opens the Channel Message Path Screen (see [Additional Features](additional-features.md))
### Context Actions (Long-Press / Right-Click) ### Context Actions (Long-Press / Right-Click)
@@ -109,7 +108,7 @@ Tap a channel card to open the channel chat screen.
| Action | Availability | Description | | Action | Availability | Description |
|---|---|---| |---|---|---|
| Reply | All messages | Triggers reply mode | | Reply | All messages | Triggers reply mode |
| Path | Desktop only | Opens message path view | | Path | All messages | Opens message path view |
| Add Reaction | Incoming messages only | Opens emoji picker (cannot react to your own messages) | | Add Reaction | Incoming messages only | Opens emoji picker (cannot react to your own messages) |
| Copy | All messages | Copies text to clipboard | | Copy | All messages | Copies text to clipboard |
| Mark as Unread | Incoming messages only | Marks this message and all subsequent incoming messages as unread | | Mark as Unread | Incoming messages only | Marks this message and all subsequent incoming messages as unread |
@@ -142,7 +141,7 @@ From the channels screen overflow menu → "Manage Communities". Opens a draggab
- **Tap a community** to directly show its QR code for sharing - **Tap a community** to directly show its QR code for sharing
- **Popup menu** per community: - **Popup menu** per community:
- **Show QR** — displays the QR code for sharing with new members - **Show QR** — displays the QR code for sharing with new members
- **Delete** — removes the community locally and deletes all associated device channels (confirmation dialog warns how many channels will be removed) - **Leave Community** — removes the community locally and deletes all associated device channels (confirmation dialog warns how many channels will be removed)
## How Channels Differ from Direct Messages ## How Channels Differ from Direct Messages
+6 -7
View File
@@ -18,17 +18,15 @@ From the Contacts screen, tap any Chat-type contact to open the ChatScreen.
- **Title**: Contact name - **Title**: Contact name
- **Subtitle**: Current routing path label (e.g., "2 hops", "flood (auto)", "direct (forced)") and unread count. Tapping the subtitle shows the full path details. - **Subtitle**: Current routing path label (e.g., "2 hops", "flood (auto)", "direct (forced)") and unread count. Tapping the subtitle shows the full path details.
- **Action buttons**: - **Action button**:
- **Routing mode** (waves icon): Switch between Auto, Direct, and Flood routing - **Overflow menu** (⋮ icon): Contains Routing, Info, Telemetry, Settings, and Clear Chat. Routing opens the routing sheet where you can switch between Auto, Direct, and Flood routing and manage recent paths (hop count, round-trip time, age, success count, color-coded by repeater). Info shows a dialog with contact type, path, GPS coordinates, and public key.
- **Path management** (timeline icon): View recent paths with hop count, round-trip time, age, and success count. Paths are color-coded by direct repeater (green/yellow/red/blue for ranked repeaters, grey for unknown). Tap a path to activate it (the device verifies and confirms via snackbar), long-press to view full path details, set custom paths, or force flood mode. A warning banner appears when history reaches 100 entries.
- **Info** (info icon): Contact info dialog showing type, path, GPS coordinates, public key, and SMAZ compression toggle
### Message List ### Message List
- Scrollable list with newest messages at the bottom - Scrollable list with newest messages at the bottom
- **Outgoing messages**: Right-aligned, primary color background. **Failed messages** change to a red-toned error container background - **Outgoing messages**: Right-aligned, primary color background. **Failed messages** change to a red-toned error container background
- **Incoming messages**: Left-aligned, grey background with a colored avatar (initial letter or first emoji of sender name; color is deterministic from a hash of the sender name) - **Incoming messages**: Left-aligned, grey background with a colored avatar (initial letter or first emoji of sender name; color is deterministic from a hash of the sender name)
- Bubble width capped at 65% of screen width - Bubble width capped at 72% of screen width
- Hyperlinks rendered as tappable green underlined text - Hyperlinks rendered as tappable green underlined text
- **Pinch-to-zoom**: Two-finger zoom (0.8x1.8x) and double-tap to reset - **Pinch-to-zoom**: Two-finger zoom (0.8x1.8x) and double-tap to reset
- **Jump to bottom**: Floating button appears when scrolled away from the bottom - **Jump to bottom**: Floating button appears when scrolled away from the bottom
@@ -87,7 +85,7 @@ When a direct message is sent:
1. The app computes an expected ACK hash: `SHA256([timestamp][attempt][text][selfPubKey])[0:4]` — matching the firmware's hash calculation. If SMAZ compression is enabled, the compressed text (not the original) is hashed 1. The app computes an expected ACK hash: `SHA256([timestamp][attempt][text][selfPubKey])[0:4]` — matching the firmware's hash calculation. If SMAZ compression is enabled, the compressed text (not the original) is hashed
2. On device acknowledgment (`RESP_CODE_SENT`), the message transitions to "sent" and a timeout timer starts 2. On device acknowledgment (`RESP_CODE_SENT`), the message transitions to "sent" and a timeout timer starts
3. **Timeout duration**: Preferably from the ML timeout prediction service; otherwise calculated from LoRa airtime physics: `500 + (airtime × 6 + 250) × (pathLength + 1)` ms for direct paths, `500 + 16 × airtime` ms for flood (airtime is estimated from the radio's current spreading factor, bandwidth, and coding rate) 3. **Timeout duration**: Preferably from the ML timeout prediction service; otherwise from the device's own `est_timeout` in `RESP_CODE_SENT` (clamped to the physics range); otherwise calculated from LoRa airtime physics: `500 + (airtime × 6 + 250) × (pathLength + 1)` ms for direct paths, `500 + 16 × airtime` ms for flood (airtime is estimated from the radio's current spreading factor, bandwidth, and coding rate). The result is capped at 45 seconds.
4. On timeout, the message is retried with **exponential backoff**: `1000 × 2^retryCount` ms (1s, 2s, 4s, 8s, 16s...) 4. On timeout, the message is retried with **exponential backoff**: `1000 × 2^retryCount` ms (1s, 2s, 4s, 8s, 16s...)
5. **Max retries**: Configurable (default 5, range 210) 5. **Max retries**: Configurable (default 5, range 210)
6. After max retries, the message is marked "failed" — but a **30-second grace window** remains during which a late ACK can still resolve the message to "delivered" 6. After max retries, the message is marked "failed" — but a **30-second grace window** remains during which a late ACK can still resolve the message to "delivered"
@@ -114,8 +112,9 @@ Add emoji reactions to incoming messages (not your own):
| Action | Availability | Description | | Action | Availability | Description |
|---|---|---| |---|---|---|
| Add reaction | Incoming messages only | Opens emoji picker | | Add reaction | Incoming messages only | Opens emoji picker |
| View path | Mobile: tap bubble directly; Desktop: long-press/right-click menu | Shows message routing path | | View path | All platforms: long-press/right-click menu | Shows message routing path |
| Copy | All messages | Copies text to clipboard | | Copy | All messages | Copies text to clipboard |
| Translate | Incoming messages only (when translation is enabled and not yet translated) | Translates the message on-demand using the on-device model |
| Mark as Unread | Incoming messages only | Marks this message and all subsequent incoming messages as unread | | Mark as Unread | Incoming messages only | Marks this message and all subsequent incoming messages as unread |
| Delete | All messages | Removes locally (not from mesh) | | Delete | All messages | Removes locally (not from mesh) |
| Retry | Failed outgoing messages | Re-sends the message | | Retry | Failed outgoing messages | Re-sends the message |
+17 -18
View File
@@ -6,18 +6,17 @@ The Contacts screen is the primary hub for managing mesh nodes your radio has a
## How to Access ## How to Access
- Automatically shown after connecting to a device - QuickSwitchBar tab 0 (leftmost) from Channels or Map screens (Channels is shown first after connecting)
- QuickSwitchBar tab 0 (leftmost) from Channels or Map screens
- Back navigation from Chat or Settings screens - Back navigation from Chat or Settings screens
## Contact Types ## Contact Types
| Type | Avatar Color | Icon | Description | | Type | Avatar Color | Icon | Description |
|---|---|---|---| |---|---|---|---|
| Chat | Blue | Chat bubble | Another user's mesh radio | | Chat | Blue | Initials / emoji | Another user's mesh radio |
| Repeater | Orange | Cell tower | A mesh repeater/relay node | | Repeater | Amber | Cell tower | A mesh repeater/relay node |
| Room | Purple | Group | A room server for group chat | | Room | Magenta | Meeting room | A room server for group chat |
| Sensor | Green | Sensors | A sensor device | | Sensor | Teal | Sensors | A sensor device |
## Contact List ## Contact List
@@ -73,42 +72,42 @@ Groups are stored per radio identity (scoped by public key).
| Action | Availability | Description | | Action | Availability | Description |
|---|---|---| |---|---|---|
| Ping | Repeaters (always) | Opens PathTraceMapScreen targeting the repeater | | Ping | Repeaters only | Opens PathTraceMapScreen targeting the repeater |
| Path Trace | Rooms (always); Chat/Sensor if `pathLength > 0` | Opens PathTraceMapScreen. For rooms, label shows "Ping" when no path bytes are known, "Path Trace" when path bytes are available | | Path Trace | Rooms (always); Chat/Sensor only if `pathLength > 0` | Opens PathTraceMapScreen. For rooms, label shows "Ping" when no path bytes are known, "Path Trace" when path bytes are available |
| Manage Repeater | Repeaters only | Login dialog → RepeaterHubScreen | | Manage Repeater | Repeaters only | Login dialog → RepeaterHubScreen |
| Room Login | Rooms only | Login dialog → ChatScreen | | Room Login | Rooms only | Login dialog → ChatScreen |
| Room Management | Rooms only | Login dialog → RepeaterHubScreen (management mode) | | Room Management | Rooms only | Login dialog → RepeaterHubScreen (management mode) |
| Open Chat | Chat/Sensor | Same as single tap |
| Add/Remove Favorite | All types | Toggles the favorite flag | | Add/Remove Favorite | All types | Toggles the favorite flag |
| Share Contact | All types | Copies `meshcore://<hex>` URI to clipboard | | Share Contact | All types | Requests advert from device → copies `meshcore://<hex>` URI to clipboard |
| Share Contact Zero-Hop | All types | Broadcasts the contact's advertisement one hop | | Share Contact Zero-Hop | All types | Broadcasts the contact's advertisement one hop |
| Delete Contact | All types | Confirmation dialog → removes from device and clears messages | | Delete Contact | All types | Confirmation dialog → removes from device and clears messages |
## App Bar Menus ## App Bar Menus
The Contacts screen has **two separate popup menus** in the app bar: The Contacts screen has a single **three-dot overflow menu** (`⋮`) in the app bar:
**Antenna icon menu** (contact sharing): - Discovered Contacts — opens the DiscoveryScreen
- Add Contact from Clipboard — reads a `meshcore://<hex>` URI from clipboard and imports it
- *(divider)*
- Zero-Hop Advert — broadcasts your advertisement to immediately adjacent nodes - Zero-Hop Advert — broadcasts your advertisement to immediately adjacent nodes
- Flood Advert — broadcasts across the full mesh network - Flood Advert — broadcasts across the full mesh network
- Copy Advert to Clipboard — copies your `meshcore://<hex>` URI for sharing externally - Copy Advert to Clipboard — copies your `meshcore://<hex>` URI for sharing externally
- Add Contact from Clipboard — reads a `meshcore://<hex>` URI from clipboard and imports it - *(divider)*
**Three-dot overflow menu**:
- Disconnect — disconnects from the device - Disconnect — disconnects from the device
- Discovered Contacts — opens the DiscoveryScreen
- Settings — opens the Settings screen - Settings — opens the Settings screen
A **floating action button** (person-add icon) provides a shortcut sheet to "Add Contact from Clipboard" or "Discovered Contacts".
## Adding Contacts ## Adding Contacts
### Automatic (Passive) ### Automatic (Passive)
When the radio hears an advertisement, the contact appears automatically if auto-add is enabled for that type (configurable in Settings → Contact Settings). When the radio hears an advertisement, the contact appears automatically if auto-add is enabled for that type (configurable in Settings → Contact Settings).
### Import from Clipboard ### Import from Clipboard
Antenna menu → "Add Contact from Clipboard". Reads a `meshcore://<hex>` URI from clipboard and imports it to the device. Overflow menu (or the FAB shortcut) → "Add Contact from Clipboard". Reads a `meshcore://<hex>` URI from clipboard and imports it to the device.
### Import from Discovered Contacts ### Import from Discovered Contacts
Overflow menu → "Discovered Contacts". Shows nodes heard passively that haven't been added yet. Tap to immediately import (no confirmation dialog), or long-press for more options (Add, Copy URI, Delete). The Discovery screen has its own search bar, type filters (Users, Repeaters, Rooms, Favorites), and sort options (Last Seen, A-Z). An overflow "Delete All" option clears all discovered contacts. Overflow menu → "Discovered Contacts". Shows nodes heard passively that haven't been added yet. Tap to immediately import (no confirmation dialog), or long-press for more options (Copy URI, Delete). The Discovery screen has its own search bar, type filters (Users, Repeaters, Rooms), and sort options (Last Seen, A-Z). An overflow "Delete All" option clears all discovered contacts.
## Contact Sharing Format ## Contact Sharing Format
+25 -18
View File
@@ -35,9 +35,9 @@ Location pins shared in chat messages are displayed as flags:
Tap a pin to see its info. Options to "Hide" (session only) or "Remove" (persistent). Tap a pin to see its info. Options to "Hide" (session only) or "Remove" (persistent).
### Predicted / Guessed Locations (Semi-Transparent) ### Predicted / Guessed Locations
Many contacts on the mesh don't have GPS hardware, so the map has no explicit coordinates for them. Instead of leaving these contacts invisible, the app **infers an approximate position** by analyzing the repeater path the contact's messages travel through. These inferred positions are displayed as semi-transparent markers with a `not_listed_location` icon, visually distinct from confirmed-location markers. Many contacts on the mesh don't have GPS hardware, so the map has no explicit coordinates for them. Instead of leaving these contacts invisible, the app **infers an approximate position** by analyzing the repeater path the contact's messages travel through. These inferred positions are displayed as markers with a `not_listed_location` icon and a muted grey or colored border, visually distinct from confirmed-location markers.
#### Why guessed locations exist #### Why guessed locations exist
@@ -55,19 +55,19 @@ In a mesh network, every message hops through one or more repeaters on its way t
5. **Compute the estimated position**: 5. **Compute the estimated position**:
- **Single anchor**: The contact is placed on a small circle (330m radius) around the repeater. The angle on the circle is deterministic — derived from an FNV-1a hash of the contact's public key — so the same contact always appears at the same offset, preventing markers from stacking on top of each other. - **Single anchor**: The contact is placed on a small circle (330m radius) around the repeater. The angle on the circle is deterministic — derived from an FNV-1a hash of the contact's public key — so the same contact always appears at the same offset, preventing markers from stacking on top of each other.
- **Two or more anchors**: The position is the average (centroid) of all anchor coordinates, with a smaller offset radius (80120m) applied for visual separation. - **Two or more anchors**: The position is a weighted average of all anchor coordinates (each subsequent anchor weighted at half the previous one, biasing toward the first), with a smaller offset radius (120m for 2 anchors, 80m for 3+) applied for visual separation.
6. **Assign confidence level**: 6. **Assign confidence level**:
- **High confidence** (2+ anchors): Displayed at 55% opacity. - **High confidence** (2+ anchors): The marker border uses the node's type color (brighter border).
- **Low confidence** (1 anchor): Displayed at 30% opacity. - **Low confidence** (1 anchor): The marker border is rendered in a muted grey.
7. **Cache the result**: The computation is cached using a key derived from the contact's paths, anchor positions, path-history version, and radio parameters. The cache is only invalidated when any of these inputs change, avoiding recomputation on every UI rebuild. 7. **Cache the result**: The computation is cached using a key derived from the contact's paths, anchor positions, path-history version, and radio parameters. The cache is only invalidated when any of these inputs change, avoiding recomputation on every UI rebuild.
#### How to read guessed locations on the map #### How to read guessed locations on the map
- **Semi-transparent marker** with a `not_listed_location` icon: This is a guessed position, not a confirmed GPS fix. - **Marker with `not_listed_location` icon**: This is a guessed position, not a confirmed GPS fix.
- **More opaque** (55%): Higher confidence — the contact was seen through 2 or more repeaters with known positions. - **Colored border** (type color): Higher confidence — the contact was seen through 2 or more repeaters with known positions.
- **More transparent** (30%): Lower confidence — based on a single repeater anchor only. - **Grey border**: Lower confidence — based on a single repeater anchor only.
- Coordinates shown in the marker info dialog are prefixed with `~` to indicate they are estimated. - Coordinates shown in the marker info dialog are prefixed with `~` to indicate they are estimated.
- Guessed locations can be toggled on/off in the map filter dialog (FAB → "Guessed locations" toggle). - Guessed locations can be toggled on/off in the map filter dialog (FAB → "Guessed locations" toggle).
@@ -110,9 +110,16 @@ A map with a polyline showing the route from your node through repeater hops to
- **Green circles**: Hops with known GPS coordinates - **Green circles**: Hops with known GPS coordinates
- **Orange circles** (`~HH`): Inferred positions (no GPS but deducible from contacts) - **Orange circles** (`~HH`): Inferred positions (no GPS but deducible from contacts)
- **Red endpoint**: Target contact with known GPS - **Red endpoint**: Target contact with known GPS
- **Purple semi-transparent endpoint**: Target with guessed position - **Magenta endpoint**: Target with guessed position
A legend card at the bottom lists each hop pair with SNR quality icons and total path distance. A bottom panel shows each hop pair with SNR quality icons and total path distance. When multiple observed paths are available, a **Single / Combined** toggle appears at the top of the map. In Combined view, all paths are overlaid; shared segments are highlighted with a white halo and a path count badge appears on shared nodes.
The bottom panel also provides **packet animation controls**:
- **Animation toggle** (on/off)
- **Step back / Play / Step forward / Replay** buttons
- **Follow packet lock** — keeps the map camera centered on the moving packet dot
- **Speed selector** (0.5×, 1×, 2×, 4×)
- A live **"Hop x of y · from → to"** label that tracks the active segment
### How It Works ### How It Works
Sends a trace request frame over the mesh. The repeater network traces the path hop-by-hop and returns per-hop SNR data. For hops without GPS, positions are inferred by averaging GPS coordinates of contacts sharing that last-hop byte. Sends a trace request frame over the mesh. The repeater network traces the path hop-by-hop and returns per-hop SNR data. For hops without GPS, positions are inferred by averaging GPS coordinates of contacts sharing that last-hop byte.
@@ -125,17 +132,17 @@ Sends a trace request frame over the mesh. The repeater network traces the path
From the main map, tap the terrain/antenna icon. From the main map, tap the terrain/antenna icon.
### What the User Sees ### What the User Sees
A full-screen map with a collapsible control panel containing: A full-screen map with a draggable bottom sheet containing:
- **Elevation profile chart**: Terrain fill (green), LOS beam line (white), radio horizon line (yellow) - **Elevation profile chart**: Terrain fill (green), LOS beam line (white), radio horizon line (yellow); obstruction points are marked as clickable dots on the chart
- **Status**: Clear (green) or blocked (red) with distance and minimum clearance - **Status summary**: Clear (green), Marginal (amber, within 5 m of obstruction), or Blocked (red) with distance and clearance/obstruction amount
- **Options panel**: Node toggles, endpoint dropdowns, antenna height sliders (0400 ft), Run LOS button - **Options section** (collapsible): Node toggles, endpoint dropdowns, antenna height sliders (0400 ft), Run LOS button
### Key Interactions ### Key Interactions
- **Long-press the map** to add custom endpoints (orange pushpin markers, renameable/deleteable) - **Long-press the map** to add custom endpoints (pushpin markers, renameable/deleteable)
- **Tap a marker** to select it as Point A or B; LOS runs automatically when both are set - **Tap a marker** to select it as Point A or B; LOS runs automatically when both are set
- **Antenna heights** are adjustable for both endpoints - **Antenna heights** are adjustable for both endpoints
- **Map line** between endpoints is colored green (clear) or red (blocked) - **Map line** between endpoints is colored green (clear), amber (marginal), or red (blocked)
- Terrain elevation is fetched from the Open-Meteo API (2181 sample points, cached 24 hours) - Terrain elevation is fetched from the Open-Meteo API (21, 41, or 81 sample points depending on link distance, cached 24 hours)
- K-factor is adjusted per radio frequency from a baseline of 4/3 at 915 MHz - K-factor is adjusted per radio frequency from a baseline of 4/3 at 915 MHz
--- ---
@@ -149,7 +156,7 @@ Settings → App Settings → Map Display → Offline Map Cache
- Map with a blue polygon overlay showing previously selected cache bounds - Map with a blue polygon overlay showing previously selected cache bounds
- Bounding box coordinates card - Bounding box coordinates card
- **Cache Area** controls: "Use Current View" and Clear buttons - **Cache Area** controls: "Use Current View" and Clear buttons
- **Zoom Range** slider (318) with estimated tile count - **Zoom Range** range slider (318, dual-handle for min and max) with estimated tile count
- **Download progress** bar (when downloading) - **Download progress** bar (when downloading)
- **Download Tiles** and **Clear Cache** buttons - **Download Tiles** and **Clear Cache** buttons
+10 -10
View File
@@ -5,7 +5,7 @@
The app follows this general flow: The app follows this general flow:
``` ```
Launch → Scanner Screen → [Connect via BLE/USB/TCP] → Contacts Screen Launch → Scanner Screen → [Connect via BLE/USB/TCP] → Channels Screen
``` ```
After connecting, the three main screens (Contacts, Channels, Map) are accessible via a persistent bottom navigation bar called the **QuickSwitchBar**. After connecting, the three main screens (Contacts, Channels, Map) are accessible via a persistent bottom navigation bar called the **QuickSwitchBar**.
@@ -24,7 +24,7 @@ Tapping a tab replaces the current screen with a subtle fade + slight horizontal
## Disconnection ## Disconnection
- The disconnect button (available in the Settings screen and other main screens) shows a confirmation dialog before disconnecting - The disconnect button (available in the overflow menu of each main screen) shows a confirmation dialog before disconnecting
- If the device disconnects unexpectedly, the app automatically navigates back to the Scanner screen (fires after the current frame completes via a post-frame callback) - If the device disconnects unexpectedly, the app automatically navigates back to the Scanner screen (fires after the current frame completes via a post-frame callback)
- This auto-navigation behavior (`DisconnectNavigationMixin`) is shared across all main screens - This auto-navigation behavior (`DisconnectNavigationMixin`) is shared across all main screens
@@ -38,11 +38,11 @@ Tapping a tab replaces the current screen with a subtle fade + slight horizontal
``` ```
ScannerScreen (root, always on stack) ScannerScreen (root, always on stack)
├─ [BLE connect] → push → ContactsScreen ├─ [BLE connect] → push → ChannelsScreen
├─ [TCP FAB] → push → TcpScreen ├─ [TCP icon button] → push → TcpScreen
│ └─ [TCP connected] → pushReplacement → ContactsScreen │ └─ [TCP connected] → pushReplacement → ChannelsScreen
└─ [USB FAB] → push → UsbScreen └─ [USB icon button] → push → UsbScreen
└─ [USB connected] → pushReplacement → ContactsScreen └─ [USB connected] → pushReplacement → ChannelsScreen
ContactsScreen (selected=0) ContactsScreen (selected=0)
├─ [quick-switch 1] → pushReplacement → ChannelsScreen ├─ [quick-switch 1] → pushReplacement → ChannelsScreen
@@ -60,9 +60,9 @@ ChannelsScreen (selected=1)
MapScreen (selected=2) MapScreen (selected=2)
├─ [quick-switch 0] → pushReplacement → ContactsScreen ├─ [quick-switch 0] → pushReplacement → ContactsScreen
├─ [quick-switch 1] → pushReplacement → ChannelsScreen ├─ [quick-switch 1] → pushReplacement → ChannelsScreen
├─ [radar button] → push → PathTraceMapScreen ├─ [radar menu item] → enters in-map path trace mode (push → PathTraceMapScreen after path is built)
├─ [terrain button] → push → LineOfSightMapScreen ├─ [terrain menu item] → push → LineOfSightMapScreen
└─ [long-press] → share marker / set location └─ [long-press] → share marker sheet
Settings (push from any main screen) Settings (push from any main screen)
└─ [App Settings] → push → AppSettingsScreen └─ [App Settings] → push → AppSettingsScreen
+2 -2
View File
@@ -22,7 +22,7 @@ MeshCore Open provides both **system notifications** (push-style OS alerts) and
### 3. Advertisement Notifications ### 3. Advertisement Notifications
- **Triggered when**: A new node is discovered on the mesh for the first time - **Triggered when**: A new node is discovered on the mesh for the first time
- **Title**: "New [type] discovered" (e.g., "New chat node discovered") - **Title**: "New [type] discovered" (e.g., "New Chat discovered")
- **Body**: Contact's name - **Body**: Contact's name
- **Priority**: Default - **Priority**: Default
- **Android channel**: `adverts` - **Android channel**: `adverts`
@@ -43,7 +43,7 @@ Red numeric badges appear throughout the UI:
- **Contacts list**: Each contact row shows a red pill badge (e.g., "3") for unread messages - **Contacts list**: Each contact row shows a red pill badge (e.g., "3") for unread messages
- **Channels list**: Each channel row shows an unread badge - **Channels list**: Each channel row shows an unread badge
- **Chat screen subtitle**: Shows unread count inline - **Chat screen subtitle**: Shows unread count inline
- Badges cap at "99+" for display - Badges cap at "9999+" for display
### How Unread Counts Work ### How Unread Counts Work
+6 -4
View File
@@ -34,8 +34,8 @@ The central management screen showing:
|---|---|---| |---|---|---|
| Status | Repeater Status Screen | All users | | Status | Repeater Status Screen | All users |
| Telemetry | Telemetry Screen | All users | | Telemetry | Telemetry Screen | All users |
| CLI | Repeater CLI Screen | Admin only |
| Neighbors | Neighbors Screen | All users | | Neighbors | Neighbors Screen | All users |
| CLI | Repeater CLI Screen | Admin only |
| Settings | Repeater Settings Screen | Admin only | | Settings | Repeater Settings Screen | Admin only |
The battery chemistry selector and CLI/Settings cards are hidden from guest users. The battery chemistry selector and CLI/Settings cards are hidden from guest users.
@@ -89,7 +89,7 @@ A terminal-style interface for sending commands directly to the repeater.
- Type a command and press send (or Enter on desktop) - Type a command and press send (or Enter on desktop)
- Up/down arrows navigate through command history - Up/down arrows navigate through command history
- Quick-command buttons populate and send common commands - Quick-command buttons populate and send common commands
- Bug report icon: Shows raw frame debug info for the next typed command (shows error snackbar if input field is empty) - Overflow menu (three-dot icon): "Debug next command" option shows raw frame debug info for the next typed command (shows error snackbar if input field is empty)
- Help icon: Opens a scrollable reference of all known CLI commands. Tapping any command populates the input field immediately - Help icon: Opens a scrollable reference of all known CLI commands. Tapping any command populates the input field immediately
- Clear icon: Wipes the command/response history - Clear icon: Wipes the command/response history
- Failed/timed-out commands are automatically retried once - Failed/timed-out commands are automatically retried once
@@ -112,7 +112,9 @@ The in-app help reference (help icon) documents all known commands. Categories:
**Power Management**: `get pwrmgt.support`, `get pwrmgt.source`, `get pwrmgt.bootreason`, `get pwrmgt.bootmv` **Power Management**: `get pwrmgt.support`, `get pwrmgt.source`, `get pwrmgt.bootreason`, `get pwrmgt.bootmv`
**Sensors**: `sensor get {key}` **Sensors**: `sensor get {key}`, `sensor set {key} {value}`, `sensor list [start]`
**GPS Management**: `gps`, `gps {on|off}`, `gps sync`, `gps setloc`, `gps advert`, `gps advert {none|share|prefs}`
**Region Management**: `region`, `region load`, `region get`, `region put`, `region remove`, `region allowf`, `region denyf`, `region home`, `region save`, `region default`, `region list allowed`, `region list denied` **Region Management**: `region`, `region load`, `region get`, `region put`, `region remove`, `region allowf`, `region denyf`, `region home`, `region save`, `region default`, `region list allowed`, `region list denied`
@@ -207,7 +209,7 @@ Nine configuration cards, each with its own per-field refresh button(s):
**Danger Zone** (red-styled card) **Danger Zone** (red-styled card)
- Reboot repeater (sends `reboot` with confirmation dialog) - Reboot repeater (sends `reboot` with confirmation dialog)
- Erase filesystem (serial-only; shows informational snackbar only — no command is sent over the air) - Erase filesystem (serial-only; shows a confirmation dialog, then an informational snackbar — no command is sent over the air)
### Key Interactions ### Key Interactions
- **Settings are NOT auto-fetched on open**. Name is pre-filled from cached contact data. Each section has its own refresh button to fetch live values from the repeater - **Settings are NOT auto-fetched on open**. Name is pre-filled from cached contact data. Each section has its own refresh button to fetch live values from the repeater
+15 -13
View File
@@ -28,9 +28,11 @@ The BLE Scanner is the app's home screen, displayed immediately on launch.
**Device List**: When no devices are found, shows a large Bluetooth icon with a prompt. The prompt text is dynamic: "Searching for devices..." while actively scanning, or "Tap Scan to search" when idle. When devices are found, shows a scrollable list of `DeviceTile` widgets. **Device List**: When no devices are found, shows a large Bluetooth icon with a prompt. The prompt text is dynamic: "Searching for devices..." while actively scanning, or "Tap Scan to search" when idle. When devices are found, shows a scrollable list of `DeviceTile` widgets.
**Bottom FAB Row**: Up to three floating action buttons: **App Bar Actions**: Icon buttons in the top-right corner of the app bar:
- **USB** button - Opens USB connection screen (Android, Windows, Linux, macOS, Chrome web only) - **USB** icon button - Opens USB connection screen (Android, Windows, Linux, macOS, Chrome web only)
- **TCP/IP** button - Opens TCP connection screen (all non-web platforms) - **TCP/IP** icon button - Opens TCP connection screen (all non-web platforms)
**Bottom FAB**: A single floating action button:
- **BLE Scan** button - Toggles BLE scanning on/off; shows a spinner when scanning. **Disabled** (greyed out, not tappable) when Bluetooth is off - **BLE Scan** button - Toggles BLE scanning on/off; shows a spinner when scanning. **Disabled** (greyed out, not tappable) when Bluetooth is off
### Device Tile ### Device Tile
@@ -51,7 +53,7 @@ Note: The weak (-80 to -90 dBm) and poor (< -90 dBm) tiers share the same icon s
### How Scanning Works ### How Scanning Works
- Filters for devices with names starting with one of the known prefixes: `MeshCore-`, `Whisper-`, `WisCore-`, `Seeed`, `Lilygo`, `HT-`, `LowMesh_MC_` - Filters for devices advertising the Nordic UART Service UUID (so community forks with non-standard names are still found). Known name prefixes used by stock firmware builds for reference: `MeshCore-`, `Whisper-`, `WisCore-`, `Seeed`, `Lilygo`, `HT-`, `LowMesh_MC_`, `NRF52`
- Uses low-latency scan mode on Android - Uses low-latency scan mode on Android
- Scans for 10 seconds then auto-stops - Scans for 10 seconds then auto-stops
- On iOS/macOS, waits for BLE adapter initialization before starting - On iOS/macOS, waits for BLE adapter initialization before starting
@@ -65,7 +67,7 @@ Tap a device tile or its Connect button:
3. Requests MTU 185 bytes for optimal throughput 3. Requests MTU 185 bytes for optimal throughput
4. Discovers BLE services and locates the Nordic UART Service 4. Discovers BLE services and locates the Nordic UART Service
5. Subscribes to TX notifications for receiving data 5. Subscribes to TX notifications for receiving data
6. On success, automatically navigates to the Contacts screen 6. On success, automatically navigates to the Channels screen
7. On failure, shows a red error snackbar 7. On failure, shows a red error snackbar
--- ---
@@ -74,7 +76,7 @@ Tap a device tile or its Connect button:
### How to Access ### How to Access
From the Scanner screen, tap the **USB** FAB button. From the Scanner screen, tap the **USB** icon button in the app bar.
### What the User Sees ### What the User Sees
@@ -82,15 +84,15 @@ From the Scanner screen, tap the **USB** FAB button.
- A list of detected USB serial ports, each showing: - A list of detected USB serial ports, each showing:
- Friendly display name - Friendly display name
- Raw port name (subtitle, only shown when it differs from the display name) - Raw port name (subtitle, only shown when it differs from the display name)
- "Connect" button - Chevron trailing icon (the entire tile is tappable to connect)
- FABs at the bottom to switch to BLE or TCP (these use `pushReplacement`, so back navigation returns to Scanner, not between USB/TCP) - Transport switcher buttons (outlined, not FABs) to switch to BLE or TCP (these use `pushReplacement`, so back navigation returns to Scanner, not between USB/TCP)
### Key Interactions ### Key Interactions
- On desktop (Windows, Linux, macOS): ports are polled every 2 seconds for hot-plug detection (polling pauses while connecting/connected) - On desktop (Windows, Linux, macOS): ports are polled every 2 seconds for hot-plug detection (polling pauses while connecting/connected)
- On mobile: tap the "Scan" FAB to manually refresh - On mobile: tap the "Scan" FAB to manually refresh
- Tap a port or its Connect button to connect - Tap a port tile to connect
- On successful connection, navigates to Contacts screen - On successful connection, navigates to Channels screen
- On connection failure, the port list automatically refreshes - On connection failure, the port list automatically refreshes
- Platform-specific error messages for common USB failures (permission denied, device missing, device detached, device busy, driver missing, port invalid, timeout, and more) - Platform-specific error messages for common USB failures (permission denied, device missing, device detached, device busy, driver missing, port invalid, timeout, and more)
@@ -100,7 +102,7 @@ From the Scanner screen, tap the **USB** FAB button.
### How to Access ### How to Access
From the Scanner screen, tap the **TCP/IP** FAB button. From the Scanner screen, tap the **TCP/IP** icon button in the app bar.
### What the User Sees ### What the User Sees
@@ -108,7 +110,7 @@ From the Scanner screen, tap the **TCP/IP** FAB button.
- **Host address** text field - **Host address** text field
- **Port number** text field - **Port number** text field
- **Connect** button - **Connect** button
- FABs at the bottom to switch to USB or BLE - Transport switcher buttons (outlined, not FABs) to switch to USB or BLE
### Key Interactions ### Key Interactions
@@ -119,6 +121,6 @@ From the Scanner screen, tap the **TCP/IP** FAB button.
- Validation errors are shown as red snackbars - Validation errors are shown as red snackbars
- The Connect button shows a spinner and "Connecting..." label while in progress - The Connect button shows a spinner and "Connecting..." label while in progress
- The status bar shows the specific host:port being connected to (e.g., "Connecting to 192.168.1.1:5000") - The status bar shows the specific host:port being connected to (e.g., "Connecting to 192.168.1.1:5000")
- On success, navigates to Contacts screen and saves the host/port to settings - On success, navigates to Channels screen and saves the host/port to settings
- On connection, the status bar shows the active TCP endpoint (e.g., "Connected to 192.168.1.1:5000") - On connection, the status bar shows the active TCP endpoint (e.g., "Connected to 192.168.1.1:5000")
- Error messages for timeout, unsupported platform, and connection failures - Error messages for timeout, unsupported platform, and connection failures
+96 -78
View File
@@ -12,12 +12,13 @@ Settings are only accessible while a device is connected.
The settings screen is a scrollable list of cards: The settings screen is a scrollable list of cards:
1. [Device Info](#device-info) 1. [Device Info](#device-info)
2. [App Settings](#app-settings) (link to sub-screen) 2. [Node Settings](#node-settings)
3. [Node Settings](#node-settings) 3. [Location](#location)
4. [Actions](#actions) 4. [App Settings](#app-settings) (link to sub-screen)
5. [Debug](#debug) 5. [Actions](#actions)
6. [Export](#export) 6. [Export](#export)
7. [About](#about) 7. [Debug](#debug)
8. [About](#about)
--- ---
@@ -40,60 +41,6 @@ Battery shows an alert icon and orange text when at 15% or below. The toggle onl
--- ---
## App Settings
A dedicated sub-screen for app-level preferences (nothing here is sent to the device). All settings persist locally via SharedPreferences.
### Appearance
- **Theme**: System / Light / Dark
- **Language**: System default or one of 18 languages (English, French, Spanish, German, Polish, Slovenian, Portuguese, Italian, Chinese, Swedish, Dutch, Slovak, Bulgarian, Russian, Ukrainian, Hungarian, Japanese, Korean)
- **Enable Message Tracing**: Shows path trace overlays and extra metadata on messages
### Notifications
- **Master enable/disable**: Requests OS permission when enabling
- **Message notifications**: New direct message alerts
- **Channel message notifications**: New channel message alerts
- **Advertisement notifications**: New node discovery alerts
### Messaging
- **Clear Path on Max Retry**: Erases the stored routing path after all retries fail
- **Jump to Oldest Unread**: When opening a chat, scrolls to the oldest unread message instead of the newest
- **Auto Route Rotation**: Enables weighted routing algorithm. When enabled, expands to show five slider sub-settings (hidden when off):
- Max Route Weight (110, default 5, integer steps)
- Initial Route Weight (0.55.0, default 3.0)
- Success Increment (0.12.0, default 0.5, 0.1 steps)
- Failure Decrement (0.12.0, default 0.2, 0.1 steps)
- Max Message Retries (210, default 5)
### Battery
- **Battery Chemistry**: NMC / LiFePO4 / LiPo (per device, used to calibrate percentage from voltage)
### Translation
Not shown on web. Controls on-device message translation powered by a locally-downloaded ML model:
- **Enable Translation**: Translates incoming messages into the selected target language
- **Translate Composer**: Translates outgoing messages from the target language back before sending
- **Target Language**: Language to translate into (searchable list; defaults to the app language)
- **Downloaded Model**: Dropdown to select among already-downloaded translation models
- **Preset Model**: Download a curated preset model with one tap
- **Custom Model URL**: Enter a URL to download a custom GGUF-format model; shows download progress and a cancel button
### Map Display
- **Show Repeaters**: Toggle repeater markers on map
- **Show Chat Nodes**: Toggle chat node markers
- **Show Other Nodes**: Toggle room/sensor markers
- **Time Filter**: All time / Last 1h / Last 6h / Last 24h / Last week
- **Units**: Metric / Imperial
- **Raster Tile Source**: Sets the MAP theme and with that from where to get the map tile data:
- OpenStreetMap (Auto/Standard/Dark) is provided by the free OpenStreetMap tile server. (Can only be used for live view or already cached.)
- Stamen Terrain / AlidadeSmooth Dark / Outdoors / OSM Bright are [StadiaMaps.com raster tile maps](https://stadiamaps.com/products/maps/interactive-basemaps/) for which you can choose an Hosted Endpoint (Worldwide / Europe hosted) and have to provide the API key to your subscription. (You can cache these maps for offline Map usage.)
There will be no account provided by meshcore-open. You will have to get you own subscription. StadiaMaps offers a [free](https://stadiamaps.com/pricing/) subscription to download up to 200'000 Standart Raster Basemap tiles.
- **Offline Map Cache**: Navigate to tile download screen
### Debug
- **App Debug Logging**: Enable the in-app debug log
---
## Node Settings ## Node Settings
These settings are sent directly to the connected device firmware. These settings are sent directly to the connected device firmware.
@@ -105,7 +52,7 @@ These settings are sent directly to the connected device firmware.
### Radio Settings ### Radio Settings
Opens a dialog pre-populated with the device's current radio settings. Contains: Opens a dialog pre-populated with the device's current radio settings. Contains:
- **Preset dropdown**: 19 regional presets — selecting a preset immediately fills all fields below. Full list: Australia, Australia (Narrow), Australia SA, WA, QLD, Czech Republic, EU 433MHz, EU/UK (Long Range), EU/UK (Medium Range), EU/UK (Narrow), New Zealand, New Zealand (Narrow), Portugal 433, Portugal 869, Switzerland, USA Arizona, USA/Canada, Vietnam, Off-Grid 433, Off-Grid 869, Off-Grid 918 - **Preset dropdown**: Regional presets — selecting a preset immediately fills all fields below. Includes presets for Australia, Australia (Narrow), Australia SA WA QLD, Czech Republic, EU 433MHz, EU/UK (Long Range), EU/UK (Medium Range), EU/UK (Narrow), New Zealand, New Zealand (Narrow), Portugal 433, Portugal 869, numerous Russia city presets, Switzerland, USA Arizona, USA/Canada, and Vietnam
- **Frequency** (MHz): Free text, validated 3002500 MHz - **Frequency** (MHz): Free text, validated 3002500 MHz
- **Bandwidth**: Dropdown (7.8 / 10.4 / 15.6 / 20.8 / 31.25 / 41.7 / 62.5 / 125 / 250 / 500 kHz) - **Bandwidth**: Dropdown (7.8 / 10.4 / 15.6 / 20.8 / 31.25 / 41.7 / 62.5 / 125 / 250 / 500 kHz)
- **Spreading Factor**: SF5SF12 - **Spreading Factor**: SF5SF12
@@ -113,6 +60,13 @@ Opens a dialog pre-populated with the device's current radio settings. Contains:
- **TX Power** (dBm): Validated 0 to device max (typically 22 dBm) - **TX Power** (dBm): Validated 0 to device max (typically 22 dBm)
- **Client Repeat** toggle: Only shown on firmware v9+; requires frequency to be exactly 433.000, 869.000, or 918.000 MHz (the Off-Grid presets). Save is blocked with a warning if enabled on other frequencies - **Client Repeat** toggle: Only shown on firmware v9+; requires frequency to be exactly 433.000, 869.000, or 918.000 MHz (the Off-Grid presets). Save is blocked with a warning if enabled on other frequencies
### Companion Radio Stats
Opens the RF statistics screen (RSSI, SNR, packet counts) for the paired radio. Only enabled when connected to a device that supports companion radio stats.
---
## Location
### Location ### Location
Opens a dialog pre-populated with the device's current coordinates (if known): Opens a dialog pre-populated with the device's current coordinates (if known):
- Latitude and longitude fields (decimal, 6 decimal places). If only one field is provided, the other uses the device's current value - Latitude and longitude fields (decimal, 6 decimal places). If only one field is provided, the other uses the device's current value
@@ -129,8 +83,72 @@ Five toggles controlling which node types are auto-added when heard:
- Auto-add Sensors - Auto-add Sensors
- Overwrite Oldest (when contact list is full) - Overwrite Oldest (when contact list is full)
### Privacy Mode ### Privacy
Opens a confirmation dialog with three buttons: Cancel, Enable, and Disable. Both states can be set from the same dialog regardless of current state. A snackbar confirms which state was applied. When on, the node stops broadcasting its location in advertisements. Opens a dialog with controls for how the node shares telemetry and location data:
- **Advert Location**: Toggle whether the node broadcasts its location in advertisements
- **Multi-Ack**: Toggle multi-ack delivery confirmations
- **Telemetry Base Mode**: Deny All / Allow by Contact / Allow All
- **Telemetry Location Mode**: Deny All / Allow by Contact / Allow All
- **Telemetry Environment Mode**: Deny All / Allow by Contact / Allow All
Settings take effect when saved. A snackbar confirms the update.
---
## App Settings
A dedicated sub-screen for app-level preferences (nothing here is sent to the device). All settings persist locally via SharedPreferences.
### Appearance
- **Theme**: System / Light / Dark
- **Language**: System default or one of 18 languages (English, French, Spanish, German, Polish, Slovenian, Portuguese, Italian, Chinese, Swedish, Dutch, Slovak, Bulgarian, Russian, Ukrainian, Hungarian, Japanese, Korean)
### Notifications
- **Master enable/disable**: Requests OS permission when enabling
- **Message notifications**: New direct message alerts
- **Channel message notifications**: New channel message alerts
- **Advertisement notifications**: New node discovery alerts
### Messaging
- **Clear Path on Max Retry**: Erases the stored routing path after all retries fail
- **Jump to Oldest Unread**: When opening a chat, scrolls to the oldest unread message instead of the newest
- **Auto Route Rotation**: Enables weighted routing algorithm. When enabled, expands to show five slider sub-settings (hidden when off):
- Max Route Weight (110, default 5, integer steps)
- Initial Route Weight (0.55.0, default 3.0)
- Success Increment (0.12.0, default 0.5, 0.1 steps)
- Failure Decrement (0.12.0, default 0.2, 0.1 steps)
- Max Message Retries (210, default 5)
- **Enable Message Tracing**: Shows path trace overlays and extra metadata on messages
### Battery
- **Battery Chemistry**: NMC / LiFePO4 / LiPo (per device, used to calibrate percentage from voltage)
### Map Display
- **Show Repeaters**: Toggle repeater markers on map
- **Show Chat Nodes**: Toggle chat node markers
- **Show Other Nodes**: Toggle room/sensor markers
- **Time Filter**: All time / Last 1h / Last 6h / Last 24h / Last week
- **Units**: Metric / Imperial
- **Raster Tile Source**: Sets the MAP theme and with that from where to get the map tile data:
- OpenStreetMap (Auto/Standard/Dark) is provided by the free OpenStreetMap tile server. (Can only be used for live view or already cached.)
- Stamen Terrain / AlidadeSmooth Dark / Outdoors / OSM Bright are [StadiaMaps.com raster tile maps](https://stadiamaps.com/products/maps/interactive-basemaps/) for which you can choose an Hosted Endpoint (Worldwide / Europe hosted) and have to provide the API key to your subscription. (You can cache these maps for offline Map usage.)
There will be no account provided by meshcore-open. You will have to get you own subscription. StadiaMaps offers a [free](https://stadiamaps.com/pricing/) subscription to download up to 200'000 Standart Raster Basemap tiles.
- **Offline Map Cache**: Navigate to tile download screen
### Translation
Not shown on web. Controls on-device message translation powered by a locally-downloaded ML model:
- **Enable Translation**: Translates incoming messages into the selected target language
- **Translate Composer**: Translates outgoing messages from the target language back before sending
- **Target Language**: Language to translate into (searchable list; defaults to the app language)
- **Downloaded Model**: Dropdown to select among already-downloaded translation models
- **Preset Model**: Download a curated preset model with one tap
- **Custom Model URL**: Enter a URL to download a custom GGUF-format model; shows download progress and a cancel button
### Cyrillic-to-Latin (Cyr2Lat)
Controls character substitution profiles used to render Cyrillic text in Latin characters. A dropdown selects the active profile; Add, Edit, and Delete buttons manage the profile list (the last remaining profile cannot be deleted). Each profile stores a JSON character map.
### Debug
- **App Debug Logging**: Enable the in-app debug log
--- ---
@@ -140,10 +158,24 @@ One-tap device operations:
| Action | Description | | Action | Description |
|---|---| |---|---|
| Send Advertisement | Floods the mesh with your node's advertisement |
| Sync Time | Sends current Unix timestamp to the device | | Sync Time | Sends current Unix timestamp to the device |
| Refresh Contacts | Re-requests the full contact list | | Refresh Contacts | Re-requests the full contact list |
| Reboot Device | Confirmation dialog → reboots the device (shown in orange) | | Reboot Device | Confirmation dialog → reboots the device (shown in warning color) |
| Delete All Paths | Confirmation dialog → clears all stored routing paths (shown in alert color) |
---
## Export
Three GPX export options (not available on web):
| Option | Exports |
|---|---|
| Export Repeaters | Repeaters and Rooms with GPS coordinates |
| Export Contacts | Chat contacts with GPS coordinates |
| Export All | All contacts with GPS coordinates |
Each creates a `.gpx` file and opens the OS share sheet. Feedback via snackbar for four outcomes: success, no contacts with coordinates, feature not available (web), or error.
--- ---
@@ -164,20 +196,6 @@ Structured log entries (Info / Warning / Error), with tag, message, and timestam
--- ---
## Export
Three GPX export options (not available on web):
| Option | Exports |
|---|---|
| Export Repeaters | Repeaters and Rooms with GPS coordinates |
| Export Contacts | Chat contacts with GPS coordinates |
| Export All | All contacts with GPS coordinates |
Each creates a `.gpx` file and opens the OS share sheet. Feedback via snackbar for four outcomes: success, no contacts with coordinates, feature not available (web), or error.
---
## About ## About
Shows the standard Flutter about dialog with app name, version, and legal notice. Shows the standard Flutter about dialog with app name, version, and legal notice.
+376 -180
View File
@@ -160,6 +160,7 @@ class MeshCoreConnector extends ChangeNotifier {
{}; // contactPubKeyHex -> Set of "targetHash_emoji" {}; // contactPubKeyHex -> Set of "targetHash_emoji"
StreamSubscription<List<ScanResult>>? _scanSubscription; StreamSubscription<List<ScanResult>>? _scanSubscription;
StreamSubscription<bool>? _isScanningSubscription;
StreamSubscription<BluetoothConnectionState>? _connectionSubscription; StreamSubscription<BluetoothConnectionState>? _connectionSubscription;
StreamSubscription<List<int>>? _notifySubscription; StreamSubscription<List<int>>? _notifySubscription;
Timer? _notifyListenersTimer; Timer? _notifyListenersTimer;
@@ -207,6 +208,9 @@ class MeshCoreConnector extends ChangeNotifier {
// Intentionally global (not per-contact): tracks overall network activity. // Intentionally global (not per-contact): tracks overall network activity.
// Frequent RX from any source indicates a busy network with more collisions. // Frequent RX from any source indicates a busy network with more collisions.
DateTime _lastRxTime = DateTime.now(); DateTime _lastRxTime = DateTime.now();
// Snapshot of _lastRxTime taken before the ACK frame updates it, so that
// onDeliveryObserved records the pre-ACK elapsed time (matching prediction).
DateTime _lastRxBeforeFrame = DateTime.fromMillisecondsSinceEpoch(0);
DateTime _lastRadioRxTime = DateTime.fromMillisecondsSinceEpoch(0); DateTime _lastRadioRxTime = DateTime.fromMillisecondsSinceEpoch(0);
DateTime _lastContactMsgRxTime = DateTime.fromMillisecondsSinceEpoch(0); DateTime _lastContactMsgRxTime = DateTime.fromMillisecondsSinceEpoch(0);
DateTime _lastChannelMsgRxTime = DateTime.fromMillisecondsSinceEpoch(0); DateTime _lastChannelMsgRxTime = DateTime.fromMillisecondsSinceEpoch(0);
@@ -327,6 +331,20 @@ class MeshCoreConnector extends ChangeNotifier {
String? get deviceId => _deviceId; String? get deviceId => _deviceId;
String get deviceIdLabel => _deviceId ?? 'Unknown'; String get deviceIdLabel => _deviceId ?? 'Unknown';
/// Stable per-radio key for transport-agnostic per-device settings such as
/// battery chemistry. On BLE this is the existing remoteId (so previously
/// saved settings are preserved); on USB/TCP — where there is no BLE
/// remoteId — it falls back to the node's public key, which identifies the
/// same physical radio across transports. Null until a device identity is
/// known.
String? get batteryDeviceKey {
if (_deviceId != null) return _deviceId;
if (_selfPublicKey != null && _selfPublicKey!.isNotEmpty) {
return selfPublicKeyHex;
}
return null;
}
MeshCoreTransportType get activeTransport => _activeTransport; MeshCoreTransportType get activeTransport => _activeTransport;
String? get activeUsbPort => _usbManager.activePortKey; String? get activeUsbPort => _usbManager.activePortKey;
String? get activeUsbPortDisplayLabel => _usbManager.activePortDisplayLabel; String? get activeUsbPortDisplayLabel => _usbManager.activePortDisplayLabel;
@@ -492,7 +510,7 @@ class MeshCoreConnector extends ChangeNotifier {
} }
String _batteryChemistryForDevice() { String _batteryChemistryForDevice() {
final deviceId = _device?.remoteId.toString(); final deviceId = batteryDeviceKey;
if (deviceId == null || _appSettingsService == null) return 'nmc'; if (deviceId == null || _appSettingsService == null) return 'nmc';
return _appSettingsService!.batteryChemistryForDevice(deviceId); return _appSettingsService!.batteryChemistryForDevice(deviceId);
} }
@@ -507,10 +525,22 @@ class MeshCoreConnector extends ChangeNotifier {
if (messages == null) return; if (messages == null) return;
final removed = messages.remove(message); final removed = messages.remove(message);
if (!removed) return; if (!removed) return;
_retryService?.untrack(message.messageId);
await _messageStore.saveMessages(contactKeyHex, messages); await _messageStore.saveMessages(contactKeyHex, messages);
notifyListeners(); notifyListeners();
} }
Future<void> resendMessage(Contact contact, Message message) async {
await deleteMessage(message);
await sendMessage(
contact,
message.text,
originalText: message.originalText,
translatedLanguageCode: message.translatedLanguageCode,
translationModelId: message.translationModelId,
);
}
Future<void> _loadMessagesForContact(String contactKeyHex) async { Future<void> _loadMessagesForContact(String contactKeyHex) async {
if (_loadedConversationKeys.contains(contactKeyHex)) return; if (_loadedConversationKeys.contains(contactKeyHex)) return;
_loadedConversationKeys.add(contactKeyHex); _loadedConversationKeys.add(contactKeyHex);
@@ -918,11 +948,17 @@ class MeshCoreConnector extends ChangeNotifier {
updateMessage: _updateMessage, updateMessage: _updateMessage,
clearContactPath: clearContactPath, clearContactPath: clearContactPath,
setContactPath: setContactPath, setContactPath: setContactPath,
calculateTimeout: (pathLength, messageBytes, {String? contactKey}) => calculateTimeout:
calculateTimeout( (
pathLength,
messageBytes, {
String? contactKey,
int? deviceTimeoutMs,
}) => calculateTimeout(
pathLength: pathLength, pathLength: pathLength,
messageBytes: messageBytes, messageBytes: messageBytes,
contactKey: contactKey, contactKey: contactKey,
deviceTimeoutMs: deviceTimeoutMs,
), ),
getSelfPublicKey: () => _selfPublicKey, getSelfPublicKey: () => _selfPublicKey,
prepareContactOutboundText: prepareContactOutboundText, prepareContactOutboundText: prepareContactOutboundText,
@@ -938,7 +974,9 @@ class MeshCoreConnector extends ChangeNotifier {
recentSelections: recentSelections, recentSelections: recentSelections,
), ),
onDeliveryObserved: (contactKey, pathLength, messageBytes, tripTimeMs) { onDeliveryObserved: (contactKey, pathLength, messageBytes, tripTimeMs) {
final secSinceRx = DateTime.now().difference(_lastRxTime).inSeconds; final secSinceRx = DateTime.now()
.difference(_lastRxBeforeFrame)
.inSeconds;
_timeoutPredictionService?.recordObservation( _timeoutPredictionService?.recordObservation(
contactKey: contactKey, contactKey: contactKey,
pathLength: pathLength, pathLength: pathLength,
@@ -1356,6 +1394,21 @@ class MeshCoreConnector extends ChangeNotifier {
}) async { }) async {
if (_state == MeshCoreConnectionState.scanning) return; if (_state == MeshCoreConnectionState.scanning) return;
// A BLE scan must never disturb an active (or in-progress) non-BLE
// connection. The connection state enum is shared across transports, so
// entering the `scanning` state while connected over TCP/USB would clobber
// the live `connected` state and later reset it to `disconnected`.
if (_state != MeshCoreConnectionState.disconnected ||
_tcpConnector.isConnected ||
_usbManager.isConnected) {
_appDebugLogService?.warn(
'startScan ignored: not idle (state=$_state, '
'tcp=${_tcpConnector.isConnected}, usb=${_usbManager.isConnected})',
tag: 'BLE Scan',
);
return;
}
_scanResults.clear(); _scanResults.clear();
_linuxSystemScanResults.clear(); _linuxSystemScanResults.clear();
_setState(MeshCoreConnectionState.scanning); _setState(MeshCoreConnectionState.scanning);
@@ -1409,20 +1462,40 @@ class MeshCoreConnector extends ChangeNotifier {
}); });
try { try {
// Filter by the Nordic UART Service UUID rather than by advertised
// name. All MeshCore-compatible firmware (ESP32 + nRF52) advertises this
// service UUID, so this matches every device regardless of the name it
// chooses to advertise (e.g. community forks like the M5 Cardputer that
// do not use a "MeshCore-" name prefix). This mirrors how the official
// app discovers devices. Note: on Android `withKeywords` cannot be
// combined with any other filter, which is why name keywords are not
// used here.
await FlutterBluePlus.startScan( await FlutterBluePlus.startScan(
withKeywords: MeshCoreUuids.deviceNamePrefixes, withServices: [Guid(MeshCoreUuids.service)],
webOptionalServices: [Guid(MeshCoreUuids.service)], webOptionalServices: [Guid(MeshCoreUuids.service)],
timeout: timeout, timeout: timeout,
androidScanMode: AndroidScanMode.lowLatency, androidScanMode: AndroidScanMode.lowLatency,
); );
} catch (error) { } catch (error) {
_appDebugLogService?.warn('Scan/picker failure: $error', tag: 'BLE Scan'); _appDebugLogService?.warn('Scan/picker failure: $error', tag: 'BLE Scan');
_setState(MeshCoreConnectionState.disconnected); await stopScan();
rethrow; rethrow;
} }
await Future.delayed(timeout); // Reset our shared state when the native scan ends — whether it was stopped
await stopScan(); // by the user (stopScan), by the platform timeout, or by Bluetooth turning
// off. This replaces a blocking `Future.delayed(timeout)` tail that kept
// startScan() pending for the whole timeout and made Stop appear ineffective.
// `isScanning` is a re-emit stream that replays its latest value on listen,
// so skip(1) to ignore that and only react to a genuine transition to false.
await _isScanningSubscription?.cancel();
_isScanningSubscription = FlutterBluePlus.isScanning.skip(1).listen((
scanning,
) {
if (!scanning && _state == MeshCoreConnectionState.scanning) {
unawaited(stopScan());
}
});
} }
Future<void> _loadLinuxSystemDevicesForScan() async { Future<void> _loadLinuxSystemDevicesForScan() async {
@@ -1430,31 +1503,28 @@ class MeshCoreConnector extends ChangeNotifier {
final systemDevices = await FlutterBluePlus.systemDevices([ final systemDevices = await FlutterBluePlus.systemDevices([
Guid(MeshCoreUuids.service), Guid(MeshCoreUuids.service),
]); ]);
// systemDevices is already filtered by the NUS service UUID above, so no
// additional name-prefix filtering is applied here. This keeps Linux
// discovery name-agnostic and consistent with the main scan path.
_linuxSystemScanResults _linuxSystemScanResults
..clear() ..clear()
..addAll( ..addAll(
systemDevices systemDevices.map(
.where( (device) => ScanResult(
(device) => MeshCoreUuids.deviceNamePrefixes.any( device: device,
device.platformName.startsWith, advertisementData: AdvertisementData(
), advName: device.platformName,
) txPowerLevel: null,
.map( appearance: null,
(device) => ScanResult( connectable: true,
device: device, manufacturerData: const <int, List<int>>{},
advertisementData: AdvertisementData( serviceData: const <Guid, List<int>>{},
advName: device.platformName, serviceUuids: <Guid>[Guid(MeshCoreUuids.service)],
txPowerLevel: null,
appearance: null,
connectable: true,
manufacturerData: const <int, List<int>>{},
serviceData: const <Guid, List<int>>{},
serviceUuids: <Guid>[Guid(MeshCoreUuids.service)],
),
rssi: 0,
timeStamp: DateTime.now(),
),
), ),
rssi: 0,
timeStamp: DateTime.now(),
),
),
); );
_mergeLinuxSystemScanResults(); _mergeLinuxSystemScanResults();
notifyListeners(); notifyListeners();
@@ -1499,9 +1569,17 @@ class MeshCoreConnector extends ChangeNotifier {
} }
await _scanSubscription?.cancel(); await _scanSubscription?.cancel();
_scanSubscription = null; _scanSubscription = null;
await _isScanningSubscription?.cancel();
_isScanningSubscription = null;
if (_state == MeshCoreConnectionState.scanning) { if (_state == MeshCoreConnectionState.scanning) {
_setState(MeshCoreConnectionState.disconnected); // Restore to `connected` if a non-BLE transport is still live, so a stray
// scan can never tear down the reported connection state. Normally there
// is no live transport here and we fall through to `disconnected`.
final restored = (_tcpConnector.isConnected || _usbManager.isConnected)
? MeshCoreConnectionState.connected
: MeshCoreConnectionState.disconnected;
_setState(restored);
} }
} }
@@ -1554,6 +1632,20 @@ class MeshCoreConnector extends ChangeNotifier {
await stopScan(); await stopScan();
} }
await Future<void>.delayed(const Duration(milliseconds: 200)); await Future<void>.delayed(const Duration(milliseconds: 200));
// The read pump can fail the instant the port opens (e.g. a device that
// re-enumerates on open). That error is emitted on a broadcast stream
// before the listener below attaches, so it would otherwise be lost and
// the connect would stall until the SELF_INFO timeout. Check transport
// liveness directly and abort fast with the real cause.
if (!_usbManager.isConnected) {
final cause = _usbManager.lastError;
throw StateError(
'USB device disconnected during connect'
'${cause == null ? '' : ': $cause'}',
);
}
_usbFrameSubscription = _usbManager.frameStream.listen( _usbFrameSubscription = _usbManager.frameStream.listen(
_handleFrame, _handleFrame,
onError: (error, stackTrace) { onError: (error, stackTrace) {
@@ -1743,6 +1835,17 @@ class MeshCoreConnector extends ChangeNotifier {
activeTransport == MeshCoreTransportType.tcp; activeTransport == MeshCoreTransportType.tcp;
} }
/// Fast (non-timeout) connect failures are usually a stale link left over
/// from a previous session and recover on an immediate retry. Timeouts mean
/// the device is likely off or out of range, so retrying would only delay
/// genuine failure feedback.
@visibleForTesting
static bool shouldRetryBleConnectAfterError(String errorText) {
final lowerErrorText = errorText.toLowerCase();
return !lowerErrorText.contains('timed out') &&
!lowerErrorText.contains('timeout');
}
Future<void> connect( Future<void> connect(
BluetoothDevice device, { BluetoothDevice device, {
String? displayName, String? displayName,
@@ -1819,7 +1922,7 @@ class MeshCoreConnector extends ChangeNotifier {
.connect( .connect(
timeout: connectTimeout, timeout: connectTimeout,
mtu: null, mtu: null,
license: License.free, license: License.nonprofit,
) )
.timeout( .timeout(
connectTimeout + const Duration(seconds: 2), connectTimeout + const Duration(seconds: 2),
@@ -1911,18 +2014,71 @@ class MeshCoreConnector extends ChangeNotifier {
} }
} }
} else { } else {
try { Future<void> attemptConnect() {
await device.connect( return device.connect(
timeout: connectTimeout, timeout: connectTimeout,
mtu: null, mtu: null,
license: License.free, license: License.nonprofit,
); );
}
// A previous app session (e.g. killed from the iOS app switcher) can
// leave the OS holding a stale link to the peripheral. Clear it before
// connecting so the fresh attempt doesn't race the stale handle.
if (!PlatformInfo.isWeb && device.isConnected) {
_appDebugLogService?.warn(
'Device reports an existing connection before connect; clearing stale link',
tag: 'BLE Connect',
);
try {
await device.disconnect(queue: false);
} catch (cleanupError) {
_appDebugLogService?.warn(
'Stale-link cleanup disconnect failed (continuing): $cleanupError',
tag: 'BLE Connect',
);
}
}
try {
await attemptConnect();
} catch (error) { } catch (error) {
_appDebugLogService?.error( _appDebugLogService?.error(
'device.connect() failure: $error', 'device.connect() failure: $error',
tag: 'BLE Connect', tag: 'BLE Connect',
); );
rethrow; if (PlatformInfo.isWeb ||
!shouldRetryBleConnectAfterError(error.toString())) {
rethrow;
}
// Fast (non-timeout) failures are usually a stale connection left by
// a previous session; clean up and retry once before surfacing.
_appDebugLogService?.warn(
'Retrying connect once after clearing possible stale connection',
tag: 'BLE Connect',
);
try {
await device.disconnect(queue: false);
} catch (cleanupError) {
_appDebugLogService?.warn(
'Pre-retry cleanup disconnect failed (continuing): $cleanupError',
tag: 'BLE Connect',
);
}
await Future<void>.delayed(const Duration(milliseconds: 500));
try {
await attemptConnect();
_appDebugLogService?.info(
'Retry connect succeeded after stale-connection cleanup',
tag: 'BLE Connect',
);
} catch (retryError) {
_appDebugLogService?.error(
'device.connect() retry failure: $retryError',
tag: 'BLE Connect',
);
rethrow;
}
} }
} }
@@ -1971,7 +2127,7 @@ class MeshCoreConnector extends ChangeNotifier {
await device.connect( await device.connect(
timeout: const Duration(seconds: 15), timeout: const Duration(seconds: 15),
mtu: null, mtu: null,
license: License.free, license: License.nonprofit,
); );
services = await device.discoverServices(); services = await device.discoverServices();
} else { } else {
@@ -2538,41 +2694,62 @@ class MeshCoreConnector extends ChangeNotifier {
Uint8List data, { Uint8List data, {
String? channelSendQueueId, String? channelSendQueueId,
bool expectsGenericAck = false, bool expectsGenericAck = false,
bool waitForGenericAck = false,
}) async { }) async {
if (!isConnected) { if (!isConnected) {
throw Exception("Not connected to a MeshCore device"); throw Exception("Not connected to a MeshCore device");
} }
_bleDebugLogService?.logFrame(data, outgoing: true); _bleDebugLogService?.logFrame(data, outgoing: true);
if (_activeTransport == MeshCoreTransportType.usb) { final pendingAck = _trackPendingGenericAck(
await _usbManager.write(data);
// Brief pause so the device firmware can process each frame before the
// next arrives. Without this, rapid-fire frames over USB can cause the
// device to miss responses (especially on reconnect).
await Future<void>.delayed(const Duration(milliseconds: 10));
} else if (_activeTransport == MeshCoreTransportType.tcp) {
await _tcpConnector.write(data);
} else {
if (_rxCharacteristic == null) {
throw Exception("MeshCore RX characteristic not available");
}
// Prefer write without response when supported; fall back to write with response.
final properties = _rxCharacteristic!.properties;
final canWriteWithoutResponse = properties.writeWithoutResponse;
final canWriteWithResponse = properties.write;
if (!canWriteWithoutResponse && !canWriteWithResponse) {
throw Exception("MeshCore RX characteristic does not support write");
}
await _rxCharacteristic!.write(
data.toList(),
withoutResponse: canWriteWithoutResponse,
);
}
_trackPendingGenericAck(
data, data,
channelSendQueueId: channelSendQueueId, channelSendQueueId: channelSendQueueId,
expectsGenericAck: expectsGenericAck, expectsGenericAck: expectsGenericAck || waitForGenericAck,
waitForAck: waitForGenericAck,
); );
try {
if (_activeTransport == MeshCoreTransportType.usb) {
await _usbManager.write(data);
// Brief pause so the device firmware can process each frame before the
// next arrives. Without this, rapid-fire frames over USB can cause the
// device to miss responses (especially on reconnect).
await Future<void>.delayed(const Duration(milliseconds: 10));
} else if (_activeTransport == MeshCoreTransportType.tcp) {
await _tcpConnector.write(data);
} else {
if (_rxCharacteristic == null) {
throw Exception("MeshCore RX characteristic not available");
}
// Prefer write without response when supported; fall back to write with response.
final properties = _rxCharacteristic!.properties;
final canWriteWithoutResponse = properties.writeWithoutResponse;
final canWriteWithResponse = properties.write;
if (!canWriteWithoutResponse && !canWriteWithResponse) {
throw Exception("MeshCore RX characteristic does not support write");
}
await _rxCharacteristic!.write(
data.toList(),
withoutResponse: canWriteWithoutResponse,
);
}
} catch (_) {
if (pendingAck != null) {
_pendingGenericAckQueue.remove(pendingAck);
}
rethrow;
}
if (pendingAck?.completer != null) {
try {
await pendingAck!.completer!.future.timeout(const Duration(seconds: 5));
} on TimeoutException {
_pendingGenericAckQueue.remove(pendingAck);
throw TimeoutException(
'Timed out waiting for firmware acknowledgement',
);
}
}
} }
Future<void> requestBatteryStatus({bool force = false}) async { Future<void> requestBatteryStatus({bool force = false}) async {
@@ -2804,6 +2981,17 @@ class MeshCoreConnector extends ChangeNotifier {
}) async { }) async {
if (!isConnected || text.isEmpty) return; if (!isConnected || text.isEmpty) return;
final outboundBytes = utf8.encode(
prepareContactOutboundText(contact, text),
);
if (outboundBytes.length > maxTextPayloadBytes) {
debugPrint(
'sendMessage: dropping overlong message '
'(${outboundBytes.length} > $maxTextPayloadBytes bytes)',
);
return;
}
// Check if this is a reaction - apply locally with pending status and route through retry service // Check if this is a reaction - apply locally with pending status and route through retry service
final reactionInfo = ReactionHelper.parseReaction(text); final reactionInfo = ReactionHelper.parseReaction(text);
if (reactionInfo != null) { if (reactionInfo != null) {
@@ -3237,6 +3425,7 @@ class MeshCoreConnector extends ChangeNotifier {
await sendFrame(buildRemoveContactFrame(contact.publicKey)); await sendFrame(buildRemoveContactFrame(contact.publicKey));
_contacts.removeWhere((c) => c.publicKeyHex == contact.publicKeyHex); _contacts.removeWhere((c) => c.publicKeyHex == contact.publicKeyHex);
_knownContactKeys.remove(contact.publicKeyHex); _knownContactKeys.remove(contact.publicKeyHex);
unawaited(updateKnownDiscovered());
unawaited(_persistContacts()); unawaited(_persistContacts());
_conversations.remove(contact.publicKeyHex); _conversations.remove(contact.publicKeyHex);
_loadedConversationKeys.remove(contact.publicKeyHex); _loadedConversationKeys.remove(contact.publicKeyHex);
@@ -3273,9 +3462,11 @@ class MeshCoreConnector extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
Future<void> importDiscoveredContact(Contact contact) async { Future<bool> importDiscoveredContact(Contact contact) async {
if (!isConnected) return; if (!isConnected) return false;
// Manual saves must bypass the firmware's auto-add discovery policy.
// CMD_IMPORT_CONTACT replays an advert and may remain discovery-only.
await sendFrame( await sendFrame(
buildUpdateContactPathFrame( buildUpdateContactPathFrame(
contact.publicKey, contact.publicKey,
@@ -3288,6 +3479,7 @@ class MeshCoreConnector extends ChangeNotifier {
lon: contact.longitude, lon: contact.longitude,
lastModified: contact.lastSeen, lastModified: contact.lastSeen,
), ),
waitForGenericAck: true,
); );
// Update the discovered contact to mark it as active (imported) // Update the discovered contact to mark it as active (imported)
@@ -3313,6 +3505,8 @@ class MeshCoreConnector extends ChangeNotifier {
), ),
); );
notifyListeners(); notifyListeners();
unawaited(_persistDiscoveredContacts());
return true;
} }
Future<void> clearContactPath(Contact contact) async { Future<void> clearContactPath(Contact contact) async {
@@ -3450,12 +3644,10 @@ class MeshCoreConnector extends ChangeNotifier {
Future<void> sendCliCommand(String command) async { Future<void> sendCliCommand(String command) async {
if (!isConnected) return; if (!isConnected) return;
final selfKey = _selfPublicKey;
// CLI commands are sent as UTF-8 text with a special prefix if (selfKey == null) return;
final commandBytes = utf8.encode(command);
final bytes = Uint8List.fromList([0x01, ...commandBytes, 0x00]);
_lastSentWasCliCommand = true; _lastSentWasCliCommand = true;
await sendFrame(bytes); await sendFrame(buildSendCliCommandFrame(selfKey, command));
} }
Future<void> setNodeName(String name) async { Future<void> setNodeName(String name) async {
@@ -3720,6 +3912,7 @@ class MeshCoreConnector extends ChangeNotifier {
void _handleFrame(List<int> data) { void _handleFrame(List<int> data) {
if (data.isEmpty) return; if (data.isEmpty) return;
_lastRxBeforeFrame = _lastRxTime;
_lastRxTime = DateTime.now(); _lastRxTime = DateTime.now();
final frame = Uint8List.fromList(data); final frame = Uint8List.fromList(data);
@@ -3872,11 +4065,15 @@ class MeshCoreConnector extends ChangeNotifier {
} }
final failedAck = _pendingGenericAckQueue.removeAt(0); final failedAck = _pendingGenericAckQueue.removeAt(0);
failedAck.completer?.completeError(
Exception('Firmware rejected command with error code $errCode'),
);
if (failedAck.commandCode != cmdSendChannelTxtMsg || if (failedAck.commandCode != cmdSendChannelTxtMsg ||
failedAck.channelSendQueueId == null) { failedAck.channelSendQueueId == null) {
return; return;
} }
_pendingChannelSentQueue.remove(failedAck.channelSendQueueId); _pendingChannelSentQueue.remove(failedAck.channelSendQueueId);
_markPendingChannelMessageFailedById(failedAck.channelSendQueueId!);
} }
void _handlePathUpdated(Uint8List frame) { void _handlePathUpdated(Uint8List frame) {
@@ -3929,8 +4126,8 @@ class MeshCoreConnector extends ChangeNotifier {
_advertLocPolicy = reader.readByte(); _advertLocPolicy = reader.readByte();
final telemetryFlag = reader.readByte(); final telemetryFlag = reader.readByte();
_telemetryModeBase = telemetryFlag & 0x03; _telemetryModeBase = telemetryFlag & 0x03;
_telemetryModeEnv = telemetryFlag >> 2 & 0x03; _telemetryModeLoc = telemetryFlag >> 2 & 0x03;
_telemetryModeLoc = telemetryFlag >> 4 & 0x03; _telemetryModeEnv = telemetryFlag >> 4 & 0x03;
_manualAddContacts = reader.readByte() & 0x01 == 0x00; _manualAddContacts = reader.readByte() & 0x01 == 0x00;
@@ -4226,16 +4423,28 @@ class MeshCoreConnector extends ChangeNotifier {
// Same as max for flood — firmware uses a single formula // Same as max for flood — firmware uses a single formula
return 500 + (16 * airtime); return 500 + (16 * airtime);
} else { } else {
return airtime * (pathLength + 1); // Include firmware base (500ms) and per-hop processing (6*airtime+250)
// so ML cannot clamp below a physically plausible round-trip.
return 500 + ((airtime * 6 + 250) * pathLength);
} }
} }
/// Hard ceiling on any ML-derived or physics-fallback timeout (ms).
/// Prevents the flood formula (500 + 16·airtime at SF12 ≈ 150s) and an
/// unstable OLS model from producing multi-minute waits.
static const int _hardMaxTimeoutMs = 45000;
/// Calculate timeout for a message based on radio settings and path length. /// Calculate timeout for a message based on radio settings and path length.
/// Returns timeout in milliseconds, considering number of hops. /// Returns timeout in milliseconds, considering number of hops.
///
/// [deviceTimeoutMs] is the firmware's own est_timeout from RESP_CODE_SENT.
/// When ML is absent it is used as the fallback (clamped to physicsMin).
/// When ML is present it is used as an additional ceiling alongside physicsMax.
int calculateTimeout({ int calculateTimeout({
required int pathLength, required int pathLength,
int messageBytes = 100, int messageBytes = 100,
String? contactKey, String? contactKey,
int? deviceTimeoutMs,
}) { }) {
final airtime = _estimateAirtimeMs(messageBytes); final airtime = _estimateAirtimeMs(messageBytes);
final physicsMin = _physicsMinTimeout(pathLength, airtime); final physicsMin = _physicsMinTimeout(pathLength, airtime);
@@ -4250,17 +4459,29 @@ class MeshCoreConnector extends ChangeNotifier {
secondsSinceLastRx: secSinceRx, secondsSinceLastRx: secSinceRx,
); );
if (mlTimeout != null) { if (mlTimeout != null) {
// Use device est_timeout as a baseline floor when available —
// the firmware computed it from real airtime. Let the learned ML
// estimate widen above it up to the hard cap, but never below it.
final floor = deviceTimeoutMs != null && deviceTimeoutMs > physicsMin
? deviceTimeoutMs.clamp(physicsMin, _hardMaxTimeoutMs)
: physicsMin.clamp(0, _hardMaxTimeoutMs);
if (pathLength < 0) { if (pathLength < 0) {
// Flood: trust ML, only enforce firmware formula as floor // Flood: trust ML, only enforce firmware estimate as floor
if (mlTimeout < physicsMin) { if (mlTimeout < floor) {
return physicsMin; return floor.clamp(0, _hardMaxTimeoutMs);
} }
} }
return mlTimeout.clamp(physicsMin, physicsMax); return mlTimeout.clamp(floor, _hardMaxTimeoutMs);
} }
// No ML data — use firmware formula // No ML data — prefer device est_timeout (it used real airtime), then physics.
return physicsMax; // Cap the floor to the hard maximum so slow-flood physicsMin cannot exceed
// the upper bound and make clamp() throw.
if (deviceTimeoutMs != null && deviceTimeoutMs > 0) {
final floor = physicsMin.clamp(0, _hardMaxTimeoutMs);
return deviceTimeoutMs.clamp(floor, _hardMaxTimeoutMs);
}
return physicsMax.clamp(0, _hardMaxTimeoutMs);
} }
void _handleContact(Uint8List frame, {bool isContact = true}) { void _handleContact(Uint8List frame, {bool isContact = true}) {
@@ -4275,7 +4496,14 @@ class MeshCoreConnector extends ChangeNotifier {
tag: 'Connector', tag: 'Connector',
); );
notifyListeners(); notifyListeners();
removeContact(contactTmp); unawaited(
removeContact(contactTmp).catchError(
(e) => appLogger.warn(
'Failed to remove self contact: $e',
tag: 'Connector',
),
),
);
return; return;
} }
final contact = getFromDiscovered(contactTmp); final contact = getFromDiscovered(contactTmp);
@@ -4609,14 +4837,11 @@ class MeshCoreConnector extends ChangeNotifier {
final existing = _conversations[message.senderKeyHex]; final existing = _conversations[message.senderKeyHex];
final incomingTimestamp = message.timestamp.millisecondsSinceEpoch; final incomingTimestamp = message.timestamp.millisecondsSinceEpoch;
if (existing != null && existing.isNotEmpty) { if (existing != null && existing.isNotEmpty) {
final startIndex = existing.length > 10 ? existing.length - 10 : 0; final last = existing.last;
for (int i = existing.length - 1; i >= startIndex; i--) { if (!last.isOutgoing &&
final recent = existing[i]; last.timestamp.millisecondsSinceEpoch == incomingTimestamp &&
if (!recent.isOutgoing && last.text == message.text) {
recent.timestamp.millisecondsSinceEpoch == incomingTimestamp && return;
recent.text == message.text) {
return;
}
} }
} }
} }
@@ -5200,12 +5425,37 @@ class MeshCoreConnector extends ChangeNotifier {
return false; return false;
} }
void _markPendingChannelMessageFailedById(String messageId) {
for (final entry in _channelMessages.entries) {
final channelMessages = entry.value;
for (int i = channelMessages.length - 1; i >= 0; i--) {
final message = channelMessages[i];
if (message.messageId != messageId) {
continue;
}
if (!message.isOutgoing ||
message.status != ChannelMessageStatus.pending) {
return;
}
channelMessages[i] = message.copyWith(
status: ChannelMessageStatus.failed,
);
unawaited(
_channelMessageStore.saveChannelMessages(entry.key, channelMessages),
);
notifyListeners();
return;
}
}
}
void _handleOk() { void _handleOk() {
if (_pendingGenericAckQueue.isEmpty) { if (_pendingGenericAckQueue.isEmpty) {
return; return;
} }
final pendingAck = _pendingGenericAckQueue.removeAt(0); final pendingAck = _pendingGenericAckQueue.removeAt(0);
pendingAck.completer?.complete();
if (pendingAck.commandCode != cmdSendChannelTxtMsg || if (pendingAck.commandCode != cmdSendChannelTxtMsg ||
pendingAck.channelSendQueueId == null) { pendingAck.channelSendQueueId == null) {
return; return;
@@ -5521,6 +5771,9 @@ class MeshCoreConnector extends ChangeNotifier {
} }
messages.add(message); messages.add(message);
if (messages.length > _messageWindowSize) {
messages.removeRange(0, messages.length - _messageWindowSize);
}
_messageStore.saveMessages(pubKeyHex, messages); _messageStore.saveMessages(pubKeyHex, messages);
notifyListeners(); notifyListeners();
} }
@@ -5611,7 +5864,9 @@ class MeshCoreConnector extends ChangeNotifier {
) { ) {
if (!isRoomServer) return null; if (!isRoomServer) return null;
if (!msg.isOutgoing) { if (!msg.isOutgoing) {
final senderContact = _contacts.cast<Contact?>().firstWhere( // Saved contacts first, then discovery-only nodes, so reaction matching
// resolves the author's name even when they haven't been saved.
final senderContact = allContactsUnfiltered.cast<Contact?>().firstWhere(
(c) => (c) =>
c != null && c != null &&
_matchesPrefix(c.publicKey, msg.fourByteRoomContactKey), _matchesPrefix(c.publicKey, msg.fourByteRoomContactKey),
@@ -5907,20 +6162,24 @@ class MeshCoreConnector extends ChangeNotifier {
bool _isChannelRepeat(ChannelMessage existing, ChannelMessage incoming) { bool _isChannelRepeat(ChannelMessage existing, ChannelMessage incoming) {
if (existing.text != incoming.text) return false; if (existing.text != incoming.text) return false;
// Self-echo: an outgoing message coming back via a repeater. The send is
// delayed by _waitForRadioQuiet (often 10s+) and propagation can add more,
// so the timestamp gap can easily exceed the cross-peer window.
final selfName = _selfName ?? 'Me';
final isSelfEcho =
existing.isOutgoing &&
!incoming.isOutgoing &&
(incoming.senderName == selfName || existing.senderName == selfName);
final windowMs = isSelfEcho ? 10 * 60 * 1000 : 30000;
final diffMs = final diffMs =
(existing.timestamp.millisecondsSinceEpoch - (existing.timestamp.millisecondsSinceEpoch -
incoming.timestamp.millisecondsSinceEpoch) incoming.timestamp.millisecondsSinceEpoch)
.abs(); .abs();
if (diffMs > 30000) return false; if (diffMs > windowMs) return false;
if (existing.senderName == incoming.senderName) return true; if (existing.senderName == incoming.senderName) return true;
if (isSelfEcho) return true;
if (existing.isOutgoing && !incoming.isOutgoing) {
final selfName = _selfName ?? 'Me';
if (incoming.senderName == selfName || existing.senderName == selfName) {
return true;
}
}
return false; return false;
} }
@@ -6028,18 +6287,25 @@ class MeshCoreConnector extends ChangeNotifier {
_scheduleReconnect(); _scheduleReconnect();
} }
void _trackPendingGenericAck( _PendingCommandAck? _trackPendingGenericAck(
Uint8List data, { Uint8List data, {
String? channelSendQueueId, String? channelSendQueueId,
required bool expectsGenericAck, required bool expectsGenericAck,
required bool waitForAck,
}) { }) {
if (!expectsGenericAck || data.isEmpty) return; if (!expectsGenericAck || data.isEmpty) return null;
_pendingGenericAckQueue.add( final pendingAck = _PendingCommandAck(
_PendingCommandAck( commandCode: data[0],
commandCode: data[0], channelSendQueueId: channelSendQueueId,
channelSendQueueId: channelSendQueueId, completer: waitForAck ? Completer<void>() : null,
),
); );
if (pendingAck.completer != null) {
// sendFrame awaits this future after transport I/O; attach an error
// handler immediately in case USB returns an error response first.
unawaited(pendingAck.completer!.future.catchError((_) {}));
}
_pendingGenericAckQueue.add(pendingAck);
return pendingAck;
} }
String _nextReactionSendQueueId() { String _nextReactionSendQueueId() {
@@ -6152,6 +6418,7 @@ class MeshCoreConnector extends ChangeNotifier {
@override @override
void dispose() { void dispose() {
_scanSubscription?.cancel(); _scanSubscription?.cancel();
_isScanningSubscription?.cancel();
_connectionSubscription?.cancel(); _connectionSubscription?.cancel();
_usbFrameSubscription?.cancel(); _usbFrameSubscription?.cancel();
_notifySubscription?.cancel(); _notifySubscription?.cancel();
@@ -6210,82 +6477,6 @@ class MeshCoreConnector extends ChangeNotifier {
} }
} }
void importContact(Uint8List frame) {
final packet = BufferReader(frame);
int payloadType = 0;
Uint8List pathBytes = Uint8List(0);
try {
packet.skipBytes(1); // Skip frame type byte
packet.skipBytes(1); // Skip SNR byte
packet.skipBytes(1); // Skip RSSI byte
final header = packet.readByte();
final routeType = header & 0x03;
payloadType = (header >> 2) & 0x0F;
if (routeType == _routeTransportFlood ||
routeType == _routeTransportDirect) {
packet.skipBytes(4); // Skip transport-specific bytes
}
//final payloadVer = (header >> 6) & 0x03;
final pathLenRaw = packet.readByte();
final pathByteLen = _decodePathByteLen(pathLenRaw);
pathBytes = packet.readBytes(pathByteLen);
} catch (e) {
appLogger.warn('Malformed RX frame: $e', tag: 'Connector');
return;
}
double? latitude;
double? longitude;
String name = '';
Uint8List publicKey = Uint8List(0);
int type = 0;
int timestamp = 0;
bool hasLocation = false;
bool hasName = false;
if (payloadType != payloadTypeADVERT) {
appLogger.warn('Unexpected payload type: $payloadType', tag: 'Connector');
return;
}
try {
publicKey = packet.readBytes(32);
timestamp = packet.readInt32LE();
//TODO add signature verification
packet.skipBytes(64); // Skip signature for now
final flags = packet.readByte();
type = flags & 0x0F;
hasLocation = (flags & 0x10) != 0;
// For future use:
//final hasFeature1 = (flags & 0x20) != 0;
//final hasFeature2 = (flags & 0x40) != 0;
hasName = (flags & 0x80) != 0;
if (hasLocation && packet.remaining >= 8) {
latitude = packet.readInt32LE() / 1e6;
longitude = packet.readInt32LE() / 1e6;
}
if (hasName && packet.remaining > 0) {
name = packet.readCString();
}
} catch (e) {
appLogger.warn('Malformed advert frame: $e', tag: 'Connector');
return;
}
importDiscoveredContact(
Contact(
rawPacket: frame,
publicKey: publicKey,
name: name,
type: type,
pathLength: pathBytes.isEmpty ? -1 : pathBytes.length,
path: Uint8List.fromList(
pathBytes.reversed.toList(),
), // Store path in reverse for easier use in outgoing messages
latitude: latitude,
longitude: longitude,
lastSeen: DateTime.fromMillisecondsSinceEpoch(timestamp * 1000),
),
);
}
bool hasValidLocation(double? latitude, double? longitude) { bool hasValidLocation(double? latitude, double? longitude) {
const double epsilon = 1e-6; const double epsilon = 1e-6;
final lat = latitude ?? 0.0; final lat = latitude ?? 0.0;
@@ -6648,6 +6839,11 @@ class _RepeaterAckContext {
class _PendingCommandAck { class _PendingCommandAck {
final int commandCode; final int commandCode;
final String? channelSendQueueId; final String? channelSendQueueId;
final Completer<void>? completer;
_PendingCommandAck({required this.commandCode, this.channelSendQueueId}); _PendingCommandAck({
required this.commandCode,
this.channelSendQueueId,
this.completer,
});
} }
@@ -19,6 +19,7 @@ class MeshCoreUsbManager {
String? get activePortKey => _activePortKey; String? get activePortKey => _activePortKey;
String? get activePortDisplayLabel => _activePortLabel ?? _activePortKey; String? get activePortDisplayLabel => _activePortLabel ?? _activePortKey;
bool get isConnected => _service.isConnected; bool get isConnected => _service.isConnected;
Object? get lastError => _service.lastError;
Stream<Uint8List> get frameStream => _service.frameStream; Stream<Uint8List> get frameStream => _service.frameStream;
// --- Configuration --- // --- Configuration ---
+13 -2
View File
@@ -224,6 +224,12 @@ const int reqTypeGetTelemetry = 0x03;
const int reqTypeGetAccessList = 0x05; const int reqTypeGetAccessList = 0x05;
const int reqTypeGetNeighbors = 0x06; const int reqTypeGetNeighbors = 0x06;
Uint8List buildTelemetryBinaryPayload() {
// Room servers/repeaters read byte 1 as an inverse telemetry permission mask.
// Zero means "request every telemetry field allowed for this contact".
return Uint8List.fromList([reqTypeGetTelemetry, 0x00, 0x00, 0x00, 0x00]);
}
// Repeater response codes // Repeater response codes
const int respServerLoginOk = 0; const int respServerLoginOk = 0;
@@ -451,8 +457,13 @@ String pubKeyToHex(Uint8List pubKey) {
// Helper to convert hex string to public key // Helper to convert hex string to public key
Uint8List hexToPubKey(String hex) { Uint8List hexToPubKey(String hex) {
if (hex.length != pubKeySize * 2) {
throw FormatException(
'Public key hex must be ${pubKeySize * 2} chars, got ${hex.length}',
);
}
final result = Uint8List(pubKeySize); final result = Uint8List(pubKeySize);
for (int i = 0; i < pubKeySize && i * 2 + 1 < hex.length; i++) { for (int i = 0; i < pubKeySize; i++) {
result[i] = int.parse(hex.substring(i * 2, i * 2 + 2), radix: 16); result[i] = int.parse(hex.substring(i * 2, i * 2 + 2), radix: 16);
} }
return result; return result;
@@ -945,7 +956,7 @@ Uint8List buildSendTelemetryReq(Uint8List? pubKey) {
writer.writeBytes(Uint8List(3)); // reserved bytes writer.writeBytes(Uint8List(3)); // reserved bytes
writer.writeBytes(pubKey); writer.writeBytes(pubKey);
} else { } else {
writer.writeBytes(Uint8List(4)); // reserved bytes writer.writeBytes(Uint8List(3)); // reserved bytes
} }
return writer.toBytes(); return writer.toBytes();
} }
+4
View File
@@ -3,6 +3,10 @@ class MeshCoreUuids {
static const String rxCharacteristic = "6e400002-b5a3-f393-e0a9-e50e24dcca9e"; static const String rxCharacteristic = "6e400002-b5a3-f393-e0a9-e50e24dcca9e";
static const String txCharacteristic = "6e400003-b5a3-f393-e0a9-e50e24dcca9e"; static const String txCharacteristic = "6e400003-b5a3-f393-e0a9-e50e24dcca9e";
/// Known advertised-name prefixes used by stock MeshCore firmware builds.
/// Discovery no longer filters on these (it filters on the [service] UUID so
/// that community forks with custom names are still found); kept for
/// reference and possible future display heuristics.
static const List<String> deviceNamePrefixes = [ static const List<String> deviceNamePrefixes = [
"MeshCore-", "MeshCore-",
"Whisper-", "Whisper-",
+209 -9
View File
@@ -96,6 +96,34 @@ class CayenneLpp {
} }
switch (type) { switch (type) {
case lppDigitalInput:
telemetry.add({
'channel': channel,
'type': type,
'value': buffer.readUInt8(),
});
break;
case lppDigitalOutput:
telemetry.add({
'channel': channel,
'type': type,
'value': buffer.readUInt8(),
});
break;
case lppAnalogInput:
telemetry.add({
'channel': channel,
'type': type,
'value': buffer.readInt16BE() / 100,
});
break;
case lppAnalogOutput:
telemetry.add({
'channel': channel,
'type': type,
'value': buffer.readInt16BE() / 100,
});
break;
case lppGenericSensor: case lppGenericSensor:
telemetry.add({ telemetry.add({
'channel': channel, 'channel': channel,
@@ -131,6 +159,17 @@ class CayenneLpp {
'value': buffer.readUInt8() / 2, 'value': buffer.readUInt8() / 2,
}); });
break; break;
case lppAccelerometer:
telemetry.add({
'channel': channel,
'type': type,
'value': {
'x': buffer.readInt16BE() / 1000,
'y': buffer.readInt16BE() / 1000,
'z': buffer.readInt16BE() / 1000,
},
});
break;
case lppBarometricPressure: case lppBarometricPressure:
telemetry.add({ telemetry.add({
'channel': channel, 'channel': channel,
@@ -138,6 +177,13 @@ class CayenneLpp {
'value': buffer.readUInt16BE() / 10, 'value': buffer.readUInt16BE() / 10,
}); });
break; break;
case lppAltitude:
telemetry.add({
'channel': channel,
'type': type,
'value': buffer.readInt16BE(),
});
break;
case lppVoltage: case lppVoltage:
telemetry.add({ telemetry.add({
'channel': channel, 'channel': channel,
@@ -152,6 +198,13 @@ class CayenneLpp {
'value': buffer.readInt16BE() / 1000, 'value': buffer.readInt16BE() / 1000,
}); });
break; break;
case lppFrequency:
telemetry.add({
'channel': channel,
'type': type,
'value': buffer.readUInt32BE(),
});
break;
case lppPercentage: case lppPercentage:
telemetry.add({ telemetry.add({
'channel': channel, 'channel': channel,
@@ -173,6 +226,56 @@ class CayenneLpp {
'value': buffer.readUInt16BE(), 'value': buffer.readUInt16BE(),
}); });
break; break;
case lppDistance:
telemetry.add({
'channel': channel,
'type': type,
'value': buffer.readUInt32BE() / 1000,
});
break;
case lppEnergy:
telemetry.add({
'channel': channel,
'type': type,
'value': buffer.readUInt32BE() / 1000,
});
break;
case lppDirection:
telemetry.add({
'channel': channel,
'type': type,
'value': buffer.readUInt16BE(),
});
break;
case lppUnixTime:
telemetry.add({
'channel': channel,
'type': type,
'value': buffer.readUInt32BE(),
});
break;
case lppGyrometer:
telemetry.add({
'channel': channel,
'type': type,
'value': {
'x': buffer.readInt16BE() / 100,
'y': buffer.readInt16BE() / 100,
'z': buffer.readInt16BE() / 100,
},
});
break;
case lppColour:
telemetry.add({
'channel': channel,
'type': type,
'value': {
'red': buffer.readUInt8(),
'green': buffer.readUInt8(),
'blue': buffer.readUInt8(),
},
});
break;
case lppGps: case lppGps:
telemetry.add({ telemetry.add({
'channel': channel, 'channel': channel,
@@ -184,6 +287,24 @@ class CayenneLpp {
}, },
}); });
break; break;
case lppSwitch:
telemetry.add({
'channel': channel,
'type': type,
'value': buffer.readUInt8(),
});
break;
case lppPolyline:
final size = buffer.readUInt8();
telemetry.add({
'channel': channel,
'type': type,
'value': {
'size': size,
'data': _bytesToHex(_readPolylinePayload(buffer, size)),
},
});
break;
default: default:
return telemetry; return telemetry;
} }
@@ -216,6 +337,19 @@ class CayenneLpp {
); );
switch (type) { switch (type) {
case lppDigitalInput:
channelData['values']['digitalInput'] = buffer.readUInt8();
break;
case lppDigitalOutput:
channelData['values']['digitalOutput'] = buffer.readUInt8();
break;
case lppAnalogInput:
channelData['values']['analogInput'] = buffer.readInt16BE() / 100.0;
break;
case lppAnalogOutput:
channelData['values']['analogOutput'] =
buffer.readInt16BE() / 100.0;
break;
case lppGenericSensor: case lppGenericSensor:
channelData['values']['generic'] = buffer.readUInt32BE(); channelData['values']['generic'] = buffer.readUInt32BE();
break; break;
@@ -231,15 +365,29 @@ class CayenneLpp {
case lppRelativeHumidity: case lppRelativeHumidity:
channelData['values']['humidity'] = buffer.readUInt8() / 2.0; channelData['values']['humidity'] = buffer.readUInt8() / 2.0;
break; break;
case lppAccelerometer:
channelData['values']['accelerometer'] = {
'x': buffer.readInt16BE() / 1000.0,
'y': buffer.readInt16BE() / 1000.0,
'z': buffer.readInt16BE() / 1000.0,
};
break;
case lppBarometricPressure: case lppBarometricPressure:
channelData['values']['pressure'] = buffer.readUInt16BE() / 10.0; channelData['values']['pressure'] = buffer.readUInt16BE() / 10.0;
break; break;
case lppAltitude:
// MeshCore encodes standalone barometric altitude as LPP type 121.
channelData['values']['altitude'] = buffer.readInt16BE();
break;
case lppVoltage: case lppVoltage:
channelData['values']['voltage'] = buffer.readInt16BE() / 100.0; channelData['values']['voltage'] = buffer.readInt16BE() / 100.0;
break; break;
case lppCurrent: case lppCurrent:
channelData['values']['current'] = buffer.readInt16BE() / 1000.0; channelData['values']['current'] = buffer.readInt16BE() / 1000.0;
break; break;
case lppFrequency:
channelData['values']['frequency'] = buffer.readUInt32BE();
break;
case lppPercentage: case lppPercentage:
channelData['values']['percentage'] = buffer.readUInt8(); channelData['values']['percentage'] = buffer.readUInt8();
break; break;
@@ -249,6 +397,32 @@ class CayenneLpp {
case lppPower: case lppPower:
channelData['values']['power'] = buffer.readUInt16BE(); channelData['values']['power'] = buffer.readUInt16BE();
break; break;
case lppDistance:
channelData['values']['distance'] = buffer.readUInt32BE() / 1000.0;
break;
case lppEnergy:
channelData['values']['energy'] = buffer.readUInt32BE() / 1000.0;
break;
case lppDirection:
channelData['values']['direction'] = buffer.readUInt16BE();
break;
case lppUnixTime:
channelData['values']['time'] = buffer.readUInt32BE();
break;
case lppGyrometer:
channelData['values']['gyrometer'] = {
'x': buffer.readInt16BE() / 100.0,
'y': buffer.readInt16BE() / 100.0,
'z': buffer.readInt16BE() / 100.0,
};
break;
case lppColour:
channelData['values']['colour'] = {
'red': buffer.readUInt8(),
'green': buffer.readUInt8(),
'blue': buffer.readUInt8(),
};
break;
case lppGps: case lppGps:
channelData['values']['gps'] = { channelData['values']['gps'] = {
'latitude': buffer.readInt24BE() / 10000.0, 'latitude': buffer.readInt24BE() / 10000.0,
@@ -256,22 +430,48 @@ class CayenneLpp {
'altitude': buffer.readInt24BE() / 100.0, 'altitude': buffer.readInt24BE() / 100.0,
}; };
break; break;
// Add more types as needed... case lppSwitch:
channelData['values']['switch'] = buffer.readUInt8() != 0;
break;
case lppPolyline:
final size = buffer.readUInt8();
channelData['values']['polyline'] = {
'size': size,
'data': _bytesToHex(_readPolylinePayload(buffer, size)),
};
break;
default: default:
//Stopped parsing to avoid misalignment // Stop parsing to avoid losing alignment on an unknown LPP type.
return channels.values.toList(); return _sortedChannelValues(channels);
} }
} }
final List<Map<String, dynamic>> channelsOut = channels.values.toList(); return _sortedChannelValues(channels);
channelsOut.sort((a, b) => a['channel'].compareTo(b['channel']));
return channelsOut;
} catch (e) { } catch (e) {
// Handle parsing errors, possibly due to malformed data // Handle parsing errors, possibly due to malformed data
appLogger.error('Error parsing Cayenne LPP data: $e'); appLogger.error('Error parsing Cayenne LPP data: $e');
return < // Preserve any fields parsed before the malformed value.
Map<String, dynamic> return _sortedChannelValues(channels);
>[]; // Return an empty list on error to avoid crashing the app
} }
} }
static Uint8List _readPolylinePayload(BufferReader buffer, int size) {
final declaredPayloadSize = size > 0 ? size - 1 : 0;
final availablePayloadSize = declaredPayloadSize <= buffer.remaining
? declaredPayloadSize
: buffer.remaining;
return buffer.readBytes(availablePayloadSize);
}
static List<Map<String, dynamic>> _sortedChannelValues(
Map<int, Map<String, dynamic>> channels,
) {
final channelsOut = channels.values.toList();
channelsOut.sort((a, b) => a['channel'].compareTo(b['channel']));
return channelsOut;
}
static String _bytesToHex(Uint8List bytes) {
return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
}
} }
+59
View File
@@ -0,0 +1,59 @@
import 'package:flutter/material.dart';
import '../connector/meshcore_protocol.dart';
import '../utils/emoji_utils.dart';
IconData contactTypeIcon(int type) {
switch (type) {
case advTypeChat:
return Icons.chat;
case advTypeRepeater:
return Icons.cell_tower;
case advTypeRoom:
return Icons.group;
case advTypeSensor:
return Icons.sensors;
default:
return Icons.device_unknown;
}
}
Color contactTypeColor(int type) {
switch (type) {
case advTypeChat:
return Colors.blue;
case advTypeRepeater:
return Colors.orange;
case advTypeRoom:
return Colors.purple;
case advTypeSensor:
return Colors.green;
default:
return Colors.grey;
}
}
Color colorForName(String name) {
const colors = [
Colors.blue,
Colors.green,
Colors.orange,
Colors.purple,
Colors.pink,
Colors.teal,
Colors.indigo,
Colors.cyan,
Colors.amber,
Colors.deepOrange,
];
return colors[name.hashCode.abs() % colors.length];
}
String firstCharacterOrEmoji(String name) {
if (name.isEmpty) return '?';
final emoji = firstEmoji(name);
if (emoji != null) return emoji;
final runes = name.runes.toList();
if (runes.isEmpty) return '?';
return String.fromCharCode(runes[0]).toUpperCase();
}
+18 -13
View File
@@ -8,24 +8,29 @@ class PathHelper {
.join(','); .join(',');
} }
static String hopHex(int byte) {
return byte.toRadixString(16).padLeft(2, '0').toUpperCase();
}
static String? hopName(int byte, List<Contact> allContacts) {
final matches = allContacts
.where(
(c) =>
c.publicKey.first == byte &&
(c.type == advTypeRepeater || c.type == advTypeRoom),
)
.toList();
if (matches.isEmpty) return null;
if (matches.length == 1) return matches.first.name;
return matches.map((c) => c.name).join(' | ');
}
static String resolvePathNames( static String resolvePathNames(
List<int> pathBytes, List<int> pathBytes,
List<Contact> allContacts, List<Contact> allContacts,
) { ) {
return pathBytes return pathBytes
.map((b) { .map((b) => hopName(b, allContacts) ?? hopHex(b))
final hex = b.toRadixString(16).padLeft(2, '0').toUpperCase();
final matches = allContacts
.where(
(c) =>
c.publicKey.first == b &&
(c.type == advTypeRepeater || c.type == advTypeRoom),
)
.toList();
if (matches.isEmpty) return hex;
if (matches.length == 1) return matches.first.name;
return matches.map((c) => c.name).join(' | ');
})
.join(' \u2192 '); .join(' \u2192 ');
} }
} }
+70
View File
@@ -0,0 +1,70 @@
import 'package:latlong2/latlong.dart';
import '../connector/meshcore_protocol.dart';
import '../models/contact.dart';
class PathHopResolver {
const PathHopResolver._();
static List<Contact?> resolve({
required List<int> pathBytes,
required List<Contact> contacts,
LatLng? endpoint,
bool resolveFromEnd = false,
}) {
final candidatesByPrefix = <int, List<Contact>>{};
for (final contact in contacts) {
if (contact.publicKey.isEmpty) continue;
if (contact.type != advTypeRepeater && contact.type != advTypeRoom) {
continue;
}
candidatesByPrefix
.putIfAbsent(contact.publicKey.first, () => <Contact>[])
.add(contact);
}
for (final candidates in candidatesByPrefix.values) {
candidates.sort((a, b) => b.lastSeen.compareTo(a.lastSeen));
}
final resolved = List<Contact?>.filled(pathBytes.length, null);
final indexes = resolveFromEnd
? List<int>.generate(pathBytes.length, (i) => pathBytes.length - 1 - i)
: List<int>.generate(pathBytes.length, (i) => i);
final distance = Distance();
var previousPosition = endpoint;
for (final index in indexes) {
final candidates = candidatesByPrefix[pathBytes[index]];
if (candidates == null || candidates.isEmpty) continue;
var bestIndex = 0;
if (previousPosition != null && candidates.length > 1) {
double? nearestDistance;
for (var i = 0; i < candidates.length; i++) {
final position = _positionOf(candidates[i]);
if (position == null) continue;
final candidateDistance = distance(previousPosition, position);
if (nearestDistance == null || candidateDistance < nearestDistance) {
nearestDistance = candidateDistance;
bestIndex = i;
}
}
}
final contact = candidates.removeAt(bestIndex);
resolved[index] = contact;
previousPosition = _positionOf(contact) ?? previousPosition;
}
return resolved;
}
static LatLng? _positionOf(Contact contact) {
if (!contact.hasLocation ||
contact.latitude == null ||
contact.longitude == null) {
return null;
}
return LatLng(contact.latitude!, contact.longitude!);
}
}
+13 -1
View File
@@ -25,7 +25,19 @@ void showDismissibleSnackBar(
DismissDirection? dismissDirection, DismissDirection? dismissDirection,
Clip? clipBehavior, Clip? clipBehavior,
}) { }) {
final messenger = ScaffoldMessenger.of(context); // Callers often reach here after an async gap; the context may already be
// unmounted, or deactivated (popped but not yet disposed) ancestor
// lookups on a deactivated element throw. Showing nothing is the right
// outcome in both cases.
if (!context.mounted) return;
var isActive = true;
assert(() {
isActive = (context as Element).debugIsActive;
return true;
}());
if (!isActive) return;
final messenger = ScaffoldMessenger.maybeOf(context);
if (messenger == null) return;
messenger.showSnackBar( messenger.showSnackBar(
SnackBar( SnackBar(
key: key, key: key,
+493 -204
View File
File diff suppressed because it is too large Load Diff
+692 -297
View File
File diff suppressed because it is too large Load Diff
+362 -73
View File
@@ -28,6 +28,12 @@
"common_remove": "Remove", "common_remove": "Remove",
"common_enable": "Enable", "common_enable": "Enable",
"common_disable": "Disable", "common_disable": "Disable",
"common_undo": "Undo",
"messageStatus_sent": "Sent",
"messageStatus_delivered": "Delivered",
"messageStatus_pending": "Sending",
"messageStatus_failed": "Failed to send",
"messageStatus_repeated": "Heard repeated",
"common_reboot": "Reboot", "common_reboot": "Reboot",
"common_loading": "Loading...", "common_loading": "Loading...",
"common_notAvailable": "—", "common_notAvailable": "—",
@@ -47,6 +53,8 @@
} }
} }
}, },
"common_autoRefresh": "Autorefresh",
"common_interval": "Interval",
"scanner_title": "MeshCore Open", "scanner_title": "MeshCore Open",
"connectionChoiceUsbLabel": "USB", "connectionChoiceUsbLabel": "USB",
"connectionChoiceBluetoothLabel": "Bluetooth", "connectionChoiceBluetoothLabel": "Bluetooth",
@@ -135,6 +143,7 @@
"scanner_chromeRequired": "Chrome Browser Required", "scanner_chromeRequired": "Chrome Browser Required",
"scanner_chromeRequiredMessage": "This web application requires Google Chrome or a Chromium-based browser for Bluetooth support.", "scanner_chromeRequiredMessage": "This web application requires Google Chrome or a Chromium-based browser for Bluetooth support.",
"scanner_enableBluetooth": "Enable Bluetooth", "scanner_enableBluetooth": "Enable Bluetooth",
"scanner_bluetoothWebUnsupported": "Bluetooth isn't available in the browser. Connect over USB instead.",
"device_quickSwitch": "Quick switch", "device_quickSwitch": "Quick switch",
"device_meshcore": "MeshCore", "device_meshcore": "MeshCore",
"settings_title": "Settings", "settings_title": "Settings",
@@ -295,17 +304,6 @@
"appSettings_routeWeightFailureDecrementSubtitle": "Weight removed from a path after failed delivery", "appSettings_routeWeightFailureDecrementSubtitle": "Weight removed from a path after failed delivery",
"appSettings_maxMessageRetries": "Max Message Retries", "appSettings_maxMessageRetries": "Max Message Retries",
"appSettings_maxMessageRetriesSubtitle": "Number of retry attempts before marking a message as failed", "appSettings_maxMessageRetriesSubtitle": "Number of retry attempts before marking a message as failed",
"path_routeWeight": "{weight}/{max}",
"@path_routeWeight": {
"placeholders": {
"weight": {
"type": "String"
},
"max": {
"type": "String"
}
}
},
"appSettings_battery": "Battery", "appSettings_battery": "Battery",
"appSettings_batteryChemistry": "Battery Chemistry", "appSettings_batteryChemistry": "Battery Chemistry",
"appSettings_batteryChemistryPerDevice": "Set per device ({deviceName})", "appSettings_batteryChemistryPerDevice": "Set per device ({deviceName})",
@@ -344,6 +342,19 @@
"appSettings_last6Hours": "Last 6 hours", "appSettings_last6Hours": "Last 6 hours",
"appSettings_last24Hours": "Last 24 hours", "appSettings_last24Hours": "Last 24 hours",
"appSettings_lastWeek": "Last week", "appSettings_lastWeek": "Last week",
"appSettings_rasterTileSource": "Raster Tile Source",
"appSettings_stadiaEndpoint": "Stadia Endpoint",
"appSettings_stadiaApiKey": "Stadia API Key",
"appSettings_stadiaApiKeyRequired": "Required for Stadia Maps usage",
"appSettings_stadiaApiKeyConfigured": "Configured: {maskedKey}",
"@appSettings_stadiaApiKeyConfigured": {
"placeholders": {
"maskedKey": {
"type": "String"
}
}
},
"appSettings_stadiaApiKeyDialogDescription": "Enter your Stadia Maps API key. This app uses it for raster tile requests.",
"appSettings_offlineMapCache": "Offline Map Cache", "appSettings_offlineMapCache": "Offline Map Cache",
"appSettings_unitsTitle": "Units", "appSettings_unitsTitle": "Units",
"appSettings_unitsMetric": "Metric (m / km)", "appSettings_unitsMetric": "Metric (m / km)",
@@ -451,6 +462,9 @@
} }
}, },
"contacts_newGroup": "New Group", "contacts_newGroup": "New Group",
"contacts_moreOptions": "More options",
"contacts_searchOpen": "Search contacts",
"contacts_searchClose": "Close search",
"contacts_groupName": "Group name", "contacts_groupName": "Group name",
"contacts_groupNameRequired": "Group name is required", "contacts_groupNameRequired": "Group name is required",
"contacts_groupNameReserved": "This group name is reserved", "contacts_groupNameReserved": "This group name is reserved",
@@ -775,15 +789,6 @@
} }
}, },
"debugFrame_hexDump": "Hex Dump:", "debugFrame_hexDump": "Hex Dump:",
"chat_pathManagement": "Path Management",
"chat_ShowAllPaths": "Show all paths",
"chat_routingMode": "Routing mode",
"chat_autoUseSavedPath": "Auto (use saved path)",
"chat_forceFloodMode": "Force Flood Mode",
"chat_recentAckPaths": "Recent ACK Paths (tap to use):",
"chat_pathHistoryFull": "Path history is full. Remove entries to add new ones.",
"chat_hopSingular": "hop",
"chat_hopPlural": "hops",
"chat_hopsCount": "{count} {count, plural, =1{hop} other{hops}}", "chat_hopsCount": "{count} {count, plural, =1{hop} other{hops}}",
"@chat_hopsCount": { "@chat_hopsCount": {
"placeholders": { "placeholders": {
@@ -792,31 +797,80 @@
} }
} }
}, },
"chat_successes": "successes",
"chat_score": "Score",
"chat_removePath": "Remove path", "chat_removePath": "Remove path",
"chat_noPathHistoryYet": "No path history yet.\nSend a message to discover paths.", "chat_noPathHistoryYet": "No path history yet.\nSend a message to discover paths.",
"chat_pathActions": "Path Actions:",
"chat_setCustomPath": "Set Custom Path",
"chat_setCustomPathSubtitle": "Manually specify routing path",
"chat_clearPath": "Clear Path",
"chat_clearPathSubtitle": "Force rediscovery on next send",
"chat_pathCleared": "Path cleared. Next message will rediscover route.", "chat_pathCleared": "Path cleared. Next message will rediscover route.",
"chat_floodModeSubtitle": "Use routing toggle in app bar",
"chat_floodModeEnabled": "Flood mode enabled. Toggle back via routing icon in app bar.",
"chat_fullPath": "Full Path", "chat_fullPath": "Full Path",
"chat_pathDetailsNotAvailable": "Path details not available yet. Try sending a message to refresh.", "routing_title": "Routing",
"chat_pathSetHops": "Path set: {hopCount} {hopCount, plural, =1{hop} other{hops}} - {status}", "routing_modeAuto": "Auto",
"@chat_pathSetHops": { "routing_modeFlood": "Flood",
"routing_modeManual": "Manual",
"routing_modeAutoHint": "Picks the best known path automatically, flooding when none is known.",
"routing_modeFloodHint": "Broadcasts through every repeater. Most reliable, but uses more airtime.",
"routing_modeManualHint": "Always sends along the exact path you set.",
"routing_currentRoute": "Current route",
"routing_directNoHops": "Direct — no repeater hops",
"routing_noPathYet": "No path yet. The next message floods until a route is discovered.",
"routing_floodBroadcast": "Broadcast through every repeater",
"routing_editPath": "Edit path",
"routing_forgetPath": "Forget path",
"routing_knownPaths": "Known paths",
"routing_knownPathsHint": "Tap a path to switch to it.",
"routing_inUse": "In use",
"routing_qualityStrong": "Strong first hop",
"routing_qualityGood": "Good first hop",
"routing_qualityFair": "Fair first hop",
"routing_qualityWorked": "Has delivered",
"routing_qualityFlood": "Heard via flood",
"routing_qualityUntested": "Untested",
"routing_lastWorked": "worked {when}",
"@routing_lastWorked": {
"placeholders": { "placeholders": {
"hopCount": { "when": {
"type": "int"
},
"status": {
"type": "String" "type": "String"
} }
} }
}, },
"routing_neverWorked": "never confirmed",
"routing_deliveryCounts": "{successes} delivered, {failures} failed",
"@routing_deliveryCounts": {
"placeholders": {
"successes": {
"type": "int"
},
"failures": {
"type": "int"
}
}
},
"routing_floodDelivery": "Flood delivery",
"pathEditor_title": "Build Path",
"pathEditor_hopCounter": "{count} of 64 hops",
"@pathEditor_hopCounter": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"pathEditor_noHops": "No hops yet. Tap repeaters below to add them in order, or save with no hops to send direct.",
"pathEditor_addHops": "Add hops in order",
"pathEditor_searchRepeaters": "Search repeaters",
"pathEditor_advancedHex": "Advanced: raw hex path",
"pathEditor_hexLabel": "Hex prefixes",
"pathEditor_hexHelper": "Two hex characters per hop, separated by commas",
"pathEditor_invalidTokens": "Invalid: {tokens}",
"@pathEditor_invalidTokens": {
"placeholders": {
"tokens": {
"type": "String"
}
}
},
"pathEditor_tooManyHops": "Maximum 64 hops",
"pathEditor_usePath": "Use this path",
"pathEditor_removeHop": "Remove hop",
"pathEditor_unknownHop": "Unknown repeater",
"chat_pathSavedLocally": "Saved locally. Connect to sync.", "chat_pathSavedLocally": "Saved locally. Connect to sync.",
"chat_pathDeviceConfirmed": "Device confirmed.", "chat_pathDeviceConfirmed": "Device confirmed.",
"chat_pathDeviceNotConfirmed": "Device not confirmed yet.", "chat_pathDeviceNotConfirmed": "Device not confirmed yet.",
@@ -860,6 +914,17 @@
}, },
"chat_invalidLink": "Invalid link format", "chat_invalidLink": "Invalid link format",
"map_title": "Node Map", "map_title": "Node Map",
"map_searchHint": "Search node name or ID",
"map_activity": "Activity",
"map_online": "Online",
"map_recent": "Recent",
"map_stale": "Stale",
"map_visible": "Visible",
"map_hidden": "Hidden",
"map_centerOnNode": "Center on node",
"map_details": "Details",
"map_noGps": "No GPS",
"map_noResults": "No matching nodes",
"map_lineOfSight": "Line of Sight", "map_lineOfSight": "Line of Sight",
"map_losScreenTitle": "Line of Sight", "map_losScreenTitle": "Line of Sight",
"map_noNodesWithLocation": "No nodes with location data", "map_noNodesWithLocation": "No nodes with location data",
@@ -1008,6 +1073,56 @@
} }
} }
}, },
"mapCache_cachedTilesLabel": "Cached tiles",
"mapCache_cachedTileSummaryLabel": "Cached tile summary",
"mapCache_bulkDownloadDisabledForSource": "Offline bulk downloads are disabled for {source}.",
"@mapCache_bulkDownloadDisabledForSource": {
"placeholders": {
"source": {
"type": "String"
}
}
},
"mapCache_bulkDownloadDisabledInConfig": "Offline bulk downloads are disabled for {source} in this app configuration.",
"@mapCache_bulkDownloadDisabledInConfig": {
"placeholders": {
"source": {
"type": "String"
}
}
},
"mapCache_summarySource": "Source: {source}",
"@mapCache_summarySource": {
"placeholders": {
"source": {
"type": "String"
}
}
},
"mapCache_summaryCachedTilesForSource": "Cached tiles for source: {count}",
"@mapCache_summaryCachedTilesForSource": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"mapCache_summaryCachedInSelection": "Cached in selected area/zoom: {count}",
"@mapCache_summaryCachedInSelection": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"mapCache_summaryApproxCacheSize": "Approx cache size: {size}",
"@mapCache_summaryApproxCacheSize": {
"placeholders": {
"size": {
"type": "String"
}
}
},
"mapCache_boundsLabel": "N {north}, S {south}, E {east}, W {west}", "mapCache_boundsLabel": "N {north}, S {south}, E {east}, W {west}",
"@mapCache_boundsLabel": { "@mapCache_boundsLabel": {
"placeholders": { "placeholders": {
@@ -1098,41 +1213,8 @@
"login_failedMessage": "Login failed. Either the password is incorrect or the repeater is unreachable.", "login_failedMessage": "Login failed. Either the password is incorrect or the repeater is unreachable.",
"common_reload": "Reload", "common_reload": "Reload",
"common_clear": "Clear", "common_clear": "Clear",
"path_currentPath": "Current path: {path}",
"@path_currentPath": {
"placeholders": {
"path": {
"type": "String"
}
}
},
"path_usingHopsPath": "Using {count} {count, plural, =1{hop} other{hops}} path",
"@path_usingHopsPath": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"path_enterCustomPath": "Enter Custom Path",
"path_currentPathLabel": "Current path", "path_currentPathLabel": "Current path",
"path_hexPrefixInstructions": "Enter 2-character hex prefixes for each hop, separated by commas.",
"path_hexPrefixExample": "Example: A1,F2,3C (each node uses first byte of its public key)",
"path_labelHexPrefixes": "Path (hex prefixes)",
"path_helperMaxHops": "Max 64 hops. Each prefix is 2 hex characters (1 byte)",
"path_selectFromContacts": "Or select from contacts:",
"path_noRepeatersFound": "No repeaters or room servers found.", "path_noRepeatersFound": "No repeaters or room servers found.",
"path_customPathsRequire": "Custom paths require intermediate hops that can relay messages.",
"path_invalidHexPrefixes": "Invalid hex prefixes: {prefixes}",
"@path_invalidHexPrefixes": {
"placeholders": {
"prefixes": {
"type": "String"
}
}
},
"path_tooLong": "Path too long. Maximum 64 hops allowed.",
"path_setPath": "Set Path",
"repeater_management": "Repeater Management", "repeater_management": "Repeater Management",
"room_management": "Room Server Management", "room_management": "Room Server Management",
"repeater_guest": "Repeater Information", "repeater_guest": "Repeater Information",
@@ -1159,9 +1241,6 @@
}, },
"repeater_statusTitle": "Repeater Status", "repeater_statusTitle": "Repeater Status",
"repeater_routingMode": "Routing mode", "repeater_routingMode": "Routing mode",
"repeater_autoUseSavedPath": "Auto (use saved path)",
"repeater_forceFloodMode": "Force Flood Mode",
"repeater_pathManagement": "Path management",
"repeater_refresh": "Refresh", "repeater_refresh": "Refresh",
"repeater_statusRequestTimeout": "Status request timed out.", "repeater_statusRequestTimeout": "Status request timed out.",
"repeater_errorLoadingStatus": "Error loading status: {error}", "repeater_errorLoadingStatus": "Error loading status: {error}",
@@ -1661,6 +1740,120 @@
} }
} }
}, },
"telemetry_digitalInputLabel": "Digital Input",
"telemetry_digitalOutputLabel": "Digital Output",
"telemetry_analogInputLabel": "Analog Input",
"telemetry_analogOutputLabel": "Analog Output",
"telemetry_genericLabel": "Generic Sensor",
"telemetry_luminosityLabel": "Luminosity",
"telemetry_presenceLabel": "Presence",
"telemetry_humidityLabel": "Humidity",
"telemetry_accelerometerLabel": "Accelerometer",
"telemetry_pressureLabel": "Pressure",
"telemetry_altitudeLabel": "Altitude",
"telemetry_frequencyLabel": "Frequency",
"telemetry_percentageLabel": "Percentage",
"telemetry_concentrationLabel": "Concentration",
"telemetry_powerLabel": "Power",
"telemetry_distanceLabel": "Distance",
"telemetry_energyLabel": "Energy",
"telemetry_directionLabel": "Direction",
"telemetry_timeLabel": "Time",
"telemetry_gyrometerLabel": "Gyrometer",
"telemetry_colourLabel": "Colour",
"telemetry_gpsLabel": "GPS",
"telemetry_switchLabel": "Switch",
"telemetry_polylineLabel": "Polyline",
"telemetry_altitudeValue": "{meters} m",
"@telemetry_altitudeValue": {
"placeholders": {
"meters": {
"type": "String"
}
}
},
"telemetry_frequencyValue": "{hertz} Hz",
"@telemetry_frequencyValue": {
"placeholders": {
"hertz": {
"type": "String"
}
}
},
"telemetry_pressureValue": "{hpa} hPa",
"@telemetry_pressureValue": {
"placeholders": {
"hpa": {
"type": "String"
}
}
},
"telemetry_luminosityValue": "{lux} lx",
"@telemetry_luminosityValue": {
"placeholders": {
"lux": {
"type": "String"
}
}
},
"telemetry_powerValue": "{watts} W",
"@telemetry_powerValue": {
"placeholders": {
"watts": {
"type": "String"
}
}
},
"telemetry_distanceValue": "{meters} m",
"@telemetry_distanceValue": {
"placeholders": {
"meters": {
"type": "String"
}
}
},
"telemetry_energyValue": "{kilowattHours} kWh",
"@telemetry_energyValue": {
"placeholders": {
"kilowattHours": {
"type": "String"
}
}
},
"telemetry_directionValue": "{degrees}°",
"@telemetry_directionValue": {
"placeholders": {
"degrees": {
"type": "String"
}
}
},
"telemetry_concentrationValue": "{ppm} ppm",
"@telemetry_concentrationValue": {
"placeholders": {
"ppm": {
"type": "String"
}
}
},
"telemetry_percentageValue": "{percent}%",
"@telemetry_percentageValue": {
"placeholders": {
"percent": {
"type": "String"
}
}
},
"telemetry_analogValue": "{value}",
"@telemetry_analogValue": {
"placeholders": {
"value": {
"type": "String"
}
}
},
"telemetry_autoFetchQuantity": "Requests quantity",
"telemetry_error": "Unable to retrieve data",
"neighbors_receivedData": "Received Neighbors Data", "neighbors_receivedData": "Received Neighbors Data",
"neighbors_requestTimedOut": "Neighbors request timed out.", "neighbors_requestTimedOut": "Neighbors request timed out.",
"neighbors_errorLoading": "Error loading neighbors: {error}", "neighbors_errorLoading": "Error loading neighbors: {error}",
@@ -2369,5 +2562,101 @@
"contact_typeRepeater": "Repeater", "contact_typeRepeater": "Repeater",
"contact_typeRoom": "Room", "contact_typeRoom": "Room",
"contact_typeSensor": "Sensor", "contact_typeSensor": "Sensor",
"contact_typeUnknown": "Unknown" "contact_typeUnknown": "Unknown",
"map_zoomIn": "Zoom in",
"map_zoomOut": "Zoom out",
"map_centerMap": "Center map",
"chrome_bluetoothRequiresChromium": "Web Bluetooth requires a Chromium browser",
"channels_communityShortId": "ID: {id}...",
"@channels_communityShortId": {
"placeholders": {
"id": {
"type": "String"
}
}
},
"pathTrace_legendGpsConfirmed": "GPS confirmed",
"pathTrace_legendInferred": "Inferred position",
"pathMap_viewSingle": "Single",
"pathMap_viewCombined": "Combined",
"pathMap_play": "Play",
"pathMap_pause": "Pause",
"pathMap_replay": "Replay",
"pathMap_stepBack": "Previous hop",
"pathMap_stepForward": "Next hop",
"pathMap_animationOn": "Show packet animation",
"pathMap_animationOff": "Hide packet animation",
"pathMap_hopOf": "Hop {current} of {total}",
"@pathMap_hopOf": {
"placeholders": {
"current": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"pathMap_observedPaths": "Observed paths: {count}",
"@pathMap_observedPaths": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"pathMap_primary": "Primary",
"pathMap_alternate": "Alt {index}",
"@pathMap_alternate": {
"placeholders": {
"index": {
"type": "int"
}
}
},
"pathMap_hopCount": "{count, plural, =1{1 hop} other{{count} hops}}",
"@pathMap_hopCount": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"pathMap_gpsCount": "{confirmed}/{total} GPS",
"@pathMap_gpsCount": {
"placeholders": {
"confirmed": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"pathMap_legendShared": "Shared segment",
"pathMap_legendEstimated": "Estimated segment",
"pathMap_sharedNodeCount": "Used by {count} paths",
"@pathMap_sharedNodeCount": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"pathMap_partialAnimation": "{count, plural, =1{1 hop has no location — the shown path is partial} other{{count} hops have no location — the shown path is partial}}",
"@pathMap_partialAnimation": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"pathMap_showAllPaths": "Show all",
"pathMap_hidePath": "Hide path",
"pathMap_showPath": "Show path",
"pathMap_collapsePanel": "Collapse panel",
"pathMap_expandPanel": "Expand panel",
"pathMap_noLocation": "No location",
"pathMap_followPacket": "Lock view to packet",
"pathMap_unfollowPacket": "Unlock view from packet"
} }
+638 -243
View File
File diff suppressed because it is too large Load Diff
+590 -174
View File
File diff suppressed because it is too large Load Diff
+1264 -975
View File
File diff suppressed because it is too large Load Diff
+678 -208
View File
File diff suppressed because it is too large Load Diff
+786 -395
View File
File diff suppressed because it is too large Load Diff
+527 -139
View File
@@ -5,8 +5,8 @@
"nav_channels": "채널", "nav_channels": "채널",
"nav_map": "지도", "nav_map": "지도",
"common_cancel": "취소", "common_cancel": "취소",
"common_ok": "알겠습니다", "common_ok": "확인",
"common_connect": "연결", "common_connect": "연결하기",
"common_unknownDevice": "알 수 없는 장치", "common_unknownDevice": "알 수 없는 장치",
"common_save": "저장", "common_save": "저장",
"common_delete": "삭제", "common_delete": "삭제",
@@ -16,19 +16,21 @@
"common_add": "추가", "common_add": "추가",
"common_settings": "설정", "common_settings": "설정",
"common_disconnect": "연결 해제", "common_disconnect": "연결 해제",
"common_connected": "연결", "common_connected": "연결",
"common_disconnected": "단절", "common_disconnected": "연결 해제됨",
"common_create": "만들", "common_create": "만들",
"common_continue": "계속", "common_continue": "계속",
"common_share": "공유", "common_share": "공유",
"common_copy": "복사", "common_copy": "복사",
"common_retry": "다시 시도", "common_retry": "다시 시도",
"common_hide": "숨기", "common_hide": "숨기",
"common_remove": "제거", "common_remove": "제거",
"common_enable": "활성화", "common_enable": "사용",
"common_disable": "비활성화", "common_disable": "사용 안 함",
"common_autoRefresh": "자동 새로고침",
"common_interval": "간격",
"common_reboot": "재부팅", "common_reboot": "재부팅",
"common_loading": "로딩 중...", "common_loading": "불러오는 중...",
"common_notAvailable": "—", "common_notAvailable": "—",
"common_voltageValue": "{volts} V", "common_voltageValue": "{volts} V",
"@common_voltageValue": { "@common_voltageValue": {
@@ -46,16 +48,16 @@
} }
} }
}, },
"scanner_title": "MeshCore 공개", "scanner_title": "MeshCore Open",
"connectionChoiceUsbLabel": "USB", "connectionChoiceUsbLabel": "USB",
"connectionChoiceBluetoothLabel": "블루투스", "connectionChoiceBluetoothLabel": "블루투스",
"connectionChoiceTcpLabel": "TCP", "connectionChoiceTcpLabel": "TCP",
"tcpScreenTitle": "TCP를 통해 연결", "tcpScreenTitle": "TCP를 통해 연결",
"tcpHostLabel": "IP 주소", "tcpHostLabel": "IP 주소",
"tcpHostHint": "192.168.40.10", "tcpHostHint": "192.168.40.10 / example.com",
"tcpPortLabel": "", "tcpPortLabel": "포트",
"tcpPortHint": "5000", "tcpPortHint": "5000",
"tcpStatus_notConnected": "목적지 주소 입력 후 연결", "tcpStatus_notConnected": "엔드포인트를 입력한 뒤 연결하세요.",
"tcpStatus_connectingTo": "{endpoint}에 연결 중...", "tcpStatus_connectingTo": "{endpoint}에 연결 중...",
"@tcpStatus_connectingTo": { "@tcpStatus_connectingTo": {
"placeholders": { "placeholders": {
@@ -78,21 +80,21 @@
}, },
"usbScreenTitle": "USB를 통해 연결", "usbScreenTitle": "USB를 통해 연결",
"usbScreenSubtitle": "감지된 시리얼 장치를 선택하고 MeshCore 노드에 직접 연결하십시오.", "usbScreenSubtitle": "감지된 시리얼 장치를 선택하고 MeshCore 노드에 직접 연결하십시오.",
"usbScreenStatus": "USB 장치를 선택합니다.", "usbScreenStatus": "USB 장치를 선택하세요.",
"usbScreenNote": "USB 직렬 통신은 지원되는 안드로이드 장치 및 데스크톱 플랫폼에서 활성화됩니다.", "usbScreenNote": "USB 직렬 통신은 지원되는 Android 기기 및 데스크톱 플랫폼에서 사용할 수 있습니다.",
"usbScreenEmptyState": "USB 장치가 탐지되지 않았습니다. USB 장치를 연결하고 다시 시도해 보세요.", "usbScreenEmptyState": "USB 장치가 없습니다. 하나 연결한 뒤 새로고침하세요.",
"usbErrorPermissionDenied": "USB 접근 권한이 거부되었습니다.", "usbErrorPermissionDenied": "USB 접근 권한이 거부되었습니다.",
"usbErrorDeviceMissing": "선택한 USB 장치 더 이상 사용 불가능합니다.", "usbErrorDeviceMissing": "선택한 USB 장치 더 이상 사용할 수 없습니다.",
"usbErrorInvalidPort": "유효한 USB 장치를 선택하세요.", "usbErrorInvalidPort": "유효한 USB 장치를 선택하세요.",
"usbErrorBusy": "다른 USB 연결 요청이 이미 진행 중입니다.", "usbErrorBusy": "다른 USB 연결 요청이 이미 진행 중입니다.",
"usbErrorNotConnected": "USB 장치가 연결되지 않았습니다.", "usbErrorNotConnected": "USB 장치가 연결되지 않았습니다.",
"usbErrorOpenFailed": "선택한 USB 장치를 열 수 없습니다.", "usbErrorOpenFailed": "선택한 USB 장치를 열 수 없습니다.",
"usbErrorConnectFailed": "선택한 USB 장치에 연결에 실패했습니다.", "usbErrorConnectFailed": "선택한 USB 장치에 연결하지 못했습니다.",
"usbErrorUnsupported": "이 플랫폼에서는 USB 직렬 통신을 지원하지 않습니다.", "usbErrorUnsupported": "이 플랫폼에서는 USB 직렬 통신을 지원하지 않습니다.",
"usbErrorAlreadyActive": "USB 연결이 이미 활성화되어 있습니다.", "usbErrorAlreadyActive": "USB 연결이 이미 활성 상태입니다.",
"usbErrorNoDeviceSelected": "USB 장치가 선택되지 않았습니다.", "usbErrorNoDeviceSelected": "USB 장치가 선택되지 않았습니다.",
"usbErrorPortClosed": "USB 연결이 활성화되지 않습니다.", "usbErrorPortClosed": "USB 연결이 열려 있지 않습니다.",
"usbErrorConnectTimedOut": "연결 시간 초과되었습니다. 장치 USB Companion 펌웨어를 가지고 있는지 확인해 주세요.", "usbErrorConnectTimedOut": "연결 시간 초과되었습니다. 장치 USB Companion 펌웨어 있는지 확인세요.",
"usbFallbackDeviceName": "웹 시리얼 장치", "usbFallbackDeviceName": "웹 시리얼 장치",
"usbStatus_notConnected": "USB 장치를 선택합니다.", "usbStatus_notConnected": "USB 장치를 선택합니다.",
"usbStatus_connecting": "USB 장치에 연결 중...", "usbStatus_connecting": "USB 장치에 연결 중...",
@@ -129,13 +131,13 @@
}, },
"scanner_stop": "멈춰", "scanner_stop": "멈춰",
"scanner_scan": "스캔", "scanner_scan": "스캔",
"scanner_bluetoothOff": "블루투스 꺼져 있습니다.", "scanner_bluetoothOff": "블루투스 꺼져 있습니다.",
"scanner_bluetoothOffMessage": "블루투스를 켜서 장치를 검색해주세요.", "scanner_bluetoothOffMessage": "기기를 검색하려면 블루투스를 켜세요.",
"scanner_chromeRequired": "크롬 브라우저 필요", "scanner_chromeRequired": "Chrome 브라우저 필요",
"scanner_chromeRequiredMessage": "이 웹 애플리케이션은 블루투 지원을 위해 Google Chrome 또는 Chromium 기반 브라우저가 필요합니다.", "scanner_chromeRequiredMessage": "이 웹 은 블루투 지원을 위해 Google Chrome 또는 Chromium 기반 브라우저가 필요합니다.",
"scanner_enableBluetooth": "블루투스 활성화", "scanner_enableBluetooth": "블루투스 켜기",
"device_quickSwitch": "빠른 전환", "device_quickSwitch": "빠른 전환",
"device_meshcore": "메쉬코어", "device_meshcore": "MeshCore",
"settings_title": "설정", "settings_title": "설정",
"settings_deviceInfo": "장치 정보", "settings_deviceInfo": "장치 정보",
"settings_appSettings": "앱 설정", "settings_appSettings": "앱 설정",
@@ -146,7 +148,7 @@
"settings_nodeNameHint": "노드 이름을 입력하세요", "settings_nodeNameHint": "노드 이름을 입력하세요",
"settings_nodeNameUpdated": "이름 변경", "settings_nodeNameUpdated": "이름 변경",
"settings_radioSettings": "라디오 설정", "settings_radioSettings": "라디오 설정",
"settings_radioSettingsSubtitle": "주파수, 전력, 스펙트럼", "settings_radioSettingsSubtitle": "주파수, 전력, 확산 계수",
"settings_radioSettingsUpdated": "라디오 설정이 업데이트되었습니다.", "settings_radioSettingsUpdated": "라디오 설정이 업데이트되었습니다.",
"settings_location": "위치", "settings_location": "위치",
"settings_locationSubtitle": "GPS 좌표", "settings_locationSubtitle": "GPS 좌표",
@@ -167,26 +169,26 @@
"settings_privacyModeEnabled": "개인 정보 보호 모드 활성화", "settings_privacyModeEnabled": "개인 정보 보호 모드 활성화",
"settings_privacyModeDisabled": "개인 정보 보호 모드 비활성화", "settings_privacyModeDisabled": "개인 정보 보호 모드 비활성화",
"settings_actions": "행동", "settings_actions": "행동",
"settings_deleteAllPaths": "Delete All Paths", "settings_deleteAllPaths": "모든 경로 삭제",
"settings_deleteAllPathsSubtitle": "Clear all path data from contacts.", "settings_deleteAllPathsSubtitle": "연락처의 모든 경로 데이터를 지웁니다.",
"settings_sendAdvertisement": "광고 전송", "settings_sendAdvertisement": "광고 전송",
"settings_sendAdvertisementSubtitle": "방송 활동", "settings_sendAdvertisementSubtitle": "현재 존재를 방송합니다.",
"settings_advertisementSent": "광고 전송", "settings_advertisementSent": "광고 전송되었습니다.",
"settings_syncTime": "동기화 시간", "settings_syncTime": "시간 동기화",
"settings_syncTimeSubtitle": "장치 시계를 휴대폰 시간으로 설정", "settings_syncTimeSubtitle": "장치 시계를 휴대폰 시간으로 설정",
"settings_timeSynchronized": "시간 동기화", "settings_timeSynchronized": "시간 동기화되었습니다.",
"settings_refreshContacts": "연락처 갱신", "settings_refreshContacts": "연락처 새로고침",
"settings_refreshContactsSubtitle": "장치에서 연락처 목록을 다시 불러오기", "settings_refreshContactsSubtitle": "장치에서 연락처 목록을 다시 불러오기",
"settings_rebootDevice": "장치 재부팅", "settings_rebootDevice": "장치 재부팅",
"settings_rebootDeviceSubtitle": "MeshCore 장치를 재부팅하세요.", "settings_rebootDeviceSubtitle": "MeshCore 장치를 재부팅합니다.",
"settings_rebootDeviceConfirm": "정말 장치를 재부팅하시겠습니까? 이 경우 연결이 끊어집니다.", "settings_rebootDeviceConfirm": "정말 장치를 재부팅하시겠습니까? 연결이 끊어집니다.",
"settings_debug": "디버", "settings_debug": "디버",
"settings_bleDebugLog": "BLE 디버그 로그", "settings_bleDebugLog": "BLE 디버그 로그",
"settings_bleDebugLogSubtitle": "BLE 명령, 응답 및 원시 데이터", "settings_bleDebugLogSubtitle": "BLE 명령, 응답 및 원시 데이터",
"settings_appDebugLog": "앱 디버 로그", "settings_appDebugLog": "앱 디버 로그",
"settings_appDebugLogSubtitle": "애플리케이션 디버 메시지", "settings_appDebugLogSubtitle": "애플리케이션 디버 메시지",
"settings_about": "소개", "settings_about": "정보",
"settings_aboutVersion": "MeshCore Open {version} 버전", "settings_aboutVersion": "MeshCore Open v{version}",
"@settings_aboutVersion": { "@settings_aboutVersion": {
"placeholders": { "placeholders": {
"version": { "version": {
@@ -194,8 +196,8 @@
} }
} }
}, },
"settings_aboutLegalese": "2026 MeshCore 오픈 소스 프로젝트", "settings_aboutLegalese": "2026 MeshCore 오픈 소스 프로젝트",
"settings_aboutDescription": "MeshCore LoRa 메시 네트워크 장치를 위한 오픈 소스 Flutter 클라이언트.", "settings_aboutDescription": "MeshCore LoRa 메시 네트워크 장치를 위한 오픈소스 Flutter 클라이언트.",
"settings_aboutOpenMeteoAttribution": "LOS 고도 데이터: Open-Meteo (CC BY 4.0)", "settings_aboutOpenMeteoAttribution": "LOS 고도 데이터: Open-Meteo (CC BY 4.0)",
"settings_infoName": "이름", "settings_infoName": "이름",
"settings_infoId": "ID", "settings_infoId": "ID",
@@ -204,19 +206,19 @@
"settings_infoPublicKey": "공개 키", "settings_infoPublicKey": "공개 키",
"settings_infoContactsCount": "연락처 수", "settings_infoContactsCount": "연락처 수",
"settings_infoChannelCount": "채널 수", "settings_infoChannelCount": "채널 수",
"settings_presets": "기본 설정", "settings_presets": "프리셋",
"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)",
"settings_bandwidth": "대역폭", "settings_bandwidth": "대역폭",
"settings_spreadingFactor": "분산 계수", "settings_spreadingFactor": "분산 계수",
"settings_codingRate": "코딩 속도", "settings_codingRate": "코딩 속도",
"settings_txPower": "TX 전력 (dBm)", "settings_txPower": "송신 전력 (dBm)",
"settings_txPowerHelper": "0 - 22", "settings_txPowerHelper": "0 - 22",
"settings_txPowerInvalid": "유효하지 않은 TX 전력 (0-22 dBm)", "settings_txPowerInvalid": "유효하지 않은 송신 전력 (0-22 dBm)",
"settings_clientRepeat": "오프그리드 반복", "settings_clientRepeat": "오프그리드 반복",
"settings_clientRepeatSubtitle": "이 장치가 다른 사람들을 위해 메시 패킷을 반복하도록 허용합니다.", "settings_clientRepeatSubtitle": "이 장치가 다른 장치의 메시 패킷을 반복하도록 허용합니다.",
"settings_clientRepeatFreqWarning": "오프그리드(무전력) 시스템 재연결에는 433MHz, 869MHz, 또는 918MHz 주파수가 필요합니다.", "settings_clientRepeatFreqWarning": "오프그리드 반복에는 433MHz, 869MHz 또는 918MHz 주파수가 필요합니다.",
"settings_error": "오류: {message}", "settings_error": "오류: {message}",
"@settings_error": { "@settings_error": {
"placeholders": { "placeholders": {
@@ -226,36 +228,36 @@
} }
}, },
"appSettings_title": "앱 설정", "appSettings_title": "앱 설정",
"appSettings_appearance": "외관", "appSettings_appearance": "모양",
"appSettings_theme": "주제", "appSettings_theme": "테마",
"appSettings_themeSystem": "기본 설정", "appSettings_themeSystem": "시스템 기본값",
"appSettings_themeLight": "", "appSettings_themeLight": "밝음",
"appSettings_themeDark": "어둡다", "appSettings_themeDark": "어두움",
"appSettings_language": "언어", "appSettings_language": "언어",
"appSettings_languageSystem": "기본 설정", "appSettings_languageSystem": "기본 설정",
"appSettings_languageEn": "영어", "appSettings_languageEn": "영어",
"appSettings_languageFr": "프랑스어", "appSettings_languageFr": "프랑스어",
"appSettings_languageEs": "스페인어", "appSettings_languageEs": "스페인어",
"appSettings_languageDe": "독일어", "appSettings_languageDe": "독일어",
"appSettings_languagePl": "폴란드", "appSettings_languagePl": "폴란드",
"appSettings_languageSl": "슬로베니아어", "appSettings_languageSl": "슬로베니아어",
"appSettings_languagePt": "포르투갈어", "appSettings_languagePt": "포르투갈어",
"appSettings_languageIt": "이탈리아어", "appSettings_languageIt": "이탈리아어",
"appSettings_languageZh": "중국어", "appSettings_languageZh": "중국어",
"appSettings_languageSv": "스웨덴어", "appSettings_languageSv": "스웨덴어",
"appSettings_languageNl": "네덜란드어", "appSettings_languageNl": "네덜란드어",
"appSettings_languageSk": "슬로베니아어", "appSettings_languageSk": "슬로바키아어",
"appSettings_languageBg": "불가리", "appSettings_languageBg": "불가리아어",
"appSettings_languageRu": "러시아어", "appSettings_languageRu": "러시아어",
"appSettings_languageUk": "우크라이나", "appSettings_languageUk": "우크라이나",
"appSettings_enableMessageTracing": "메시지 추적 기능 활성화", "appSettings_enableMessageTracing": "메시지 추적 기능 활성화",
"appSettings_enableMessageTracingSubtitle": "메시지에 대한 상세한 경로 및 시간 정보를 표시", "appSettings_enableMessageTracingSubtitle": "메시지에 대한 상세한 경로 및 시간 정보를 표시",
"appSettings_notifications": "알림", "appSettings_notifications": "알림",
"appSettings_enableNotifications": "알림 활성화", "appSettings_enableNotifications": "알림 활성화",
"appSettings_enableNotificationsSubtitle": "메시지와 광고에 대한 알림을 받으세요.", "appSettings_enableNotificationsSubtitle": "메시지와 광고에 대한 알림을 받으세요.",
"appSettings_notificationPermissionDenied": "알림 권한 거부", "appSettings_notificationPermissionDenied": "알림 권한 거부",
"appSettings_notificationsEnabled": "알림 기능 활성화", "appSettings_notificationsEnabled": "알림 사용",
"appSettings_notificationsDisabled": "알림 기능 끄기", "appSettings_notificationsDisabled": "알림 사용 안 함",
"appSettings_messageNotifications": "메시지 알림", "appSettings_messageNotifications": "메시지 알림",
"appSettings_messageNotificationsSubtitle": "새로운 메시지를 받을 때 알림 표시", "appSettings_messageNotificationsSubtitle": "새로운 메시지를 받을 때 알림 표시",
"appSettings_channelMessageNotifications": "채널 메시지 알림", "appSettings_channelMessageNotifications": "채널 메시지 알림",
@@ -263,22 +265,22 @@
"appSettings_advertisementNotifications": "광고 알림", "appSettings_advertisementNotifications": "광고 알림",
"appSettings_advertisementNotificationsSubtitle": "새 노드가 발견되었을 때 알림 표시", "appSettings_advertisementNotificationsSubtitle": "새 노드가 발견되었을 때 알림 표시",
"appSettings_messaging": "메시징", "appSettings_messaging": "메시징",
"appSettings_clearPathOnMaxRetry": "Max 재시도 시 경로 명확하게 설정", "appSettings_clearPathOnMaxRetry": "최대 재시도 시 경로 지우기",
"appSettings_clearPathOnMaxRetrySubtitle": "5번의 전송 시도가 실패하면 연락 경로를 재설정", "appSettings_clearPathOnMaxRetrySubtitle": "전송 시도가 5번 실패하면 연락 경로를 재설정합니다.",
"appSettings_pathsWillBeCleared": "5번의 시도 실패 후, 해당 경로가 확보될 것입니다.", "appSettings_pathsWillBeCleared": "5번 실패하면 해당 경로를 지웁니다.",
"appSettings_pathsWillNotBeCleared": "경로 자동으로 정리되지 않습니다.", "appSettings_pathsWillNotBeCleared": "경로 자동으로 지우지 않습니다.",
"appSettings_autoRouteRotation": "자동 경로 순환", "appSettings_autoRouteRotation": "자동 경로 순환",
"appSettings_autoRouteRotationSubtitle": "최적 경로와 방수 모드 사이를 전환", "appSettings_autoRouteRotationSubtitle": "최적 경로와 플러드 모드 사이를 전환합니다.",
"appSettings_autoRouteRotationEnabled": "자동 경로 순환 기능 활성화", "appSettings_autoRouteRotationEnabled": "자동 경로 순환 기능 활성화",
"appSettings_autoRouteRotationDisabled": "자동 경로 순환 기능 비활성화", "appSettings_autoRouteRotationDisabled": "자동 경로 순환 기능 비활성화",
"appSettings_maxRouteWeight": "최대 경로 무게", "appSettings_maxRouteWeight": "최대 경로 가중치",
"appSettings_maxRouteWeightSubtitle": "한 경로가 성공적인 송을 통해 누적할 수 있는 최대 무게", "appSettings_maxRouteWeightSubtitle": "한 경로가 성공적인 송을 통해 누적할 수 있는 최대 가중치",
"appSettings_initialRouteWeight": "초기 경로 가중치", "appSettings_initialRouteWeight": "초기 경로 가중치",
"appSettings_initialRouteWeightSubtitle": "새롭게 발견된 경로의 초기 무게", "appSettings_initialRouteWeightSubtitle": "새 발견된 경로의 초기 가중치",
"appSettings_routeWeightSuccessIncrement": "성공 횟수 증가", "appSettings_routeWeightSuccessIncrement": "성공 증가",
"appSettings_routeWeightSuccessIncrementSubtitle": "성공적으로 송된 경로에 추가된 무게", "appSettings_routeWeightSuccessIncrementSubtitle": "성공적으로 송된 경로에 추가되는 가중치",
"appSettings_routeWeightFailureDecrement": "오류 가중치 감소", "appSettings_routeWeightFailureDecrement": "실패 시 감소",
"appSettings_routeWeightFailureDecrementSubtitle": "송 실패 후 경로에서 제거된 무게", "appSettings_routeWeightFailureDecrementSubtitle": "송 실패 후 경로에서 제거되는 가중치",
"appSettings_maxMessageRetries": "최대 메시지 재시도 횟수", "appSettings_maxMessageRetries": "최대 메시지 재시도 횟수",
"appSettings_maxMessageRetriesSubtitle": "메시지를 실패로 처리하기 전 시도 횟수", "appSettings_maxMessageRetriesSubtitle": "메시지를 실패로 처리하기 전 시도 횟수",
"path_routeWeight": "{weight}/{max}", "path_routeWeight": "{weight}/{max}",
@@ -293,8 +295,8 @@
} }
}, },
"appSettings_battery": "배터리", "appSettings_battery": "배터리",
"appSettings_batteryChemistry": "배터리 화학", "appSettings_batteryChemistry": "배터리 종류",
"appSettings_batteryChemistryPerDevice": "{deviceName} 당분간", "appSettings_batteryChemistryPerDevice": "{deviceName}",
"@appSettings_batteryChemistryPerDevice": { "@appSettings_batteryChemistryPerDevice": {
"placeholders": { "placeholders": {
"deviceName": { "deviceName": {
@@ -302,20 +304,20 @@
} }
} }
}, },
"appSettings_batteryChemistryConnectFirst": "장치를 선택하기 위해 연결", "appSettings_batteryChemistryConnectFirst": "배터리 종류를 선택하려면 먼저 장치를 연결하세요.",
"appSettings_batteryNmc": "18650 NMC (3.0-4.2V)", "appSettings_batteryNmc": "18650 NMC (3.0-4.2V)",
"appSettings_batteryLifepo4": "LiFePO4 (2.6-3.65V)", "appSettings_batteryLifepo4": "LiFePO4 (2.6-3.65V)",
"appSettings_batteryLipo": "리튬 폴리머 (3.0-4.2V)", "appSettings_batteryLipo": "리튬 폴리머 (3.0-4.2V)",
"appSettings_mapDisplay": "지도 표시", "appSettings_mapDisplay": "지도 표시",
"appSettings_showRepeaters": "반복 기능 표시", "appSettings_showRepeaters": "리피터 표시",
"appSettings_showRepeatersSubtitle": "지도에 반복자 노드를 표시", "appSettings_showRepeatersSubtitle": "지도에 리피터 노드를 표시",
"appSettings_showChatNodes": "채팅 노드 표시", "appSettings_showChatNodes": "채팅 노드 표시",
"appSettings_showChatNodesSubtitle": "지도에 채팅 노드를 표시", "appSettings_showChatNodesSubtitle": "지도에 채팅 노드를 표시",
"appSettings_showOtherNodes": "다른 노드 표시", "appSettings_showOtherNodes": "다른 노드 표시",
"appSettings_showOtherNodesSubtitle": "지도에 다른 노드 유형을 표시", "appSettings_showOtherNodesSubtitle": "지도에 다른 노드 유형을 표시",
"appSettings_timeFilter": "시간 필터", "appSettings_timeFilter": "시간 필터",
"appSettings_timeFilterShowAll": "모든 노드 표시", "appSettings_timeFilterShowAll": "모든 노드 표시",
"appSettings_timeFilterShowLast": "지난 {hours} 시간 동안의 노드 표시", "appSettings_timeFilterShowLast": "최근 {hours}시간 동안의 노드 표시",
"@appSettings_timeFilterShowLast": { "@appSettings_timeFilterShowLast": {
"placeholders": { "placeholders": {
"hours": { "hours": {
@@ -323,17 +325,30 @@
} }
} }
}, },
"appSettings_mapTimeFilter": "지도 필터", "appSettings_mapTimeFilter": "지도 시간 필터",
"appSettings_showNodesDiscoveredWithin": "다음 내역에서 발견된 노드 표시:", "appSettings_showNodesDiscoveredWithin": "다음 기간 내에 발견된 노드 표시:",
"appSettings_allTime": "모든 시간", "appSettings_allTime": "전체 기간",
"appSettings_lastHour": "지난 시간", "appSettings_lastHour": "지난 1시간",
"appSettings_last6Hours": "지난 6시간", "appSettings_last6Hours": "지난 6시간",
"appSettings_last24Hours": "지난 24시간", "appSettings_last24Hours": "지난 24시간",
"appSettings_lastWeek": "지난 주", "appSettings_lastWeek": "지난 주",
"appSettings_rasterTileSource": "래스터 타일 소스",
"appSettings_stadiaEndpoint": "Stadia 엔드포인트",
"appSettings_stadiaApiKey": "Stadia API 키",
"appSettings_stadiaApiKeyRequired": "Stadia Maps를 사용하려면 필요합니다",
"appSettings_stadiaApiKeyConfigured": "설정됨: {maskedKey}",
"@appSettings_stadiaApiKeyConfigured": {
"placeholders": {
"maskedKey": {
"type": "String"
}
}
},
"appSettings_stadiaApiKeyDialogDescription": "Stadia Maps API 키를 입력하세요. 이 앱은 래스터 타일 요청에 이 키를 사용합니다.",
"appSettings_offlineMapCache": "오프라인 지도 캐시", "appSettings_offlineMapCache": "오프라인 지도 캐시",
"appSettings_unitsTitle": "단위", "appSettings_unitsTitle": "단위",
"appSettings_unitsMetric": "단위 (m / km)", "appSettings_unitsMetric": "미터법 (m / km)",
"appSettings_unitsImperial": "제국 (피트/마일)", "appSettings_unitsImperial": "영국식 (ft / mi)",
"appSettings_noAreaSelected": "선택된 영역 없음", "appSettings_noAreaSelected": "선택된 영역 없음",
"appSettings_areaSelectedZoom": "선택된 영역 (줌 레벨: {minZoom} - {maxZoom})", "appSettings_areaSelectedZoom": "선택된 영역 (줌 레벨: {minZoom} - {maxZoom})",
"@appSettings_areaSelectedZoom": { "@appSettings_areaSelectedZoom": {
@@ -346,9 +361,9 @@
} }
} }
}, },
"appSettings_debugCard": "디버", "appSettings_debugCard": "디버",
"appSettings_appDebugLogging": "앱 디버 로깅", "appSettings_appDebugLogging": "앱 디버 로깅",
"appSettings_appDebugLoggingSubtitle": "로그 앱 디버 메시지 (문제 해결을 위한)", "appSettings_appDebugLoggingSubtitle": "문제 해결을 위한 앱 디버 메시지를 기록합니다.",
"appSettings_appDebugLoggingEnabled": "앱 디버깅 로깅 활성화", "appSettings_appDebugLoggingEnabled": "앱 디버깅 로깅 활성화",
"appSettings_appDebugLoggingDisabled": "앱 디버깅 로깅 비활성화", "appSettings_appDebugLoggingDisabled": "앱 디버깅 로깅 비활성화",
"contacts_title": "연락처", "contacts_title": "연락처",
@@ -452,7 +467,7 @@
"contacts_noContactsMatchFilter": "입력하신 검색 조건과 일치하는 연락처가 없습니다.", "contacts_noContactsMatchFilter": "입력하신 검색 조건과 일치하는 연락처가 없습니다.",
"contacts_noMembers": "회원 없음", "contacts_noMembers": "회원 없음",
"contacts_lastSeenNow": "최근", "contacts_lastSeenNow": "최근",
"contacts_lastSeenMinsAgo": "~ {minutes} min.", "contacts_lastSeenMinsAgo": "~ {minutes}",
"@contacts_lastSeenMinsAgo": { "@contacts_lastSeenMinsAgo": {
"placeholders": { "placeholders": {
"minutes": { "minutes": {
@@ -461,7 +476,7 @@
} }
}, },
"contacts_lastSeenHourAgo": "약 1시간", "contacts_lastSeenHourAgo": "약 1시간",
"contacts_lastSeenHoursAgo": "~ {hours} hours", "contacts_lastSeenHoursAgo": "~ {hours}시간",
"@contacts_lastSeenHoursAgo": { "@contacts_lastSeenHoursAgo": {
"placeholders": { "placeholders": {
"hours": { "hours": {
@@ -719,7 +734,7 @@
} }
}, },
"debugFrame_textTypeCli": "명령줄 인터페이스 (CLI)", "debugFrame_textTypeCli": "명령줄 인터페이스 (CLI)",
"debugFrame_textTypePlain": "단순한", "debugFrame_textTypePlain": "일반 텍스트",
"debugFrame_text": "- 텍스트: \"{text}\"", "debugFrame_text": "- 텍스트: \"{text}\"",
"@debugFrame_text": { "@debugFrame_text": {
"placeholders": { "placeholders": {
@@ -733,7 +748,7 @@
"chat_ShowAllPaths": "모든 경로 표시", "chat_ShowAllPaths": "모든 경로 표시",
"chat_routingMode": "라우팅 방식", "chat_routingMode": "라우팅 방식",
"chat_autoUseSavedPath": "자동 (저장된 경로 사용)", "chat_autoUseSavedPath": "자동 (저장된 경로 사용)",
"chat_forceFloodMode": "강수 모드 활성화", "chat_forceFloodMode": "플러드 모드 활성화",
"chat_recentAckPaths": "최근 사용한 ACK 경로 (사용하려면 탭):", "chat_recentAckPaths": "최근 사용한 ACK 경로 (사용하려면 탭):",
"chat_pathHistoryFull": "이력 기록은 이미 가득 차 있습니다. 항목을 삭제하여 새로운 항목을 추가할 수 있습니다.", "chat_pathHistoryFull": "이력 기록은 이미 가득 차 있습니다. 항목을 삭제하여 새로운 항목을 추가할 수 있습니다.",
"chat_hopSingular": "점프", "chat_hopSingular": "점프",
@@ -746,20 +761,20 @@
} }
} }
}, },
"chat_successes": "성공 사례", "chat_successes": "성공",
"chat_removePath": "경로 제거", "chat_removePath": "경로 제거",
"chat_noPathHistoryYet": "아직 경로 기록이 없습니다.\n경로를 찾기 위해 메시지를 보내세요.", "chat_noPathHistoryYet": "아직 경로 기록이 없습니다.\n경로를 찾기 위해 메시지를 보내세요.",
"chat_pathActions": "경로 작업:", "chat_pathActions": "경로 작업:",
"chat_setCustomPath": "사용자 지정 경로 설정", "chat_setCustomPath": "사용자 지정 경로 설정",
"chat_setCustomPathSubtitle": "수동으로 경로를 지정", "chat_setCustomPathSubtitle": "수동으로 경로를 지정",
"chat_clearPath": "명확한 길", "chat_clearPath": "경로 지우기",
"chat_clearPathSubtitle": "다음 전송 시, 강제 재전송 설정", "chat_clearPathSubtitle": "다음 전송 시 강제로 새 경로를 찾습니다.",
"chat_pathCleared": "경로가 확보되었습니다. 다음 메시지는 경로를 다시 찾을 것입니다.", "chat_pathCleared": "경로가 확보되었습니다. 다음 메시지는 경로를 다시 찾을 것입니다.",
"chat_floodModeSubtitle": "앱 바에서 라우팅 스위치를 사용", "chat_floodModeSubtitle": "앱 바 라우팅 스위치를 사용하세요.",
"chat_floodModeEnabled": "홍수 모드 활성화. 앱 바의 경로 아이콘을 사용하여 다시 전환할 수 있습니다.", "chat_floodModeEnabled": "플러드 모드 활성화되었습니다. 앱 바의 경로 아이콘으로 다시 전환할 수 있습니다.",
"chat_fullPath": "전체 경로", "chat_fullPath": "전체 경로",
"chat_pathDetailsNotAvailable": "경로 정보는 아직 제공되지 않습니다. 메시지를 보내어 다시 시도해 보세요.", "chat_pathDetailsNotAvailable": "경로 정보는 아직 제공되지 않습니다. 메시지를 보내어 다시 시도해 보세요.",
"chat_pathSetHops": "Path set: {hopCount} {hopCount, plural, =1{hop} other{hops}} - {status}", "chat_pathSetHops": "경로 설정: {hopCount} {hopCount, plural, =1{} other{}} - {status}",
"@chat_pathSetHops": { "@chat_pathSetHops": {
"placeholders": { "placeholders": {
"hopCount": { "hopCount": {
@@ -770,16 +785,16 @@
} }
} }
}, },
"chat_pathSavedLocally": "로컬에 저장. 동기화 연결", "chat_pathSavedLocally": "로컬에 저장되었습니다. 동기화할 장치에 연결하세요.",
"chat_pathDeviceConfirmed": "장치 확인 완료.", "chat_pathDeviceConfirmed": "장치 확인되었습니다.",
"chat_pathDeviceNotConfirmed": "기기가 아직 확인되지 않았습니다.", "chat_pathDeviceNotConfirmed": "기기가 아직 확인되지 않았습니다.",
"chat_type": "종류", "chat_type": "유형",
"chat_path": "경로", "chat_path": "경로",
"chat_publicKey": "공개 키", "chat_publicKey": "공개 키",
"chat_compressOutgoingMessages": "전송되는 메시지 압축", "chat_compressOutgoingMessages": "전송되는 메시지 압축",
"chat_floodForced": "홍수 (강제)", "chat_floodForced": "플러드 (강제)",
"chat_directForced": "직접적인 (강제적인)", "chat_directForced": "직접 (강제)",
"chat_hopsForced": "{count}번 띄우기 (강제)", "chat_hopsForced": "{count} (강제)",
"@chat_hopsForced": { "@chat_hopsForced": {
"placeholders": { "placeholders": {
"count": { "count": {
@@ -787,7 +802,7 @@
} }
} }
}, },
"chat_floodAuto": "홍수 (자동)", "chat_floodAuto": "플러드 (자동)",
"chat_direct": "직접", "chat_direct": "직접",
"chat_poiShared": "공유된 POI", "chat_poiShared": "공유된 POI",
"chat_unread": "읽지 않음: {count}", "chat_unread": "읽지 않음: {count}",
@@ -903,7 +918,7 @@
} }
} }
}, },
"mapCache_cachedTilesWithFailed": "Cached {downloaded} tiles ({failed} failed)", "mapCache_cachedTilesWithFailed": "캐시된 타일 {downloaded} ({failed}개 실패)",
"@mapCache_cachedTilesWithFailed": { "@mapCache_cachedTilesWithFailed": {
"placeholders": { "placeholders": {
"downloaded": { "downloaded": {
@@ -929,7 +944,7 @@
} }
} }
}, },
"mapCache_downloadedTiles": "Downloaded {completed} / {total}", "mapCache_downloadedTiles": "다운로드됨 {completed} / {total}",
"@mapCache_downloadedTiles": { "@mapCache_downloadedTiles": {
"placeholders": { "placeholders": {
"completed": { "completed": {
@@ -950,6 +965,56 @@
} }
} }
}, },
"mapCache_cachedTilesLabel": "Cached tiles",
"mapCache_cachedTileSummaryLabel": "Cached tile summary",
"mapCache_bulkDownloadDisabledForSource": "Offline bulk downloads are disabled for {source}.",
"@mapCache_bulkDownloadDisabledForSource": {
"placeholders": {
"source": {
"type": "String"
}
}
},
"mapCache_bulkDownloadDisabledInConfig": "Offline bulk downloads are disabled for {source} in this app configuration.",
"@mapCache_bulkDownloadDisabledInConfig": {
"placeholders": {
"source": {
"type": "String"
}
}
},
"mapCache_summarySource": "Source: {source}",
"@mapCache_summarySource": {
"placeholders": {
"source": {
"type": "String"
}
}
},
"mapCache_summaryCachedTilesForSource": "Cached tiles for source: {count}",
"@mapCache_summaryCachedTilesForSource": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"mapCache_summaryCachedInSelection": "Cached in selected area/zoom: {count}",
"@mapCache_summaryCachedInSelection": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"mapCache_summaryApproxCacheSize": "Approx cache size: {size}",
"@mapCache_summaryApproxCacheSize": {
"placeholders": {
"size": {
"type": "String"
}
}
},
"mapCache_boundsLabel": "N {north}, S {south}, E {east}, W {west}", "mapCache_boundsLabel": "N {north}, S {south}, E {east}, W {west}",
"@mapCache_boundsLabel": { "@mapCache_boundsLabel": {
"placeholders": { "placeholders": {
@@ -976,7 +1041,7 @@
} }
} }
}, },
"time_hoursAgo": "{hours}h ago", "time_hoursAgo": "{hours}시간 전",
"@time_hoursAgo": { "@time_hoursAgo": {
"placeholders": { "placeholders": {
"hours": { "hours": {
@@ -1038,8 +1103,8 @@
} }
}, },
"login_failedMessage": "로그인에 실패했습니다. 비밀번호가 잘못되었거나, 연결이 되지 않는 것 같습니다.", "login_failedMessage": "로그인에 실패했습니다. 비밀번호가 잘못되었거나, 연결이 되지 않는 것 같습니다.",
"common_reload": "다시 로드", "common_reload": "다시 불러오기",
"common_clear": "명확하게", "common_clear": "지우기",
"path_currentPath": "현재 경로: {path}", "path_currentPath": "현재 경로: {path}",
"@path_currentPath": { "@path_currentPath": {
"placeholders": { "placeholders": {
@@ -1048,7 +1113,7 @@
} }
} }
}, },
"path_usingHopsPath": "Using {count} {count, plural, =1{hop} other{hops}} path", "path_usingHopsPath": "{count} {count, plural, =1{} other{홉}} 경로 사용 중",
"@path_usingHopsPath": { "@path_usingHopsPath": {
"placeholders": { "placeholders": {
"count": { "count": {
@@ -1466,6 +1531,43 @@
} }
} }
}, },
"telemetry_digitalInputLabel": "디지털 입력",
"telemetry_digitalOutputLabel": "디지털 출력",
"telemetry_analogInputLabel": "아날로그 입력",
"telemetry_analogOutputLabel": "아날로그 출력",
"telemetry_genericLabel": "일반 센서",
"telemetry_luminosityLabel": "조도",
"telemetry_presenceLabel": "존재 감지",
"telemetry_humidityLabel": "습도",
"telemetry_accelerometerLabel": "가속도계",
"telemetry_pressureLabel": "압력",
"telemetry_altitudeLabel": "고도",
"telemetry_frequencyLabel": "주파수",
"telemetry_percentageLabel": "백분율",
"telemetry_concentrationLabel": "농도",
"telemetry_powerLabel": "전력",
"telemetry_distanceLabel": "거리",
"telemetry_energyLabel": "에너지",
"telemetry_directionLabel": "방향",
"telemetry_timeLabel": "시간",
"telemetry_gyrometerLabel": "자이로미터",
"telemetry_colourLabel": "색상",
"telemetry_gpsLabel": "GPS",
"telemetry_switchLabel": "스위치",
"telemetry_polylineLabel": "폴리라인",
"telemetry_altitudeValue": "{meters} m",
"telemetry_frequencyValue": "{hertz} Hz",
"telemetry_pressureValue": "{hpa} hPa",
"telemetry_luminosityValue": "{lux} lx",
"telemetry_powerValue": "{watts} W",
"telemetry_distanceValue": "{meters} m",
"telemetry_energyValue": "{kilowattHours} kWh",
"telemetry_directionValue": "{degrees}°",
"telemetry_concentrationValue": "{ppm} ppm",
"telemetry_percentageValue": "{percent}%",
"telemetry_analogValue": "{value}",
"telemetry_autoFetchQuantity": "요청 수",
"telemetry_error": "데이터를 가져올 수 없습니다",
"telemetry_noData": "텔레메트리 데이터는 제공되지 않습니다.", "telemetry_noData": "텔레메트리 데이터는 제공되지 않습니다.",
"telemetry_channelTitle": "채널 {channel}", "telemetry_channelTitle": "채널 {channel}",
"@telemetry_channelTitle": { "@telemetry_channelTitle": {
@@ -1538,7 +1640,7 @@
} }
} }
}, },
"neighbors_heardAgo": "Heard: {time} ago", "neighbors_heardAgo": "수신: {time} ",
"@neighbors_heardAgo": { "@neighbors_heardAgo": {
"placeholders": { "placeholders": {
"time": { "time": {
@@ -1602,7 +1704,7 @@
} }
} }
}, },
"channelPath_observedSomeOf": "{observed} of {total} hops", "channelPath_observedSomeOf": "{observed}/{total} 홉 관찰됨",
"@channelPath_observedSomeOf": { "@channelPath_observedSomeOf": {
"placeholders": { "placeholders": {
"observed": { "observed": {
@@ -1838,7 +1940,7 @@
} }
} }
}, },
"losAntennaB": "Antenna B: {value} {unit}", "losAntennaB": "안테나 B: {value} {unit}",
"@losAntennaB": { "@losAntennaB": {
"placeholders": { "placeholders": {
"value": { "value": {
@@ -1851,7 +1953,7 @@
}, },
"losRun": "LOS (Loss of Signal) 상태로 전환", "losRun": "LOS (Loss of Signal) 상태로 전환",
"losNoElevationData": "고도 정보 없음", "losNoElevationData": "고도 정보 없음",
"losProfileClear": "{distance} {distanceUnit}, clear LOS, min clearance {clearance} {heightUnit}", "losProfileClear": "{distance} {distanceUnit}, LOS 확보, 최소 여유 {clearance} {heightUnit}",
"@losProfileClear": { "@losProfileClear": {
"placeholders": { "placeholders": {
"distance": { "distance": {
@@ -1868,7 +1970,7 @@
} }
} }
}, },
"losProfileBlocked": "{distance} {distanceUnit}, blocked by {obstruction} {heightUnit}", "losProfileBlocked": "{distance} {distanceUnit}, {obstruction} {heightUnit}에 의해 차단됨",
"@losProfileBlocked": { "@losProfileBlocked": {
"placeholders": { "placeholders": {
"distance": { "distance": {
@@ -2266,10 +2368,10 @@
"repeater_cliHelpStatsPackets": "(전송 속도만 표시) 패킷 수준의 통계 정보를 보여줍니다.", "repeater_cliHelpStatsPackets": "(전송 속도만 표시) 패킷 수준의 통계 정보를 보여줍니다.",
"repeater_cliHelpStatsRadio": "(특정 시리즈만 해당) 라디오 통계 정보를 표시합니다.", "repeater_cliHelpStatsRadio": "(특정 시리즈만 해당) 라디오 통계 정보를 표시합니다.",
"repeater_cliHelpStatsCore": "(시리얼 번호만 표시) 핵심 펌웨어 통계 정보를 보여줍니다.", "repeater_cliHelpStatsCore": "(시리얼 번호만 표시) 핵심 펌웨어 통계 정보를 보여줍니다.",
"common_done": "Done", "common_done": "완료",
"background_serviceTitle": "MeshCore running", "background_serviceTitle": "MeshCore 실행 중",
"background_serviceText": "Keeping BLE connected", "background_serviceText": "BLE 연결 유지 중",
"appSettings_translationModelDeleted": "Deleted {name}", "appSettings_translationModelDeleted": "{name} 삭제됨",
"@appSettings_translationModelDeleted": { "@appSettings_translationModelDeleted": {
"placeholders": { "placeholders": {
"name": { "name": {
@@ -2277,7 +2379,7 @@
} }
} }
}, },
"appSettings_translationModelDeleteFailed": "Failed to delete: {error}", "appSettings_translationModelDeleteFailed": "삭제 실패: {error}",
"@appSettings_translationModelDeleteFailed": { "@appSettings_translationModelDeleteFailed": {
"placeholders": { "placeholders": {
"error": { "error": {
@@ -2285,7 +2387,7 @@
} }
} }
}, },
"channels_channelUpdateFailed": "Failed to update channel: {error}", "channels_channelUpdateFailed": "채널 업데이트 실패: {error}",
"@channels_channelUpdateFailed": { "@channels_channelUpdateFailed": {
"placeholders": { "placeholders": {
"error": { "error": {
@@ -2293,19 +2395,19 @@
} }
} }
}, },
"map_type": "Type", "map_type": "유형",
"map_path": "Path", "map_path": "경로",
"map_location": "Location", "map_location": "위치",
"map_estLocation": "Est. Location", "map_estLocation": "추정 위치",
"map_publicKey": "Public Key", "map_publicKey": "공개 키",
"map_publicKeyPrefixHint": "e.g. ab12", "map_publicKeyPrefixHint": "예: ab12",
"contact_typeChat": "Chat", "contact_typeChat": "채팅",
"contact_typeRepeater": "Repeater", "contact_typeRepeater": "리피터",
"contact_typeRoom": "Room", "contact_typeRoom": "",
"contact_typeSensor": "Sensor", "contact_typeSensor": "센서",
"contact_typeUnknown": "Unknown", "contact_typeUnknown": "알 수 없음",
"channels_via": "via {path}", "channels_via": "{path} 경유",
"chat_score": "Score", "chat_score": "점수",
"settings_multiAck": "다중 ACK", "settings_multiAck": "다중 ACK",
"map_sharedAt": "공유됨", "map_sharedAt": "공유됨",
"@losBlockedSpotChip": { "@losBlockedSpotChip": {
@@ -2347,10 +2449,296 @@
"losBlockedSpotsTitle": "차단된 공간", "losBlockedSpotsTitle": "차단된 공간",
"losSelectedObstructionTitle": "선택된 장애물", "losSelectedObstructionTitle": "선택된 장애물",
"losBlockedSpotChip": "{distance} {distanceUnit} • {obstruction} {heightUnit}", "losBlockedSpotChip": "{distance} {distanceUnit} • {obstruction} {heightUnit}",
"losSelectedObstructionDetails": "Blocked by {obstruction} {heightUnit}, {distanceFromA} from A and {distanceFromB} from B ({distanceUnit}).", "losSelectedObstructionDetails": "{obstruction} {heightUnit}에 의해 차단됨, A에서 {distanceFromA}, B에서 {distanceFromB} ({distanceUnit})",
"settings_companionDebugLog": "동반 디버깅 로그", "settings_companionDebugLog": "동반 디버깅 로그",
"chat_newMessages": "새로운 메시지", "chat_newMessages": "새로운 메시지",
"settings_companionDebugLogSubtitle": "BLE/TCP/USB 명령어, 응답 및 원시 데이터", "settings_companionDebugLogSubtitle": "BLE/TCP/USB 명령어, 응답 및 원시 데이터",
"chat_markAsUnread": "미리 읽지 않음으로 표시", "chat_markAsUnread": "미리 읽지 않음으로 표시",
"repeater_chanUtil": "채널 활용도" "repeater_chanUtil": "채널 활용도",
"@routing_lastWorked": {
"placeholders": {
"when": {
"type": "String"
}
}
},
"@routing_deliveryCounts": {
"placeholders": {
"successes": {
"type": "int"
},
"failures": {
"type": "int"
}
}
},
"@pathEditor_hopCounter": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"@pathEditor_invalidTokens": {
"placeholders": {
"tokens": {
"type": "String"
}
}
},
"@channels_communityShortId": {
"placeholders": {
"id": {
"type": "String"
}
}
},
"messageStatus_pending": "발송",
"messageStatus_sent": "발송",
"messageStatus_delivered": "배송 완료",
"common_undo": "되돌리기",
"messageStatus_pending": "전송 중",
"messageStatus_sent": "전송됨",
"messageStatus_delivered": "전달됨",
"messageStatus_failed": "전송 실패",
"messageStatus_repeated": "반복 수신됨",
"contacts_searchOpen": "연락처 검색",
"contacts_moreOptions": "더 많은 옵션",
"contacts_searchClose": "검색 닫기",
"routing_title": "라우팅",
"routing_modeAuto": "자동",
"routing_modeFlood": "플러드",
"routing_modeManual": "수동",
"routing_modeAutoHint": "가장 잘 알려진 경로를 자동으로 선택하고, 경로가 없으면 플러드로 전환합니다.",
"routing_modeFloodHint": "모든 중계기를 통해 방송합니다. 가장 안정적이지만 송신 시간을 더 많이 사용합니다.",
"routing_modeManualHint": "항상 지정한 정확한 경로를 따릅니다.",
"routing_currentRoute": "현재 경로",
"routing_directNoHops": "직접 연결 - 중계 없음",
"routing_noPathYet": "아직 경로가 없습니다. 다음 메시지가 도착할 때까지 계속 탐색합니다.",
"routing_floodBroadcast": "모든 중계기를 통해 방송",
"routing_editPath": "경로 편집",
"routing_forgetPath": "경로 지우기",
"routing_knownPaths": "알려진 경로",
"routing_knownPathsHint": "전환할 경로를 선택하세요.",
"routing_inUse": "사용 중",
"routing_qualityStrong": "매우 좋음",
"routing_qualityGood": "좋음",
"routing_qualityFair": "보통",
"routing_qualityWorked": "작동함",
"routing_qualityFlood": "플러드로 수신됨",
"routing_qualityUntested": "미검증",
"routing_lastWorked": "{when}에 작동",
"routing_neverWorked": "아직 작동한 적 없음",
"routing_floodDelivery": "플러드 전송",
"pathEditor_title": "경로 만들기",
"pathEditor_hopCounter": "64개 중 {count} 홉",
"pathEditor_noHops": "아직 홉이 추가되지 않았습니다. 아래 탭을 사용해 순서대로 추가하거나, 홉 없이 바로 보내려면 \"홉 없음\"으로 저장하세요.",
"pathEditor_addHops": "홉을 순서대로 추가하세요.",
"pathEditor_searchRepeaters": "리피터 검색",
"pathEditor_advancedHex": "고급: 원시 HEX 경로",
"pathEditor_hexLabel": "HEX 접두사",
"pathEditor_hexHelper": "각 홉마다 2개의 16진수 바이트, 쉼표로 구분",
"pathEditor_invalidTokens": "유효하지 않음: {tokens}",
"pathEditor_tooManyHops": "최대 64개의 홉",
"pathEditor_usePath": "이 경로 사용",
"pathEditor_removeHop": "홉 제거",
"pathEditor_unknownHop": "알 수 없는 중계기",
"map_zoomIn": "확대",
"routing_deliveryCounts": "{successes}건 성공, {failures}건 실패",
"map_zoomOut": "축소",
"map_centerMap": "지도 중앙 맞추기",
"chrome_bluetoothRequiresChromium": "웹 블루투스는 Chromium 기반 브라우저가 필요합니다.",
"channels_communityShortId": "ID: {id}...",
"pathTrace_legendGpsConfirmed": "GPS로 확인됨",
"pathTrace_legendInferred": "추정된 위치",
"@pathMap_hopOf": {
"placeholders": {
"current": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"@pathMap_observedPaths": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"@pathMap_alternate": {
"placeholders": {
"index": {
"type": "int"
}
}
},
"@pathMap_hopCount": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"@pathMap_gpsCount": {
"placeholders": {
"confirmed": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"@pathMap_sharedNodeCount": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"@pathMap_partialAnimation": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"map_activity": "활동",
"map_searchHint": "노드 이름 또는 ID 검색",
"map_online": "온라인",
"scanner_bluetoothWebUnsupported": "브라우저에서는 블루투스를 사용할 수 없습니다. 대신 USB로 연결하세요.",
"map_recent": "최근",
"map_stale": "오래됨",
"map_visible": "보임",
"map_hidden": "숨김",
"map_centerOnNode": "노드 중심으로 보기",
"map_details": "세부 정보",
"map_noGps": "GPS 없음",
"map_noResults": "일치하는 노드가 없습니다.",
"pathMap_viewSingle": "단일",
"pathMap_viewCombined": "결합",
"pathMap_play": "재생",
"pathMap_pause": "일시 정지",
"pathMap_replay": "다시 재생",
"pathMap_stepBack": "이전 홉",
"pathMap_stepForward": "다음 홉",
"pathMap_animationOn": "패킷 애니메이션 표시",
"pathMap_animationOff": "패킷 애니메이션 숨기기",
"pathMap_hopOf": "{current}/{total} 홉",
"pathMap_observedPaths": "관찰된 경로: {count}",
"pathMap_primary": "주 경로",
"pathMap_alternate": "대체 {index}",
"pathMap_hopCount": "{count, plural, =1{1 홉} other{{count} 홉}}",
"pathMap_legendShared": "공유 구간",
"pathMap_legendEstimated": "추정 구간",
"pathMap_sharedNodeCount": "{count}개의 경로에서 사용됨",
"pathMap_partialAnimation": "{count, plural, =1{1 홉은 위치가 없어 표시된 경로가 일부입니다} other{{count} 홉은 위치가 없어 표시된 경로가 일부입니다}}",
"pathMap_showAllPaths": "모두 보기",
"pathMap_hidePath": "경로 숨기기",
"pathMap_showPath": "경로 표시",
"pathMap_collapsePanel": "패널 접기",
"pathMap_expandPanel": "패널 펼치기",
"pathMap_noLocation": "위치 없음",
"pathMap_followPacket": "패킷 고정",
"pathMap_unfollowPacket": "패킷 고정 해제",
"pathMap_gpsCount": "{confirmed}/{total} GPS",
"@channels_cyr2latSettingsDialogWrongJSON": {
"placeholders": {
"error": {}
}
},
"@channels_via": {
"placeholders": {
"path": {
"type": "String"
}
}
},
"@settings_cyr2latProfileDeleteDscr": {
"placeholders": {
"name": {
"type": "String"
}
}
},
"@telemetry_altitudeValue": {
"placeholders": {
"meters": {
"type": "String"
}
}
},
"@telemetry_analogValue": {
"placeholders": {
"value": {
"type": "String"
}
}
},
"@telemetry_concentrationValue": {
"placeholders": {
"ppm": {
"type": "String"
}
}
},
"@telemetry_directionValue": {
"placeholders": {
"degrees": {
"type": "String"
}
}
},
"@telemetry_distanceValue": {
"placeholders": {
"meters": {
"type": "String"
}
}
},
"@telemetry_energyValue": {
"placeholders": {
"kilowattHours": {
"type": "String"
}
}
},
"@telemetry_frequencyValue": {
"placeholders": {
"hertz": {
"type": "String"
}
}
},
"@telemetry_luminosityValue": {
"placeholders": {
"lux": {
"type": "String"
}
}
},
"@telemetry_percentageValue": {
"placeholders": {
"percent": {
"type": "String"
}
}
},
"@telemetry_powerValue": {
"placeholders": {
"watts": {
"type": "String"
}
}
},
"@telemetry_pressureValue": {
"placeholders": {
"hpa": {
"type": "String"
}
}
}
} }
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+518 -132
View File
@@ -92,6 +92,24 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get common_disable => 'Disable'; String get common_disable => 'Disable';
@override
String get common_undo => 'Undo';
@override
String get messageStatus_sent => 'Sent';
@override
String get messageStatus_delivered => 'Delivered';
@override
String get messageStatus_pending => 'Sending';
@override
String get messageStatus_failed => 'Failed to send';
@override
String get messageStatus_repeated => 'Heard repeated';
@override @override
String get common_reboot => 'Reboot'; String get common_reboot => 'Reboot';
@@ -111,6 +129,12 @@ class AppLocalizationsEn extends AppLocalizations {
return '$percent%'; return '$percent%';
} }
@override
String get common_autoRefresh => 'Autorefresh';
@override
String get common_interval => 'Interval';
@override @override
String get scanner_title => 'MeshCore Open'; String get scanner_title => 'MeshCore Open';
@@ -291,6 +315,10 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get scanner_enableBluetooth => 'Enable Bluetooth'; String get scanner_enableBluetooth => 'Enable Bluetooth';
@override
String get scanner_bluetoothWebUnsupported =>
'Bluetooth isn\'t available in the browser. Connect over USB instead.';
@override @override
String get device_quickSwitch => 'Quick switch'; String get device_quickSwitch => 'Quick switch';
@@ -771,11 +799,6 @@ class AppLocalizationsEn extends AppLocalizations {
String get appSettings_maxMessageRetriesSubtitle => String get appSettings_maxMessageRetriesSubtitle =>
'Number of retry attempts before marking a message as failed'; 'Number of retry attempts before marking a message as failed';
@override
String path_routeWeight(String weight, String max) {
return '$weight/$max';
}
@override @override
String get appSettings_battery => 'Battery'; String get appSettings_battery => 'Battery';
@@ -857,6 +880,28 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get appSettings_lastWeek => 'Last week'; String get appSettings_lastWeek => 'Last week';
@override
String get appSettings_rasterTileSource => 'Raster Tile Source';
@override
String get appSettings_stadiaEndpoint => 'Stadia Endpoint';
@override
String get appSettings_stadiaApiKey => 'Stadia API Key';
@override
String get appSettings_stadiaApiKeyRequired =>
'Required for Stadia Maps usage';
@override
String appSettings_stadiaApiKeyConfigured(String maskedKey) {
return 'Configured: $maskedKey';
}
@override
String get appSettings_stadiaApiKeyDialogDescription =>
'Enter your Stadia Maps API key. This app uses it for raster tile requests.';
@override @override
String get appSettings_offlineMapCache => 'Offline Map Cache'; String get appSettings_offlineMapCache => 'Offline Map Cache';
@@ -975,6 +1020,15 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get contacts_newGroup => 'New Group'; String get contacts_newGroup => 'New Group';
@override
String get contacts_moreOptions => 'More options';
@override
String get contacts_searchOpen => 'Search contacts';
@override
String get contacts_searchClose => 'Close search';
@override @override
String get contacts_groupName => 'Group name'; String get contacts_groupName => 'Group name';
@@ -1446,34 +1500,6 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get debugFrame_hexDump => 'Hex Dump:'; String get debugFrame_hexDump => 'Hex Dump:';
@override
String get chat_pathManagement => 'Path Management';
@override
String get chat_ShowAllPaths => 'Show all paths';
@override
String get chat_routingMode => 'Routing mode';
@override
String get chat_autoUseSavedPath => 'Auto (use saved path)';
@override
String get chat_forceFloodMode => 'Force Flood Mode';
@override
String get chat_recentAckPaths => 'Recent ACK Paths (tap to use):';
@override
String get chat_pathHistoryFull =>
'Path history is full. Remove entries to add new ones.';
@override
String get chat_hopSingular => 'hop';
@override
String get chat_hopPlural => 'hops';
@override @override
String chat_hopsCount(int count) { String chat_hopsCount(int count) {
String _temp0 = intl.Intl.pluralLogic( String _temp0 = intl.Intl.pluralLogic(
@@ -1485,12 +1511,6 @@ class AppLocalizationsEn extends AppLocalizations {
return '$count $_temp0'; return '$count $_temp0';
} }
@override
String get chat_successes => 'successes';
@override
String get chat_score => 'Score';
@override @override
String get chat_removePath => 'Remove path'; String get chat_removePath => 'Remove path';
@@ -1498,50 +1518,144 @@ class AppLocalizationsEn extends AppLocalizations {
String get chat_noPathHistoryYet => String get chat_noPathHistoryYet =>
'No path history yet.\nSend a message to discover paths.'; 'No path history yet.\nSend a message to discover paths.';
@override
String get chat_pathActions => 'Path Actions:';
@override
String get chat_setCustomPath => 'Set Custom Path';
@override
String get chat_setCustomPathSubtitle => 'Manually specify routing path';
@override
String get chat_clearPath => 'Clear Path';
@override
String get chat_clearPathSubtitle => 'Force rediscovery on next send';
@override @override
String get chat_pathCleared => String get chat_pathCleared =>
'Path cleared. Next message will rediscover route.'; 'Path cleared. Next message will rediscover route.';
@override
String get chat_floodModeSubtitle => 'Use routing toggle in app bar';
@override
String get chat_floodModeEnabled =>
'Flood mode enabled. Toggle back via routing icon in app bar.';
@override @override
String get chat_fullPath => 'Full Path'; String get chat_fullPath => 'Full Path';
@override @override
String get chat_pathDetailsNotAvailable => String get routing_title => 'Routing';
'Path details not available yet. Try sending a message to refresh.';
@override @override
String chat_pathSetHops(int hopCount, String status) { String get routing_modeAuto => 'Auto';
String _temp0 = intl.Intl.pluralLogic(
hopCount, @override
locale: localeName, String get routing_modeFlood => 'Flood';
other: 'hops',
one: 'hop', @override
); String get routing_modeManual => 'Manual';
return 'Path set: $hopCount $_temp0 - $status';
@override
String get routing_modeAutoHint =>
'Picks the best known path automatically, flooding when none is known.';
@override
String get routing_modeFloodHint =>
'Broadcasts through every repeater. Most reliable, but uses more airtime.';
@override
String get routing_modeManualHint =>
'Always sends along the exact path you set.';
@override
String get routing_currentRoute => 'Current route';
@override
String get routing_directNoHops => 'Direct — no repeater hops';
@override
String get routing_noPathYet =>
'No path yet. The next message floods until a route is discovered.';
@override
String get routing_floodBroadcast => 'Broadcast through every repeater';
@override
String get routing_editPath => 'Edit path';
@override
String get routing_forgetPath => 'Forget path';
@override
String get routing_knownPaths => 'Known paths';
@override
String get routing_knownPathsHint => 'Tap a path to switch to it.';
@override
String get routing_inUse => 'In use';
@override
String get routing_qualityStrong => 'Strong first hop';
@override
String get routing_qualityGood => 'Good first hop';
@override
String get routing_qualityFair => 'Fair first hop';
@override
String get routing_qualityWorked => 'Has delivered';
@override
String get routing_qualityFlood => 'Heard via flood';
@override
String get routing_qualityUntested => 'Untested';
@override
String routing_lastWorked(String when) {
return 'worked $when';
} }
@override
String get routing_neverWorked => 'never confirmed';
@override
String routing_deliveryCounts(int successes, int failures) {
return '$successes delivered, $failures failed';
}
@override
String get routing_floodDelivery => 'Flood delivery';
@override
String get pathEditor_title => 'Build Path';
@override
String pathEditor_hopCounter(int count) {
return '$count of 64 hops';
}
@override
String get pathEditor_noHops =>
'No hops yet. Tap repeaters below to add them in order, or save with no hops to send direct.';
@override
String get pathEditor_addHops => 'Add hops in order';
@override
String get pathEditor_searchRepeaters => 'Search repeaters';
@override
String get pathEditor_advancedHex => 'Advanced: raw hex path';
@override
String get pathEditor_hexLabel => 'Hex prefixes';
@override
String get pathEditor_hexHelper =>
'Two hex characters per hop, separated by commas';
@override
String pathEditor_invalidTokens(String tokens) {
return 'Invalid: $tokens';
}
@override
String get pathEditor_tooManyHops => 'Maximum 64 hops';
@override
String get pathEditor_usePath => 'Use this path';
@override
String get pathEditor_removeHop => 'Remove hop';
@override
String get pathEditor_unknownHop => 'Unknown repeater';
@override @override
String get chat_pathSavedLocally => 'Saved locally. Connect to sync.'; String get chat_pathSavedLocally => 'Saved locally. Connect to sync.';
@@ -1615,6 +1729,39 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get map_title => 'Node Map'; String get map_title => 'Node Map';
@override
String get map_searchHint => 'Search node name or ID';
@override
String get map_activity => 'Activity';
@override
String get map_online => 'Online';
@override
String get map_recent => 'Recent';
@override
String get map_stale => 'Stale';
@override
String get map_visible => 'Visible';
@override
String get map_hidden => 'Hidden';
@override
String get map_centerOnNode => 'Center on node';
@override
String get map_details => 'Details';
@override
String get map_noGps => 'No GPS';
@override
String get map_noResults => 'No matching nodes';
@override @override
String get map_lineOfSight => 'Line of Sight'; String get map_lineOfSight => 'Line of Sight';
@@ -1873,6 +2020,42 @@ class AppLocalizationsEn extends AppLocalizations {
return 'Failed downloads: $count'; return 'Failed downloads: $count';
} }
@override
String get mapCache_cachedTilesLabel => 'Cached tiles';
@override
String get mapCache_cachedTileSummaryLabel => 'Cached tile summary';
@override
String mapCache_bulkDownloadDisabledForSource(String source) {
return 'Offline bulk downloads are disabled for $source.';
}
@override
String mapCache_bulkDownloadDisabledInConfig(String source) {
return 'Offline bulk downloads are disabled for $source in this app configuration.';
}
@override
String mapCache_summarySource(String source) {
return 'Source: $source';
}
@override
String mapCache_summaryCachedTilesForSource(int count) {
return 'Cached tiles for source: $count';
}
@override
String mapCache_summaryCachedInSelection(int count) {
return 'Cached in selected area/zoom: $count';
}
@override
String mapCache_summaryApproxCacheSize(String size) {
return 'Approx cache size: $size';
}
@override @override
String mapCache_boundsLabel( String mapCache_boundsLabel(
String north, String north,
@@ -2003,64 +2186,12 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get common_clear => 'Clear'; String get common_clear => 'Clear';
@override
String path_currentPath(String path) {
return 'Current path: $path';
}
@override
String path_usingHopsPath(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'hops',
one: 'hop',
);
return 'Using $count $_temp0 path';
}
@override
String get path_enterCustomPath => 'Enter Custom Path';
@override @override
String get path_currentPathLabel => 'Current path'; String get path_currentPathLabel => 'Current path';
@override
String get path_hexPrefixInstructions =>
'Enter 2-character hex prefixes for each hop, separated by commas.';
@override
String get path_hexPrefixExample =>
'Example: A1,F2,3C (each node uses first byte of its public key)';
@override
String get path_labelHexPrefixes => 'Path (hex prefixes)';
@override
String get path_helperMaxHops =>
'Max 64 hops. Each prefix is 2 hex characters (1 byte)';
@override
String get path_selectFromContacts => 'Or select from contacts:';
@override @override
String get path_noRepeatersFound => 'No repeaters or room servers found.'; String get path_noRepeatersFound => 'No repeaters or room servers found.';
@override
String get path_customPathsRequire =>
'Custom paths require intermediate hops that can relay messages.';
@override
String path_invalidHexPrefixes(String prefixes) {
return 'Invalid hex prefixes: $prefixes';
}
@override
String get path_tooLong => 'Path too long. Maximum 64 hops allowed.';
@override
String get path_setPath => 'Set Path';
@override @override
String get repeater_management => 'Repeater Management'; String get repeater_management => 'Repeater Management';
@@ -2124,15 +2255,6 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get repeater_routingMode => 'Routing mode'; String get repeater_routingMode => 'Routing mode';
@override
String get repeater_autoUseSavedPath => 'Auto (use saved path)';
@override
String get repeater_forceFloodMode => 'Force Flood Mode';
@override
String get repeater_pathManagement => 'Path management';
@override @override
String get repeater_refresh => 'Refresh'; String get repeater_refresh => 'Refresh';
@@ -3213,6 +3335,139 @@ class AppLocalizationsEn extends AppLocalizations {
return '$celsius°C / $fahrenheit°F'; return '$celsius°C / $fahrenheit°F';
} }
@override
String get telemetry_digitalInputLabel => 'Digital Input';
@override
String get telemetry_digitalOutputLabel => 'Digital Output';
@override
String get telemetry_analogInputLabel => 'Analog Input';
@override
String get telemetry_analogOutputLabel => 'Analog Output';
@override
String get telemetry_genericLabel => 'Generic Sensor';
@override
String get telemetry_luminosityLabel => 'Luminosity';
@override
String get telemetry_presenceLabel => 'Presence';
@override
String get telemetry_humidityLabel => 'Humidity';
@override
String get telemetry_accelerometerLabel => 'Accelerometer';
@override
String get telemetry_pressureLabel => 'Pressure';
@override
String get telemetry_altitudeLabel => 'Altitude';
@override
String get telemetry_frequencyLabel => 'Frequency';
@override
String get telemetry_percentageLabel => 'Percentage';
@override
String get telemetry_concentrationLabel => 'Concentration';
@override
String get telemetry_powerLabel => 'Power';
@override
String get telemetry_distanceLabel => 'Distance';
@override
String get telemetry_energyLabel => 'Energy';
@override
String get telemetry_directionLabel => 'Direction';
@override
String get telemetry_timeLabel => 'Time';
@override
String get telemetry_gyrometerLabel => 'Gyrometer';
@override
String get telemetry_colourLabel => 'Colour';
@override
String get telemetry_gpsLabel => 'GPS';
@override
String get telemetry_switchLabel => 'Switch';
@override
String get telemetry_polylineLabel => 'Polyline';
@override
String telemetry_altitudeValue(String meters) {
return '$meters m';
}
@override
String telemetry_frequencyValue(String hertz) {
return '$hertz Hz';
}
@override
String telemetry_pressureValue(String hpa) {
return '$hpa hPa';
}
@override
String telemetry_luminosityValue(String lux) {
return '$lux lx';
}
@override
String telemetry_powerValue(String watts) {
return '$watts W';
}
@override
String telemetry_distanceValue(String meters) {
return '$meters m';
}
@override
String telemetry_energyValue(String kilowattHours) {
return '$kilowattHours kWh';
}
@override
String telemetry_directionValue(String degrees) {
return '$degrees°';
}
@override
String telemetry_concentrationValue(String ppm) {
return '$ppm ppm';
}
@override
String telemetry_percentageValue(String percent) {
return '$percent%';
}
@override
String telemetry_analogValue(String value) {
return '$value';
}
@override
String get telemetry_autoFetchQuantity => 'Requests quantity';
@override
String get telemetry_error => 'Unable to retrieve data';
@override @override
String get neighbors_receivedData => 'Received Neighbors Data'; String get neighbors_receivedData => 'Received Neighbors Data';
@@ -4221,4 +4476,135 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get contact_typeUnknown => 'Unknown'; String get contact_typeUnknown => 'Unknown';
@override
String get map_zoomIn => 'Zoom in';
@override
String get map_zoomOut => 'Zoom out';
@override
String get map_centerMap => 'Center map';
@override
String get chrome_bluetoothRequiresChromium =>
'Web Bluetooth requires a Chromium browser';
@override
String channels_communityShortId(String id) {
return 'ID: $id...';
}
@override
String get pathTrace_legendGpsConfirmed => 'GPS confirmed';
@override
String get pathTrace_legendInferred => 'Inferred position';
@override
String get pathMap_viewSingle => 'Single';
@override
String get pathMap_viewCombined => 'Combined';
@override
String get pathMap_play => 'Play';
@override
String get pathMap_pause => 'Pause';
@override
String get pathMap_replay => 'Replay';
@override
String get pathMap_stepBack => 'Previous hop';
@override
String get pathMap_stepForward => 'Next hop';
@override
String get pathMap_animationOn => 'Show packet animation';
@override
String get pathMap_animationOff => 'Hide packet animation';
@override
String pathMap_hopOf(int current, int total) {
return 'Hop $current of $total';
}
@override
String pathMap_observedPaths(int count) {
return 'Observed paths: $count';
}
@override
String get pathMap_primary => 'Primary';
@override
String pathMap_alternate(int index) {
return 'Alt $index';
}
@override
String pathMap_hopCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count hops',
one: '1 hop',
);
return '$_temp0';
}
@override
String pathMap_gpsCount(int confirmed, int total) {
return '$confirmed/$total GPS';
}
@override
String get pathMap_legendShared => 'Shared segment';
@override
String get pathMap_legendEstimated => 'Estimated segment';
@override
String pathMap_sharedNodeCount(int count) {
return 'Used by $count paths';
}
@override
String pathMap_partialAnimation(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count hops have no location — the shown path is partial',
one: '1 hop has no location — the shown path is partial',
);
return '$_temp0';
}
@override
String get pathMap_showAllPaths => 'Show all';
@override
String get pathMap_hidePath => 'Hide path';
@override
String get pathMap_showPath => 'Show path';
@override
String get pathMap_collapsePanel => 'Collapse panel';
@override
String get pathMap_expandPanel => 'Expand panel';
@override
String get pathMap_noLocation => 'No location';
@override
String get pathMap_followPacket => 'Lock view to packet';
@override
String get pathMap_unfollowPacket => 'Unlock view from packet';
} }
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+519 -136
View File
@@ -92,6 +92,24 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get common_disable => 'Uitschakelen'; String get common_disable => 'Uitschakelen';
@override
String get common_undo => 'Achterhalen/Annuleren';
@override
String get messageStatus_sent => 'Verzonden';
@override
String get messageStatus_delivered => 'Leverd';
@override
String get messageStatus_pending => 'Verzenden';
@override
String get messageStatus_failed => 'Niet verzonden';
@override
String get messageStatus_repeated => 'Hearsay, herhaald';
@override @override
String get common_reboot => 'Herstarten'; String get common_reboot => 'Herstarten';
@@ -111,6 +129,12 @@ class AppLocalizationsNl extends AppLocalizations {
return '$percent%'; return '$percent%';
} }
@override
String get common_autoRefresh => 'Automatisch vernieuwen';
@override
String get common_interval => 'Tijdsinterval';
@override @override
String get scanner_title => 'MeshCore Open'; String get scanner_title => 'MeshCore Open';
@@ -293,6 +317,10 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get scanner_enableBluetooth => 'Activeer Bluetooth'; String get scanner_enableBluetooth => 'Activeer Bluetooth';
@override
String get scanner_bluetoothWebUnsupported =>
'Bluetooth is niet beschikbaar in de browser. Verbind dan via USB.';
@override @override
String get device_quickSwitch => 'Snelle overschakeling'; String get device_quickSwitch => 'Snelle overschakeling';
@@ -780,11 +808,6 @@ class AppLocalizationsNl extends AppLocalizations {
String get appSettings_maxMessageRetriesSubtitle => String get appSettings_maxMessageRetriesSubtitle =>
'Aantal pogingen om een bericht opnieuw te versturen voordat het als mislukt wordt gemarkeerd'; 'Aantal pogingen om een bericht opnieuw te versturen voordat het als mislukt wordt gemarkeerd';
@override
String path_routeWeight(String weight, String max) {
return '$weight/$max';
}
@override @override
String get appSettings_battery => 'Batterij'; String get appSettings_battery => 'Batterij';
@@ -866,6 +889,28 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get appSettings_lastWeek => 'Afgelopen week'; String get appSettings_lastWeek => 'Afgelopen week';
@override
String get appSettings_rasterTileSource => 'Rastertegelbron';
@override
String get appSettings_stadiaEndpoint => 'Stadia-eindpunt';
@override
String get appSettings_stadiaApiKey => 'Stadia API-sleutel';
@override
String get appSettings_stadiaApiKeyRequired =>
'Vereist voor gebruik van Stadia Maps';
@override
String appSettings_stadiaApiKeyConfigured(String maskedKey) {
return 'Geconfigureerd: $maskedKey';
}
@override
String get appSettings_stadiaApiKeyDialogDescription =>
'Voer je Stadia Maps API-sleutel in. De app gebruikt die voor rastertegelverzoeken.';
@override @override
String get appSettings_offlineMapCache => 'Offline Kaartcache'; String get appSettings_offlineMapCache => 'Offline Kaartcache';
@@ -985,6 +1030,15 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get contacts_newGroup => 'Nieuwe Groep'; String get contacts_newGroup => 'Nieuwe Groep';
@override
String get contacts_moreOptions => 'Meer opties';
@override
String get contacts_searchOpen => 'Zoek contactpersonen';
@override
String get contacts_searchClose => 'Zoeken';
@override @override
String get contacts_groupName => 'Groepnaam'; String get contacts_groupName => 'Groepnaam';
@@ -1462,34 +1516,6 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get debugFrame_hexDump => 'Hex-dump:'; String get debugFrame_hexDump => 'Hex-dump:';
@override
String get chat_pathManagement => 'Beheer van Paden';
@override
String get chat_ShowAllPaths => 'Toon alle paden';
@override
String get chat_routingMode => 'Routeerwijze';
@override
String get chat_autoUseSavedPath => 'Automatisch (gebruik opgeslagen pad)';
@override
String get chat_forceFloodMode => 'Dwing Floodsmodus';
@override
String get chat_recentAckPaths => 'Recente ACK Paden (tik om te gebruiken):';
@override
String get chat_pathHistoryFull =>
'De voorgeschiedenis is vol. Verwijder vermeldingen om er nieuwe aan toe te voegen.';
@override
String get chat_hopSingular => 'Hop';
@override
String get chat_hopPlural => 'hoppen';
@override @override
String chat_hopsCount(int count) { String chat_hopsCount(int count) {
String _temp0 = intl.Intl.pluralLogic( String _temp0 = intl.Intl.pluralLogic(
@@ -1501,12 +1527,6 @@ class AppLocalizationsNl extends AppLocalizations {
return '$count $_temp0'; return '$count $_temp0';
} }
@override
String get chat_successes => 'Succesvol';
@override
String get chat_score => 'Score';
@override @override
String get chat_removePath => 'Pad verwijderen'; String get chat_removePath => 'Pad verwijderen';
@@ -1514,52 +1534,144 @@ class AppLocalizationsNl extends AppLocalizations {
String get chat_noPathHistoryYet => String get chat_noPathHistoryYet =>
'Geen geschiedenis van paden nog beschikbaar.\nVerzend een bericht om paden te ontdekken.'; 'Geen geschiedenis van paden nog beschikbaar.\nVerzend een bericht om paden te ontdekken.';
@override
String get chat_pathActions => 'Padacties:';
@override
String get chat_setCustomPath => 'Stel aangepaste pad in';
@override
String get chat_setCustomPathSubtitle => 'Handmatig routepad specificeren';
@override
String get chat_clearPath => 'Duidelijke Pad';
@override
String get chat_clearPathSubtitle =>
'Dwing herontdekking bij volgende verzending';
@override @override
String get chat_pathCleared => String get chat_pathCleared =>
'Pad is vrijgegeven. Volgende bericht herontdekt route.'; 'Pad is vrijgegeven. Volgende bericht herontdekt route.';
@override
String get chat_floodModeSubtitle =>
'Gebruik de route-schakelaar in de app-balk';
@override
String get chat_floodModeEnabled =>
'Floodmodus is ingeschakeld. Schakel dit uit via het route-icoon in de app-balk.';
@override @override
String get chat_fullPath => 'Volledige Pad'; String get chat_fullPath => 'Volledige Pad';
@override @override
String get chat_pathDetailsNotAvailable => String get routing_title => 'Routeplanning';
'De paddetails zijn nog niet beschikbaar. Probeer een bericht te sturen om te vernieuwen.';
@override @override
String chat_pathSetHops(int hopCount, String status) { String get routing_modeAuto => 'Auto';
String _temp0 = intl.Intl.pluralLogic(
hopCount, @override
locale: localeName, String get routing_modeFlood => 'Overstroming';
other: 'hops',
one: 'hop', @override
); String get routing_modeManual => 'Handleiding';
return 'Pad ingesteld: $hopCount $_temp0 - $status';
@override
String get routing_modeAutoHint =>
'Selecteert automatisch het bekendste pad, en gebruikt een flood-algoritme als er geen bekend pad is.';
@override
String get routing_modeFloodHint =>
'Uitzendingen via elke zender. De meest betrouwbare methode, maar vereist meer uitzendtijd.';
@override
String get routing_modeManualHint =>
'Stuurt altijd de exacte route die u heeft aangegeven.';
@override
String get routing_currentRoute => 'Huidige route';
@override
String get routing_directNoHops => 'Direct zonder tussenliggende schakels';
@override
String get routing_noPathYet =>
'Er is nog geen route gevonden. De berichten blijven binnenkomen totdat een route is ontdekt.';
@override
String get routing_floodBroadcast => 'Uitgestoten via elke zender.';
@override
String get routing_editPath => 'Pad bewerken';
@override
String get routing_forgetPath => 'Vergeet het pad';
@override
String get routing_knownPaths => 'Bekende routes';
@override
String get routing_knownPathsHint => 'Maak een route om er naartoe te gaan.';
@override
String get routing_inUse => 'In gebruik';
@override
String get routing_qualityStrong => 'Sterke eerste sprong';
@override
String get routing_qualityGood => 'Een goede eerste stap';
@override
String get routing_qualityFair => 'Een goede eerste hop';
@override
String get routing_qualityWorked => 'Is geleverd';
@override
String get routing_qualityFlood => 'Hears via een overstroming';
@override
String get routing_qualityUntested => 'Niet getest';
@override
String routing_lastWorked(String when) {
return 'worked $when';
} }
@override
String get routing_neverWorked => 'nooit bevestigd';
@override
String routing_deliveryCounts(int successes, int failures) {
return '$successes zijn behaald, $failures zijn mislukt';
}
@override
String get routing_floodDelivery => 'Levering bij overstroming';
@override
String get pathEditor_title => 'Pad creëren';
@override
String pathEditor_hopCounter(int count) {
return '$count van 64 hopgranen';
}
@override
String get pathEditor_noHops =>
'Er zijn nog geen hop toegevoegd. Klik op de onderstaande knoppen om ze in de juiste volgorde toe te voegen, of sla de bestelling op zonder hop om deze direct te versturen.';
@override
String get pathEditor_addHops => 'Voeg hop toe in de juiste volgorde.';
@override
String get pathEditor_searchRepeaters => 'Zoek naar herhaaldelijke zenders';
@override
String get pathEditor_advancedHex => 'Geavanceerd: ruwe hex-pad';
@override
String get pathEditor_hexLabel => 'Hex-voorkanten';
@override
String get pathEditor_hexHelper =>
'Twee hex-tekens per stap, gescheiden door komma\'s';
@override
String pathEditor_invalidTokens(String tokens) {
return 'Ongeldig: $tokens';
}
@override
String get pathEditor_tooManyHops => 'Maximaal 64 hopken';
@override
String get pathEditor_usePath => 'Gebruik deze route.';
@override
String get pathEditor_removeHop => 'Verwijder de hop';
@override
String get pathEditor_unknownHop => 'Onbekend type zender';
@override @override
String get chat_pathSavedLocally => String get chat_pathSavedLocally =>
'Opgeslagen lokaal. Verbinden om te synchroniseren.'; 'Opgeslagen lokaal. Verbinden om te synchroniseren.';
@@ -1635,6 +1747,39 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get map_title => 'Kaart van de knopen'; String get map_title => 'Kaart van de knopen';
@override
String get map_searchHint => 'Zoek op naam of ID van de knoop';
@override
String get map_activity => 'Activiteit';
@override
String get map_online => 'Online';
@override
String get map_recent => 'Recent';
@override
String get map_stale => 'Verouderd';
@override
String get map_visible => 'Zichtbaar';
@override
String get map_hidden => 'Verborgen';
@override
String get map_centerOnNode => 'Centreer op node';
@override
String get map_details => 'Details';
@override
String get map_noGps => 'Geen GPS';
@override
String get map_noResults => 'Geen overeenkomende nodes';
@override @override
String get map_lineOfSight => 'Zichtlijn'; String get map_lineOfSight => 'Zichtlijn';
@@ -1898,6 +2043,42 @@ class AppLocalizationsNl extends AppLocalizations {
return 'Mislukte downloads: $count'; return 'Mislukte downloads: $count';
} }
@override
String get mapCache_cachedTilesLabel => 'Cached tiles';
@override
String get mapCache_cachedTileSummaryLabel => 'Cached tile summary';
@override
String mapCache_bulkDownloadDisabledForSource(String source) {
return 'Offline bulk downloads are disabled for $source.';
}
@override
String mapCache_bulkDownloadDisabledInConfig(String source) {
return 'Offline bulk downloads are disabled for $source in this app configuration.';
}
@override
String mapCache_summarySource(String source) {
return 'Source: $source';
}
@override
String mapCache_summaryCachedTilesForSource(int count) {
return 'Cached tiles for source: $count';
}
@override
String mapCache_summaryCachedInSelection(int count) {
return 'Cached in selected area/zoom: $count';
}
@override
String mapCache_summaryApproxCacheSize(String size) {
return 'Approx cache size: $size';
}
@override @override
String mapCache_boundsLabel( String mapCache_boundsLabel(
String north, String north,
@@ -2028,65 +2209,12 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get common_clear => 'Schoonmaken'; String get common_clear => 'Schoonmaken';
@override
String path_currentPath(String path) {
return 'Huidige pad: $path';
}
@override
String path_usingHopsPath(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'hops',
one: 'hop',
);
return 'Gebruik $count $_temp0 pad';
}
@override
String get path_enterCustomPath => 'Voer aangepaste pad in';
@override @override
String get path_currentPathLabel => 'Huidige pad'; String get path_currentPathLabel => 'Huidige pad';
@override
String get path_hexPrefixInstructions =>
'Voer 2-letter hex-voorgiffen voor elke hop in, gescheiden door komma\'s.';
@override
String get path_hexPrefixExample =>
'Voorbeeld: A1,F2,3C (elke node gebruikt het eerste byte van zijn openbare sleutel)';
@override
String get path_labelHexPrefixes => 'Pad (hex-voorkeursletters)';
@override
String get path_helperMaxHops =>
'Maximaal 64 sprongen. Elke prefix is 2 hexadecimale tekens (1 byte)';
@override
String get path_selectFromContacts => 'Of select contacten:';
@override @override
String get path_noRepeatersFound => 'Geen repeaters of roomservers gevonden.'; String get path_noRepeatersFound => 'Geen repeaters of roomservers gevonden.';
@override
String get path_customPathsRequire =>
'Aangepaste paden vereisen tussentse overstappen die berichten kunnen doorgeven.';
@override
String path_invalidHexPrefixes(String prefixes) {
return 'Ongeldige hex-voorkeursletters: $prefixes';
}
@override
String get path_tooLong =>
'Pad is te lang. Maximaal 64 sprongen zijn toegestaan.';
@override
String get path_setPath => 'Stel Pad in';
@override @override
String get repeater_management => 'Beheer Repeaters'; String get repeater_management => 'Beheer Repeaters';
@@ -2151,16 +2279,6 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get repeater_routingMode => 'Routeerwijze'; String get repeater_routingMode => 'Routeerwijze';
@override
String get repeater_autoUseSavedPath =>
'Automatisch (gebruik opgeslagen pad)';
@override
String get repeater_forceFloodMode => 'Dwing Floodmodus Af';
@override
String get repeater_pathManagement => 'Beheer van paden';
@override @override
String get repeater_refresh => 'Vernieuwen'; String get repeater_refresh => 'Vernieuwen';
@@ -3256,6 +3374,139 @@ class AppLocalizationsNl extends AppLocalizations {
return '$celsius°C / $fahrenheit°F'; return '$celsius°C / $fahrenheit°F';
} }
@override
String get telemetry_digitalInputLabel => 'Digitale ingang';
@override
String get telemetry_digitalOutputLabel => 'Digitale uitgang';
@override
String get telemetry_analogInputLabel => 'Analoge ingang';
@override
String get telemetry_analogOutputLabel => 'Analoge uitgang';
@override
String get telemetry_genericLabel => 'Algemene sensor';
@override
String get telemetry_luminosityLabel => 'Lichtsterkte';
@override
String get telemetry_presenceLabel => 'Aanwezigheid';
@override
String get telemetry_humidityLabel => 'Luchtvochtigheid';
@override
String get telemetry_accelerometerLabel => 'Versnellingsmeter';
@override
String get telemetry_pressureLabel => 'Druk';
@override
String get telemetry_altitudeLabel => 'Hoogte';
@override
String get telemetry_frequencyLabel => 'Frequentie';
@override
String get telemetry_percentageLabel => 'Percentage';
@override
String get telemetry_concentrationLabel => 'Concentratie';
@override
String get telemetry_powerLabel => 'Vermogen';
@override
String get telemetry_distanceLabel => 'Afstand';
@override
String get telemetry_energyLabel => 'Energie';
@override
String get telemetry_directionLabel => 'Richting';
@override
String get telemetry_timeLabel => 'Tijd';
@override
String get telemetry_gyrometerLabel => 'Gyrometer';
@override
String get telemetry_colourLabel => 'Kleur';
@override
String get telemetry_gpsLabel => 'GPS';
@override
String get telemetry_switchLabel => 'Schakelaar';
@override
String get telemetry_polylineLabel => 'Polylijn';
@override
String telemetry_altitudeValue(String meters) {
return '$meters m';
}
@override
String telemetry_frequencyValue(String hertz) {
return '$hertz Hz';
}
@override
String telemetry_pressureValue(String hpa) {
return '$hpa hPa';
}
@override
String telemetry_luminosityValue(String lux) {
return '$lux lx';
}
@override
String telemetry_powerValue(String watts) {
return '$watts W';
}
@override
String telemetry_distanceValue(String meters) {
return '$meters m';
}
@override
String telemetry_energyValue(String kilowattHours) {
return '$kilowattHours kWh';
}
@override
String telemetry_directionValue(String degrees) {
return '$degrees°';
}
@override
String telemetry_concentrationValue(String ppm) {
return '$ppm ppm';
}
@override
String telemetry_percentageValue(String percent) {
return '$percent%';
}
@override
String telemetry_analogValue(String value) {
return '$value';
}
@override
String get telemetry_autoFetchQuantity => 'Aantal aanvragen';
@override
String get telemetry_error => 'Kan gegevens niet ophalen';
@override @override
String get neighbors_receivedData => 'Ontvangen Buurdata'; String get neighbors_receivedData => 'Ontvangen Buurdata';
@@ -4283,4 +4534,136 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get contact_typeUnknown => 'Unknown'; String get contact_typeUnknown => 'Unknown';
@override
String get map_zoomIn => 'Inzoomen';
@override
String get map_zoomOut => 'Inzoomen';
@override
String get map_centerMap => 'Centraal overzicht';
@override
String get chrome_bluetoothRequiresChromium =>
'Web Bluetooth vereist een Chromium-browser.';
@override
String channels_communityShortId(String id) {
return 'ID: $id...';
}
@override
String get pathTrace_legendGpsConfirmed => 'GPS-locatie bevestigd';
@override
String get pathTrace_legendInferred => 'Afgeleide positie';
@override
String get pathMap_viewSingle => 'Enkel';
@override
String get pathMap_viewCombined => 'Gezamenlijk';
@override
String get pathMap_play => 'Afspelen';
@override
String get pathMap_pause => 'Pauze';
@override
String get pathMap_replay => 'Herhalen';
@override
String get pathMap_stepBack => 'Vorige hop';
@override
String get pathMap_stepForward => 'Volgende hop';
@override
String get pathMap_animationOn => 'Pakketanimatie tonen';
@override
String get pathMap_animationOff => 'Pakketanimatie verbergen';
@override
String pathMap_hopOf(int current, int total) {
return 'Hop $current van $total';
}
@override
String pathMap_observedPaths(int count) {
return 'Waargenomen paden: $count';
}
@override
String get pathMap_primary => 'Primair';
@override
String pathMap_alternate(int index) {
return 'Alternatief $index';
}
@override
String pathMap_hopCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count hops',
one: '1 hop',
);
return '$_temp0';
}
@override
String pathMap_gpsCount(int confirmed, int total) {
return '$confirmed/$total GPS';
}
@override
String get pathMap_legendShared => 'Gedeeld segment';
@override
String get pathMap_legendEstimated => 'Geschat segment';
@override
String pathMap_sharedNodeCount(int count) {
return 'Gebruikt door $count paden';
}
@override
String pathMap_partialAnimation(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other:
'$count hops hebben geen locatie — het weergegeven pad is onvolledig',
one: '1 hop heeft geen locatie — het weergegeven pad is onvolledig',
);
return '$_temp0';
}
@override
String get pathMap_showAllPaths => 'Toon alles';
@override
String get pathMap_hidePath => 'Verberg pad';
@override
String get pathMap_showPath => 'Toon pad';
@override
String get pathMap_collapsePanel => 'Paneel inklappen';
@override
String get pathMap_expandPanel => 'Paneel uitklappen';
@override
String get pathMap_noLocation => 'Geen locatie';
@override
String get pathMap_followPacket => 'Weergave vergrendelen op pakket';
@override
String get pathMap_unfollowPacket => 'Weergave ontgrendelen van pakket';
} }
+529 -139
View File
@@ -92,6 +92,24 @@ class AppLocalizationsPl extends AppLocalizations {
@override @override
String get common_disable => 'Wyłącz'; String get common_disable => 'Wyłącz';
@override
String get common_undo => 'Wycofaj';
@override
String get messageStatus_sent => 'Wysłane';
@override
String get messageStatus_delivered => 'Dostarczone';
@override
String get messageStatus_pending => 'Wysyłanie';
@override
String get messageStatus_failed => 'Nie udało się wysłać';
@override
String get messageStatus_repeated => 'Usłyszałem to wielokrotnie';
@override @override
String get common_reboot => 'Uruchom ponownie'; String get common_reboot => 'Uruchom ponownie';
@@ -111,6 +129,12 @@ class AppLocalizationsPl extends AppLocalizations {
return '$percent%'; return '$percent%';
} }
@override
String get common_autoRefresh => 'Automatyczne odświeżanie';
@override
String get common_interval => 'Interwał';
@override @override
String get scanner_title => 'MeshCore wersja open source'; String get scanner_title => 'MeshCore wersja open source';
@@ -298,6 +322,10 @@ class AppLocalizationsPl extends AppLocalizations {
@override @override
String get scanner_enableBluetooth => 'Włącz Bluetooth'; String get scanner_enableBluetooth => 'Włącz Bluetooth';
@override
String get scanner_bluetoothWebUnsupported =>
'Bluetooth nie jest dostępny w przeglądarce. Połącz się przez USB.';
@override @override
String get device_quickSwitch => 'Szybka zmiana'; String get device_quickSwitch => 'Szybka zmiana';
@@ -790,11 +818,6 @@ class AppLocalizationsPl extends AppLocalizations {
String get appSettings_maxMessageRetriesSubtitle => String get appSettings_maxMessageRetriesSubtitle =>
'Liczba prób ponownego wysłania wiadomości przed oznaczaniem jej jako nieudanej'; 'Liczba prób ponownego wysłania wiadomości przed oznaczaniem jej jako nieudanej';
@override
String path_routeWeight(String weight, String max) {
return '$weight/$max';
}
@override @override
String get appSettings_battery => 'Bateria'; String get appSettings_battery => 'Bateria';
@@ -875,6 +898,28 @@ class AppLocalizationsPl extends AppLocalizations {
@override @override
String get appSettings_lastWeek => 'Ostatni tydzień'; String get appSettings_lastWeek => 'Ostatni tydzień';
@override
String get appSettings_rasterTileSource => 'Źródło kafelków rastrowych';
@override
String get appSettings_stadiaEndpoint => 'Punkt końcowy Stadia';
@override
String get appSettings_stadiaApiKey => 'Klucz API Stadia';
@override
String get appSettings_stadiaApiKeyRequired =>
'Wymagane do korzystania ze Stadia Maps';
@override
String appSettings_stadiaApiKeyConfigured(String maskedKey) {
return 'Skonfigurowano: $maskedKey';
}
@override
String get appSettings_stadiaApiKeyDialogDescription =>
'Wprowadź swój klucz API Stadia Maps. Aplikacja używa go do żądań kafelków rastrowych.';
@override @override
String get appSettings_offlineMapCache => 'Pamięć podręczna map offline'; String get appSettings_offlineMapCache => 'Pamięć podręczna map offline';
@@ -1003,6 +1048,15 @@ class AppLocalizationsPl extends AppLocalizations {
@override @override
String get contacts_newGroup => 'Nowa Grupa'; String get contacts_newGroup => 'Nowa Grupa';
@override
String get contacts_moreOptions => 'Więcej opcji';
@override
String get contacts_searchOpen => 'Wyszukaj kontakty';
@override
String get contacts_searchClose => 'Zaawansowane wyszukiwanie';
@override @override
String get contacts_groupName => 'Nazwa grupy'; String get contacts_groupName => 'Nazwa grupy';
@@ -1485,35 +1539,6 @@ class AppLocalizationsPl extends AppLocalizations {
@override @override
String get debugFrame_hexDump => 'Zrzut hex:'; String get debugFrame_hexDump => 'Zrzut hex:';
@override
String get chat_pathManagement => 'Zarządzanie ścieżkami';
@override
String get chat_ShowAllPaths => 'Pokaż wszystkie ścieżki';
@override
String get chat_routingMode => 'Tryb routingu';
@override
String get chat_autoUseSavedPath => 'Automatyczne (użyj zapisanej ścieżki)';
@override
String get chat_forceFloodMode => 'Wymuś tryb zalewowy';
@override
String get chat_recentAckPaths =>
'Ostatnie ścieżki ACK (naciśnij, aby użyć):';
@override
String get chat_pathHistoryFull =>
'Historia ścieżek jest pełna. Usuń wpisy, aby dodać nowe.';
@override
String get chat_hopSingular => 'skok';
@override
String get chat_hopPlural => 'skoki';
@override @override
String chat_hopsCount(int count) { String chat_hopsCount(int count) {
String _temp0 = intl.Intl.pluralLogic( String _temp0 = intl.Intl.pluralLogic(
@@ -1527,12 +1552,6 @@ class AppLocalizationsPl extends AppLocalizations {
return '$count $_temp0'; return '$count $_temp0';
} }
@override
String get chat_successes => 'Sukcesy';
@override
String get chat_score => 'Score';
@override @override
String get chat_removePath => 'Usuń ścieżkę'; String get chat_removePath => 'Usuń ścieżkę';
@@ -1540,52 +1559,148 @@ class AppLocalizationsPl extends AppLocalizations {
String get chat_noPathHistoryYet => String get chat_noPathHistoryYet =>
'Brak historii ścieżek.\nWyślij wiadomość, aby odkryć ścieżki.'; 'Brak historii ścieżek.\nWyślij wiadomość, aby odkryć ścieżki.';
@override
String get chat_pathActions => 'Działania ścieżki:';
@override
String get chat_setCustomPath => 'Ustaw ścieżkę niestandardową';
@override
String get chat_setCustomPathSubtitle => 'Ręcznie określ trasę.';
@override
String get chat_clearPath => 'Wyczyść Ścieżkę';
@override
String get chat_clearPathSubtitle =>
'Wymuś ponowne wyznaczenie trasy przy następnym wysłaniu';
@override @override
String get chat_pathCleared => String get chat_pathCleared =>
'Ścieżka wyczyszczona. Następna wiadomość odnajdzie trasę.'; 'Ścieżka wyczyszczona. Następna wiadomość odnajdzie trasę.';
@override
String get chat_floodModeSubtitle =>
'Użyj przełącznika routingu w pasku narzędzi.';
@override
String get chat_floodModeEnabled =>
'Tryb zalewowy włączony. Przełącz z powrotem ikoną routingu w pasku aplikacji.';
@override @override
String get chat_fullPath => 'Pełna ścieżka'; String get chat_fullPath => 'Pełna ścieżka';
@override @override
String get chat_pathDetailsNotAvailable => String get routing_title => 'Planowanie tras';
'Szczegóły ścieżki jeszcze niedostępne. Spróbuj wysłać wiadomość, aby odświeżyć.';
@override @override
String chat_pathSetHops(int hopCount, String status) { String get routing_modeAuto => 'Samochód';
String _temp0 = intl.Intl.pluralLogic(
hopCount, @override
locale: localeName, String get routing_modeFlood => 'Powódź';
other: 'hops',
one: 'hop', @override
); String get routing_modeManual => 'Instrukcja obsługi';
return 'Ścieżka ustawiona: $hopCount $_temp0 - $status';
@override
String get routing_modeAutoHint =>
'Automatycznie wybiera najpopularniejszą ścieżkę, a w przypadku braku znanej, przechodzi do trybu \"przepływu\".';
@override
String get routing_modeFloodHint =>
'Transmisje za pośrednictwem każdego repeatera. Najbardziej niezawodna metoda, ale zużywa więcej czasu transmisji.';
@override
String get routing_modeManualHint =>
'Zawsze prowadzi dokładnie po trasie, którą określiłeś.';
@override
String get routing_currentRoute => 'Obecna trasa';
@override
String get routing_directNoHops =>
'Bezpośrednio bez pośrednictwa repeaterów';
@override
String get routing_noPathYet =>
'Na razie nie ma żadnej ścieżki. Komunikacja trwa do momentu, gdy zostanie odkryta trasa.';
@override
String get routing_floodBroadcast =>
'Transmisja za pośrednictwem każdego urządzenia powielającego';
@override
String get routing_editPath => 'Edytuj ścieżkę';
@override
String get routing_forgetPath => 'Zapomnij o ścieżce';
@override
String get routing_knownPaths => 'Znane trasy';
@override
String get routing_knownPathsHint =>
'Wybierz ścieżkę, aby przełączyć się na nią.';
@override
String get routing_inUse => 'W użyciu';
@override
String get routing_qualityStrong => 'Silny pierwszy skok';
@override
String get routing_qualityGood => 'Świetny początek';
@override
String get routing_qualityFair => 'Świetny pierwszy krzak';
@override
String get routing_qualityWorked => 'Zostało dostarczone';
@override
String get routing_qualityFlood => 'Usłyszano dzięki doniesieniom';
@override
String get routing_qualityUntested => 'Nieużywany';
@override
String routing_lastWorked(String when) {
return 'pracował $when';
} }
@override
String get routing_neverWorked => 'nigdy nie zostało potwierdzone';
@override
String routing_deliveryCounts(int successes, int failures) {
return '$successes delivered, $failures failed';
}
@override
String get routing_floodDelivery => 'Dostawa w przypadku powodzi';
@override
String get pathEditor_title => 'Stworzenie ścieżki';
@override
String pathEditor_hopCounter(int count) {
return '$count z 64 rodzajów chmielu';
}
@override
String get pathEditor_noHops =>
'Na razie nie dodano żadnych chmielu. Aby dodać je w odpowiedniej kolejności, kliknij w odpowiednie przyciski poniżej, lub zapisz przepis bez chmielu, aby wysłać go bezpośrednio.';
@override
String get pathEditor_addHops => 'Dodawaj chmiel zgodnie z kolejnością.';
@override
String get pathEditor_searchRepeaters => 'Funkcje powtarzania';
@override
String get pathEditor_advancedHex =>
'Zaawansowane: ścieżka w formacie szesnastkowym';
@override
String get pathEditor_hexLabel => 'Prefiksy heksadecymalne';
@override
String get pathEditor_hexHelper =>
'Dwa znaki szesnastkowe na każdym kroku, oddzielone przecinkami';
@override
String pathEditor_invalidTokens(String tokens) {
return 'Nieprawidłowe: $tokens';
}
@override
String get pathEditor_tooManyHops => 'Maksymalnie 64 hopów';
@override
String get pathEditor_usePath => 'Użyj tej ścieżki.';
@override
String get pathEditor_removeHop => 'Usuń dziką psiankę';
@override
String get pathEditor_unknownHop => 'Nieznany repeater';
@override @override
String get chat_pathSavedLocally => String get chat_pathSavedLocally =>
'Zapisano lokalnie. Połącz się, aby zsynchronizować.'; 'Zapisano lokalnie. Połącz się, aby zsynchronizować.';
@@ -1661,6 +1776,39 @@ class AppLocalizationsPl extends AppLocalizations {
@override @override
String get map_title => 'Mapa węzłów'; String get map_title => 'Mapa węzłów';
@override
String get map_searchHint => 'Wyszukaj nazwę lub identyfikator węzła';
@override
String get map_activity => 'Aktywność';
@override
String get map_online => 'Online';
@override
String get map_recent => 'Ostatnie';
@override
String get map_stale => 'Nieaktualne';
@override
String get map_visible => 'Widoczny';
@override
String get map_hidden => 'Ukryty';
@override
String get map_centerOnNode => 'Wyśrodkuj na węźle';
@override
String get map_details => 'Szczegóły';
@override
String get map_noGps => 'Brak GPS';
@override
String get map_noResults => 'Brak pasujących węzłów';
@override @override
String get map_lineOfSight => 'Linia wzroku'; String get map_lineOfSight => 'Linia wzroku';
@@ -1925,6 +2073,42 @@ class AppLocalizationsPl extends AppLocalizations {
return 'Nieudane pobrania: $count'; return 'Nieudane pobrania: $count';
} }
@override
String get mapCache_cachedTilesLabel => 'Cached tiles';
@override
String get mapCache_cachedTileSummaryLabel => 'Cached tile summary';
@override
String mapCache_bulkDownloadDisabledForSource(String source) {
return 'Offline bulk downloads are disabled for $source.';
}
@override
String mapCache_bulkDownloadDisabledInConfig(String source) {
return 'Offline bulk downloads are disabled for $source in this app configuration.';
}
@override
String mapCache_summarySource(String source) {
return 'Source: $source';
}
@override
String mapCache_summaryCachedTilesForSource(int count) {
return 'Cached tiles for source: $count';
}
@override
String mapCache_summaryCachedInSelection(int count) {
return 'Cached in selected area/zoom: $count';
}
@override
String mapCache_summaryApproxCacheSize(String size) {
return 'Approx cache size: $size';
}
@override @override
String mapCache_boundsLabel( String mapCache_boundsLabel(
String north, String north,
@@ -2055,68 +2239,13 @@ class AppLocalizationsPl extends AppLocalizations {
@override @override
String get common_clear => 'Wyczyść'; String get common_clear => 'Wyczyść';
@override
String path_currentPath(String path) {
return 'Aktualna ścieżka: $path';
}
@override
String path_usingHopsPath(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'skoków',
many: 'skoków',
few: 'skoki',
one: 'skok',
);
return 'Użyj ścieżki $count $_temp0.';
}
@override
String get path_enterCustomPath => 'Wprowadź własną ścieżkę';
@override @override
String get path_currentPathLabel => 'Aktualna ścieżka'; String get path_currentPathLabel => 'Aktualna ścieżka';
@override
String get path_hexPrefixInstructions =>
'Wprowadź 2-znakowe prefiksy szesnastkowe dla każdego skoku, oddzielone przecinkami.';
@override
String get path_hexPrefixExample =>
'A1,F2,3C (każdy węzeł używa pierwszego bajtu swojego klucza publicznego)';
@override
String get path_labelHexPrefixes => 'Ścieżka (prefiksy hex)';
@override
String get path_helperMaxHops =>
'Maksymalnie 64 skoki. Każdy prefiks ma 2 znaki szesnastkowe (1 bajt).';
@override
String get path_selectFromContacts => 'Albo wybierz z kontaktów:';
@override @override
String get path_noRepeatersFound => String get path_noRepeatersFound =>
'Nie znaleziono przekaźników ani serwerów pokoi.'; 'Nie znaleziono przekaźników ani serwerów pokoi.';
@override
String get path_customPathsRequire =>
'Dostosowane ścieżki wymagają pośrednich skoków, które mogą przekazywać wiadomości.';
@override
String path_invalidHexPrefixes(String prefixes) {
return 'Nieprawidłowe prefiksy szesnastkowe: $prefixes';
}
@override
String get path_tooLong =>
'Ścieżka jest zbyt długa. Dozwolonych skoków wynosi 64.';
@override
String get path_setPath => 'Ustaw Ścieżkę';
@override @override
String get repeater_management => 'Zarządzanie przekaźnikami'; String get repeater_management => 'Zarządzanie przekaźnikami';
@@ -2181,16 +2310,6 @@ class AppLocalizationsPl extends AppLocalizations {
@override @override
String get repeater_routingMode => 'Tryb routingu'; String get repeater_routingMode => 'Tryb routingu';
@override
String get repeater_autoUseSavedPath =>
'Automatycznie (użyj zapisanej ścieżki)';
@override
String get repeater_forceFloodMode => 'Wymuś tryb zalewowy';
@override
String get repeater_pathManagement => 'Zarządzanie ścieżkami';
@override @override
String get repeater_refresh => 'Odśwież'; String get repeater_refresh => 'Odśwież';
@@ -3288,6 +3407,139 @@ class AppLocalizationsPl extends AppLocalizations {
return '$celsius°C / $fahrenheit°F'; return '$celsius°C / $fahrenheit°F';
} }
@override
String get telemetry_digitalInputLabel => 'Wejście cyfrowe';
@override
String get telemetry_digitalOutputLabel => 'Wyjście cyfrowe';
@override
String get telemetry_analogInputLabel => 'Wejście analogowe';
@override
String get telemetry_analogOutputLabel => 'Wyjście analogowe';
@override
String get telemetry_genericLabel => 'Czujnik ogólny';
@override
String get telemetry_luminosityLabel => 'Jasność';
@override
String get telemetry_presenceLabel => 'Obecność';
@override
String get telemetry_humidityLabel => 'Wilgotność';
@override
String get telemetry_accelerometerLabel => 'Akcelerometr';
@override
String get telemetry_pressureLabel => 'Ciśnienie';
@override
String get telemetry_altitudeLabel => 'Wysokość';
@override
String get telemetry_frequencyLabel => 'Częstotliwość';
@override
String get telemetry_percentageLabel => 'Procent';
@override
String get telemetry_concentrationLabel => 'Stężenie';
@override
String get telemetry_powerLabel => 'Moc';
@override
String get telemetry_distanceLabel => 'Odległość';
@override
String get telemetry_energyLabel => 'Energia';
@override
String get telemetry_directionLabel => 'Kierunek';
@override
String get telemetry_timeLabel => 'Czas';
@override
String get telemetry_gyrometerLabel => 'Żyrometr';
@override
String get telemetry_colourLabel => 'Kolor';
@override
String get telemetry_gpsLabel => 'GPS';
@override
String get telemetry_switchLabel => 'Przełącznik';
@override
String get telemetry_polylineLabel => 'Polilinia';
@override
String telemetry_altitudeValue(String meters) {
return '$meters m';
}
@override
String telemetry_frequencyValue(String hertz) {
return '$hertz Hz';
}
@override
String telemetry_pressureValue(String hpa) {
return '$hpa hPa';
}
@override
String telemetry_luminosityValue(String lux) {
return '$lux lx';
}
@override
String telemetry_powerValue(String watts) {
return '$watts W';
}
@override
String telemetry_distanceValue(String meters) {
return '$meters m';
}
@override
String telemetry_energyValue(String kilowattHours) {
return '$kilowattHours kWh';
}
@override
String telemetry_directionValue(String degrees) {
return '$degrees°';
}
@override
String telemetry_concentrationValue(String ppm) {
return '$ppm ppm';
}
@override
String telemetry_percentageValue(String percent) {
return '$percent%';
}
@override
String telemetry_analogValue(String value) {
return '$value';
}
@override
String get telemetry_autoFetchQuantity => 'Liczba żądań';
@override
String get telemetry_error => 'Nie udało się pobrać danych';
@override @override
String get neighbors_receivedData => 'Otrzymano dane sąsiedztwa'; String get neighbors_receivedData => 'Otrzymano dane sąsiedztwa';
@@ -4320,4 +4572,142 @@ class AppLocalizationsPl extends AppLocalizations {
@override @override
String get contact_typeUnknown => 'Unknown'; String get contact_typeUnknown => 'Unknown';
@override
String get map_zoomIn => 'Przybliż';
@override
String get map_zoomOut => 'Przybliż z powrotem';
@override
String get map_centerMap => 'Mapa centrum';
@override
String get chrome_bluetoothRequiresChromium =>
'Web Bluetooth wymaga przeglądarki Chromium.';
@override
String channels_communityShortId(String id) {
return 'ID: $id...';
}
@override
String get pathTrace_legendGpsConfirmed => 'GPS potwierdzone';
@override
String get pathTrace_legendInferred => 'Wywnioskowana pozycja';
@override
String get pathMap_viewSingle => 'Pojedyncza';
@override
String get pathMap_viewCombined => 'Połączone';
@override
String get pathMap_play => 'Odtwórz';
@override
String get pathMap_pause => 'Wstrzymaj';
@override
String get pathMap_replay => 'Odtwórz ponownie';
@override
String get pathMap_stepBack => 'Poprzedni skok';
@override
String get pathMap_stepForward => 'Następny skok';
@override
String get pathMap_animationOn => 'Pokaż animację pakietu';
@override
String get pathMap_animationOff => 'Ukryj animację pakietu';
@override
String pathMap_hopOf(int current, int total) {
return 'Skok $current z $total';
}
@override
String pathMap_observedPaths(int count) {
return 'Obserwowane trasy: $count';
}
@override
String get pathMap_primary => 'Główna';
@override
String pathMap_alternate(int index) {
return 'Alt. $index';
}
@override
String pathMap_hopCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count skoku',
many: '$count skoków',
few: '$count skoki',
one: '1 skok',
);
return '$_temp0';
}
@override
String pathMap_gpsCount(int confirmed, int total) {
return '$confirmed/$total GPS';
}
@override
String get pathMap_legendShared => 'Wspólny segment';
@override
String get pathMap_legendEstimated => 'Szacunkowy segment';
@override
String pathMap_sharedNodeCount(int count) {
return 'Wykorzystywane przez $count ścieżek';
}
@override
String pathMap_partialAnimation(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other:
'$count skoku nie ma lokalizacji — pokazana ścieżka jest niekompletna',
many:
'$count skoków nie ma lokalizacji — pokazana ścieżka jest niekompletna',
few:
'$count skoki nie mają lokalizacji — pokazana ścieżka jest niekompletna',
one: '1 skok nie ma lokalizacji — pokazana ścieżka jest niekompletna',
);
return '$_temp0';
}
@override
String get pathMap_showAllPaths => 'Pokaż wszystkie';
@override
String get pathMap_hidePath => 'Ukryj ścieżkę';
@override
String get pathMap_showPath => 'Wyświetl trasę';
@override
String get pathMap_collapsePanel => 'Zwiń panel';
@override
String get pathMap_expandPanel => 'Rozwiń panel';
@override
String get pathMap_noLocation => 'Brak lokalizacji';
@override
String get pathMap_followPacket => 'Śledź pakiet';
@override
String get pathMap_unfollowPacket => 'Przestań śledzić pakiet';
} }
+522 -136
View File
@@ -92,6 +92,24 @@ class AppLocalizationsPt extends AppLocalizations {
@override @override
String get common_disable => 'Desativar'; String get common_disable => 'Desativar';
@override
String get common_undo => 'Desfazer';
@override
String get messageStatus_sent => 'Enviado';
@override
String get messageStatus_delivered => 'Entregue';
@override
String get messageStatus_pending => 'Enviar';
@override
String get messageStatus_failed => 'Falhou ao enviar';
@override
String get messageStatus_repeated => 'Ouvi repetidamente';
@override @override
String get common_reboot => 'Reiniciar'; String get common_reboot => 'Reiniciar';
@@ -111,6 +129,12 @@ class AppLocalizationsPt extends AppLocalizations {
return '$percent%'; return '$percent%';
} }
@override
String get common_autoRefresh => 'Atualização automática';
@override
String get common_interval => 'Intervalo';
@override @override
String get scanner_title => 'MeshCore: Versão aberta'; String get scanner_title => 'MeshCore: Versão aberta';
@@ -296,6 +320,10 @@ class AppLocalizationsPt extends AppLocalizations {
@override @override
String get scanner_enableBluetooth => 'Ative o Bluetooth'; String get scanner_enableBluetooth => 'Ative o Bluetooth';
@override
String get scanner_bluetoothWebUnsupported =>
'A funcionalidade Bluetooth não está disponível no navegador. Conecte-se via USB em vez disso.';
@override @override
String get device_quickSwitch => 'Mudar rapidamente'; String get device_quickSwitch => 'Mudar rapidamente';
@@ -787,11 +815,6 @@ class AppLocalizationsPt extends AppLocalizations {
String get appSettings_maxMessageRetriesSubtitle => String get appSettings_maxMessageRetriesSubtitle =>
'Número de tentativas de reenvio antes de classificar uma mensagem como falha.'; 'Número de tentativas de reenvio antes de classificar uma mensagem como falha.';
@override
String path_routeWeight(String weight, String max) {
return '$weight/$max';
}
@override @override
String get appSettings_battery => 'Bateria'; String get appSettings_battery => 'Bateria';
@@ -872,6 +895,28 @@ class AppLocalizationsPt extends AppLocalizations {
@override @override
String get appSettings_lastWeek => 'Da última semana'; String get appSettings_lastWeek => 'Da última semana';
@override
String get appSettings_rasterTileSource => 'Fonte de blocos raster';
@override
String get appSettings_stadiaEndpoint => 'Endpoint da Stadia';
@override
String get appSettings_stadiaApiKey => 'Chave da API Stadia';
@override
String get appSettings_stadiaApiKeyRequired =>
'Obrigatório para usar o Stadia Maps';
@override
String appSettings_stadiaApiKeyConfigured(String maskedKey) {
return 'Configurado: $maskedKey';
}
@override
String get appSettings_stadiaApiKeyDialogDescription =>
'Insira sua chave da API Stadia Maps. O aplicativo a usa para solicitações de blocos raster.';
@override @override
String get appSettings_offlineMapCache => 'Cache de Mapa Offline'; String get appSettings_offlineMapCache => 'Cache de Mapa Offline';
@@ -993,6 +1038,15 @@ class AppLocalizationsPt extends AppLocalizations {
@override @override
String get contacts_newGroup => 'Novo Grupo'; String get contacts_newGroup => 'Novo Grupo';
@override
String get contacts_moreOptions => 'Mais opções';
@override
String get contacts_searchOpen => 'Pesquisar contatos';
@override
String get contacts_searchClose => 'Pesquisa avançada';
@override @override
String get contacts_groupName => 'Nome do grupo'; String get contacts_groupName => 'Nome do grupo';
@@ -1472,34 +1526,6 @@ class AppLocalizationsPt extends AppLocalizations {
@override @override
String get debugFrame_hexDump => 'Espaço Hexadecimal:'; String get debugFrame_hexDump => 'Espaço Hexadecimal:';
@override
String get chat_pathManagement => 'Gerenciamento de Caminhos';
@override
String get chat_ShowAllPaths => 'Mostrar todos os caminhos';
@override
String get chat_routingMode => 'Modo de roteamento';
@override
String get chat_autoUseSavedPath => 'Auto (usar caminho salvo)';
@override
String get chat_forceFloodMode => 'Modo de Inundação Forçado';
@override
String get chat_recentAckPaths => 'Rotas de ACK Recentes (toque para usar):';
@override
String get chat_pathHistoryFull =>
'O histórico está cheio. Remova entradas para adicionar novas.';
@override
String get chat_hopSingular => 'pule';
@override
String get chat_hopPlural => 'salta';
@override @override
String chat_hopsCount(int count) { String chat_hopsCount(int count) {
String _temp0 = intl.Intl.pluralLogic( String _temp0 = intl.Intl.pluralLogic(
@@ -1511,12 +1537,6 @@ class AppLocalizationsPt extends AppLocalizations {
return '$count $_temp0'; return '$count $_temp0';
} }
@override
String get chat_successes => 'Sucessos';
@override
String get chat_score => 'Score';
@override @override
String get chat_removePath => 'Remover caminho'; String get chat_removePath => 'Remover caminho';
@@ -1524,53 +1544,148 @@ class AppLocalizationsPt extends AppLocalizations {
String get chat_noPathHistoryYet => String get chat_noPathHistoryYet =>
'Ainda não há histórico de caminhos.\nEnvie uma mensagem para descobrir caminhos.'; 'Ainda não há histórico de caminhos.\nEnvie uma mensagem para descobrir caminhos.';
@override
String get chat_pathActions => 'Ações do Caminho:';
@override
String get chat_setCustomPath => 'Definir Caminho Personalizado';
@override
String get chat_setCustomPathSubtitle =>
'Especifique manualmente o caminho de roteamento';
@override
String get chat_clearPath => 'Limpar Caminho';
@override
String get chat_clearPathSubtitle =>
'Forçar a descoberta na próxima transmissão';
@override @override
String get chat_pathCleared => String get chat_pathCleared =>
'Caminho limpo. A próxima mensagem redescobrirá a rota.'; 'Caminho limpo. A próxima mensagem redescobrirá a rota.';
@override
String get chat_floodModeSubtitle =>
'Use a chave de roteamento na barra de ferramentas';
@override
String get chat_floodModeEnabled =>
'Modo de inundação ativado. Desative-o novamente através do ícone de roteamento na barra de ferramentas.';
@override @override
String get chat_fullPath => 'Caminho Completo'; String get chat_fullPath => 'Caminho Completo';
@override @override
String get chat_pathDetailsNotAvailable => String get routing_title => 'Rotas';
'Os detalhes do caminho ainda não estão disponíveis. Tente enviar uma mensagem para atualizar.';
@override @override
String chat_pathSetHops(int hopCount, String status) { String get routing_modeAuto => 'Carro';
String _temp0 = intl.Intl.pluralLogic(
hopCount, @override
locale: localeName, String get routing_modeFlood => 'Inundação';
other: 'hops',
one: 'hop', @override
); String get routing_modeManual => 'Manual';
return 'Caminho definido: $hopCount $_temp0 - $status';
@override
String get routing_modeAutoHint =>
'Seleciona automaticamente o caminho mais conhecido, e, se nenhum caminho conhecido for encontrado, utiliza a estratégia de \"inundação\".';
@override
String get routing_modeFloodHint =>
'Transmissão através de todos os repetidores. É a opção mais confiável, mas utiliza mais tempo de transmissão.';
@override
String get routing_modeManualHint =>
'Sempre segue exatamente o caminho que você define.';
@override
String get routing_currentRoute => 'Rota atual';
@override
String get routing_directNoHops => 'Direto sem saltos de repetidor';
@override
String get routing_noPathYet =>
'Ainda não há um caminho definido. A mensagem continua a ser enviada até que uma rota seja encontrada.';
@override
String get routing_floodBroadcast =>
'Transmissão através de todos os repetidores';
@override
String get routing_editPath => 'Editar caminho';
@override
String get routing_forgetPath => 'Esqueça o caminho';
@override
String get routing_knownPaths => 'Rotas conhecidas';
@override
String get routing_knownPathsHint =>
'Toque em um caminho para alternar para ele.';
@override
String get routing_inUse => 'Em uso';
@override
String get routing_qualityStrong => 'Primeiro salto notável';
@override
String get routing_qualityGood => 'Primeiro salto bem-sucedido';
@override
String get routing_qualityFair => 'Primeira etapa bem-sucedida';
@override
String get routing_qualityWorked => 'Foi entregue';
@override
String get routing_qualityFlood =>
'Informação obtida através de relatos generalizados.';
@override
String get routing_qualityUntested => 'Não testado';
@override
String routing_lastWorked(String when) {
return 'worked $when';
} }
@override
String get routing_neverWorked => 'nunca confirmado';
@override
String routing_deliveryCounts(int successes, int failures) {
return '$successes delivered, $failures failed';
}
@override
String get routing_floodDelivery =>
'Entrega em áreas afetadas por inundações';
@override
String get pathEditor_title => 'Criar Caminho';
@override
String pathEditor_hopCounter(int count) {
return '$count de 64 gramas de lúpulo';
}
@override
String get pathEditor_noHops =>
'Ainda não há lúpulos adicionados. Clique nos repetidores abaixo para adicioná-los na ordem desejada, ou salve sem adicionar lúpulos para enviar diretamente.';
@override
String get pathEditor_addHops => 'Adicione os lúpulos na seguinte ordem.';
@override
String get pathEditor_searchRepeaters => 'Encontrar repetidores';
@override
String get pathEditor_advancedHex => 'Avançado: caminho hexadecimal bruto';
@override
String get pathEditor_hexLabel => 'Prefixos hexadecimais';
@override
String get pathEditor_hexHelper =>
'Dois caracteres hexadecimais por salto, separados por vírgulas.';
@override
String pathEditor_invalidTokens(String tokens) {
return 'Inválido: $tokens';
}
@override
String get pathEditor_tooManyHops => 'Máximo de 64 saltos';
@override
String get pathEditor_usePath => 'Utilize este caminho.';
@override
String get pathEditor_removeHop => 'Remova o lúpulo';
@override
String get pathEditor_unknownHop => 'Repetidor desconhecido';
@override @override
String get chat_pathSavedLocally => String get chat_pathSavedLocally =>
'Salvo localmente. Conectar para sincronizar.'; 'Salvo localmente. Conectar para sincronizar.';
@@ -1645,6 +1760,39 @@ class AppLocalizationsPt extends AppLocalizations {
@override @override
String get map_title => 'Mapa de Nós'; String get map_title => 'Mapa de Nós';
@override
String get map_searchHint => 'Pesquisar por nome ou ID do nó';
@override
String get map_activity => 'Atividade';
@override
String get map_online => 'Online';
@override
String get map_recent => 'Recente';
@override
String get map_stale => 'Vencido';
@override
String get map_visible => 'Visível';
@override
String get map_hidden => 'Escondido';
@override
String get map_centerOnNode => 'Centralizar no nó';
@override
String get map_details => 'Detalhes';
@override
String get map_noGps => 'Sem GPS';
@override
String get map_noResults => 'Nenhum nó encontrado';
@override @override
String get map_lineOfSight => 'Linha de visão'; String get map_lineOfSight => 'Linha de visão';
@@ -1908,6 +2056,42 @@ class AppLocalizationsPt extends AppLocalizations {
return 'Downloads falhas: $count'; return 'Downloads falhas: $count';
} }
@override
String get mapCache_cachedTilesLabel => 'Cached tiles';
@override
String get mapCache_cachedTileSummaryLabel => 'Cached tile summary';
@override
String mapCache_bulkDownloadDisabledForSource(String source) {
return 'Offline bulk downloads are disabled for $source.';
}
@override
String mapCache_bulkDownloadDisabledInConfig(String source) {
return 'Offline bulk downloads are disabled for $source in this app configuration.';
}
@override
String mapCache_summarySource(String source) {
return 'Source: $source';
}
@override
String mapCache_summaryCachedTilesForSource(int count) {
return 'Cached tiles for source: $count';
}
@override
String mapCache_summaryCachedInSelection(int count) {
return 'Cached in selected area/zoom: $count';
}
@override
String mapCache_summaryApproxCacheSize(String size) {
return 'Approx cache size: $size';
}
@override @override
String mapCache_boundsLabel( String mapCache_boundsLabel(
String north, String north,
@@ -2038,66 +2222,13 @@ class AppLocalizationsPt extends AppLocalizations {
@override @override
String get common_clear => 'Limpar'; String get common_clear => 'Limpar';
@override
String path_currentPath(String path) {
return 'Caminho atual: $path';
}
@override
String path_usingHopsPath(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'hops',
one: 'hop',
);
return 'Usando $count $_temp0 caminho';
}
@override
String get path_enterCustomPath => 'Insira Caminho Personalizado';
@override @override
String get path_currentPathLabel => 'Caminho atual'; String get path_currentPathLabel => 'Caminho atual';
@override
String get path_hexPrefixInstructions =>
'Insira os prefixos hexadecimais de 2 caracteres para cada salto, separados por vírgulas.';
@override
String get path_hexPrefixExample =>
'A1,F2,3C (cada nó usa o primeiro byte de sua chave pública)';
@override
String get path_labelHexPrefixes => 'Prefixo Hexadecimal';
@override
String get path_helperMaxHops =>
'Máximo de 64 saltos. Cada prefixo tem 2 caracteres hexadecimais (1 byte)';
@override
String get path_selectFromContacts => 'Ou selecione de contatos:';
@override @override
String get path_noRepeatersFound => String get path_noRepeatersFound =>
'Não foram encontrados repetidores ou servidores de sala.'; 'Não foram encontrados repetidores ou servidores de sala.';
@override
String get path_customPathsRequire =>
'Caminhos personalizados exigem saltos intermediários que podem transmitir mensagens.';
@override
String path_invalidHexPrefixes(String prefixes) {
return 'Prefixos hexadecimais inválidos: $prefixes';
}
@override
String get path_tooLong =>
'Caminho muito longo. Máximo de 64 saltos permitidos.';
@override
String get path_setPath => 'Definir Caminho';
@override @override
String get repeater_management => 'Gerenciamento de Repetidor'; String get repeater_management => 'Gerenciamento de Repetidor';
@@ -2162,15 +2293,6 @@ class AppLocalizationsPt extends AppLocalizations {
@override @override
String get repeater_routingMode => 'Modo de roteamento'; String get repeater_routingMode => 'Modo de roteamento';
@override
String get repeater_autoUseSavedPath => 'Auto (usar caminho salvo)';
@override
String get repeater_forceFloodMode => 'Modo de Inundação Forçado';
@override
String get repeater_pathManagement => 'Gerenciamento de caminhos';
@override @override
String get repeater_refresh => 'Atualizar'; String get repeater_refresh => 'Atualizar';
@@ -3269,6 +3391,139 @@ class AppLocalizationsPt extends AppLocalizations {
return '$celsius°C / $fahrenheit°F'; return '$celsius°C / $fahrenheit°F';
} }
@override
String get telemetry_digitalInputLabel => 'Entrada digital';
@override
String get telemetry_digitalOutputLabel => 'Saída digital';
@override
String get telemetry_analogInputLabel => 'Entrada analógica';
@override
String get telemetry_analogOutputLabel => 'Saída analógica';
@override
String get telemetry_genericLabel => 'Sensor genérico';
@override
String get telemetry_luminosityLabel => 'Luminosidade';
@override
String get telemetry_presenceLabel => 'Presença';
@override
String get telemetry_humidityLabel => 'Humidade';
@override
String get telemetry_accelerometerLabel => 'Acelerómetro';
@override
String get telemetry_pressureLabel => 'Pressão';
@override
String get telemetry_altitudeLabel => 'Altitude';
@override
String get telemetry_frequencyLabel => 'Frequência';
@override
String get telemetry_percentageLabel => 'Percentagem';
@override
String get telemetry_concentrationLabel => 'Concentração';
@override
String get telemetry_powerLabel => 'Potência';
@override
String get telemetry_distanceLabel => 'Distância';
@override
String get telemetry_energyLabel => 'Energia';
@override
String get telemetry_directionLabel => 'Direção';
@override
String get telemetry_timeLabel => 'Hora';
@override
String get telemetry_gyrometerLabel => 'Girómetro';
@override
String get telemetry_colourLabel => 'Cor';
@override
String get telemetry_gpsLabel => 'GPS';
@override
String get telemetry_switchLabel => 'Interruptor';
@override
String get telemetry_polylineLabel => 'Polilinha';
@override
String telemetry_altitudeValue(String meters) {
return '$meters m';
}
@override
String telemetry_frequencyValue(String hertz) {
return '$hertz Hz';
}
@override
String telemetry_pressureValue(String hpa) {
return '$hpa hPa';
}
@override
String telemetry_luminosityValue(String lux) {
return '$lux lx';
}
@override
String telemetry_powerValue(String watts) {
return '$watts W';
}
@override
String telemetry_distanceValue(String meters) {
return '$meters m';
}
@override
String telemetry_energyValue(String kilowattHours) {
return '$kilowattHours kWh';
}
@override
String telemetry_directionValue(String degrees) {
return '$degrees°';
}
@override
String telemetry_concentrationValue(String ppm) {
return '$ppm ppm';
}
@override
String telemetry_percentageValue(String percent) {
return '$percent%';
}
@override
String telemetry_analogValue(String value) {
return '$value';
}
@override
String get telemetry_autoFetchQuantity => 'Número de solicitações';
@override
String get telemetry_error => 'Não foi possível obter os dados';
@override @override
String get neighbors_receivedData => 'Dados dos Vizinhos Recebidos'; String get neighbors_receivedData => 'Dados dos Vizinhos Recebidos';
@@ -4296,4 +4551,135 @@ class AppLocalizationsPt extends AppLocalizations {
@override @override
String get contact_typeUnknown => 'Unknown'; String get contact_typeUnknown => 'Unknown';
@override
String get map_zoomIn => 'Ampliar';
@override
String get map_zoomOut => 'Ampliar';
@override
String get map_centerMap => 'Mapa do centro';
@override
String get chrome_bluetoothRequiresChromium =>
'O Web Bluetooth requer um navegador Chromium.';
@override
String channels_communityShortId(String id) {
return 'ID: $id...';
}
@override
String get pathTrace_legendGpsConfirmed => 'GPS confirmado';
@override
String get pathTrace_legendInferred => 'Posição inferida';
@override
String get pathMap_viewSingle => 'Único';
@override
String get pathMap_viewCombined => 'Combinado';
@override
String get pathMap_play => 'Reproduzir';
@override
String get pathMap_pause => 'Pausa';
@override
String get pathMap_replay => 'Repetir';
@override
String get pathMap_stepBack => 'Salto anterior';
@override
String get pathMap_stepForward => 'Próximo salto';
@override
String get pathMap_animationOn => 'Exibir animação do pacote';
@override
String get pathMap_animationOff => 'Ocultar a animação do pacote';
@override
String pathMap_hopOf(int current, int total) {
return 'Salto $current de $total';
}
@override
String pathMap_observedPaths(int count) {
return 'Caminhos observados: $count';
}
@override
String get pathMap_primary => 'Primário';
@override
String pathMap_alternate(int index) {
return 'Alt $index';
}
@override
String pathMap_hopCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count saltos',
one: '1 salto',
);
return '$_temp0';
}
@override
String pathMap_gpsCount(int confirmed, int total) {
return '$confirmed/$total GPS';
}
@override
String get pathMap_legendShared => 'Segmento compartilhado';
@override
String get pathMap_legendEstimated => 'Segmento estimado';
@override
String pathMap_sharedNodeCount(int count) {
return 'Utilizado em $count caminhos';
}
@override
String pathMap_partialAnimation(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count saltos não têm localização — o caminho mostrado é parcial',
one: '1 salto não tem localização — o caminho mostrado é parcial',
);
return '$_temp0';
}
@override
String get pathMap_showAllPaths => 'Mostrar tudo';
@override
String get pathMap_hidePath => 'Esconder caminho';
@override
String get pathMap_showPath => 'Mostrar o caminho';
@override
String get pathMap_collapsePanel => 'Recolher painel';
@override
String get pathMap_expandPanel => 'Expandir painel';
@override
String get pathMap_noLocation => 'Sem localização';
@override
String get pathMap_followPacket => 'Fixar vista no pacote';
@override
String get pathMap_unfollowPacket => 'Liberar vista do pacote';
} }
+528 -140
View File
@@ -92,6 +92,24 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get common_disable => 'Выключить'; String get common_disable => 'Выключить';
@override
String get common_undo => 'Отменить';
@override
String get messageStatus_sent => 'Отправлено';
@override
String get messageStatus_delivered => 'Доставлено';
@override
String get messageStatus_pending => 'Отправка';
@override
String get messageStatus_failed => 'Не удалось отправить';
@override
String get messageStatus_repeated => 'Услышал несколько раз';
@override @override
String get common_reboot => 'Перезагрузить'; String get common_reboot => 'Перезагрузить';
@@ -111,6 +129,12 @@ class AppLocalizationsRu extends AppLocalizations {
return '$percent%'; return '$percent%';
} }
@override
String get common_autoRefresh => 'Автообновление';
@override
String get common_interval => 'Интервал';
@override @override
String get scanner_title => 'MeshCore Open'; String get scanner_title => 'MeshCore Open';
@@ -296,6 +320,10 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get scanner_enableBluetooth => 'Включите Bluetooth'; String get scanner_enableBluetooth => 'Включите Bluetooth';
@override
String get scanner_bluetoothWebUnsupported =>
'Bluetooth недоступен в браузере. Подключитесь через USB.';
@override @override
String get device_quickSwitch => 'Быстрое переключение'; String get device_quickSwitch => 'Быстрое переключение';
@@ -789,11 +817,6 @@ class AppLocalizationsRu extends AppLocalizations {
String get appSettings_maxMessageRetriesSubtitle => String get appSettings_maxMessageRetriesSubtitle =>
'Количество попыток повторной отправки сообщения перед тем, как пометить его как неудачное.'; 'Количество попыток повторной отправки сообщения перед тем, как пометить его как неудачное.';
@override
String path_routeWeight(String weight, String max) {
return '$weight/$max';
}
@override @override
String get appSettings_battery => 'Батарея'; String get appSettings_battery => 'Батарея';
@@ -875,6 +898,28 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get appSettings_lastWeek => 'Последнюю неделю'; String get appSettings_lastWeek => 'Последнюю неделю';
@override
String get appSettings_rasterTileSource => 'Источник растровых тайлов';
@override
String get appSettings_stadiaEndpoint => 'Конечная точка Stadia';
@override
String get appSettings_stadiaApiKey => 'Ключ API Stadia';
@override
String get appSettings_stadiaApiKeyRequired =>
'Требуется для использования Stadia Maps';
@override
String appSettings_stadiaApiKeyConfigured(String maskedKey) {
return 'Настроено: $maskedKey';
}
@override
String get appSettings_stadiaApiKeyDialogDescription =>
'Введите свой ключ API Stadia Maps. Приложение использует его для запросов растровых тайлов.';
@override @override
String get appSettings_offlineMapCache => 'Кэш офлайн-карты'; String get appSettings_offlineMapCache => 'Кэш офлайн-карты';
@@ -994,6 +1039,15 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get contacts_newGroup => 'Новая группа'; String get contacts_newGroup => 'Новая группа';
@override
String get contacts_moreOptions => 'Больше вариантов';
@override
String get contacts_searchOpen => 'Найти контакты';
@override
String get contacts_searchClose => 'Закрыть поиск';
@override @override
String get contacts_groupName => 'Имя группы'; String get contacts_groupName => 'Имя группы';
@@ -1473,35 +1527,6 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get debugFrame_hexDump => 'Шестнадцатеричный дамп:'; String get debugFrame_hexDump => 'Шестнадцатеричный дамп:';
@override
String get chat_pathManagement => 'Управление маршрутами';
@override
String get chat_ShowAllPaths => 'Показать все пути';
@override
String get chat_routingMode => 'Режим маршрутизации';
@override
String get chat_autoUseSavedPath => 'Авто (использовать сохранённый маршрут)';
@override
String get chat_forceFloodMode => 'Принудительный режим рассылки';
@override
String get chat_recentAckPaths =>
'Недавние подтверждённые маршруты (нажмите, чтобы использовать):';
@override
String get chat_pathHistoryFull =>
'История маршрутов заполнена. Удалите записи, чтобы добавить новые.';
@override
String get chat_hopSingular => 'хоп';
@override
String get chat_hopPlural => 'хопов';
@override @override
String chat_hopsCount(int count) { String chat_hopsCount(int count) {
String _temp0 = intl.Intl.pluralLogic( String _temp0 = intl.Intl.pluralLogic(
@@ -1515,12 +1540,6 @@ class AppLocalizationsRu extends AppLocalizations {
return '$count $_temp0'; return '$count $_temp0';
} }
@override
String get chat_successes => 'успешно';
@override
String get chat_score => 'Оценка';
@override @override
String get chat_removePath => 'Удалить маршрут'; String get chat_removePath => 'Удалить маршрут';
@@ -1528,54 +1547,150 @@ class AppLocalizationsRu extends AppLocalizations {
String get chat_noPathHistoryYet => String get chat_noPathHistoryYet =>
'История маршрутов пока пуста.\nОтправьте сообщение, чтобы обнаружить маршруты.'; 'История маршрутов пока пуста.\nОтправьте сообщение, чтобы обнаружить маршруты.';
@override
String get chat_pathActions => 'Действия с маршрутом:';
@override
String get chat_setCustomPath => 'Указать маршрут вручную';
@override
String get chat_setCustomPathSubtitle => 'Вручную задать маршрут передачи';
@override
String get chat_clearPath => 'Очистить маршрут';
@override
String get chat_clearPathSubtitle =>
'Принудительно обновить маршрут при следующей отправке';
@override @override
String get chat_pathCleared => String get chat_pathCleared =>
'Маршрут очищен. Следующее сообщение обновит маршрут.'; 'Маршрут очищен. Следующее сообщение обновит маршрут.';
@override
String get chat_floodModeSubtitle =>
'Используйте переключатель маршрутизации в панели приложения';
@override
String get chat_floodModeEnabled =>
'Режим рассылки включён. Отключите через значок маршрутизации в панели приложения.';
@override @override
String get chat_fullPath => 'Полный маршрут'; String get chat_fullPath => 'Полный маршрут';
@override @override
String get chat_pathDetailsNotAvailable => String get routing_title => 'Маршрутизация';
'Детали маршрута ещё недоступны. Попробуйте отправить сообщение для обновления.';
@override @override
String chat_pathSetHops(int hopCount, String status) { String get routing_modeAuto => 'Авто';
String _temp0 = intl.Intl.pluralLogic(
hopCount, @override
locale: localeName, String get routing_modeFlood => 'Наводнение';
other: 'хопов',
many: 'хопов', @override
few: 'хопа', String get routing_modeManual => 'Инструкция';
one: 'хоп',
); @override
return 'Маршрут установлен: $hopCount $_temp0$status'; String get routing_modeAutoHint =>
'Автоматически выбирает наиболее известный путь, и если такой путь неизвестен, использует алгоритм поиска пути.';
@override
String get routing_modeFloodHint =>
'Передача сигнала через все ретрансляторы. Самый надежный способ, но требует больше времени на передачу.';
@override
String get routing_modeManualHint =>
'Всегда следует точно по указанному вами маршруту.';
@override
String get routing_currentRoute => 'Текущий маршрут';
@override
String get routing_directNoHops =>
'Прямое соединение – без использования ретрансляторов';
@override
String get routing_noPathYet =>
'Пока нет пути. Следующее сообщение будет отправлено до тех пор, пока не будет обнаружен маршрут.';
@override
String get routing_floodBroadcast => 'Транслируется через все ретрансляторы';
@override
String get routing_editPath => 'Изменить путь';
@override
String get routing_forgetPath => 'Забудьте о маршруте';
@override
String get routing_knownPaths => 'Известные маршруты';
@override
String get routing_knownPathsHint =>
'Создайте маршрут для переключения на этот пункт.';
@override
String get routing_inUse => 'В эксплуатации';
@override
String get routing_qualityStrong => 'Сильный первый скачок';
@override
String get routing_qualityGood => 'Хорошее начало';
@override
String get routing_qualityFair => 'Первый хороший урожай';
@override
String get routing_qualityWorked => 'Осуществлено';
@override
String get routing_qualityFlood =>
'Узнал из новостей, распространяющихся в интернете.';
@override
String get routing_qualityUntested => 'Непроверенный';
@override
String routing_lastWorked(String when) {
return 'worked $when';
} }
@override
String get routing_neverWorked => 'никогда не было подтверждено';
@override
String routing_deliveryCounts(int successes, int failures) {
return '$successes delivered, $failures failed';
}
@override
String get routing_floodDelivery => 'Доставка при затоплении';
@override
String get pathEditor_title => 'Создать маршрут';
@override
String pathEditor_hopCounter(int count) {
return '$count из 64 хмеля';
}
@override
String get pathEditor_noHops =>
'На данный момент хмель еще не добавлен. Чтобы добавить его, нажмите на соответствующие кнопки ниже в нужном порядке, или сохраните рецепт без хмеля, чтобы отправить его напрямую.';
@override
String get pathEditor_addHops =>
'Добавляйте хмель в соответствии с указанным порядком.';
@override
String get pathEditor_searchRepeaters => 'Поиск повторителей';
@override
String get pathEditor_advancedHex =>
'Продвинутый уровень: прямой путь в шестнадцатеричном формате';
@override
String get pathEditor_hexLabel => 'Префиксы шестнадцатеричной системы';
@override
String get pathEditor_hexHelper =>
'Два шестнадцатеричных символа на каждом шаге, разделенные запятыми.';
@override
String pathEditor_invalidTokens(String tokens) {
return 'Неверно: $tokens';
}
@override
String get pathEditor_tooManyHops =>
'Максимальное количество ингредиентов – 64';
@override
String get pathEditor_usePath => 'Используйте этот путь';
@override
String get pathEditor_removeHop => 'Удалить хмель';
@override
String get pathEditor_unknownHop => 'Неизвестный ретранслятор';
@override @override
String get chat_pathSavedLocally => String get chat_pathSavedLocally =>
'Сохранено локально. Подключитесь для синхронизации.'; 'Сохранено локально. Подключитесь для синхронизации.';
@@ -1650,6 +1765,39 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get map_title => 'Карта нод'; String get map_title => 'Карта нод';
@override
String get map_searchHint => 'Поиск по имени или ID узла';
@override
String get map_activity => 'Активность';
@override
String get map_online => 'Онлайн';
@override
String get map_recent => 'Недавно';
@override
String get map_stale => 'Устаревший';
@override
String get map_visible => 'Видимый';
@override
String get map_hidden => 'Скрытый';
@override
String get map_centerOnNode => 'Центрировать на узле';
@override
String get map_details => 'Детали';
@override
String get map_noGps => 'Без GPS';
@override
String get map_noResults => 'Не найдено соответствующих узлов';
@override @override
String get map_lineOfSight => 'Линия видимости'; String get map_lineOfSight => 'Линия видимости';
@@ -1912,6 +2060,42 @@ class AppLocalizationsRu extends AppLocalizations {
return 'Неудачных загрузок: $count'; return 'Неудачных загрузок: $count';
} }
@override
String get mapCache_cachedTilesLabel => 'Cached tiles';
@override
String get mapCache_cachedTileSummaryLabel => 'Cached tile summary';
@override
String mapCache_bulkDownloadDisabledForSource(String source) {
return 'Offline bulk downloads are disabled for $source.';
}
@override
String mapCache_bulkDownloadDisabledInConfig(String source) {
return 'Offline bulk downloads are disabled for $source in this app configuration.';
}
@override
String mapCache_summarySource(String source) {
return 'Source: $source';
}
@override
String mapCache_summaryCachedTilesForSource(int count) {
return 'Cached tiles for source: $count';
}
@override
String mapCache_summaryCachedInSelection(int count) {
return 'Cached in selected area/zoom: $count';
}
@override
String mapCache_summaryApproxCacheSize(String size) {
return 'Approx cache size: $size';
}
@override @override
String mapCache_boundsLabel( String mapCache_boundsLabel(
String north, String north,
@@ -2043,66 +2227,12 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get common_clear => 'Очистить'; String get common_clear => 'Очистить';
@override
String path_currentPath(String path) {
return 'Текущий маршрут: $path';
}
@override
String path_usingHopsPath(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'хопов',
many: 'хопов',
few: 'хопа',
one: 'хоп',
);
return 'Используется маршрут из $count $_temp0';
}
@override
String get path_enterCustomPath => 'Введите маршрут вручную';
@override @override
String get path_currentPathLabel => 'Текущий маршрут'; String get path_currentPathLabel => 'Текущий маршрут';
@override
String get path_hexPrefixInstructions =>
'Введите 2-символьные шестнадцатеричные префиксы для каждого хопа, разделённые запятыми.';
@override
String get path_hexPrefixExample =>
'Пример: A1,F2,3C (каждый узел использует первый байт своего публичного ключа)';
@override
String get path_labelHexPrefixes => 'Маршрут (шестнадцатеричные префиксы)';
@override
String get path_helperMaxHops =>
'Максимум 64 хопа. Каждый префикс — 2 шестнадцатеричных символа (1 байт)';
@override
String get path_selectFromContacts => 'Или выберите из контактов:';
@override @override
String get path_noRepeatersFound => 'Репитеры или серверы комнат не найдены.'; String get path_noRepeatersFound => 'Репитеры или серверы комнат не найдены.';
@override
String get path_customPathsRequire =>
'Пользовательские маршруты требуют промежуточных узлов, способных ретранслировать сообщения.';
@override
String path_invalidHexPrefixes(String prefixes) {
return 'Недопустимые шестнадцатеричные префиксы: $prefixes';
}
@override
String get path_tooLong => 'Маршрут слишком длинный. Максимум 64 хопа.';
@override
String get path_setPath => 'Установить маршрут';
@override @override
String get repeater_management => 'Управление репитером'; String get repeater_management => 'Управление репитером';
@@ -2167,16 +2297,6 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get repeater_routingMode => 'Режим маршрутизации'; String get repeater_routingMode => 'Режим маршрутизации';
@override
String get repeater_autoUseSavedPath =>
'Авто (использовать сохранённый маршрут)';
@override
String get repeater_forceFloodMode => 'Принудительный режим рассылки';
@override
String get repeater_pathManagement => 'Управление маршрутами';
@override @override
String get repeater_refresh => 'Обновить'; String get repeater_refresh => 'Обновить';
@@ -3277,6 +3397,139 @@ class AppLocalizationsRu extends AppLocalizations {
return '$celsius°C / $fahrenheit°F'; return '$celsius°C / $fahrenheit°F';
} }
@override
String get telemetry_digitalInputLabel => 'Цифровой вход';
@override
String get telemetry_digitalOutputLabel => 'Цифровой выход';
@override
String get telemetry_analogInputLabel => 'Аналоговый вход';
@override
String get telemetry_analogOutputLabel => 'Аналоговый выход';
@override
String get telemetry_genericLabel => 'Общий датчик';
@override
String get telemetry_luminosityLabel => 'Освещённость';
@override
String get telemetry_presenceLabel => 'Присутствие';
@override
String get telemetry_humidityLabel => 'Влажность';
@override
String get telemetry_accelerometerLabel => 'Акселерометр';
@override
String get telemetry_pressureLabel => 'Давление';
@override
String get telemetry_altitudeLabel => 'Высота';
@override
String get telemetry_frequencyLabel => 'Частота';
@override
String get telemetry_percentageLabel => 'Процент';
@override
String get telemetry_concentrationLabel => 'Концентрация';
@override
String get telemetry_powerLabel => 'Мощность';
@override
String get telemetry_distanceLabel => 'Расстояние';
@override
String get telemetry_energyLabel => 'Энергия';
@override
String get telemetry_directionLabel => 'Направление';
@override
String get telemetry_timeLabel => 'Время';
@override
String get telemetry_gyrometerLabel => 'Гирометр';
@override
String get telemetry_colourLabel => 'Цвет';
@override
String get telemetry_gpsLabel => 'GPS';
@override
String get telemetry_switchLabel => 'Переключатель';
@override
String get telemetry_polylineLabel => 'Полилиния';
@override
String telemetry_altitudeValue(String meters) {
return '$meters м';
}
@override
String telemetry_frequencyValue(String hertz) {
return '$hertz Гц';
}
@override
String telemetry_pressureValue(String hpa) {
return '$hpa гПа';
}
@override
String telemetry_luminosityValue(String lux) {
return '$lux лк';
}
@override
String telemetry_powerValue(String watts) {
return '$watts Вт';
}
@override
String telemetry_distanceValue(String meters) {
return '$meters м';
}
@override
String telemetry_energyValue(String kilowattHours) {
return '$kilowattHours кВт⋅ч';
}
@override
String telemetry_directionValue(String degrees) {
return '$degrees°';
}
@override
String telemetry_concentrationValue(String ppm) {
return '$ppm ppm';
}
@override
String telemetry_percentageValue(String percent) {
return '$percent%';
}
@override
String telemetry_analogValue(String value) {
return '$value';
}
@override
String get telemetry_autoFetchQuantity => 'Количество запросов';
@override
String get telemetry_error => 'Не удалось получить данные';
@override @override
String get neighbors_receivedData => 'Полученные данные о соседях'; String get neighbors_receivedData => 'Полученные данные о соседях';
@@ -4314,4 +4567,139 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get contact_typeUnknown => 'Неизвестно'; String get contact_typeUnknown => 'Неизвестно';
@override
String get map_zoomIn => 'Увеличить масштаб';
@override
String get map_zoomOut => 'Увеличить масштаб';
@override
String get map_centerMap => 'Карта центра';
@override
String get chrome_bluetoothRequiresChromium =>
'Для работы Web Bluetooth требуется браузер на основе Chromium.';
@override
String channels_communityShortId(String id) {
return 'Идентификатор: $id...';
}
@override
String get pathTrace_legendGpsConfirmed => 'GPS подтверждено';
@override
String get pathTrace_legendInferred => 'Выведенная позиция';
@override
String get pathMap_viewSingle => 'Одиночный';
@override
String get pathMap_viewCombined => 'Объединённые';
@override
String get pathMap_play => 'Воспроизвести';
@override
String get pathMap_pause => 'Пауза';
@override
String get pathMap_replay => 'Повтор';
@override
String get pathMap_stepBack => 'Предыдущий хоп';
@override
String get pathMap_stepForward => 'Следующий хоп';
@override
String get pathMap_animationOn => 'Показать анимацию пакета';
@override
String get pathMap_animationOff => 'Скрыть анимацию пакета';
@override
String pathMap_hopOf(int current, int total) {
return 'Хоп $current из $total';
}
@override
String pathMap_observedPaths(int count) {
return 'Наблюдаемые маршруты: $count';
}
@override
String get pathMap_primary => 'Основной';
@override
String pathMap_alternate(int index) {
return 'Альт $index';
}
@override
String pathMap_hopCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count хопов',
many: '$count хопов',
few: '$count хопа',
one: '$count хоп',
);
return '$_temp0';
}
@override
String pathMap_gpsCount(int confirmed, int total) {
return '$confirmed/$total GPS';
}
@override
String get pathMap_legendShared => 'Общий сегмент';
@override
String get pathMap_legendEstimated => 'Расчётный сегмент';
@override
String pathMap_sharedNodeCount(int count) {
return 'Используется в $count маршрутах';
}
@override
String pathMap_partialAnimation(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count хопов не имеют координат — показанный путь неполный',
many: '$count хопов не имеют координат — показанный путь неполный',
few: '$count хопа не имеют координат — показанный путь неполный',
one: '$count хоп не имеет координат — показанный путь неполный',
);
return '$_temp0';
}
@override
String get pathMap_showAllPaths => 'Показать всё';
@override
String get pathMap_hidePath => 'Скрыть путь';
@override
String get pathMap_showPath => 'Показать маршрут';
@override
String get pathMap_collapsePanel => 'Скрыть панель';
@override
String get pathMap_expandPanel => 'Расширить панель';
@override
String get pathMap_noLocation => 'Нет координат';
@override
String get pathMap_followPacket => 'Следить за пакетом';
@override
String get pathMap_unfollowPacket => 'Не следить за пакетом';
} }
+524 -137
View File
@@ -92,6 +92,24 @@ class AppLocalizationsSk extends AppLocalizations {
@override @override
String get common_disable => 'Zakázať'; String get common_disable => 'Zakázať';
@override
String get common_undo => 'Zrušiť';
@override
String get messageStatus_sent => 'Odoslané';
@override
String get messageStatus_delivered => 'Doručené';
@override
String get messageStatus_pending => 'Odoslanie';
@override
String get messageStatus_failed => 'Neúspešné odeslanie';
@override
String get messageStatus_repeated => 'Slyšal som to opakovane';
@override @override
String get common_reboot => 'Restartovať'; String get common_reboot => 'Restartovať';
@@ -111,6 +129,12 @@ class AppLocalizationsSk extends AppLocalizations {
return '$percent%'; return '$percent%';
} }
@override
String get common_autoRefresh => 'Automatické obnovenie';
@override
String get common_interval => 'Časový interval';
@override @override
String get scanner_title => 'MeshCore Verzia pre verejnosť'; String get scanner_title => 'MeshCore Verzia pre verejnosť';
@@ -295,6 +319,10 @@ class AppLocalizationsSk extends AppLocalizations {
@override @override
String get scanner_enableBluetooth => 'Povolte Bluetooth'; String get scanner_enableBluetooth => 'Povolte Bluetooth';
@override
String get scanner_bluetoothWebUnsupported =>
'Funkcia Bluetooth nie je dostupná v prehliadači. Prepojte sa pomocou USB.';
@override @override
String get device_quickSwitch => 'Rýchle prepínač'; String get device_quickSwitch => 'Rýchle prepínač';
@@ -776,11 +804,6 @@ class AppLocalizationsSk extends AppLocalizations {
String get appSettings_maxMessageRetriesSubtitle => String get appSettings_maxMessageRetriesSubtitle =>
'Počet pokusov o odošleť pred označením správy ako neúspešnej'; 'Počet pokusov o odošleť pred označením správy ako neúspešnej';
@override
String path_routeWeight(String weight, String max) {
return '$weight/$max';
}
@override @override
String get appSettings_battery => 'Batéria'; String get appSettings_battery => 'Batéria';
@@ -862,6 +885,28 @@ class AppLocalizationsSk extends AppLocalizations {
@override @override
String get appSettings_lastWeek => 'Minul týždeň'; String get appSettings_lastWeek => 'Minul týždeň';
@override
String get appSettings_rasterTileSource => 'Zdroj rastrových dlaždíc';
@override
String get appSettings_stadiaEndpoint => 'Koncový bod Stadia';
@override
String get appSettings_stadiaApiKey => 'Kľúč API Stadia';
@override
String get appSettings_stadiaApiKeyRequired =>
'Vyžaduje sa na používanie Stadia Maps';
@override
String appSettings_stadiaApiKeyConfigured(String maskedKey) {
return 'Nakonfigurované: $maskedKey';
}
@override
String get appSettings_stadiaApiKeyDialogDescription =>
'Zadajte svoj kľúč API pre Stadia Maps. Aplikácia ho používa na požiadavky na rastrové dlaždice.';
@override @override
String get appSettings_offlineMapCache => 'Offline Mapa Pamäť'; String get appSettings_offlineMapCache => 'Offline Mapa Pamäť';
@@ -982,6 +1027,15 @@ class AppLocalizationsSk extends AppLocalizations {
@override @override
String get contacts_newGroup => 'Nová skupina'; String get contacts_newGroup => 'Nová skupina';
@override
String get contacts_moreOptions => 'Ďalšie možnosti';
@override
String get contacts_searchOpen => 'Vyhľadajte kontakty';
@override
String get contacts_searchClose => 'Zavrieť vyhľadávanie';
@override @override
String get contacts_groupName => 'Názov skupiny'; String get contacts_groupName => 'Názov skupiny';
@@ -1463,35 +1517,6 @@ class AppLocalizationsSk extends AppLocalizations {
@override @override
String get debugFrame_hexDump => 'Hexová analýza:'; String get debugFrame_hexDump => 'Hexová analýza:';
@override
String get chat_pathManagement => 'Správa ciest';
@override
String get chat_ShowAllPaths => 'Zobraziť všetky cesty';
@override
String get chat_routingMode => 'Režim trasy';
@override
String get chat_autoUseSavedPath => 'Použiť uloženú cestu';
@override
String get chat_forceFloodMode =>
'Zavrieť režim núdzového povodňového režimu';
@override
String get chat_recentAckPaths => 'Nedávne cesty ACK (klepni na použitie):';
@override
String get chat_pathHistoryFull =>
'História ciest je plná. Odstráňte záznamy, aby ste mohli pridať nové.';
@override
String get chat_hopSingular => 'Skok';
@override
String get chat_hopPlural => 'Skákať';
@override @override
String chat_hopsCount(int count) { String chat_hopsCount(int count) {
String _temp0 = intl.Intl.pluralLogic( String _temp0 = intl.Intl.pluralLogic(
@@ -1503,12 +1528,6 @@ class AppLocalizationsSk extends AppLocalizations {
return '$count $_temp0'; return '$count $_temp0';
} }
@override
String get chat_successes => 'Úspechy';
@override
String get chat_score => 'Score';
@override @override
String get chat_removePath => 'Odstrániť cestu'; String get chat_removePath => 'Odstrániť cestu';
@@ -1516,52 +1535,148 @@ class AppLocalizationsSk extends AppLocalizations {
String get chat_noPathHistoryYet => String get chat_noPathHistoryYet =>
'Zatiaľ žiadna história trás.\nPošlite správu a objavte trasy.'; 'Zatiaľ žiadna história trás.\nPošlite správu a objavte trasy.';
@override
String get chat_pathActions => 'Cesty:';
@override
String get chat_setCustomPath => 'Nastaviť vlastnú cestu';
@override
String get chat_setCustomPathSubtitle => 'Ručne zadajte trasu.';
@override
String get chat_clearPath => 'Vyčistiš cestu';
@override
String get chat_clearPathSubtitle =>
'Znovu nájsť vynútene pri nasledujúcej pošlite';
@override @override
String get chat_pathCleared => String get chat_pathCleared =>
'Cesta vyčistená. Nasledujúce prepočetné získa trasu znova.'; 'Cesta vyčistená. Nasledujúce prepočetné získa trasu znova.';
@override
String get chat_floodModeSubtitle =>
'Použite prepínanie trasy v navigačnom paneli.';
@override
String get chat_floodModeEnabled =>
'Odosporňovacia prevádzka je zapnutá. Vypnite ju znova cez ikonu routovania v navigačnom páse.';
@override @override
String get chat_fullPath => 'Celá cesta'; String get chat_fullPath => 'Celá cesta';
@override @override
String get chat_pathDetailsNotAvailable => String get routing_title => 'Navigácia';
'Podrobnosti o ceste zatiaľ dostupné nie sú. Skúste poslať správu na obnovenie.';
@override @override
String chat_pathSetHops(int hopCount, String status) { String get routing_modeAuto => 'Auto';
String _temp0 = intl.Intl.pluralLogic(
hopCount, @override
locale: localeName, String get routing_modeFlood => 'Povodňová vlna';
other: 'hops',
one: 'hop', @override
); String get routing_modeManual => 'Ručná príručka';
return 'Cesta nastavená: $hopCount $_temp0 - $status';
@override
String get routing_modeAutoHint =>
'Automaticky vyberá najznámejší trasa, a ak žiadna nie je známa, použije náhodnú trasu.';
@override
String get routing_modeFloodHint =>
'Prenos prostredníctvom všetkých opakovačov. Najspoľahlivejší spôsob, ale vyžaduje viac času vysielania.';
@override
String get routing_modeManualHint =>
'Vždy dodáva presne podľa zadaného trasy.';
@override
String get routing_currentRoute => 'Aktuálna trasa';
@override
String get routing_directNoHops => 'Priamo bez prechodných trás';
@override
String get routing_noPathYet =>
'Zatiaľ neexistuje žiadna cesta. Nasledujúce správy budú pokračovať, kým sa nenájde trasa.';
@override
String get routing_floodBroadcast =>
'Prenos prostredníctvom každého opakovača';
@override
String get routing_editPath => 'Upraviť trasu';
@override
String get routing_forgetPath => 'Zabudnite na trasu';
@override
String get routing_knownPaths => 'Známe cesty';
@override
String get routing_knownPathsHint =>
'Kliknite na cestu, aby ste sa k nej presunuli.';
@override
String get routing_inUse => 'V prevádzke';
@override
String get routing_qualityStrong => 'Silný prvý krok';
@override
String get routing_qualityGood => 'Úspešný prvý krok';
@override
String get routing_qualityFair => 'Prvá, spravodlivá fáza';
@override
String get routing_qualityWorked => 'Dosiahnutý úspech';
@override
String get routing_qualityFlood =>
'Zistil som to z informácií, ktoré som získal v dôsledku povodňovej situácie.';
@override
String get routing_qualityUntested => 'Neotestované';
@override
String routing_lastWorked(String when) {
return 'worked $when';
} }
@override
String get routing_neverWorked => 'nikedy nebolo potvrdené';
@override
String routing_deliveryCounts(int successes, int failures) {
return '$successes delivered, $failures failed';
}
@override
String get routing_floodDelivery => 'Doručenie v prípade povodní';
@override
String get pathEditor_title => 'Vytvorenie cesty';
@override
String pathEditor_hopCounter(int count) {
return '$count z 64 chmelových zŕš';
}
@override
String get pathEditor_noHops =>
'Zatiaľ žiadne chmel. Kliknite na opakované, aby ste ich pridali postupne, alebo uložte bez chmelu, aby ste ho mohli poslať priamo.';
@override
String get pathEditor_addHops => 'Pridávajte chmel podľa zadaného poriadku.';
@override
String get pathEditor_searchRepeaters => 'Hľadať opakované';
@override
String get pathEditor_advancedHex => 'Pokročilé: pôvodná hexová cesta';
@override
String get pathEditor_hexLabel => 'Prefiksy pre hexadecimálne čísla';
@override
String get pathEditor_hexHelper =>
'Dve hexové čísla na každý krok, oddelené čiarkami';
@override
String pathEditor_invalidTokens(String tokens) {
return 'Neplatné: $tokens';
}
@override
String get pathEditor_tooManyHops => 'Maximálne 64 krokov';
@override
String get pathEditor_usePath => 'Použite túto cestu';
@override
String get pathEditor_removeHop => 'Odstráňte chmel';
@override
String get pathEditor_unknownHop =>
'Neznáme zariadenie na opakované vysielanie';
@override @override
String get chat_pathSavedLocally => String get chat_pathSavedLocally =>
'Uložené lokálne. Spojte sa na synchronizáciu.'; 'Uložené lokálne. Spojte sa na synchronizáciu.';
@@ -1637,6 +1752,39 @@ class AppLocalizationsSk extends AppLocalizations {
@override @override
String get map_title => 'Mapa uzlov'; String get map_title => 'Mapa uzlov';
@override
String get map_searchHint => 'Vyhľadajte podľa názvu alebo ID uzla';
@override
String get map_activity => 'Aktivita';
@override
String get map_online => 'Online';
@override
String get map_recent => 'Nedávne';
@override
String get map_stale => 'Neaktuálne';
@override
String get map_visible => 'Viditeľný';
@override
String get map_hidden => 'Skrytý';
@override
String get map_centerOnNode => 'Nacentrovať na uzol';
@override
String get map_details => 'Podrobnosti';
@override
String get map_noGps => 'Bez GPS';
@override
String get map_noResults => 'Nenašli sa žiadne zodpovedajúce uzly.';
@override @override
String get map_lineOfSight => 'Úroveň výhľadu'; String get map_lineOfSight => 'Úroveň výhľadu';
@@ -1898,6 +2046,42 @@ class AppLocalizationsSk extends AppLocalizations {
return 'Neúspešné stiahnutia: $count'; return 'Neúspešné stiahnutia: $count';
} }
@override
String get mapCache_cachedTilesLabel => 'Cached tiles';
@override
String get mapCache_cachedTileSummaryLabel => 'Cached tile summary';
@override
String mapCache_bulkDownloadDisabledForSource(String source) {
return 'Offline bulk downloads are disabled for $source.';
}
@override
String mapCache_bulkDownloadDisabledInConfig(String source) {
return 'Offline bulk downloads are disabled for $source in this app configuration.';
}
@override
String mapCache_summarySource(String source) {
return 'Source: $source';
}
@override
String mapCache_summaryCachedTilesForSource(int count) {
return 'Cached tiles for source: $count';
}
@override
String mapCache_summaryCachedInSelection(int count) {
return 'Cached in selected area/zoom: $count';
}
@override
String mapCache_summaryApproxCacheSize(String size) {
return 'Approx cache size: $size';
}
@override @override
String mapCache_boundsLabel( String mapCache_boundsLabel(
String north, String north,
@@ -2029,66 +2213,13 @@ class AppLocalizationsSk extends AppLocalizations {
@override @override
String get common_clear => 'Zmazať'; String get common_clear => 'Zmazať';
@override
String path_currentPath(String path) {
return 'Aktívna cesta: $path';
}
@override
String path_usingHopsPath(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'hops',
one: 'hop',
);
return 'Používa $count $_temp0 cestu';
}
@override
String get path_enterCustomPath => 'Zadajte vlastný priebeh';
@override @override
String get path_currentPathLabel => 'Aktuálny priebeh'; String get path_currentPathLabel => 'Aktuálny priebeh';
@override
String get path_hexPrefixInstructions =>
'Zadajte 2-miestne hexové predpony pre každú fázu, oddelené čiarkami.';
@override
String get path_hexPrefixExample =>
'A1,F2,3C (každý uzel používa prvý bajt svojho verejného kľúča)';
@override
String get path_labelHexPrefixes => 'Cesty (hexové predpony)';
@override
String get path_helperMaxHops =>
'Max 64 skokov. Každý prefix je 2 hexadecimálne znaky (1 bajt).';
@override
String get path_selectFromContacts => 'Vyberte sa z kontaktov:';
@override @override
String get path_noRepeatersFound => String get path_noRepeatersFound =>
'Nenašli sa žiadne opakovače ani serverové miestnosti.'; 'Nenašli sa žiadne opakovače ani serverové miestnosti.';
@override
String get path_customPathsRequire =>
'Vlastné cesty vyžadujú medziletoch, ktoré môžu prenášať správky.';
@override
String path_invalidHexPrefixes(String prefixes) {
return 'Neplatné hexové predpony: $prefixes';
}
@override
String get path_tooLong =>
'Cesta je príliš dlhá. Umožnené je maximum 64 skokov.';
@override
String get path_setPath => 'Nastaviť cestu';
@override @override
String get repeater_management => 'Správa opakérov'; String get repeater_management => 'Správa opakérov';
@@ -2153,16 +2284,6 @@ class AppLocalizationsSk extends AppLocalizations {
@override @override
String get repeater_routingMode => 'Režim trasy'; String get repeater_routingMode => 'Režim trasy';
@override
String get repeater_autoUseSavedPath => 'Použiť uloženú cestu';
@override
String get repeater_forceFloodMode =>
'Zavrieť režim núdzového povodňového režimu';
@override
String get repeater_pathManagement => 'Správa trás';
@override @override
String get repeater_refresh => 'Obnoviť'; String get repeater_refresh => 'Obnoviť';
@@ -3255,6 +3376,139 @@ class AppLocalizationsSk extends AppLocalizations {
return '$celsius°C / $fahrenheit°F'; return '$celsius°C / $fahrenheit°F';
} }
@override
String get telemetry_digitalInputLabel => 'Digitálny vstup';
@override
String get telemetry_digitalOutputLabel => 'Digitálny výstup';
@override
String get telemetry_analogInputLabel => 'Analógový vstup';
@override
String get telemetry_analogOutputLabel => 'Analógový výstup';
@override
String get telemetry_genericLabel => 'Všeobecný senzor';
@override
String get telemetry_luminosityLabel => 'Osvetlenie';
@override
String get telemetry_presenceLabel => 'Prítomnosť';
@override
String get telemetry_humidityLabel => 'Vlhkosť';
@override
String get telemetry_accelerometerLabel => 'Akcelerometer';
@override
String get telemetry_pressureLabel => 'Tlak';
@override
String get telemetry_altitudeLabel => 'Nadmorská výška';
@override
String get telemetry_frequencyLabel => 'Frekvencia';
@override
String get telemetry_percentageLabel => 'Percento';
@override
String get telemetry_concentrationLabel => 'Koncentrácia';
@override
String get telemetry_powerLabel => 'Výkon';
@override
String get telemetry_distanceLabel => 'Vzdialenosť';
@override
String get telemetry_energyLabel => 'Energia';
@override
String get telemetry_directionLabel => 'Smer';
@override
String get telemetry_timeLabel => 'Čas';
@override
String get telemetry_gyrometerLabel => 'Gyrometer';
@override
String get telemetry_colourLabel => 'Farba';
@override
String get telemetry_gpsLabel => 'GPS';
@override
String get telemetry_switchLabel => 'Prepínač';
@override
String get telemetry_polylineLabel => 'Lomená čiara';
@override
String telemetry_altitudeValue(String meters) {
return '$meters m';
}
@override
String telemetry_frequencyValue(String hertz) {
return '$hertz Hz';
}
@override
String telemetry_pressureValue(String hpa) {
return '$hpa hPa';
}
@override
String telemetry_luminosityValue(String lux) {
return '$lux lx';
}
@override
String telemetry_powerValue(String watts) {
return '$watts W';
}
@override
String telemetry_distanceValue(String meters) {
return '$meters m';
}
@override
String telemetry_energyValue(String kilowattHours) {
return '$kilowattHours kWh';
}
@override
String telemetry_directionValue(String degrees) {
return '$degrees°';
}
@override
String telemetry_concentrationValue(String ppm) {
return '$ppm ppm';
}
@override
String telemetry_percentageValue(String percent) {
return '$percent%';
}
@override
String telemetry_analogValue(String value) {
return '$value';
}
@override
String get telemetry_autoFetchQuantity => 'Počet požiadaviek';
@override
String get telemetry_error => 'Nepodarilo sa získať údaje';
@override @override
String get neighbors_receivedData => 'Obdielo dáta suseda'; String get neighbors_receivedData => 'Obdielo dáta suseda';
@@ -4279,4 +4533,137 @@ class AppLocalizationsSk extends AppLocalizations {
@override @override
String get contact_typeUnknown => 'Unknown'; String get contact_typeUnknown => 'Unknown';
@override
String get map_zoomIn => 'Zväčšiť';
@override
String get map_zoomOut => 'Zmenť zamer zblízka';
@override
String get map_centerMap => 'Mapa centra';
@override
String get chrome_bluetoothRequiresChromium =>
'Web Bluetooth vyžaduje prehliadač Chromium.';
@override
String channels_communityShortId(String id) {
return 'ID: $id...';
}
@override
String get pathTrace_legendGpsConfirmed => 'GPS potvrdilo';
@override
String get pathTrace_legendInferred => 'Odvodená poloha';
@override
String get pathMap_viewSingle => 'Jednotlivý';
@override
String get pathMap_viewCombined => 'Spojené';
@override
String get pathMap_play => 'Prehrať';
@override
String get pathMap_pause => 'Pozastaviť';
@override
String get pathMap_replay => 'Prehrať znova';
@override
String get pathMap_stepBack => 'Predchádzajúci skok';
@override
String get pathMap_stepForward => 'Nasledujúci skok';
@override
String get pathMap_animationOn => 'Zobraziť animáciu paketu';
@override
String get pathMap_animationOff => 'Skryť animáciu paketu';
@override
String pathMap_hopOf(int current, int total) {
return 'Skok $current z $total';
}
@override
String pathMap_observedPaths(int count) {
return 'Pozorované cesty: $count';
}
@override
String get pathMap_primary => 'Primárna';
@override
String pathMap_alternate(int index) {
return 'Alternatívny $index';
}
@override
String pathMap_hopCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count skokov',
few: '$count skoky',
one: '1 skok',
);
return '$_temp0';
}
@override
String pathMap_gpsCount(int confirmed, int total) {
return '$confirmed/$total GPS';
}
@override
String get pathMap_legendShared => 'Spoločný segment';
@override
String get pathMap_legendEstimated => 'Odhadovaný segment';
@override
String pathMap_sharedNodeCount(int count) {
return 'Používané $count cestami';
}
@override
String pathMap_partialAnimation(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count skokov nemá polohu — zobrazená trasa je neúplná',
few: '$count skoky nemajú polohu — zobrazená trasa je neúplná',
one: '1 skok nemá polohu — zobrazená trasa je neúplná',
);
return '$_temp0';
}
@override
String get pathMap_showAllPaths => 'Zobraziť všetky';
@override
String get pathMap_hidePath => 'Skryť cestu';
@override
String get pathMap_showPath => 'Zobraziť trasu';
@override
String get pathMap_collapsePanel => 'Zatvoriť panel';
@override
String get pathMap_expandPanel => 'Rozbaliť panel';
@override
String get pathMap_noLocation => 'Bez polohy';
@override
String get pathMap_followPacket => 'Uzamknúť pohľad na paket';
@override
String get pathMap_unfollowPacket => 'Odomknúť pohľad od paketu';
} }
+523 -133
View File
@@ -92,6 +92,25 @@ class AppLocalizationsSl extends AppLocalizations {
@override @override
String get common_disable => 'Izklopiti'; String get common_disable => 'Izklopiti';
@override
String get common_undo => 'Preobrn';
@override
String get messageStatus_sent => 'Pošljeno';
@override
String get messageStatus_delivered => 'Dostavljeno';
@override
String get messageStatus_pending => 'Pošiljanje';
@override
String get messageStatus_failed =>
'Uspešno ni bilo mogo, da se sporočilo pošlje';
@override
String get messageStatus_repeated => 'Slišal sem večkrat';
@override @override
String get common_reboot => 'Ponoviti'; String get common_reboot => 'Ponoviti';
@@ -111,6 +130,12 @@ class AppLocalizationsSl extends AppLocalizations {
return '$percent %'; return '$percent %';
} }
@override
String get common_autoRefresh => 'Samodejno osveževanje';
@override
String get common_interval => 'Časovni interval';
@override @override
String get scanner_title => 'MeshCore Odprto'; String get scanner_title => 'MeshCore Odprto';
@@ -293,6 +318,10 @@ class AppLocalizationsSl extends AppLocalizations {
@override @override
String get scanner_enableBluetooth => 'Omogočite Bluetooth'; String get scanner_enableBluetooth => 'Omogočite Bluetooth';
@override
String get scanner_bluetoothWebUnsupported =>
'Funkcija Bluetooth v brskalniku ni na voljo. Povežite se preko USB-ja namesto tega.';
@override @override
String get device_quickSwitch => 'Hitro preklop'; String get device_quickSwitch => 'Hitro preklop';
@@ -777,11 +806,6 @@ class AppLocalizationsSl extends AppLocalizations {
String get appSettings_maxMessageRetriesSubtitle => String get appSettings_maxMessageRetriesSubtitle =>
'Število poskusov ponovnega poslanja, preden se sporočilo označuje kot neuspešno'; 'Število poskusov ponovnega poslanja, preden se sporočilo označuje kot neuspešno';
@override
String path_routeWeight(String weight, String max) {
return '$weight/$max';
}
@override @override
String get appSettings_battery => 'Baterija'; String get appSettings_battery => 'Baterija';
@@ -862,6 +886,28 @@ class AppLocalizationsSl extends AppLocalizations {
@override @override
String get appSettings_lastWeek => 'Prejšnji teden'; String get appSettings_lastWeek => 'Prejšnji teden';
@override
String get appSettings_rasterTileSource => 'Vir rastrskih ploščic';
@override
String get appSettings_stadiaEndpoint => 'Končna točka Stadia';
@override
String get appSettings_stadiaApiKey => 'Ključ API Stadia';
@override
String get appSettings_stadiaApiKeyRequired =>
'Obvezno za uporabo Stadia Maps';
@override
String appSettings_stadiaApiKeyConfigured(String maskedKey) {
return 'Nastavljeno: $maskedKey';
}
@override
String get appSettings_stadiaApiKeyDialogDescription =>
'Vnesite svoj ključ API za Stadia Maps. Aplikacija ga uporablja za zahteve rastrskih ploščic.';
@override @override
String get appSettings_offlineMapCache => 'Shramba zemljevidov brez povezave'; String get appSettings_offlineMapCache => 'Shramba zemljevidov brez povezave';
@@ -981,6 +1027,15 @@ class AppLocalizationsSl extends AppLocalizations {
@override @override
String get contacts_newGroup => 'Nova skupina'; String get contacts_newGroup => 'Nova skupina';
@override
String get contacts_moreOptions => 'Več možnosti';
@override
String get contacts_searchOpen => 'Iskanje kontaktov';
@override
String get contacts_searchClose => 'Izklopi iskanje';
@override @override
String get contacts_groupName => 'Ime skupine'; String get contacts_groupName => 'Ime skupine';
@@ -1460,34 +1515,6 @@ class AppLocalizationsSl extends AppLocalizations {
@override @override
String get debugFrame_hexDump => 'Izpis heksadecimalnih vrednosti:'; String get debugFrame_hexDump => 'Izpis heksadecimalnih vrednosti:';
@override
String get chat_pathManagement => 'Upravljanje poti';
@override
String get chat_ShowAllPaths => 'Prikaži vse poti';
@override
String get chat_routingMode => 'Navodilo za usmerjevalni način';
@override
String get chat_autoUseSavedPath => 'Avto (uporabi shranjeno pot)';
@override
String get chat_forceFloodMode => 'Nasilje obvezati v način';
@override
String get chat_recentAckPaths => 'Nedavni poti ACK (tap za uporabo):';
@override
String get chat_pathHistoryFull =>
'Zapiske o poti so popolni. Izbriši vnose, da dodaš nove.';
@override
String get chat_hopSingular => 'skok';
@override
String get chat_hopPlural => 'skokov';
@override @override
String chat_hopsCount(int count) { String chat_hopsCount(int count) {
String _temp0 = intl.Intl.pluralLogic( String _temp0 = intl.Intl.pluralLogic(
@@ -1499,12 +1526,6 @@ class AppLocalizationsSl extends AppLocalizations {
return '$count $_temp0'; return '$count $_temp0';
} }
@override
String get chat_successes => 'Uspešni';
@override
String get chat_score => 'Score';
@override @override
String get chat_removePath => 'Izbriši pot'; String get chat_removePath => 'Izbriši pot';
@@ -1512,51 +1533,144 @@ class AppLocalizationsSl extends AppLocalizations {
String get chat_noPathHistoryYet => String get chat_noPathHistoryYet =>
'Ni shranjenih poti.\nPošlji sporočilo za odkrivanje poti.'; 'Ni shranjenih poti.\nPošlji sporočilo za odkrivanje poti.';
@override
String get chat_pathActions => 'Potni ukazi:';
@override
String get chat_setCustomPath => 'Nastavi Prilozeno Pot';
@override
String get chat_setCustomPathSubtitle => 'Ročno določite potniško pot.';
@override
String get chat_clearPath => 'Počisti pot';
@override
String get chat_clearPathSubtitle => 'Ob naslednji pošiljanju znova zbrati.';
@override @override
String get chat_pathCleared => String get chat_pathCleared =>
'Pot je očiščena. Naslednje sporočilo bo ponovno odkril pot.'; 'Pot je očiščena. Naslednje sporočilo bo ponovno odkril pot.';
@override
String get chat_floodModeSubtitle =>
'Uporabi tipko usmerjevanja v meniju aplikacije.';
@override
String get chat_floodModeEnabled =>
'Narejena je bila omrežna modaliteta. Vklopi jo znova preko ikone v meniju aplikacije.';
@override @override
String get chat_fullPath => 'Polna pot'; String get chat_fullPath => 'Polna pot';
@override @override
String get chat_pathDetailsNotAvailable => String get routing_title => 'Navigacija';
'Podrobnosti poti zaenkrat niso na voljo. Poskusite poslati sporočilo za osvežitev.';
@override @override
String chat_pathSetHops(int hopCount, String status) { String get routing_modeAuto => 'Avto';
String _temp0 = intl.Intl.pluralLogic(
hopCount, @override
locale: localeName, String get routing_modeFlood => 'Poplavo';
other: 'hops',
one: 'hop', @override
); String get routing_modeManual => 'Navodilo';
return 'Pot nastavljen: $hopCount $_temp0 - $status';
@override
String get routing_modeAutoHint =>
'Samodejno izbere najbolj poznano pot, in sicer, ko ni na voljo nobena.';
@override
String get routing_modeFloodHint =>
'Prenosi preko vseh repetitorjev. Najzanesljivejši način, vendar zahteva več časa.';
@override
String get routing_modeManualHint =>
'Vedno sledi natančni poti, ki jo ste določili.';
@override
String get routing_currentRoute => 'Trenutna pot';
@override
String get routing_directNoHops => 'Neposredno brez prehodov';
@override
String get routing_noPathYet =>
'Žep trenutno ni mogoče najti. Naslednje sporočilo bo posredovano, dokler ne bo ugotovljeno, kje je pot.';
@override
String get routing_floodBroadcast => 'Prenos preko vseh repetitiv';
@override
String get routing_editPath => 'Uredi pot';
@override
String get routing_forgetPath => 'Pozabi na pot';
@override
String get routing_knownPaths => 'Poznati poti';
@override
String get routing_knownPathsHint => 'Kliknite na pot, da jo izberete.';
@override
String get routing_inUse => 'V uporabi';
@override
String get routing_qualityStrong => 'Močan prvi korak';
@override
String get routing_qualityGood => 'Prva uspešna faza';
@override
String get routing_qualityFair => 'Prva, uspešna faza';
@override
String get routing_qualityWorked => 'Izpolnil';
@override
String get routing_qualityFlood => 'Slišano preko poplave';
@override
String get routing_qualityUntested => 'Ne preizkušen';
@override
String routing_lastWorked(String when) {
return 'delal/a $when';
} }
@override
String get routing_neverWorked => 'nikoli ni bilo potrjeno';
@override
String routing_deliveryCounts(int successes, int failures) {
return '$successes delivered, $failures failed';
}
@override
String get routing_floodDelivery => 'Dostava zaradi poplave';
@override
String get pathEditor_title => 'Izgradnja poti';
@override
String pathEditor_hopCounter(int count) {
return '$count od 64 različnih sort hropa';
}
@override
String get pathEditor_noHops =>
'Še niso dodani hmelji. Za dodajanje hmelja v vrstnem redu kliknite na povezavo spodaj, ali pa shranite brez dodanega hmelja, da ga lahko posredujete neposredno.';
@override
String get pathEditor_addHops => 'Dodajte suho travo v skladu s postopkom.';
@override
String get pathEditor_searchRepeaters => 'Iskanje ponovitev';
@override
String get pathEditor_advancedHex => 'Napredno: surovi šestnajstni pot';
@override
String get pathEditor_hexLabel => 'Predfiks za heksadecimalno šifro';
@override
String get pathEditor_hexHelper =>
'Dva šestbitna znaka na vsak skok, ločena z vejico';
@override
String pathEditor_invalidTokens(String tokens) {
return 'Neveljaven: $tokens';
}
@override
String get pathEditor_tooManyHops => 'Največ 64 hopov';
@override
String get pathEditor_usePath => 'Uporabite to poto';
@override
String get pathEditor_removeHop => 'Odstranite hmelj';
@override
String get pathEditor_unknownHop => 'Neznani ponovitelj';
@override @override
String get chat_pathSavedLocally => String get chat_pathSavedLocally =>
'Shrano lokalno. Povežite se za sinhronizacijo.'; 'Shrano lokalno. Povežite se za sinhronizacijo.';
@@ -1631,6 +1745,39 @@ class AppLocalizationsSl extends AppLocalizations {
@override @override
String get map_title => 'Mapa omrežja'; String get map_title => 'Mapa omrežja';
@override
String get map_searchHint => 'Iščite ime ali ID vozlišča';
@override
String get map_activity => 'Dejavnost';
@override
String get map_online => 'V omrežju';
@override
String get map_recent => 'Nedavni';
@override
String get map_stale => 'Zastarelo';
@override
String get map_visible => 'Vidno';
@override
String get map_hidden => 'Skrit';
@override
String get map_centerOnNode => 'Centriraj na vozlišče';
@override
String get map_details => 'Podrobnosti';
@override
String get map_noGps => 'Brez GPS';
@override
String get map_noResults => 'Ni ujemajočih se vozlišč';
@override @override
String get map_lineOfSight => 'Linija vida'; String get map_lineOfSight => 'Linija vida';
@@ -1896,6 +2043,42 @@ class AppLocalizationsSl extends AppLocalizations {
return 'Poslovniški izniki: $count'; return 'Poslovniški izniki: $count';
} }
@override
String get mapCache_cachedTilesLabel => 'Cached tiles';
@override
String get mapCache_cachedTileSummaryLabel => 'Cached tile summary';
@override
String mapCache_bulkDownloadDisabledForSource(String source) {
return 'Offline bulk downloads are disabled for $source.';
}
@override
String mapCache_bulkDownloadDisabledInConfig(String source) {
return 'Offline bulk downloads are disabled for $source in this app configuration.';
}
@override
String mapCache_summarySource(String source) {
return 'Source: $source';
}
@override
String mapCache_summaryCachedTilesForSource(int count) {
return 'Cached tiles for source: $count';
}
@override
String mapCache_summaryCachedInSelection(int count) {
return 'Cached in selected area/zoom: $count';
}
@override
String mapCache_summaryApproxCacheSize(String size) {
return 'Approx cache size: $size';
}
@override @override
String mapCache_boundsLabel( String mapCache_boundsLabel(
String north, String north,
@@ -2026,65 +2209,13 @@ class AppLocalizationsSl extends AppLocalizations {
@override @override
String get common_clear => 'Ponoviti'; String get common_clear => 'Ponoviti';
@override
String path_currentPath(String path) {
return 'Trenutna pot: $path';
}
@override
String path_usingHopsPath(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'hops',
one: 'hop',
);
return 'Uporablja $count $_temp0 pot';
}
@override
String get path_enterCustomPath => 'Vnesite prilagojeno pot';
@override @override
String get path_currentPathLabel => 'Trenutna pot'; String get path_currentPathLabel => 'Trenutna pot';
@override
String get path_hexPrefixInstructions =>
'Vnesite 2-karakterne heksadecimalne prefixe za vsako skopo, ločeno z zvezekami.';
@override
String get path_hexPrefixExample =>
'Primer: A1,F2,3C (vsak notranji element uporablja prvi bajt svojega javnega ključa)';
@override
String get path_labelHexPrefixes => 'Pot (heksafixne skrajšave)';
@override
String get path_helperMaxHops =>
'Maksimalno 64 skokov. Vsak prefiks je 2 heksadecimalna znamenja (1 bajt).';
@override
String get path_selectFromContacts => 'Izberi iz kontaktov:';
@override @override
String get path_noRepeatersFound => String get path_noRepeatersFound =>
'Ne najdenih ponoviteljev ali strežnikov sob.'; 'Ne najdenih ponoviteljev ali strežnikov sob.';
@override
String get path_customPathsRequire =>
'Prilojene poti zahtevajo medhodne prenose, ki lahko prenašajo sporočila.';
@override
String path_invalidHexPrefixes(String prefixes) {
return 'Neveljačni šesteročlenski prefiksi: $prefixes';
}
@override
String get path_tooLong => 'Pot je prevelika. Dovoljeno največ 64 skokov.';
@override
String get path_setPath => 'Nastavi Pot';
@override @override
String get repeater_management => 'Upravljanje ponovitve'; String get repeater_management => 'Upravljanje ponovitve';
@@ -2150,15 +2281,6 @@ class AppLocalizationsSl extends AppLocalizations {
@override @override
String get repeater_routingMode => 'Navodilo za usmerjevalni način'; String get repeater_routingMode => 'Navodilo za usmerjevalni način';
@override
String get repeater_autoUseSavedPath => 'Avto (uporabi shranjeno pot)';
@override
String get repeater_forceFloodMode => 'Nasilje obvezati v način';
@override
String get repeater_pathManagement => 'Upravljanje poti';
@override @override
String get repeater_refresh => 'Ponovno obnavljati'; String get repeater_refresh => 'Ponovno obnavljati';
@@ -3250,6 +3372,139 @@ class AppLocalizationsSl extends AppLocalizations {
return '$celsius°C / $fahrenheit°F'; return '$celsius°C / $fahrenheit°F';
} }
@override
String get telemetry_digitalInputLabel => 'Digitalni vhod';
@override
String get telemetry_digitalOutputLabel => 'Digitalni izhod';
@override
String get telemetry_analogInputLabel => 'Analogni vhod';
@override
String get telemetry_analogOutputLabel => 'Analogni izhod';
@override
String get telemetry_genericLabel => 'Splošni senzor';
@override
String get telemetry_luminosityLabel => 'Osvetljenost';
@override
String get telemetry_presenceLabel => 'Prisotnost';
@override
String get telemetry_humidityLabel => 'Vlažnost';
@override
String get telemetry_accelerometerLabel => 'Merilnik pospeška';
@override
String get telemetry_pressureLabel => 'Tlak';
@override
String get telemetry_altitudeLabel => 'Nadmorska višina';
@override
String get telemetry_frequencyLabel => 'Frekvenca';
@override
String get telemetry_percentageLabel => 'Odstotek';
@override
String get telemetry_concentrationLabel => 'Koncentracija';
@override
String get telemetry_powerLabel => 'Moč';
@override
String get telemetry_distanceLabel => 'Razdalja';
@override
String get telemetry_energyLabel => 'Energija';
@override
String get telemetry_directionLabel => 'Smer';
@override
String get telemetry_timeLabel => 'Čas';
@override
String get telemetry_gyrometerLabel => 'Žiroskop';
@override
String get telemetry_colourLabel => 'Barva';
@override
String get telemetry_gpsLabel => 'GPS';
@override
String get telemetry_switchLabel => 'Stikalo';
@override
String get telemetry_polylineLabel => 'Polilinija';
@override
String telemetry_altitudeValue(String meters) {
return '$meters m';
}
@override
String telemetry_frequencyValue(String hertz) {
return '$hertz Hz';
}
@override
String telemetry_pressureValue(String hpa) {
return '$hpa hPa';
}
@override
String telemetry_luminosityValue(String lux) {
return '$lux lx';
}
@override
String telemetry_powerValue(String watts) {
return '$watts W';
}
@override
String telemetry_distanceValue(String meters) {
return '$meters m';
}
@override
String telemetry_energyValue(String kilowattHours) {
return '$kilowattHours kWh';
}
@override
String telemetry_directionValue(String degrees) {
return '$degrees°';
}
@override
String telemetry_concentrationValue(String ppm) {
return '$ppm ppm';
}
@override
String telemetry_percentageValue(String percent) {
return '$percent%';
}
@override
String telemetry_analogValue(String value) {
return '$value';
}
@override
String get telemetry_autoFetchQuantity => 'Število zahtev';
@override
String get telemetry_error => 'Podatkov ni bilo mogoče pridobiti';
@override @override
String get neighbors_receivedData => 'Prejeto podatke o sosedih'; String get neighbors_receivedData => 'Prejeto podatke o sosedih';
@@ -4277,4 +4532,139 @@ class AppLocalizationsSl extends AppLocalizations {
@override @override
String get contact_typeUnknown => 'Unknown'; String get contact_typeUnknown => 'Unknown';
@override
String get map_zoomIn => 'Povečaj';
@override
String get map_zoomOut => 'Povečajte pogled';
@override
String get map_centerMap => 'Krajšarska karta';
@override
String get chrome_bluetoothRequiresChromium =>
'Web Bluetooth zahteva brskalnik Chromium.';
@override
String channels_communityShortId(String id) {
return 'ID: $id...';
}
@override
String get pathTrace_legendGpsConfirmed => 'GPS potrdilo';
@override
String get pathTrace_legendInferred => 'Izpeljana lokacija';
@override
String get pathMap_viewSingle => 'Posamično';
@override
String get pathMap_viewCombined => 'Skupno';
@override
String get pathMap_play => 'Predvajaj';
@override
String get pathMap_pause => 'Premor';
@override
String get pathMap_replay => 'Ponovitev';
@override
String get pathMap_stepBack => 'Prejšnji skok';
@override
String get pathMap_stepForward => 'Naslednji skok';
@override
String get pathMap_animationOn => 'Prikaži animacijo paketa';
@override
String get pathMap_animationOff => 'Skrij animacijo paketa';
@override
String pathMap_hopOf(int current, int total) {
return 'Skok $current od $total';
}
@override
String pathMap_observedPaths(int count) {
return 'Opazovane poti: $count';
}
@override
String get pathMap_primary => 'Primarna';
@override
String pathMap_alternate(int index) {
return 'Alternativa $index';
}
@override
String pathMap_hopCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count skokov',
few: '$count skoki',
two: '2 skoka',
one: '1 skok',
);
return '$_temp0';
}
@override
String pathMap_gpsCount(int confirmed, int total) {
return '$confirmed/$total GPS';
}
@override
String get pathMap_legendShared => 'Deljen segment';
@override
String get pathMap_legendEstimated => 'Ocenjen segment';
@override
String pathMap_sharedNodeCount(int count) {
return 'Uporablja $count poti';
}
@override
String pathMap_partialAnimation(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count skokov nima lokacije — prikazana pot je delna',
few: '$count skoki nimajo lokacije — prikazana pot je delna',
two: '2 skoka nimata lokacije — prikazana pot je delna',
one: '1 skok nima lokacije — prikazana pot je delna',
);
return '$_temp0';
}
@override
String get pathMap_showAllPaths => 'Pokaži vse';
@override
String get pathMap_hidePath => 'Skrij pot';
@override
String get pathMap_showPath => 'Pokaži pot';
@override
String get pathMap_collapsePanel => 'Strni ploščo';
@override
String get pathMap_expandPanel => 'Razširi ploščo';
@override
String get pathMap_noLocation => 'Brez lokacije';
@override
String get pathMap_followPacket => 'Zakleni pogled na paket';
@override
String get pathMap_unfollowPacket => 'Odkleni pogled od paketa';
} }
+518 -133
View File
@@ -92,6 +92,24 @@ class AppLocalizationsSv extends AppLocalizations {
@override @override
String get common_disable => 'Inaktivera'; String get common_disable => 'Inaktivera';
@override
String get common_undo => 'Ångra';
@override
String get messageStatus_sent => 'Sen';
@override
String get messageStatus_delivered => 'Levererad';
@override
String get messageStatus_pending => 'Skicka';
@override
String get messageStatus_failed => 'Misslyckades med att skicka';
@override
String get messageStatus_repeated => 'Hördes upprepade gånger';
@override @override
String get common_reboot => 'Start om'; String get common_reboot => 'Start om';
@@ -111,6 +129,12 @@ class AppLocalizationsSv extends AppLocalizations {
return '$percent%'; return '$percent%';
} }
@override
String get common_autoRefresh => 'Automatisk uppdatering';
@override
String get common_interval => 'Intervall';
@override @override
String get scanner_title => 'MeshCore Öppen version'; String get scanner_title => 'MeshCore Öppen version';
@@ -292,6 +316,10 @@ class AppLocalizationsSv extends AppLocalizations {
@override @override
String get scanner_enableBluetooth => 'Aktivera Bluetooth'; String get scanner_enableBluetooth => 'Aktivera Bluetooth';
@override
String get scanner_bluetoothWebUnsupported =>
'Bluetooth är inte tillgängligt i webbläsaren. Anslut istället via USB.';
@override @override
String get device_quickSwitch => 'Snabb växling'; String get device_quickSwitch => 'Snabb växling';
@@ -771,11 +799,6 @@ class AppLocalizationsSv extends AppLocalizations {
String get appSettings_maxMessageRetriesSubtitle => String get appSettings_maxMessageRetriesSubtitle =>
'Antal försök att skicka om ett meddelande innan det markeras som misslyckat.'; 'Antal försök att skicka om ett meddelande innan det markeras som misslyckat.';
@override
String path_routeWeight(String weight, String max) {
return '$weight/$max';
}
@override @override
String get appSettings_battery => 'Batteri'; String get appSettings_battery => 'Batteri';
@@ -856,6 +879,28 @@ class AppLocalizationsSv extends AppLocalizations {
@override @override
String get appSettings_lastWeek => 'Förra veckan'; String get appSettings_lastWeek => 'Förra veckan';
@override
String get appSettings_rasterTileSource => 'Källa för rasterplattor';
@override
String get appSettings_stadiaEndpoint => 'Stadia-slutpunkt';
@override
String get appSettings_stadiaApiKey => 'Stadia API-nyckel';
@override
String get appSettings_stadiaApiKeyRequired =>
'Krävs för att använda Stadia Maps';
@override
String appSettings_stadiaApiKeyConfigured(String maskedKey) {
return 'Konfigurerad: $maskedKey';
}
@override
String get appSettings_stadiaApiKeyDialogDescription =>
'Ange din Stadia Maps API-nyckel. Appen använder den för förfrågningar om rasterplattor.';
@override @override
String get appSettings_offlineMapCache => 'Offline Kartcache'; String get appSettings_offlineMapCache => 'Offline Kartcache';
@@ -976,6 +1021,15 @@ class AppLocalizationsSv extends AppLocalizations {
@override @override
String get contacts_newGroup => 'Ny grupp'; String get contacts_newGroup => 'Ny grupp';
@override
String get contacts_moreOptions => 'Fler alternativ';
@override
String get contacts_searchOpen => 'Sök efter kontakter';
@override
String get contacts_searchClose => 'Avancerad sökning';
@override @override
String get contacts_groupName => 'Gruppnamn'; String get contacts_groupName => 'Gruppnamn';
@@ -1454,35 +1508,6 @@ class AppLocalizationsSv extends AppLocalizations {
@override @override
String get debugFrame_hexDump => 'Hexdump:'; String get debugFrame_hexDump => 'Hexdump:';
@override
String get chat_pathManagement => 'Stigarhantering';
@override
String get chat_ShowAllPaths => 'Visa alla vägar';
@override
String get chat_routingMode => 'Ruttläge';
@override
String get chat_autoUseSavedPath => 'Automatisk (använd sparad sökväg)';
@override
String get chat_forceFloodMode => 'Tvinga Översvämningsläge';
@override
String get chat_recentAckPaths =>
'Nyligen Ack-vägar (tryck för att använda):';
@override
String get chat_pathHistoryFull =>
'Historisk sökväg är full. Ta bort poster för att lägga till nya.';
@override
String get chat_hopSingular => 'hoppa';
@override
String get chat_hopPlural => 'hoppar';
@override @override
String chat_hopsCount(int count) { String chat_hopsCount(int count) {
String _temp0 = intl.Intl.pluralLogic( String _temp0 = intl.Intl.pluralLogic(
@@ -1494,12 +1519,6 @@ class AppLocalizationsSv extends AppLocalizations {
return '$count $_temp0'; return '$count $_temp0';
} }
@override
String get chat_successes => 'framgångar';
@override
String get chat_score => 'Score';
@override @override
String get chat_removePath => 'Ta bort sökväg'; String get chat_removePath => 'Ta bort sökväg';
@@ -1507,50 +1526,144 @@ class AppLocalizationsSv extends AppLocalizations {
String get chat_noPathHistoryYet => String get chat_noPathHistoryYet =>
'Ingen historik ännu.\nSkicka ett meddelande för att upptäcka spår.'; 'Ingen historik ännu.\nSkicka ett meddelande för att upptäcka spår.';
@override
String get chat_pathActions => 'Stigar:';
@override
String get chat_setCustomPath => 'Ange anpassad sökväg';
@override
String get chat_setCustomPathSubtitle => 'Ange ruttväg manuellt';
@override
String get chat_clearPath => 'Rensa Vägen';
@override
String get chat_clearPathSubtitle => 'Tvinga fram omstart vid nästa sändning';
@override @override
String get chat_pathCleared => String get chat_pathCleared =>
'Routen är nu fri. Nästa meddelande kommer att upptäcka rutten igen.'; 'Routen är nu fri. Nästa meddelande kommer att upptäcka rutten igen.';
@override
String get chat_floodModeSubtitle => 'Använd routningsomkopplaren i appraden';
@override
String get chat_floodModeEnabled =>
'Översvämningsläge aktiverat. Stäng av via ruttikonen i appraden.';
@override @override
String get chat_fullPath => 'Fullständig sökväg'; String get chat_fullPath => 'Fullständig sökväg';
@override @override
String get chat_pathDetailsNotAvailable => String get routing_title => 'Ruttplanering';
'Stigaruppgifterna är ännu inte tillgängliga. Försök att skicka ett meddelande för att uppdatera.';
@override @override
String chat_pathSetHops(int hopCount, String status) { String get routing_modeAuto => 'Bil';
String _temp0 = intl.Intl.pluralLogic(
hopCount, @override
locale: localeName, String get routing_modeFlood => 'Översvämning';
other: 'hoppar',
one: 'hopp', @override
); String get routing_modeManual => 'Instruktioner';
return 'Sökväg inställd: $hopCount $_temp0 - $status';
@override
String get routing_modeAutoHint =>
'Väljer automatiskt den bästa kända vägen, och använder en \"flooding\"-strategi om ingen väg är känd.';
@override
String get routing_modeFloodHint =>
'Sändningar via alla repetrar. Det mest pålitliga alternativet, men kräver mer sändtid.';
@override
String get routing_modeManualHint =>
'Skickar alltid den exakta väg du har angivit.';
@override
String get routing_currentRoute => 'Nuvarande rutt';
@override
String get routing_directNoHops => 'Direkt utan mellanliggande routrar';
@override
String get routing_noPathYet =>
'Ingen väg hittad ännu. Nästa meddelande skickas tills en rutt har upptäckts.';
@override
String get routing_floodBroadcast => 'Sändas via alla repetrar';
@override
String get routing_editPath => 'Redigera sökväg';
@override
String get routing_forgetPath => 'Glöm vägen';
@override
String get routing_knownPaths => 'Kända vägar';
@override
String get routing_knownPathsHint => 'Välj en väg för att byta till den.';
@override
String get routing_inUse => 'I användning';
@override
String get routing_qualityStrong => 'En stark start';
@override
String get routing_qualityGood => 'Bra första steg';
@override
String get routing_qualityFair => 'Bra första hopp';
@override
String get routing_qualityWorked => 'Har levererat';
@override
String get routing_qualityFlood => 'Fått information via nyhetsflöde';
@override
String get routing_qualityUntested => 'Ej testat';
@override
String routing_lastWorked(String when) {
return 'arbetade $when';
} }
@override
String get routing_neverWorked => 'aldrig bekräftat';
@override
String routing_deliveryCounts(int successes, int failures) {
return '$successes delivered, $failures failed';
}
@override
String get routing_floodDelivery => 'Leverans vid översvämningsområde';
@override
String get pathEditor_title => 'Skapa väg';
@override
String pathEditor_hopCounter(int count) {
return '$count av 64 humlor';
}
@override
String get pathEditor_noHops =>
'Inga humle än. Använd knapparna nedan för att lägga till dem i rätt ordning, eller spara utan humle för att skicka direkt.';
@override
String get pathEditor_addHops => 'Tillsätt humlen i rätt ordning.';
@override
String get pathEditor_searchRepeaters => 'Sök efter återupptagna samtal';
@override
String get pathEditor_advancedHex => 'Avancerat: rå hex-sökväg';
@override
String get pathEditor_hexLabel => 'Hex-prefikser';
@override
String get pathEditor_hexHelper =>
'Två hex-tecken per steg, separerade med kommatecken.';
@override
String pathEditor_invalidTokens(String tokens) {
return 'Ogiltigt: $tokens';
}
@override
String get pathEditor_tooManyHops => 'Maximalt 64 humlörter';
@override
String get pathEditor_usePath => 'Använd denna väg';
@override
String get pathEditor_removeHop => 'Ta bort humlen';
@override
String get pathEditor_unknownHop => 'Okänd förstärkare';
@override @override
String get chat_pathSavedLocally => String get chat_pathSavedLocally =>
'Sparat lokalt. Anslut för att synkronisera.'; 'Sparat lokalt. Anslut för att synkronisera.';
@@ -1625,6 +1738,39 @@ class AppLocalizationsSv extends AppLocalizations {
@override @override
String get map_title => 'Nodkarta'; String get map_title => 'Nodkarta';
@override
String get map_searchHint => 'Sök efter nodens namn eller ID';
@override
String get map_activity => 'Aktivitet';
@override
String get map_online => 'Online';
@override
String get map_recent => 'Nyligen';
@override
String get map_stale => 'Inaktuell';
@override
String get map_visible => 'Synlig';
@override
String get map_hidden => 'Dold';
@override
String get map_centerOnNode => 'Centrera på nod';
@override
String get map_details => 'Detaljer';
@override
String get map_noGps => 'Ingen GPS';
@override
String get map_noResults => 'Inga matchande noder';
@override @override
String get map_lineOfSight => 'Synlinje'; String get map_lineOfSight => 'Synlinje';
@@ -1885,6 +2031,42 @@ class AppLocalizationsSv extends AppLocalizations {
return 'Misslyckade nedladdningar: $count'; return 'Misslyckade nedladdningar: $count';
} }
@override
String get mapCache_cachedTilesLabel => 'Cached tiles';
@override
String get mapCache_cachedTileSummaryLabel => 'Cached tile summary';
@override
String mapCache_bulkDownloadDisabledForSource(String source) {
return 'Offline bulk downloads are disabled for $source.';
}
@override
String mapCache_bulkDownloadDisabledInConfig(String source) {
return 'Offline bulk downloads are disabled for $source in this app configuration.';
}
@override
String mapCache_summarySource(String source) {
return 'Source: $source';
}
@override
String mapCache_summaryCachedTilesForSource(int count) {
return 'Cached tiles for source: $count';
}
@override
String mapCache_summaryCachedInSelection(int count) {
return 'Cached in selected area/zoom: $count';
}
@override
String mapCache_summaryApproxCacheSize(String size) {
return 'Approx cache size: $size';
}
@override @override
String mapCache_boundsLabel( String mapCache_boundsLabel(
String north, String north,
@@ -2015,65 +2197,13 @@ class AppLocalizationsSv extends AppLocalizations {
@override @override
String get common_clear => 'Rensa'; String get common_clear => 'Rensa';
@override
String path_currentPath(String path) {
return 'Nuvarande sökväg: $path';
}
@override
String path_usingHopsPath(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'hops',
one: 'hop',
);
return 'Använda $count $_temp0 sökväg';
}
@override
String get path_enterCustomPath => 'Ange anpassad sökväg';
@override @override
String get path_currentPathLabel => 'Nuvarande sökväg'; String get path_currentPathLabel => 'Nuvarande sökväg';
@override
String get path_hexPrefixInstructions =>
'Ange 2-tecknets hex-prefett för varje hopp, åtskilda med komma.';
@override
String get path_hexPrefixExample =>
'Exempel: A1,F2,3C (varje nod använder det första bytet av sitt publika nyckel)';
@override
String get path_labelHexPrefixes => 'Hexprefixer';
@override
String get path_helperMaxHops =>
'Max 64 hopp. Varje prefix är 2 hex-tecken (1 byte)';
@override
String get path_selectFromContacts => 'Välj istället från kontakter:';
@override @override
String get path_noRepeatersFound => String get path_noRepeatersFound =>
'Inga återuppspelare eller rumsservrar hittades.'; 'Inga återuppspelare eller rumsservrar hittades.';
@override
String get path_customPathsRequire =>
'Anpassade sökvägar kräver mellansteg som kan vidarebefordra meddelanden.';
@override
String path_invalidHexPrefixes(String prefixes) {
return 'Ogiltiga hex-prefikser: $prefixes';
}
@override
String get path_tooLong => 'Sökvägen är för lång. Max 64 hopp tillåtna.';
@override
String get path_setPath => 'Ange Sökväg';
@override @override
String get repeater_management => 'Återuppspelarens Hantering'; String get repeater_management => 'Återuppspelarens Hantering';
@@ -2138,15 +2268,6 @@ class AppLocalizationsSv extends AppLocalizations {
@override @override
String get repeater_routingMode => 'Ruttläge'; String get repeater_routingMode => 'Ruttläge';
@override
String get repeater_autoUseSavedPath => 'Automatisk (använd sparad sökväg)';
@override
String get repeater_forceFloodMode => 'Tvinga Översvämningsläge';
@override
String get repeater_pathManagement => 'Stigarhantering';
@override @override
String get repeater_refresh => 'Uppdatera'; String get repeater_refresh => 'Uppdatera';
@@ -3231,6 +3352,139 @@ class AppLocalizationsSv extends AppLocalizations {
return '$celsius°C / $fahrenheit°F'; return '$celsius°C / $fahrenheit°F';
} }
@override
String get telemetry_digitalInputLabel => 'Digital ingång';
@override
String get telemetry_digitalOutputLabel => 'Digital utgång';
@override
String get telemetry_analogInputLabel => 'Analog ingång';
@override
String get telemetry_analogOutputLabel => 'Analog utgång';
@override
String get telemetry_genericLabel => 'Allmän sensor';
@override
String get telemetry_luminosityLabel => 'Ljusstyrka';
@override
String get telemetry_presenceLabel => 'Närvaro';
@override
String get telemetry_humidityLabel => 'Luftfuktighet';
@override
String get telemetry_accelerometerLabel => 'Accelerometer';
@override
String get telemetry_pressureLabel => 'Tryck';
@override
String get telemetry_altitudeLabel => 'Höjd';
@override
String get telemetry_frequencyLabel => 'Frekvens';
@override
String get telemetry_percentageLabel => 'Procent';
@override
String get telemetry_concentrationLabel => 'Koncentration';
@override
String get telemetry_powerLabel => 'Effekt';
@override
String get telemetry_distanceLabel => 'Avstånd';
@override
String get telemetry_energyLabel => 'Energi';
@override
String get telemetry_directionLabel => 'Riktning';
@override
String get telemetry_timeLabel => 'Tid';
@override
String get telemetry_gyrometerLabel => 'Gyrometer';
@override
String get telemetry_colourLabel => 'Färg';
@override
String get telemetry_gpsLabel => 'GPS';
@override
String get telemetry_switchLabel => 'Brytare';
@override
String get telemetry_polylineLabel => 'Polylinje';
@override
String telemetry_altitudeValue(String meters) {
return '$meters m';
}
@override
String telemetry_frequencyValue(String hertz) {
return '$hertz Hz';
}
@override
String telemetry_pressureValue(String hpa) {
return '$hpa hPa';
}
@override
String telemetry_luminosityValue(String lux) {
return '$lux lx';
}
@override
String telemetry_powerValue(String watts) {
return '$watts W';
}
@override
String telemetry_distanceValue(String meters) {
return '$meters m';
}
@override
String telemetry_energyValue(String kilowattHours) {
return '$kilowattHours kWh';
}
@override
String telemetry_directionValue(String degrees) {
return '$degrees°';
}
@override
String telemetry_concentrationValue(String ppm) {
return '$ppm ppm';
}
@override
String telemetry_percentageValue(String percent) {
return '$percent%';
}
@override
String telemetry_analogValue(String value) {
return '$value';
}
@override
String get telemetry_autoFetchQuantity => 'Antal förfrågningar';
@override
String get telemetry_error => 'Det gick inte att hämta data';
@override @override
String get neighbors_receivedData => 'Mottagna grannars data'; String get neighbors_receivedData => 'Mottagna grannars data';
@@ -4252,4 +4506,135 @@ class AppLocalizationsSv extends AppLocalizations {
@override @override
String get contact_typeUnknown => 'Unknown'; String get contact_typeUnknown => 'Unknown';
@override
String get map_zoomIn => 'Zooma in';
@override
String get map_zoomOut => 'Zooma ut';
@override
String get map_centerMap => 'Kartöversikt';
@override
String get chrome_bluetoothRequiresChromium =>
'Web Bluetooth kräver en Chromium-baserad webbläsare.';
@override
String channels_communityShortId(String id) {
return 'ID: $id...';
}
@override
String get pathTrace_legendGpsConfirmed => 'GPS-verifierat';
@override
String get pathTrace_legendInferred => 'Antagen position';
@override
String get pathMap_viewSingle => 'Enkel';
@override
String get pathMap_viewCombined => 'Kombinerat';
@override
String get pathMap_play => 'Spela';
@override
String get pathMap_pause => 'Pausa';
@override
String get pathMap_replay => 'Återspela';
@override
String get pathMap_stepBack => 'Föregående hopp';
@override
String get pathMap_stepForward => 'Nästa hopp';
@override
String get pathMap_animationOn => 'Visa paketanimering';
@override
String get pathMap_animationOff => 'Dölj paketanimering';
@override
String pathMap_hopOf(int current, int total) {
return 'Hopp $current av $total';
}
@override
String pathMap_observedPaths(int count) {
return 'Observerade vägar: $count';
}
@override
String get pathMap_primary => 'Primär';
@override
String pathMap_alternate(int index) {
return 'Alternativ $index';
}
@override
String pathMap_hopCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count hopp',
one: '1 hopp',
);
return '$_temp0';
}
@override
String pathMap_gpsCount(int confirmed, int total) {
return '$confirmed/$total GPS';
}
@override
String get pathMap_legendShared => 'Delat segment';
@override
String get pathMap_legendEstimated => 'Uppskattat segment';
@override
String pathMap_sharedNodeCount(int count) {
return 'Används av $count vägar';
}
@override
String pathMap_partialAnimation(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count hopp saknar position — den visade vägen är ofullständig',
one: '1 hopp saknar position — den visade vägen är ofullständig',
);
return '$_temp0';
}
@override
String get pathMap_showAllPaths => 'Visa allt';
@override
String get pathMap_hidePath => 'Dölj väg';
@override
String get pathMap_showPath => 'Visa väg';
@override
String get pathMap_collapsePanel => 'Fäll ihop panel';
@override
String get pathMap_expandPanel => 'Expandera panel';
@override
String get pathMap_noLocation => 'Ingen position';
@override
String get pathMap_followPacket => 'Lås vy till paket';
@override
String get pathMap_unfollowPacket => 'Lås upp vy från paket';
} }
+527 -140
View File
@@ -92,6 +92,24 @@ class AppLocalizationsUk extends AppLocalizations {
@override @override
String get common_disable => 'Вимкнути'; String get common_disable => 'Вимкнути';
@override
String get common_undo => 'Скасувати';
@override
String get messageStatus_sent => 'Надіслано';
@override
String get messageStatus_delivered => 'Доставлено';
@override
String get messageStatus_pending => 'Надсилання';
@override
String get messageStatus_failed => 'Не вдалося надіслати';
@override
String get messageStatus_repeated => 'Почув неодноразово';
@override @override
String get common_reboot => 'Перезавантажити'; String get common_reboot => 'Перезавантажити';
@@ -111,6 +129,12 @@ class AppLocalizationsUk extends AppLocalizations {
return '$percent%'; return '$percent%';
} }
@override
String get common_autoRefresh => 'Автооновлення';
@override
String get common_interval => 'Інтервал';
@override @override
String get scanner_title => 'MeshCore: Відкритий доступ'; String get scanner_title => 'MeshCore: Відкритий доступ';
@@ -295,6 +319,10 @@ class AppLocalizationsUk extends AppLocalizations {
@override @override
String get scanner_enableBluetooth => 'Увімкніть Bluetooth'; String get scanner_enableBluetooth => 'Увімкніть Bluetooth';
@override
String get scanner_bluetoothWebUnsupported =>
'Bluetooth недоступний у браузері. Підключіться через USB.';
@override @override
String get device_quickSwitch => 'Швидке перемикання'; String get device_quickSwitch => 'Швидке перемикання';
@@ -783,11 +811,6 @@ class AppLocalizationsUk extends AppLocalizations {
String get appSettings_maxMessageRetriesSubtitle => String get appSettings_maxMessageRetriesSubtitle =>
'Кількість спроб повторного відправлення повідомлення перед тим, як позначити його як невдале'; 'Кількість спроб повторного відправлення повідомлення перед тим, як позначити його як невдале';
@override
String path_routeWeight(String weight, String max) {
return '$weight/$max';
}
@override @override
String get appSettings_battery => 'Батарея'; String get appSettings_battery => 'Батарея';
@@ -869,6 +892,28 @@ class AppLocalizationsUk extends AppLocalizations {
@override @override
String get appSettings_lastWeek => 'Минулий тиждень'; String get appSettings_lastWeek => 'Минулий тиждень';
@override
String get appSettings_rasterTileSource => 'Джерело растрових тайлів';
@override
String get appSettings_stadiaEndpoint => 'Кінцева точка Stadia';
@override
String get appSettings_stadiaApiKey => 'Ключ API Stadia';
@override
String get appSettings_stadiaApiKeyRequired =>
'Потрібно для використання Stadia Maps';
@override
String appSettings_stadiaApiKeyConfigured(String maskedKey) {
return 'Налаштовано: $maskedKey';
}
@override
String get appSettings_stadiaApiKeyDialogDescription =>
'Введіть свій ключ API Stadia Maps. Програма використовує його для запитів растрових тайлів.';
@override @override
String get appSettings_offlineMapCache => 'Офлайн-кеш карти'; String get appSettings_offlineMapCache => 'Офлайн-кеш карти';
@@ -989,6 +1034,15 @@ class AppLocalizationsUk extends AppLocalizations {
@override @override
String get contacts_newGroup => 'Нова група'; String get contacts_newGroup => 'Нова група';
@override
String get contacts_moreOptions => 'Більше можливостей';
@override
String get contacts_searchOpen => 'Пошук контактів';
@override
String get contacts_searchClose => 'Закрити пошук';
@override @override
String get contacts_groupName => 'Назва групи'; String get contacts_groupName => 'Назва групи';
@@ -1468,35 +1522,6 @@ class AppLocalizationsUk extends AppLocalizations {
@override @override
String get debugFrame_hexDump => 'Дамп Hex:'; String get debugFrame_hexDump => 'Дамп Hex:';
@override
String get chat_pathManagement => 'Керування шляхами';
@override
String get chat_ShowAllPaths => 'Показати всі шляхи';
@override
String get chat_routingMode => 'Режим маршрутизації';
@override
String get chat_autoUseSavedPath => 'Авто (використовувати збережений шлях)';
@override
String get chat_forceFloodMode => 'Примусово через всю мережу';
@override
String get chat_recentAckPaths =>
'Підтверджені шляхи (натисніть, щоб використати):';
@override
String get chat_pathHistoryFull =>
'Історія шляхів заповнена. Видаліть записи, щоб додати нові.';
@override
String get chat_hopSingular => 'Перехід';
@override
String get chat_hopPlural => 'переходів';
@override @override
String chat_hopsCount(int count) { String chat_hopsCount(int count) {
String _temp0 = intl.Intl.pluralLogic( String _temp0 = intl.Intl.pluralLogic(
@@ -1510,12 +1535,6 @@ class AppLocalizationsUk extends AppLocalizations {
return '$count $_temp0'; return '$count $_temp0';
} }
@override
String get chat_successes => 'Успішно';
@override
String get chat_score => 'Оцінка';
@override @override
String get chat_removePath => 'Видалити шлях'; String get chat_removePath => 'Видалити шлях';
@@ -1523,54 +1542,148 @@ class AppLocalizationsUk extends AppLocalizations {
String get chat_noPathHistoryYet => String get chat_noPathHistoryYet =>
'Історія шляхів недоступна.\nНадішліть повідомлення, щоб виявити шляхи.'; 'Історія шляхів недоступна.\nНадішліть повідомлення, щоб виявити шляхи.';
@override
String get chat_pathActions => 'Дії зі шляхом:';
@override
String get chat_setCustomPath => 'Встановити власний шлях';
@override
String get chat_setCustomPathSubtitle => 'Вказати шлях маршрутизації вручну';
@override
String get chat_clearPath => 'Очистити шлях';
@override
String get chat_clearPathSubtitle =>
'Примусово повторити пошук при наступному надсиланні';
@override @override
String get chat_pathCleared => String get chat_pathCleared =>
'Шлях очищено. Наступне повідомлення оновить маршрут.'; 'Шлях очищено. Наступне повідомлення оновить маршрут.';
@override
String get chat_floodModeSubtitle =>
'Використовувати перемикач маршрутизації в панелі застосунку';
@override
String get chat_floodModeEnabled =>
'Увімкнено режим «через всю мережу». Перемикайте через іконку маршрутизації на панелі інструментів.';
@override @override
String get chat_fullPath => 'Повний шлях'; String get chat_fullPath => 'Повний шлях';
@override @override
String get chat_pathDetailsNotAvailable => String get routing_title => 'Маршрутизація';
'Деталі шляху ще недоступні. Спробуйте надіслати повідомлення для оновлення.';
@override @override
String chat_pathSetHops(int hopCount, String status) { String get routing_modeAuto => 'Автомобіль';
String _temp0 = intl.Intl.pluralLogic(
hopCount, @override
locale: localeName, String get routing_modeFlood => 'Повені';
other: 'переходів',
many: 'переходів', @override
few: 'переходи', String get routing_modeManual => 'Інструкція';
one: 'перехід',
); @override
return 'Шлях встановлено: $hopCount $_temp0 - $status'; String get routing_modeAutoHint =>
'Автоматично обирає найкращий відомий шлях, та у разі відсутності відомого шляху, використовує алгоритм \"занурення\".';
@override
String get routing_modeFloodHint =>
'Передавання через усі ретранслятори. Найбільш надійний спосіб, але потребує більше часу.';
@override
String get routing_modeManualHint =>
'Завжди доставляє точно за вказаним вами маршрутом.';
@override
String get routing_currentRoute => 'Поточний маршрут';
@override
String get routing_directNoHops =>
'Пряме з\'єднання – без проміжних ретрансляторів';
@override
String get routing_noPathYet =>
'Поки що немає жодного шляху. Повідомлення продовжуються надходити, поки не буде знайдено маршрут.';
@override
String get routing_floodBroadcast => 'Поширення через усі ретранслятори';
@override
String get routing_editPath => 'Редагувати шлях';
@override
String get routing_forgetPath => 'Забудь про шлях';
@override
String get routing_knownPaths => 'Відомі маршрути';
@override
String get routing_knownPathsHint =>
'Виберіть опцію, щоб переключитися на неї.';
@override
String get routing_inUse => 'У робочому стані';
@override
String get routing_qualityStrong => 'Сильний перший стрибок';
@override
String get routing_qualityGood => 'Чудова перша спроба';
@override
String get routing_qualityFair => 'Перший, але вдалий, крок';
@override
String get routing_qualityWorked => 'Доставлено';
@override
String get routing_qualityFlood => 'Дізнався через новини';
@override
String get routing_qualityUntested => 'Не протестовано';
@override
String routing_lastWorked(String when) {
return 'worked $when';
} }
@override
String get routing_neverWorked => 'ніколи не підтверджено';
@override
String routing_deliveryCounts(int successes, int failures) {
return '$successes delivered, $failures failed';
}
@override
String get routing_floodDelivery => 'Доставка під час повені';
@override
String get pathEditor_title => 'Створити маршрут';
@override
String pathEditor_hopCounter(int count) {
return '$count з 64 штук хмелю';
}
@override
String get pathEditor_noHops =>
'Ще не додано хміль. Натисніть на відповідні кнопки, щоб додати його в потрібному порядку, або збережіть рецепт без хмілю, щоб відправити його безпосередньо.';
@override
String get pathEditor_addHops => 'Додавайте хміль у наступній послідовності.';
@override
String get pathEditor_searchRepeaters => 'Пошук повторювачів';
@override
String get pathEditor_advancedHex =>
'Просунутий рівень: пряма шлях у форматі шестнадцяткової системи.';
@override
String get pathEditor_hexLabel =>
'Префікси для шестнадцяткової системи числення';
@override
String get pathEditor_hexHelper =>
'Два шестизначні символи на кожний крок, розділені комами';
@override
String pathEditor_invalidTokens(String tokens) {
return 'Неправильно: $tokens';
}
@override
String get pathEditor_tooManyHops => 'Максимум 64 хмелеві колоди';
@override
String get pathEditor_usePath => 'Використовуйте цей шлях';
@override
String get pathEditor_removeHop => 'Видалити хміль';
@override
String get pathEditor_unknownHop => 'Невідомий ретранслятор';
@override @override
String get chat_pathSavedLocally => String get chat_pathSavedLocally =>
'Збережено локально. Підключіться для синхронізації.'; 'Збережено локально. Підключіться для синхронізації.';
@@ -1645,6 +1758,39 @@ class AppLocalizationsUk extends AppLocalizations {
@override @override
String get map_title => 'Карта вузлів'; String get map_title => 'Карта вузлів';
@override
String get map_searchHint => 'Назва або ID вузла';
@override
String get map_activity => 'Активність';
@override
String get map_online => 'Онлайн';
@override
String get map_recent => 'Нещодавні';
@override
String get map_stale => 'Застаріло';
@override
String get map_visible => 'Видимий';
@override
String get map_hidden => 'Прихований';
@override
String get map_centerOnNode => 'Центрувати на вузлі';
@override
String get map_details => 'Деталі';
@override
String get map_noGps => 'Без GPS';
@override
String get map_noResults => 'Не знайдено відповідних вузлів';
@override @override
String get map_lineOfSight => 'Пряма видимість'; String get map_lineOfSight => 'Пряма видимість';
@@ -1907,6 +2053,42 @@ class AppLocalizationsUk extends AppLocalizations {
return 'Невдалі завантаження: $count'; return 'Невдалі завантаження: $count';
} }
@override
String get mapCache_cachedTilesLabel => 'Cached tiles';
@override
String get mapCache_cachedTileSummaryLabel => 'Cached tile summary';
@override
String mapCache_bulkDownloadDisabledForSource(String source) {
return 'Offline bulk downloads are disabled for $source.';
}
@override
String mapCache_bulkDownloadDisabledInConfig(String source) {
return 'Offline bulk downloads are disabled for $source in this app configuration.';
}
@override
String mapCache_summarySource(String source) {
return 'Source: $source';
}
@override
String mapCache_summaryCachedTilesForSource(int count) {
return 'Cached tiles for source: $count';
}
@override
String mapCache_summaryCachedInSelection(int count) {
return 'Cached in selected area/zoom: $count';
}
@override
String mapCache_summaryApproxCacheSize(String size) {
return 'Approx cache size: $size';
}
@override @override
String mapCache_boundsLabel( String mapCache_boundsLabel(
String north, String north,
@@ -2037,67 +2219,13 @@ class AppLocalizationsUk extends AppLocalizations {
@override @override
String get common_clear => 'Очистити'; String get common_clear => 'Очистити';
@override
String path_currentPath(String path) {
return 'Поточний шлях: $path';
}
@override
String path_usingHopsPath(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'переходами',
many: 'переходами',
few: 'переходами',
one: 'переходом',
);
return 'Використання шляху з $count $_temp0';
}
@override
String get path_enterCustomPath => 'Ввести власний шлях';
@override @override
String get path_currentPathLabel => 'Поточний шлях'; String get path_currentPathLabel => 'Поточний шлях';
@override
String get path_hexPrefixInstructions =>
'Введіть 2-символьні hex-префікси для кожного переходу, розділені комами.';
@override
String get path_hexPrefixExample =>
'Приклад: A1,F2,3C (кожен вузол використовує перший байт свого відкритого ключа).';
@override
String get path_labelHexPrefixes => 'Hex-префікси';
@override
String get path_helperMaxHops =>
'Макс. 64 переходи. Кожен префікс — 2 шістнадцяткові символи (1 байт)';
@override
String get path_selectFromContacts => 'Вибрати з контактів:';
@override @override
String get path_noRepeatersFound => String get path_noRepeatersFound =>
'Ретрансляторів або серверів кімнат не знайдено.'; 'Ретрансляторів або серверів кімнат не знайдено.';
@override
String get path_customPathsRequire =>
'Власні шляхи вимагають проміжних вузлів, які можуть передавати повідомлення.';
@override
String path_invalidHexPrefixes(String prefixes) {
return 'Некоректні hex-префікси: $prefixes';
}
@override
String get path_tooLong => 'Шлях занадто довгий. Максимум 64 переходи.';
@override
String get path_setPath => 'Встановити шлях';
@override @override
String get repeater_management => 'Керування ретранслятором'; String get repeater_management => 'Керування ретранслятором';
@@ -2162,16 +2290,6 @@ class AppLocalizationsUk extends AppLocalizations {
@override @override
String get repeater_routingMode => 'Режим маршрутизації'; String get repeater_routingMode => 'Режим маршрутизації';
@override
String get repeater_autoUseSavedPath =>
'Авто (використовувати збережений шлях)';
@override
String get repeater_forceFloodMode => 'Примусово через всю мережу';
@override
String get repeater_pathManagement => 'Керування шляхами';
@override @override
String get repeater_refresh => 'Оновити'; String get repeater_refresh => 'Оновити';
@@ -3274,6 +3392,139 @@ class AppLocalizationsUk extends AppLocalizations {
return '$celsius°C / $fahrenheit°F'; return '$celsius°C / $fahrenheit°F';
} }
@override
String get telemetry_digitalInputLabel => 'Цифровий вхід';
@override
String get telemetry_digitalOutputLabel => 'Цифровий вихід';
@override
String get telemetry_analogInputLabel => 'Аналоговий вхід';
@override
String get telemetry_analogOutputLabel => 'Аналоговий вихід';
@override
String get telemetry_genericLabel => 'Загальний датчик';
@override
String get telemetry_luminosityLabel => 'Освітленість';
@override
String get telemetry_presenceLabel => 'Присутність';
@override
String get telemetry_humidityLabel => 'Вологість';
@override
String get telemetry_accelerometerLabel => 'Акселерометр';
@override
String get telemetry_pressureLabel => 'Тиск';
@override
String get telemetry_altitudeLabel => 'Висота';
@override
String get telemetry_frequencyLabel => 'Частота';
@override
String get telemetry_percentageLabel => 'Відсоток';
@override
String get telemetry_concentrationLabel => 'Концентрація';
@override
String get telemetry_powerLabel => 'Потужність';
@override
String get telemetry_distanceLabel => 'Відстань';
@override
String get telemetry_energyLabel => 'Енергія';
@override
String get telemetry_directionLabel => 'Напрямок';
@override
String get telemetry_timeLabel => 'Час';
@override
String get telemetry_gyrometerLabel => 'Гірометр';
@override
String get telemetry_colourLabel => 'Колір';
@override
String get telemetry_gpsLabel => 'GPS';
@override
String get telemetry_switchLabel => 'Перемикач';
@override
String get telemetry_polylineLabel => 'Полілінія';
@override
String telemetry_altitudeValue(String meters) {
return '$meters м';
}
@override
String telemetry_frequencyValue(String hertz) {
return '$hertz Гц';
}
@override
String telemetry_pressureValue(String hpa) {
return '$hpa гПа';
}
@override
String telemetry_luminosityValue(String lux) {
return '$lux лк';
}
@override
String telemetry_powerValue(String watts) {
return '$watts Вт';
}
@override
String telemetry_distanceValue(String meters) {
return '$meters м';
}
@override
String telemetry_energyValue(String kilowattHours) {
return '$kilowattHours кВт⋅год';
}
@override
String telemetry_directionValue(String degrees) {
return '$degrees°';
}
@override
String telemetry_concentrationValue(String ppm) {
return '$ppm ppm';
}
@override
String telemetry_percentageValue(String percent) {
return '$percent%';
}
@override
String telemetry_analogValue(String value) {
return '$value';
}
@override
String get telemetry_autoFetchQuantity => 'Кількість запитів';
@override
String get telemetry_error => 'Не вдалося отримати дані';
@override @override
String get neighbors_receivedData => 'Дані сусідів отримано'; String get neighbors_receivedData => 'Дані сусідів отримано';
@@ -4315,4 +4566,140 @@ class AppLocalizationsUk extends AppLocalizations {
@override @override
String get contact_typeUnknown => 'Невідомо'; String get contact_typeUnknown => 'Невідомо';
@override
String get map_zoomIn => 'Увійти в режим збільшення';
@override
String get map_zoomOut => 'Видалити зум';
@override
String get map_centerMap => 'Карта центру';
@override
String get chrome_bluetoothRequiresChromium =>
'Web Bluetooth вимагає браузера на основі Chromium';
@override
String channels_communityShortId(String id) {
return 'ID: $id...';
}
@override
String get pathTrace_legendGpsConfirmed => 'GPS підтверджено';
@override
String get pathTrace_legendInferred => 'Висновок щодо положення';
@override
String get pathMap_viewSingle => 'Один';
@override
String get pathMap_viewCombined => 'Об\'єднаний';
@override
String get pathMap_play => 'Відтворити';
@override
String get pathMap_pause => 'Призупинити';
@override
String get pathMap_replay => 'Повтор';
@override
String get pathMap_stepBack => 'Попередній перехід';
@override
String get pathMap_stepForward => 'Наступний перехід';
@override
String get pathMap_animationOn => 'Відобразити анімацію пакета';
@override
String get pathMap_animationOff => 'Приховати анімацію пакета';
@override
String pathMap_hopOf(int current, int total) {
return 'Перехід $current з $total';
}
@override
String pathMap_observedPaths(int count) {
return 'Зафіксовані маршрути: $count';
}
@override
String get pathMap_primary => 'Основний';
@override
String pathMap_alternate(int index) {
return 'Альт. $index';
}
@override
String pathMap_hopCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count переходів',
many: '$count переходів',
few: '$count переходи',
one: '1 перехід',
);
return '$_temp0';
}
@override
String pathMap_gpsCount(int confirmed, int total) {
return '$confirmed/$total GPS';
}
@override
String get pathMap_legendShared => 'Об\'єднаний сегмент';
@override
String get pathMap_legendEstimated => 'Орієнтовний сегмент';
@override
String pathMap_sharedNodeCount(int count) {
return 'Використовується $count шляхами';
}
@override
String pathMap_partialAnimation(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other:
'$count переходів не мають геопозиції — показаний шлях є частковим',
many: '$count переходів не мають геопозиції — показаний шлях є частковим',
few: '$count переходи не мають геопозиції — показаний шлях є частковим',
one: '1 перехід не має геопозиції — показаний шлях є частковим',
);
return '$_temp0';
}
@override
String get pathMap_showAllPaths => 'Показати все';
@override
String get pathMap_hidePath => 'Приховати шлях';
@override
String get pathMap_showPath => 'Показати шлях';
@override
String get pathMap_collapsePanel => 'Згорнути панель';
@override
String get pathMap_expandPanel => 'Розгорнути панель';
@override
String get pathMap_noLocation => 'Без геопозиції';
@override
String get pathMap_followPacket => 'Прив\'язати вигляд до пакету';
@override
String get pathMap_unfollowPacket => 'Відв\'язати вигляд від пакету';
} }
+511 -113
View File
@@ -92,6 +92,24 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get common_disable => '禁用'; String get common_disable => '禁用';
@override
String get common_undo => '撤销';
@override
String get messageStatus_sent => '发送';
@override
String get messageStatus_delivered => '已送达';
@override
String get messageStatus_pending => '发送';
@override
String get messageStatus_failed => '发送失败';
@override
String get messageStatus_repeated => '多次听到';
@override @override
String get common_reboot => '重启'; String get common_reboot => '重启';
@@ -111,6 +129,12 @@ class AppLocalizationsZh extends AppLocalizations {
return '$percent%'; return '$percent%';
} }
@override
String get common_autoRefresh => '自动刷新';
@override
String get common_interval => '间隔';
@override @override
String get scanner_title => '连接设备'; String get scanner_title => '连接设备';
@@ -281,6 +305,9 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get scanner_enableBluetooth => '启用蓝牙'; String get scanner_enableBluetooth => '启用蓝牙';
@override
String get scanner_bluetoothWebUnsupported => '浏览器不支持蓝牙,请改用 USB 连接。';
@override @override
String get device_quickSwitch => '快速切换'; String get device_quickSwitch => '快速切换';
@@ -729,11 +756,6 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get appSettings_maxMessageRetriesSubtitle => '在将消息标记为失败之前,允许尝试的次数'; String get appSettings_maxMessageRetriesSubtitle => '在将消息标记为失败之前,允许尝试的次数';
@override
String path_routeWeight(String weight, String max) {
return '$weight/$max';
}
@override @override
String get appSettings_battery => '电池'; String get appSettings_battery => '电池';
@@ -810,6 +832,27 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get appSettings_lastWeek => '上周'; String get appSettings_lastWeek => '上周';
@override
String get appSettings_rasterTileSource => '栅格瓦片源';
@override
String get appSettings_stadiaEndpoint => 'Stadia 端点';
@override
String get appSettings_stadiaApiKey => 'Stadia API 密钥';
@override
String get appSettings_stadiaApiKeyRequired => '使用 Stadia Maps 时必需';
@override
String appSettings_stadiaApiKeyConfigured(String maskedKey) {
return '已配置:$maskedKey';
}
@override
String get appSettings_stadiaApiKeyDialogDescription =>
'请输入你的 Stadia Maps API 密钥。该应用会使用它来请求栅格瓦片。';
@override @override
String get appSettings_offlineMapCache => '离线地图缓存'; String get appSettings_offlineMapCache => '离线地图缓存';
@@ -925,6 +968,15 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get contacts_newGroup => '新建群聊'; String get contacts_newGroup => '新建群聊';
@override
String get contacts_moreOptions => '更多选择';
@override
String get contacts_searchOpen => '搜索联系人';
@override
String get contacts_searchClose => '高级搜索';
@override @override
String get contacts_groupName => '群聊名称'; String get contacts_groupName => '群聊名称';
@@ -1391,85 +1443,149 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get debugFrame_hexDump => '十六进制数据:'; String get debugFrame_hexDump => '十六进制数据:';
@override
String get chat_pathManagement => '路径管理';
@override
String get chat_ShowAllPaths => '显示所有路径';
@override
String get chat_routingMode => '路由模式';
@override
String get chat_autoUseSavedPath => '自动(使用保存的路径)';
@override
String get chat_forceFloodMode => '强制泛洪模式';
@override
String get chat_recentAckPaths => '最近使用的 ACK 路径(点击使用):';
@override
String get chat_pathHistoryFull => '路径历史已满,请删除后再添加。';
@override
String get chat_hopSingular => '';
@override
String get chat_hopPlural => '';
@override @override
String chat_hopsCount(int count) { String chat_hopsCount(int count) {
return '$count'; return '$count';
} }
@override
String get chat_successes => '成功';
@override
String get chat_score => 'Score';
@override @override
String get chat_removePath => '移除路径'; String get chat_removePath => '移除路径';
@override @override
String get chat_noPathHistoryYet => '暂无路径历史。\n发送消息以探索路径。'; String get chat_noPathHistoryYet => '暂无路径历史。\n发送消息以探索路径。';
@override
String get chat_pathActions => '路径操作:';
@override
String get chat_setCustomPath => '设置自定义路径';
@override
String get chat_setCustomPathSubtitle => '手动指定路由路径';
@override
String get chat_clearPath => '清除路径';
@override
String get chat_clearPathSubtitle => '清除当前路径,下次发送将重新尝试。';
@override @override
String get chat_pathCleared => '路径已清除。下一条消息将重新路由。'; String get chat_pathCleared => '路径已清除。下一条消息将重新路由。';
@override
String get chat_floodModeSubtitle => '在应用栏中切换路由模式。';
@override
String get chat_floodModeEnabled => '泛洪模式已启用。可通过应用栏的路由图标切换。';
@override @override
String get chat_fullPath => '完整路径'; String get chat_fullPath => '完整路径';
@override @override
String get chat_pathDetailsNotAvailable => '径信息暂不可用,请尝试发送消息刷新。'; String get routing_title => '';
@override @override
String chat_pathSetHops(int hopCount, String status) { String get routing_modeAuto => '汽车';
return '路径设置:$hopCount 跳 - $status';
@override
String get routing_modeFlood => '洪水';
@override
String get routing_modeManual => '手册';
@override
String get routing_modeAutoHint => '自动选择已知最佳路径,当没有已知路径时,则进行“洪水”搜索。';
@override
String get routing_modeFloodHint => '通过所有中继站进行广播。 这种方式最可靠,但占用更多的时间。';
@override
String get routing_modeManualHint => '总是按照您设置的路径进行导航。';
@override
String get routing_currentRoute => '当前路线';
@override
String get routing_directNoHops => '直接连接— 无中继跳';
@override
String get routing_noPathYet => '目前还没有找到路径。直到找到路径,才会收到后续消息。';
@override
String get routing_floodBroadcast => '通过所有中继器进行广播';
@override
String get routing_editPath => '编辑路径';
@override
String get routing_forgetPath => '忘记原路';
@override
String get routing_knownPaths => '已知的路径';
@override
String get routing_knownPathsHint => '点击该路径以切换到它。';
@override
String get routing_inUse => '使用中';
@override
String get routing_qualityStrong => '强劲的初始阶段';
@override
String get routing_qualityGood => '不错的开端';
@override
String get routing_qualityFair => '第一次尝试,结果良好';
@override
String get routing_qualityWorked => '已完成';
@override
String get routing_qualityFlood => '通过新闻报道';
@override
String get routing_qualityUntested => '未经测试';
@override
String routing_lastWorked(String when) {
return '工作于 $when';
} }
@override
String get routing_neverWorked => '从未得到证实';
@override
String routing_deliveryCounts(int successes, int failures) {
return '$successes delivered, $failures failed';
}
@override
String get routing_floodDelivery => '洪水配送';
@override
String get pathEditor_title => '构建路径';
@override
String pathEditor_hopCounter(int count) {
return '$count of 64 hops';
}
@override
String get pathEditor_noHops =>
'目前还没有添加任何啤酒花。点击下面的“添加”按钮,按顺序添加,或者直接保存,不添加任何啤酒花。';
@override
String get pathEditor_addHops => '按照顺序添加啤酒花';
@override
String get pathEditor_searchRepeaters => '重复搜索';
@override
String get pathEditor_advancedHex => '高级:原始十六进制路径';
@override
String get pathEditor_hexLabel => '十六进制前缀';
@override
String get pathEditor_hexHelper => '每次跳跃,使用两个十六进制字符,用逗号分隔。';
@override
String pathEditor_invalidTokens(String tokens) {
return '无效:$tokens';
}
@override
String get pathEditor_tooManyHops => '最多 64 个跳跃';
@override
String get pathEditor_usePath => '请使用此路径';
@override
String get pathEditor_removeHop => '去除啤酒花';
@override
String get pathEditor_unknownHop => '未知的重复器';
@override @override
String get chat_pathSavedLocally => '已本地保存,连接设备后可同步。'; String get chat_pathSavedLocally => '已本地保存,连接设备后可同步。';
@@ -1542,6 +1658,39 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get map_title => '节点地图'; String get map_title => '节点地图';
@override
String get map_searchHint => '搜索节点名称或ID';
@override
String get map_activity => '活动';
@override
String get map_online => '在线';
@override
String get map_recent => '最近';
@override
String get map_stale => '过时';
@override
String get map_visible => '可见';
@override
String get map_hidden => '已隐藏';
@override
String get map_centerOnNode => '以节点为中心';
@override
String get map_details => '详细信息';
@override
String get map_noGps => '无 GPS';
@override
String get map_noResults => '未找到匹配的节点';
@override @override
String get map_lineOfSight => '视线'; String get map_lineOfSight => '视线';
@@ -1797,6 +1946,42 @@ class AppLocalizationsZh extends AppLocalizations {
return '下载失败:$count'; return '下载失败:$count';
} }
@override
String get mapCache_cachedTilesLabel => 'Cached tiles';
@override
String get mapCache_cachedTileSummaryLabel => 'Cached tile summary';
@override
String mapCache_bulkDownloadDisabledForSource(String source) {
return 'Offline bulk downloads are disabled for $source.';
}
@override
String mapCache_bulkDownloadDisabledInConfig(String source) {
return 'Offline bulk downloads are disabled for $source in this app configuration.';
}
@override
String mapCache_summarySource(String source) {
return 'Source: $source';
}
@override
String mapCache_summaryCachedTilesForSource(int count) {
return 'Cached tiles for source: $count';
}
@override
String mapCache_summaryCachedInSelection(int count) {
return 'Cached in selected area/zoom: $count';
}
@override
String mapCache_summaryApproxCacheSize(String size) {
return 'Approx cache size: $size';
}
@override @override
String mapCache_boundsLabel( String mapCache_boundsLabel(
String north, String north,
@@ -1922,54 +2107,12 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get common_clear => '清除'; String get common_clear => '清除';
@override
String path_currentPath(String path) {
return '当前路径:$path';
}
@override
String path_usingHopsPath(int count) {
return '使用 $count 跳路径';
}
@override
String get path_enterCustomPath => '输入自定义路径';
@override @override
String get path_currentPathLabel => '当前路径'; String get path_currentPathLabel => '当前路径';
@override
String get path_hexPrefixInstructions => '请输入每个中继节点的2字符十六进制前缀,用逗号分隔。';
@override
String get path_hexPrefixExample => '例如:A1, F2, 3C(每个节点使用其公钥的第一字节)';
@override
String get path_labelHexPrefixes => '路径(十六进制前缀)';
@override
String get path_helperMaxHops => '最多 64 跳。每个前缀由 2 个十六进制字符(1 字节)组成。';
@override
String get path_selectFromContacts => '或从联系人列表中选择:';
@override @override
String get path_noRepeatersFound => '未找到任何转发节点或房间服务器。'; String get path_noRepeatersFound => '未找到任何转发节点或房间服务器。';
@override
String get path_customPathsRequire => '自定义路径需要中间节点转发消息。';
@override
String path_invalidHexPrefixes(String prefixes) {
return '无效的十六进制前缀:$prefixes';
}
@override
String get path_tooLong => '路径过长,最多允许 64 跳。';
@override
String get path_setPath => '设置路径';
@override @override
String get repeater_management => '转发节点管理'; String get repeater_management => '转发节点管理';
@@ -2030,15 +2173,6 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get repeater_routingMode => '路由模式'; String get repeater_routingMode => '路由模式';
@override
String get repeater_autoUseSavedPath => '自动(使用保存的路径)';
@override
String get repeater_forceFloodMode => '强制泛洪模式';
@override
String get repeater_pathManagement => '路径管理';
@override @override
String get repeater_refresh => '刷新'; String get repeater_refresh => '刷新';
@@ -2999,6 +3133,139 @@ class AppLocalizationsZh extends AppLocalizations {
return '$celsius°C / $fahrenheit°F'; return '$celsius°C / $fahrenheit°F';
} }
@override
String get telemetry_digitalInputLabel => '数字输入';
@override
String get telemetry_digitalOutputLabel => '数字输出';
@override
String get telemetry_analogInputLabel => '模拟输入';
@override
String get telemetry_analogOutputLabel => '模拟输出';
@override
String get telemetry_genericLabel => '通用传感器';
@override
String get telemetry_luminosityLabel => '照度';
@override
String get telemetry_presenceLabel => '存在检测';
@override
String get telemetry_humidityLabel => '湿度';
@override
String get telemetry_accelerometerLabel => '加速度计';
@override
String get telemetry_pressureLabel => '气压';
@override
String get telemetry_altitudeLabel => '高度';
@override
String get telemetry_frequencyLabel => '频率';
@override
String get telemetry_percentageLabel => '百分比';
@override
String get telemetry_concentrationLabel => '浓度';
@override
String get telemetry_powerLabel => '功率';
@override
String get telemetry_distanceLabel => '距离';
@override
String get telemetry_energyLabel => '能量';
@override
String get telemetry_directionLabel => '方向';
@override
String get telemetry_timeLabel => '时间';
@override
String get telemetry_gyrometerLabel => '陀螺仪';
@override
String get telemetry_colourLabel => '颜色';
@override
String get telemetry_gpsLabel => 'GPS';
@override
String get telemetry_switchLabel => '开关';
@override
String get telemetry_polylineLabel => '折线';
@override
String telemetry_altitudeValue(String meters) {
return '$meters m';
}
@override
String telemetry_frequencyValue(String hertz) {
return '$hertz Hz';
}
@override
String telemetry_pressureValue(String hpa) {
return '$hpa hPa';
}
@override
String telemetry_luminosityValue(String lux) {
return '$lux lx';
}
@override
String telemetry_powerValue(String watts) {
return '$watts W';
}
@override
String telemetry_distanceValue(String meters) {
return '$meters m';
}
@override
String telemetry_energyValue(String kilowattHours) {
return '$kilowattHours kWh';
}
@override
String telemetry_directionValue(String degrees) {
return '$degrees°';
}
@override
String telemetry_concentrationValue(String ppm) {
return '$ppm ppm';
}
@override
String telemetry_percentageValue(String percent) {
return '$percent%';
}
@override
String telemetry_analogValue(String value) {
return '$value';
}
@override
String get telemetry_autoFetchQuantity => '请求次数';
@override
String get telemetry_error => '无法获取数据';
@override @override
String get neighbors_receivedData => '已接收邻居信息'; String get neighbors_receivedData => '已接收邻居信息';
@@ -3947,4 +4214,135 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get contact_typeUnknown => 'Unknown'; String get contact_typeUnknown => 'Unknown';
@override
String get map_zoomIn => '放大';
@override
String get map_zoomOut => '放大';
@override
String get map_centerMap => '中心地图';
@override
String get chrome_bluetoothRequiresChromium =>
'Web Bluetooth 需要 Chromium 浏览器';
@override
String channels_communityShortId(String id) {
return 'ID$id...';
}
@override
String get pathTrace_legendGpsConfirmed => '通过GPS确认';
@override
String get pathTrace_legendInferred => '推测的位置';
@override
String get pathMap_viewSingle => '单条';
@override
String get pathMap_viewCombined => '综合';
@override
String get pathMap_play => '播放';
@override
String get pathMap_pause => '暂停';
@override
String get pathMap_replay => '重播';
@override
String get pathMap_stepBack => '上一跳';
@override
String get pathMap_stepForward => '下一跳';
@override
String get pathMap_animationOn => '显示数据包动画';
@override
String get pathMap_animationOff => '隐藏数据包动画';
@override
String pathMap_hopOf(int current, int total) {
return '$current 跳,共 $total';
}
@override
String pathMap_observedPaths(int count) {
return '观测到的路径:$count';
}
@override
String get pathMap_primary => '主路径';
@override
String pathMap_alternate(int index) {
return '备用 $index';
}
@override
String pathMap_hopCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count',
one: '1 跳',
);
return '$_temp0';
}
@override
String pathMap_gpsCount(int confirmed, int total) {
return '$confirmed/$total GPS';
}
@override
String get pathMap_legendShared => '共享路段';
@override
String get pathMap_legendEstimated => '估算路段';
@override
String pathMap_sharedNodeCount(int count) {
return '已被 $count 条路径使用';
}
@override
String pathMap_partialAnimation(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count 跳无位置信息 — 显示的路径不完整',
one: '1 跳无位置信息 — 显示的路径不完整',
);
return '$_temp0';
}
@override
String get pathMap_showAllPaths => '显示全部';
@override
String get pathMap_hidePath => '隐藏路径';
@override
String get pathMap_showPath => '显示路径';
@override
String get pathMap_collapsePanel => '收起面板';
@override
String get pathMap_expandPanel => '展开面板';
@override
String get pathMap_noLocation => '无位置';
@override
String get pathMap_followPacket => '锁定视图跟随数据包';
@override
String get pathMap_unfollowPacket => '解锁视图跟随';
} }
+290 -1
View File
@@ -33,6 +33,8 @@
"common_remove": "Verwijderen", "common_remove": "Verwijderen",
"common_enable": "Activeren", "common_enable": "Activeren",
"common_disable": "Uitschakelen", "common_disable": "Uitschakelen",
"common_autoRefresh": "Automatisch vernieuwen",
"common_interval": "Tijdsinterval",
"common_reboot": "Herstarten", "common_reboot": "Herstarten",
"common_loading": "Laden...", "common_loading": "Laden...",
"common_notAvailable": "—", "common_notAvailable": "—",
@@ -238,6 +240,19 @@
"appSettings_last6Hours": "Afgelopen 6 uur", "appSettings_last6Hours": "Afgelopen 6 uur",
"appSettings_last24Hours": "Afgelopen 24 uur", "appSettings_last24Hours": "Afgelopen 24 uur",
"appSettings_lastWeek": "Afgelopen week", "appSettings_lastWeek": "Afgelopen week",
"appSettings_rasterTileSource": "Rastertegelbron",
"appSettings_stadiaEndpoint": "Stadia-eindpunt",
"appSettings_stadiaApiKey": "Stadia API-sleutel",
"appSettings_stadiaApiKeyRequired": "Vereist voor gebruik van Stadia Maps",
"appSettings_stadiaApiKeyConfigured": "Geconfigureerd: {maskedKey}",
"@appSettings_stadiaApiKeyConfigured": {
"placeholders": {
"maskedKey": {
"type": "String"
}
}
},
"appSettings_stadiaApiKeyDialogDescription": "Voer je Stadia Maps API-sleutel in. De app gebruikt die voor rastertegelverzoeken.",
"appSettings_offlineMapCache": "Offline Kaartcache", "appSettings_offlineMapCache": "Offline Kaartcache",
"appSettings_noAreaSelected": "Geen gebied geselecteerd", "appSettings_noAreaSelected": "Geen gebied geselecteerd",
"appSettings_areaSelectedZoom": "Geselecteerd gebied (zoom {minZoom}-{maxZoom})", "appSettings_areaSelectedZoom": "Geselecteerd gebied (zoom {minZoom}-{maxZoom})",
@@ -767,6 +782,56 @@
} }
} }
}, },
"mapCache_cachedTilesLabel": "Cached tiles",
"mapCache_cachedTileSummaryLabel": "Cached tile summary",
"mapCache_bulkDownloadDisabledForSource": "Offline bulk downloads are disabled for {source}.",
"@mapCache_bulkDownloadDisabledForSource": {
"placeholders": {
"source": {
"type": "String"
}
}
},
"mapCache_bulkDownloadDisabledInConfig": "Offline bulk downloads are disabled for {source} in this app configuration.",
"@mapCache_bulkDownloadDisabledInConfig": {
"placeholders": {
"source": {
"type": "String"
}
}
},
"mapCache_summarySource": "Source: {source}",
"@mapCache_summarySource": {
"placeholders": {
"source": {
"type": "String"
}
}
},
"mapCache_summaryCachedTilesForSource": "Cached tiles for source: {count}",
"@mapCache_summaryCachedTilesForSource": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"mapCache_summaryCachedInSelection": "Cached in selected area/zoom: {count}",
"@mapCache_summaryCachedInSelection": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"mapCache_summaryApproxCacheSize": "Approx cache size: {size}",
"@mapCache_summaryApproxCacheSize": {
"placeholders": {
"size": {
"type": "String"
}
}
},
"mapCache_boundsLabel": "N {north}, S {south}, E {east}, W {west}", "mapCache_boundsLabel": "N {north}, S {south}, E {east}, W {west}",
"@mapCache_boundsLabel": { "@mapCache_boundsLabel": {
"placeholders": { "placeholders": {
@@ -1280,6 +1345,43 @@
} }
} }
}, },
"telemetry_digitalInputLabel": "Digitale ingang",
"telemetry_digitalOutputLabel": "Digitale uitgang",
"telemetry_analogInputLabel": "Analoge ingang",
"telemetry_analogOutputLabel": "Analoge uitgang",
"telemetry_genericLabel": "Algemene sensor",
"telemetry_luminosityLabel": "Lichtsterkte",
"telemetry_presenceLabel": "Aanwezigheid",
"telemetry_humidityLabel": "Luchtvochtigheid",
"telemetry_accelerometerLabel": "Versnellingsmeter",
"telemetry_pressureLabel": "Druk",
"telemetry_altitudeLabel": "Hoogte",
"telemetry_frequencyLabel": "Frequentie",
"telemetry_percentageLabel": "Percentage",
"telemetry_concentrationLabel": "Concentratie",
"telemetry_powerLabel": "Vermogen",
"telemetry_distanceLabel": "Afstand",
"telemetry_energyLabel": "Energie",
"telemetry_directionLabel": "Richting",
"telemetry_timeLabel": "Tijd",
"telemetry_gyrometerLabel": "Gyrometer",
"telemetry_colourLabel": "Kleur",
"telemetry_gpsLabel": "GPS",
"telemetry_switchLabel": "Schakelaar",
"telemetry_polylineLabel": "Polylijn",
"telemetry_altitudeValue": "{meters} m",
"telemetry_frequencyValue": "{hertz} Hz",
"telemetry_pressureValue": "{hpa} hPa",
"telemetry_luminosityValue": "{lux} lx",
"telemetry_powerValue": "{watts} W",
"telemetry_distanceValue": "{meters} m",
"telemetry_energyValue": "{kilowattHours} kWh",
"telemetry_directionValue": "{degrees}°",
"telemetry_concentrationValue": "{ppm} ppm",
"telemetry_percentageValue": "{percent}%",
"telemetry_analogValue": "{value}",
"telemetry_autoFetchQuantity": "Aantal aanvragen",
"telemetry_error": "Kan gegevens niet ophalen",
"telemetry_noData": "Geen telemetriedata beschikbaar.", "telemetry_noData": "Geen telemetriedata beschikbaar.",
"telemetry_channelTitle": "Kanaal {channel}", "telemetry_channelTitle": "Kanaal {channel}",
"@telemetry_channelTitle": { "@telemetry_channelTitle": {
@@ -2314,5 +2416,192 @@
"chat_newMessages": "Nieuwe berichten", "chat_newMessages": "Nieuwe berichten",
"chat_markAsUnread": "Markeer als ongelezen", "chat_markAsUnread": "Markeer als ongelezen",
"settings_companionDebugLogSubtitle": "BLE/TCP/USB commando's, antwoorden en ruwe data", "settings_companionDebugLogSubtitle": "BLE/TCP/USB commando's, antwoorden en ruwe data",
"repeater_chanUtil": "Gebruik van het kanaal" "repeater_chanUtil": "Gebruik van het kanaal",
"@routing_lastWorked": {
"placeholders": {
"when": {
"type": "String"
}
}
},
"@routing_deliveryCounts": {
"placeholders": {
"successes": {
"type": "int"
},
"failures": {
"type": "int"
}
}
},
"@pathEditor_hopCounter": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"@pathEditor_invalidTokens": {
"placeholders": {
"tokens": {
"type": "String"
}
}
},
"@channels_communityShortId": {
"placeholders": {
"id": {
"type": "String"
}
}
},
"messageStatus_sent": "Verzonden",
"common_undo": "Achterhalen/Annuleren",
"messageStatus_delivered": "Leverd",
"messageStatus_pending": "Verzenden",
"messageStatus_failed": "Niet verzonden",
"messageStatus_repeated": "Hearsay, herhaald",
"contacts_moreOptions": "Meer opties",
"contacts_searchOpen": "Zoek contactpersonen",
"contacts_searchClose": "Zoeken",
"routing_title": "Routeplanning",
"routing_modeAuto": "Auto",
"routing_modeFlood": "Overstroming",
"routing_modeManual": "Handleiding",
"routing_modeAutoHint": "Selecteert automatisch het bekendste pad, en gebruikt een flood-algoritme als er geen bekend pad is.",
"routing_modeFloodHint": "Uitzendingen via elke zender. De meest betrouwbare methode, maar vereist meer uitzendtijd.",
"routing_modeManualHint": "Stuurt altijd de exacte route die u heeft aangegeven.",
"routing_currentRoute": "Huidige route",
"routing_directNoHops": "Direct zonder tussenliggende schakels",
"routing_noPathYet": "Er is nog geen route gevonden. De berichten blijven binnenkomen totdat een route is ontdekt.",
"routing_floodBroadcast": "Uitgestoten via elke zender.",
"routing_editPath": "Pad bewerken",
"routing_forgetPath": "Vergeet het pad",
"routing_knownPaths": "Bekende routes",
"routing_knownPathsHint": "Maak een route om er naartoe te gaan.",
"routing_inUse": "In gebruik",
"routing_qualityStrong": "Sterke eerste sprong",
"routing_qualityGood": "Een goede eerste stap",
"routing_qualityFair": "Een goede eerste hop",
"routing_qualityWorked": "Is geleverd",
"routing_qualityFlood": "Hears via een overstroming",
"routing_qualityUntested": "Niet getest",
"routing_neverWorked": "nooit bevestigd",
"routing_deliveryCounts": "{successes} zijn behaald, {failures} zijn mislukt",
"routing_floodDelivery": "Levering bij overstroming",
"pathEditor_title": "Pad creëren",
"pathEditor_hopCounter": "{count} van 64 hopgranen",
"pathEditor_noHops": "Er zijn nog geen hop toegevoegd. Klik op de onderstaande knoppen om ze in de juiste volgorde toe te voegen, of sla de bestelling op zonder hop om deze direct te versturen.",
"pathEditor_addHops": "Voeg hop toe in de juiste volgorde.",
"pathEditor_searchRepeaters": "Zoek naar herhaaldelijke zenders",
"pathEditor_advancedHex": "Geavanceerd: ruwe hex-pad",
"pathEditor_hexLabel": "Hex-voorkanten",
"pathEditor_hexHelper": "Twee hex-tekens per stap, gescheiden door komma's",
"pathEditor_invalidTokens": "Ongeldig: {tokens}",
"pathEditor_tooManyHops": "Maximaal 64 hopken",
"pathEditor_usePath": "Gebruik deze route.",
"pathEditor_removeHop": "Verwijder de hop",
"pathEditor_unknownHop": "Onbekend type zender",
"map_zoomIn": "Inzoomen",
"routing_lastWorked": "worked {when}",
"map_zoomOut": "Inzoomen",
"map_centerMap": "Centraal overzicht",
"chrome_bluetoothRequiresChromium": "Web Bluetooth vereist een Chromium-browser.",
"channels_communityShortId": "ID: {id}...",
"pathTrace_legendGpsConfirmed": "GPS-locatie bevestigd",
"pathTrace_legendInferred": "Afgeleide positie",
"@pathMap_hopOf": {
"placeholders": {
"current": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"@pathMap_observedPaths": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"@pathMap_alternate": {
"placeholders": {
"index": {
"type": "int"
}
}
},
"@pathMap_hopCount": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"@pathMap_gpsCount": {
"placeholders": {
"confirmed": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"@pathMap_sharedNodeCount": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"@pathMap_partialAnimation": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"map_activity": "Activiteit",
"map_searchHint": "Zoek op naam of ID van de knoop",
"scanner_bluetoothWebUnsupported": "Bluetooth is niet beschikbaar in de browser. Verbind dan via USB.",
"map_online": "Online",
"map_recent": "Recent",
"map_stale": "Verouderd",
"map_visible": "Zichtbaar",
"map_hidden": "Verborgen",
"map_centerOnNode": "Centreer op node",
"map_details": "Details",
"map_noGps": "Geen GPS",
"map_noResults": "Geen overeenkomende nodes",
"pathMap_viewSingle": "Enkel",
"pathMap_viewCombined": "Gezamenlijk",
"pathMap_play": "Afspelen",
"pathMap_pause": "Pauze",
"pathMap_replay": "Herhalen",
"pathMap_stepBack": "Vorige hop",
"pathMap_stepForward": "Volgende hop",
"pathMap_animationOn": "Pakketanimatie tonen",
"pathMap_animationOff": "Pakketanimatie verbergen",
"pathMap_hopOf": "Hop {current} van {total}",
"pathMap_observedPaths": "Waargenomen paden: {count}",
"pathMap_primary": "Primair",
"pathMap_alternate": "Alternatief {index}",
"pathMap_hopCount": "{count, plural, =1{1 hop} other{{count} hops}}",
"pathMap_legendShared": "Gedeeld segment",
"pathMap_legendEstimated": "Geschat segment",
"pathMap_sharedNodeCount": "Gebruikt door {count} paden",
"pathMap_partialAnimation": "{count, plural, =1{1 hop heeft geen locatie — het weergegeven pad is onvolledig} other{{count} hops hebben geen locatie — het weergegeven pad is onvolledig}}",
"pathMap_showAllPaths": "Toon alles",
"pathMap_hidePath": "Verberg pad",
"pathMap_showPath": "Toon pad",
"pathMap_collapsePanel": "Paneel inklappen",
"pathMap_expandPanel": "Paneel uitklappen",
"pathMap_noLocation": "Geen locatie",
"pathMap_followPacket": "Weergave vergrendelen op pakket",
"pathMap_unfollowPacket": "Weergave ontgrendelen van pakket",
"pathMap_gpsCount": "{confirmed}/{total} GPS"
} }
+290 -1
View File
@@ -33,6 +33,8 @@
"common_remove": "Usuń", "common_remove": "Usuń",
"common_enable": "Włącz", "common_enable": "Włącz",
"common_disable": "Wyłącz", "common_disable": "Wyłącz",
"common_autoRefresh": "Automatyczne odświeżanie",
"common_interval": "Interwał",
"common_reboot": "Uruchom ponownie", "common_reboot": "Uruchom ponownie",
"common_loading": "Ładowanie...", "common_loading": "Ładowanie...",
"common_notAvailable": "—", "common_notAvailable": "—",
@@ -238,6 +240,19 @@
"appSettings_last6Hours": "Ostatnie 6 godzin", "appSettings_last6Hours": "Ostatnie 6 godzin",
"appSettings_last24Hours": "Ostatnie 24 godziny", "appSettings_last24Hours": "Ostatnie 24 godziny",
"appSettings_lastWeek": "Ostatni tydzień", "appSettings_lastWeek": "Ostatni tydzień",
"appSettings_rasterTileSource": "Źródło kafelków rastrowych",
"appSettings_stadiaEndpoint": "Punkt końcowy Stadia",
"appSettings_stadiaApiKey": "Klucz API Stadia",
"appSettings_stadiaApiKeyRequired": "Wymagane do korzystania ze Stadia Maps",
"appSettings_stadiaApiKeyConfigured": "Skonfigurowano: {maskedKey}",
"@appSettings_stadiaApiKeyConfigured": {
"placeholders": {
"maskedKey": {
"type": "String"
}
}
},
"appSettings_stadiaApiKeyDialogDescription": "Wprowadź swój klucz API Stadia Maps. Aplikacja używa go do żądań kafelków rastrowych.",
"appSettings_offlineMapCache": "Pamięć podręczna map offline", "appSettings_offlineMapCache": "Pamięć podręczna map offline",
"appSettings_noAreaSelected": "Nie wybrano żadnego obszaru.", "appSettings_noAreaSelected": "Nie wybrano żadnego obszaru.",
"appSettings_areaSelectedZoom": "Wybrany obszar (skala {minZoom}-{maxZoom})", "appSettings_areaSelectedZoom": "Wybrany obszar (skala {minZoom}-{maxZoom})",
@@ -777,6 +792,56 @@
} }
} }
}, },
"mapCache_cachedTilesLabel": "Cached tiles",
"mapCache_cachedTileSummaryLabel": "Cached tile summary",
"mapCache_bulkDownloadDisabledForSource": "Offline bulk downloads are disabled for {source}.",
"@mapCache_bulkDownloadDisabledForSource": {
"placeholders": {
"source": {
"type": "String"
}
}
},
"mapCache_bulkDownloadDisabledInConfig": "Offline bulk downloads are disabled for {source} in this app configuration.",
"@mapCache_bulkDownloadDisabledInConfig": {
"placeholders": {
"source": {
"type": "String"
}
}
},
"mapCache_summarySource": "Source: {source}",
"@mapCache_summarySource": {
"placeholders": {
"source": {
"type": "String"
}
}
},
"mapCache_summaryCachedTilesForSource": "Cached tiles for source: {count}",
"@mapCache_summaryCachedTilesForSource": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"mapCache_summaryCachedInSelection": "Cached in selected area/zoom: {count}",
"@mapCache_summaryCachedInSelection": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"mapCache_summaryApproxCacheSize": "Approx cache size: {size}",
"@mapCache_summaryApproxCacheSize": {
"placeholders": {
"size": {
"type": "String"
}
}
},
"mapCache_boundsLabel": "N {north}, S {south}, E {east}, W {west}", "mapCache_boundsLabel": "N {north}, S {south}, E {east}, W {west}",
"@mapCache_boundsLabel": { "@mapCache_boundsLabel": {
"placeholders": { "placeholders": {
@@ -1290,6 +1355,43 @@
} }
} }
}, },
"telemetry_digitalInputLabel": "Wejście cyfrowe",
"telemetry_digitalOutputLabel": "Wyjście cyfrowe",
"telemetry_analogInputLabel": "Wejście analogowe",
"telemetry_analogOutputLabel": "Wyjście analogowe",
"telemetry_genericLabel": "Czujnik ogólny",
"telemetry_luminosityLabel": "Jasność",
"telemetry_presenceLabel": "Obecność",
"telemetry_humidityLabel": "Wilgotność",
"telemetry_accelerometerLabel": "Akcelerometr",
"telemetry_pressureLabel": "Ciśnienie",
"telemetry_altitudeLabel": "Wysokość",
"telemetry_frequencyLabel": "Częstotliwość",
"telemetry_percentageLabel": "Procent",
"telemetry_concentrationLabel": "Stężenie",
"telemetry_powerLabel": "Moc",
"telemetry_distanceLabel": "Odległość",
"telemetry_energyLabel": "Energia",
"telemetry_directionLabel": "Kierunek",
"telemetry_timeLabel": "Czas",
"telemetry_gyrometerLabel": "Żyrometr",
"telemetry_colourLabel": "Kolor",
"telemetry_gpsLabel": "GPS",
"telemetry_switchLabel": "Przełącznik",
"telemetry_polylineLabel": "Polilinia",
"telemetry_altitudeValue": "{meters} m",
"telemetry_frequencyValue": "{hertz} Hz",
"telemetry_pressureValue": "{hpa} hPa",
"telemetry_luminosityValue": "{lux} lx",
"telemetry_powerValue": "{watts} W",
"telemetry_distanceValue": "{meters} m",
"telemetry_energyValue": "{kilowattHours} kWh",
"telemetry_directionValue": "{degrees}°",
"telemetry_concentrationValue": "{ppm} ppm",
"telemetry_percentageValue": "{percent}%",
"telemetry_analogValue": "{value}",
"telemetry_autoFetchQuantity": "Liczba żądań",
"telemetry_error": "Nie udało się pobrać danych",
"telemetry_noData": "Brak dostępnych danych telemetrycznych.", "telemetry_noData": "Brak dostępnych danych telemetrycznych.",
"telemetry_channelTitle": "Kanał {channel}", "telemetry_channelTitle": "Kanał {channel}",
"@telemetry_channelTitle": { "@telemetry_channelTitle": {
@@ -2352,5 +2454,192 @@
"settings_companionDebugLogSubtitle": "Polecenia, odpowiedzi i surowe dane związane z protokołami BLE/TCP/USB", "settings_companionDebugLogSubtitle": "Polecenia, odpowiedzi i surowe dane związane z protokołami BLE/TCP/USB",
"chat_markAsUnread": "Oznacz jako nieprzeczytane", "chat_markAsUnread": "Oznacz jako nieprzeczytane",
"settings_companionDebugLog": "Log debugowania (dla pomocy w rozwiązywaniu problemów)", "settings_companionDebugLog": "Log debugowania (dla pomocy w rozwiązywaniu problemów)",
"repeater_chanUtil": "Wykorzystanie kanału" "repeater_chanUtil": "Wykorzystanie kanału",
"@routing_lastWorked": {
"placeholders": {
"when": {
"type": "String"
}
}
},
"@routing_deliveryCounts": {
"placeholders": {
"successes": {
"type": "int"
},
"failures": {
"type": "int"
}
}
},
"@pathEditor_hopCounter": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"@pathEditor_invalidTokens": {
"placeholders": {
"tokens": {
"type": "String"
}
}
},
"@channels_communityShortId": {
"placeholders": {
"id": {
"type": "String"
}
}
},
"messageStatus_sent": "Wysłane",
"messageStatus_delivered": "Dostarczone",
"messageStatus_pending": "Wysyłanie",
"common_undo": "Wycofaj",
"messageStatus_failed": "Nie udało się wysłać",
"messageStatus_repeated": "Usłyszałem to wielokrotnie",
"contacts_moreOptions": "Więcej opcji",
"contacts_searchOpen": "Wyszukaj kontakty",
"contacts_searchClose": "Zaawansowane wyszukiwanie",
"routing_title": "Planowanie tras",
"routing_modeAuto": "Samochód",
"routing_modeFlood": "Powódź",
"routing_modeManual": "Instrukcja obsługi",
"routing_modeAutoHint": "Automatycznie wybiera najpopularniejszą ścieżkę, a w przypadku braku znanej, przechodzi do trybu \"przepływu\".",
"routing_modeFloodHint": "Transmisje za pośrednictwem każdego repeatera. Najbardziej niezawodna metoda, ale zużywa więcej czasu transmisji.",
"routing_modeManualHint": "Zawsze prowadzi dokładnie po trasie, którą określiłeś.",
"routing_currentRoute": "Obecna trasa",
"routing_directNoHops": "Bezpośrednio bez pośrednictwa repeaterów",
"routing_noPathYet": "Na razie nie ma żadnej ścieżki. Komunikacja trwa do momentu, gdy zostanie odkryta trasa.",
"routing_floodBroadcast": "Transmisja za pośrednictwem każdego urządzenia powielającego",
"routing_editPath": "Edytuj ścieżkę",
"routing_forgetPath": "Zapomnij o ścieżce",
"routing_knownPaths": "Znane trasy",
"routing_knownPathsHint": "Wybierz ścieżkę, aby przełączyć się na nią.",
"routing_inUse": "W użyciu",
"routing_qualityStrong": "Silny pierwszy skok",
"routing_qualityGood": "Świetny początek",
"routing_qualityFair": "Świetny pierwszy krzak",
"routing_qualityWorked": "Zostało dostarczone",
"routing_qualityFlood": "Usłyszano dzięki doniesieniom",
"routing_qualityUntested": "Nieużywany",
"routing_lastWorked": "pracował {when}",
"routing_neverWorked": "nigdy nie zostało potwierdzone",
"routing_floodDelivery": "Dostawa w przypadku powodzi",
"pathEditor_title": "Stworzenie ścieżki",
"pathEditor_hopCounter": "{count} z 64 rodzajów chmielu",
"pathEditor_noHops": "Na razie nie dodano żadnych chmielu. Aby dodać je w odpowiedniej kolejności, kliknij w odpowiednie przyciski poniżej, lub zapisz przepis bez chmielu, aby wysłać go bezpośrednio.",
"pathEditor_addHops": "Dodawaj chmiel zgodnie z kolejnością.",
"pathEditor_searchRepeaters": "Funkcje powtarzania",
"pathEditor_advancedHex": "Zaawansowane: ścieżka w formacie szesnastkowym",
"pathEditor_hexLabel": "Prefiksy heksadecymalne",
"pathEditor_hexHelper": "Dwa znaki szesnastkowe na każdym kroku, oddzielone przecinkami",
"pathEditor_invalidTokens": "Nieprawidłowe: {tokens}",
"pathEditor_tooManyHops": "Maksymalnie 64 hopów",
"pathEditor_usePath": "Użyj tej ścieżki.",
"pathEditor_removeHop": "Usuń dziką psiankę",
"pathEditor_unknownHop": "Nieznany repeater",
"map_zoomIn": "Przybliż",
"routing_deliveryCounts": "{successes} delivered, {failures} failed",
"map_zoomOut": "Przybliż z powrotem",
"map_centerMap": "Mapa centrum",
"chrome_bluetoothRequiresChromium": "Web Bluetooth wymaga przeglądarki Chromium.",
"channels_communityShortId": "ID: {id}...",
"pathTrace_legendGpsConfirmed": "GPS potwierdzone",
"pathTrace_legendInferred": "Wywnioskowana pozycja",
"@pathMap_hopOf": {
"placeholders": {
"current": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"@pathMap_observedPaths": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"@pathMap_alternate": {
"placeholders": {
"index": {
"type": "int"
}
}
},
"@pathMap_hopCount": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"@pathMap_gpsCount": {
"placeholders": {
"confirmed": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"@pathMap_sharedNodeCount": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"@pathMap_partialAnimation": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"map_online": "Online",
"map_searchHint": "Wyszukaj nazwę lub identyfikator węzła",
"scanner_bluetoothWebUnsupported": "Bluetooth nie jest dostępny w przeglądarce. Połącz się przez USB.",
"map_activity": "Aktywność",
"map_recent": "Ostatnie",
"map_stale": "Nieaktualne",
"map_visible": "Widoczny",
"map_hidden": "Ukryty",
"map_centerOnNode": "Wyśrodkuj na węźle",
"map_details": "Szczegóły",
"map_noGps": "Brak GPS",
"map_noResults": "Brak pasujących węzłów",
"pathMap_viewSingle": "Pojedyncza",
"pathMap_viewCombined": "Połączone",
"pathMap_play": "Odtwórz",
"pathMap_pause": "Wstrzymaj",
"pathMap_replay": "Odtwórz ponownie",
"pathMap_stepBack": "Poprzedni skok",
"pathMap_stepForward": "Następny skok",
"pathMap_animationOn": "Pokaż animację pakietu",
"pathMap_animationOff": "Ukryj animację pakietu",
"pathMap_hopOf": "Skok {current} z {total}",
"pathMap_observedPaths": "Obserwowane trasy: {count}",
"pathMap_primary": "Główna",
"pathMap_alternate": "Alt. {index}",
"pathMap_hopCount": "{count, plural, =1{1 skok} few{{count} skoki} many{{count} skoków} other{{count} skoku}}",
"pathMap_legendShared": "Wspólny segment",
"pathMap_legendEstimated": "Szacunkowy segment",
"pathMap_sharedNodeCount": "Wykorzystywane przez {count} ścieżek",
"pathMap_partialAnimation": "{count, plural, =1{1 skok nie ma lokalizacji — pokazana ścieżka jest niekompletna} few{{count} skoki nie mają lokalizacji — pokazana ścieżka jest niekompletna} many{{count} skoków nie ma lokalizacji — pokazana ścieżka jest niekompletna} other{{count} skoku nie ma lokalizacji — pokazana ścieżka jest niekompletna}}",
"pathMap_showAllPaths": "Pokaż wszystkie",
"pathMap_hidePath": "Ukryj ścieżkę",
"pathMap_showPath": "Wyświetl trasę",
"pathMap_collapsePanel": "Zwiń panel",
"pathMap_expandPanel": "Rozwiń panel",
"pathMap_noLocation": "Brak lokalizacji",
"pathMap_followPacket": "Śledź pakiet",
"pathMap_unfollowPacket": "Przestań śledzić pakiet",
"pathMap_gpsCount": "{confirmed}/{total} GPS"
} }
+290 -1
View File
@@ -33,6 +33,8 @@
"common_remove": "Remover", "common_remove": "Remover",
"common_enable": "Ativar", "common_enable": "Ativar",
"common_disable": "Desativar", "common_disable": "Desativar",
"common_autoRefresh": "Atualização automática",
"common_interval": "Intervalo",
"common_reboot": "Reiniciar", "common_reboot": "Reiniciar",
"common_loading": "Carregando...", "common_loading": "Carregando...",
"common_notAvailable": "—", "common_notAvailable": "—",
@@ -238,6 +240,19 @@
"appSettings_last6Hours": "Últimos 6 horas", "appSettings_last6Hours": "Últimos 6 horas",
"appSettings_last24Hours": "Últimas 24 horas", "appSettings_last24Hours": "Últimas 24 horas",
"appSettings_lastWeek": "Da última semana", "appSettings_lastWeek": "Da última semana",
"appSettings_rasterTileSource": "Fonte de blocos raster",
"appSettings_stadiaEndpoint": "Endpoint da Stadia",
"appSettings_stadiaApiKey": "Chave da API Stadia",
"appSettings_stadiaApiKeyRequired": "Obrigatório para usar o Stadia Maps",
"appSettings_stadiaApiKeyConfigured": "Configurado: {maskedKey}",
"@appSettings_stadiaApiKeyConfigured": {
"placeholders": {
"maskedKey": {
"type": "String"
}
}
},
"appSettings_stadiaApiKeyDialogDescription": "Insira sua chave da API Stadia Maps. O aplicativo a usa para solicitações de blocos raster.",
"appSettings_offlineMapCache": "Cache de Mapa Offline", "appSettings_offlineMapCache": "Cache de Mapa Offline",
"appSettings_noAreaSelected": "Nenhuma área selecionada", "appSettings_noAreaSelected": "Nenhuma área selecionada",
"appSettings_areaSelectedZoom": "Área selecionada (zoom {minZoom}-{maxZoom})", "appSettings_areaSelectedZoom": "Área selecionada (zoom {minZoom}-{maxZoom})",
@@ -767,6 +782,56 @@
} }
} }
}, },
"mapCache_cachedTilesLabel": "Cached tiles",
"mapCache_cachedTileSummaryLabel": "Cached tile summary",
"mapCache_bulkDownloadDisabledForSource": "Offline bulk downloads are disabled for {source}.",
"@mapCache_bulkDownloadDisabledForSource": {
"placeholders": {
"source": {
"type": "String"
}
}
},
"mapCache_bulkDownloadDisabledInConfig": "Offline bulk downloads are disabled for {source} in this app configuration.",
"@mapCache_bulkDownloadDisabledInConfig": {
"placeholders": {
"source": {
"type": "String"
}
}
},
"mapCache_summarySource": "Source: {source}",
"@mapCache_summarySource": {
"placeholders": {
"source": {
"type": "String"
}
}
},
"mapCache_summaryCachedTilesForSource": "Cached tiles for source: {count}",
"@mapCache_summaryCachedTilesForSource": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"mapCache_summaryCachedInSelection": "Cached in selected area/zoom: {count}",
"@mapCache_summaryCachedInSelection": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"mapCache_summaryApproxCacheSize": "Approx cache size: {size}",
"@mapCache_summaryApproxCacheSize": {
"placeholders": {
"size": {
"type": "String"
}
}
},
"mapCache_boundsLabel": "N {north}, S {south}, E {east}, W {west}", "mapCache_boundsLabel": "N {north}, S {south}, E {east}, W {west}",
"@mapCache_boundsLabel": { "@mapCache_boundsLabel": {
"placeholders": { "placeholders": {
@@ -1280,6 +1345,43 @@
} }
} }
}, },
"telemetry_digitalInputLabel": "Entrada digital",
"telemetry_digitalOutputLabel": "Saída digital",
"telemetry_analogInputLabel": "Entrada analógica",
"telemetry_analogOutputLabel": "Saída analógica",
"telemetry_genericLabel": "Sensor genérico",
"telemetry_luminosityLabel": "Luminosidade",
"telemetry_presenceLabel": "Presença",
"telemetry_humidityLabel": "Humidade",
"telemetry_accelerometerLabel": "Acelerómetro",
"telemetry_pressureLabel": "Pressão",
"telemetry_altitudeLabel": "Altitude",
"telemetry_frequencyLabel": "Frequência",
"telemetry_percentageLabel": "Percentagem",
"telemetry_concentrationLabel": "Concentração",
"telemetry_powerLabel": "Potência",
"telemetry_distanceLabel": "Distância",
"telemetry_energyLabel": "Energia",
"telemetry_directionLabel": "Direção",
"telemetry_timeLabel": "Hora",
"telemetry_gyrometerLabel": "Girómetro",
"telemetry_colourLabel": "Cor",
"telemetry_gpsLabel": "GPS",
"telemetry_switchLabel": "Interruptor",
"telemetry_polylineLabel": "Polilinha",
"telemetry_altitudeValue": "{meters} m",
"telemetry_frequencyValue": "{hertz} Hz",
"telemetry_pressureValue": "{hpa} hPa",
"telemetry_luminosityValue": "{lux} lx",
"telemetry_powerValue": "{watts} W",
"telemetry_distanceValue": "{meters} m",
"telemetry_energyValue": "{kilowattHours} kWh",
"telemetry_directionValue": "{degrees}°",
"telemetry_concentrationValue": "{ppm} ppm",
"telemetry_percentageValue": "{percent}%",
"telemetry_analogValue": "{value}",
"telemetry_autoFetchQuantity": "Número de solicitações",
"telemetry_error": "Não foi possível obter os dados",
"telemetry_noData": "Não estão disponíveis dados de telemetria.", "telemetry_noData": "Não estão disponíveis dados de telemetria.",
"telemetry_channelTitle": "Canal {channel}", "telemetry_channelTitle": "Canal {channel}",
"@telemetry_channelTitle": { "@telemetry_channelTitle": {
@@ -2314,5 +2416,192 @@
"settings_companionDebugLogSubtitle": "Comandos, respostas e dados brutos para protocolos BLE/TCP/USB", "settings_companionDebugLogSubtitle": "Comandos, respostas e dados brutos para protocolos BLE/TCP/USB",
"chat_markAsUnread": "Marcar como não lido", "chat_markAsUnread": "Marcar como não lido",
"chat_newMessages": "Novas mensagens", "chat_newMessages": "Novas mensagens",
"repeater_chanUtil": "Utilização do canal" "repeater_chanUtil": "Utilização do canal",
"@routing_lastWorked": {
"placeholders": {
"when": {
"type": "String"
}
}
},
"@routing_deliveryCounts": {
"placeholders": {
"successes": {
"type": "int"
},
"failures": {
"type": "int"
}
}
},
"@pathEditor_hopCounter": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"@pathEditor_invalidTokens": {
"placeholders": {
"tokens": {
"type": "String"
}
}
},
"@channels_communityShortId": {
"placeholders": {
"id": {
"type": "String"
}
}
},
"common_undo": "Desfazer",
"messageStatus_sent": "Enviado",
"messageStatus_pending": "Enviar",
"messageStatus_delivered": "Entregue",
"messageStatus_failed": "Falhou ao enviar",
"messageStatus_repeated": "Ouvi repetidamente",
"contacts_moreOptions": "Mais opções",
"contacts_searchOpen": "Pesquisar contatos",
"contacts_searchClose": "Pesquisa avançada",
"routing_title": "Rotas",
"routing_modeAuto": "Carro",
"routing_modeFlood": "Inundação",
"routing_modeManual": "Manual",
"routing_modeAutoHint": "Seleciona automaticamente o caminho mais conhecido, e, se nenhum caminho conhecido for encontrado, utiliza a estratégia de \"inundação\".",
"routing_modeFloodHint": "Transmissão através de todos os repetidores. É a opção mais confiável, mas utiliza mais tempo de transmissão.",
"routing_modeManualHint": "Sempre segue exatamente o caminho que você define.",
"routing_currentRoute": "Rota atual",
"routing_directNoHops": "Direto sem saltos de repetidor",
"routing_noPathYet": "Ainda não há um caminho definido. A mensagem continua a ser enviada até que uma rota seja encontrada.",
"routing_floodBroadcast": "Transmissão através de todos os repetidores",
"routing_editPath": "Editar caminho",
"routing_forgetPath": "Esqueça o caminho",
"routing_knownPaths": "Rotas conhecidas",
"routing_knownPathsHint": "Toque em um caminho para alternar para ele.",
"routing_inUse": "Em uso",
"routing_qualityStrong": "Primeiro salto notável",
"routing_qualityGood": "Primeiro salto bem-sucedido",
"routing_qualityFair": "Primeira etapa bem-sucedida",
"routing_qualityWorked": "Foi entregue",
"routing_qualityFlood": "Informação obtida através de relatos generalizados.",
"routing_qualityUntested": "Não testado",
"routing_neverWorked": "nunca confirmado",
"routing_floodDelivery": "Entrega em áreas afetadas por inundações",
"pathEditor_title": "Criar Caminho",
"pathEditor_hopCounter": "{count} de 64 gramas de lúpulo",
"pathEditor_noHops": "Ainda não há lúpulos adicionados. Clique nos repetidores abaixo para adicioná-los na ordem desejada, ou salve sem adicionar lúpulos para enviar diretamente.",
"pathEditor_addHops": "Adicione os lúpulos na seguinte ordem.",
"pathEditor_searchRepeaters": "Encontrar repetidores",
"pathEditor_advancedHex": "Avançado: caminho hexadecimal bruto",
"pathEditor_hexLabel": "Prefixos hexadecimais",
"pathEditor_hexHelper": "Dois caracteres hexadecimais por salto, separados por vírgulas.",
"pathEditor_invalidTokens": "Inválido: {tokens}",
"routing_lastWorked": "worked {when}",
"pathEditor_tooManyHops": "Máximo de 64 saltos",
"routing_deliveryCounts": "{successes} delivered, {failures} failed",
"pathEditor_usePath": "Utilize este caminho.",
"pathEditor_removeHop": "Remova o lúpulo",
"pathEditor_unknownHop": "Repetidor desconhecido",
"map_zoomIn": "Ampliar",
"map_zoomOut": "Ampliar",
"map_centerMap": "Mapa do centro",
"chrome_bluetoothRequiresChromium": "O Web Bluetooth requer um navegador Chromium.",
"channels_communityShortId": "ID: {id}...",
"pathTrace_legendGpsConfirmed": "GPS confirmado",
"pathTrace_legendInferred": "Posição inferida",
"@pathMap_hopOf": {
"placeholders": {
"current": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"@pathMap_observedPaths": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"@pathMap_alternate": {
"placeholders": {
"index": {
"type": "int"
}
}
},
"@pathMap_hopCount": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"@pathMap_gpsCount": {
"placeholders": {
"confirmed": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"@pathMap_sharedNodeCount": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"@pathMap_partialAnimation": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"map_online": "Online",
"map_activity": "Atividade",
"scanner_bluetoothWebUnsupported": "A funcionalidade Bluetooth não está disponível no navegador. Conecte-se via USB em vez disso.",
"map_searchHint": "Pesquisar por nome ou ID do nó",
"map_recent": "Recente",
"map_stale": "Vencido",
"map_visible": "Visível",
"map_hidden": "Escondido",
"map_centerOnNode": "Centralizar no nó",
"map_details": "Detalhes",
"map_noGps": "Sem GPS",
"map_noResults": "Nenhum nó encontrado",
"pathMap_viewSingle": "Único",
"pathMap_viewCombined": "Combinado",
"pathMap_play": "Reproduzir",
"pathMap_pause": "Pausa",
"pathMap_stepBack": "Salto anterior",
"pathMap_replay": "Repetir",
"pathMap_stepForward": "Próximo salto",
"pathMap_animationOn": "Exibir animação do pacote",
"pathMap_animationOff": "Ocultar a animação do pacote",
"pathMap_hopOf": "Salto {current} de {total}",
"pathMap_observedPaths": "Caminhos observados: {count}",
"pathMap_primary": "Primário",
"pathMap_alternate": "Alt {index}",
"pathMap_hopCount": "{count, plural, =1{1 salto} other{{count} saltos}}",
"pathMap_legendShared": "Segmento compartilhado",
"pathMap_legendEstimated": "Segmento estimado",
"pathMap_sharedNodeCount": "Utilizado em {count} caminhos",
"pathMap_partialAnimation": "{count, plural, =1{1 salto não tem localização — o caminho mostrado é parcial} other{{count} saltos não têm localização — o caminho mostrado é parcial}}",
"pathMap_showAllPaths": "Mostrar tudo",
"pathMap_hidePath": "Esconder caminho",
"pathMap_showPath": "Mostrar o caminho",
"pathMap_collapsePanel": "Recolher painel",
"pathMap_expandPanel": "Expandir painel",
"pathMap_noLocation": "Sem localização",
"pathMap_followPacket": "Fixar vista no pacote",
"pathMap_unfollowPacket": "Liberar vista do pacote",
"pathMap_gpsCount": "{confirmed}/{total} GPS"
} }
+290 -1
View File
@@ -39,6 +39,8 @@
"common_notAvailable": "—", "common_notAvailable": "—",
"common_voltageValue": "{volts} В", "common_voltageValue": "{volts} В",
"common_percentValue": "{percent}%", "common_percentValue": "{percent}%",
"common_autoRefresh": "Автообновление",
"common_interval": "Интервал",
"scanner_title": "MeshCore Open", "scanner_title": "MeshCore Open",
"scanner_scanning": "Поиск устройств...", "scanner_scanning": "Поиск устройств...",
"scanner_connecting": "Подключение...", "scanner_connecting": "Подключение...",
@@ -188,6 +190,19 @@
"appSettings_last6Hours": "Последние 6 часов", "appSettings_last6Hours": "Последние 6 часов",
"appSettings_last24Hours": "Последние 24 часа", "appSettings_last24Hours": "Последние 24 часа",
"appSettings_lastWeek": "Последнюю неделю", "appSettings_lastWeek": "Последнюю неделю",
"appSettings_rasterTileSource": "Источник растровых тайлов",
"appSettings_stadiaEndpoint": "Конечная точка Stadia",
"appSettings_stadiaApiKey": "Ключ API Stadia",
"appSettings_stadiaApiKeyRequired": "Требуется для использования Stadia Maps",
"appSettings_stadiaApiKeyConfigured": "Настроено: {maskedKey}",
"@appSettings_stadiaApiKeyConfigured": {
"placeholders": {
"maskedKey": {
"type": "String"
}
}
},
"appSettings_stadiaApiKeyDialogDescription": "Введите свой ключ API Stadia Maps. Приложение использует его для запросов растровых тайлов.",
"appSettings_offlineMapCache": "Кэш офлайн-карты", "appSettings_offlineMapCache": "Кэш офлайн-карты",
"appSettings_noAreaSelected": "Область не выбрана", "appSettings_noAreaSelected": "Область не выбрана",
"appSettings_areaSelectedZoom": "Область выбрана (масштаб {minZoom}{maxZoom})", "appSettings_areaSelectedZoom": "Область выбрана (масштаб {minZoom}{maxZoom})",
@@ -436,6 +451,56 @@
"mapCache_downloadTilesButton": "Загрузить плитки", "mapCache_downloadTilesButton": "Загрузить плитки",
"mapCache_clearCacheButton": "Очистить кэш", "mapCache_clearCacheButton": "Очистить кэш",
"mapCache_failedDownloads": "Неудачных загрузок: {count}", "mapCache_failedDownloads": "Неудачных загрузок: {count}",
"mapCache_cachedTilesLabel": "Cached tiles",
"mapCache_cachedTileSummaryLabel": "Cached tile summary",
"mapCache_bulkDownloadDisabledForSource": "Offline bulk downloads are disabled for {source}.",
"@mapCache_bulkDownloadDisabledForSource": {
"placeholders": {
"source": {
"type": "String"
}
}
},
"mapCache_bulkDownloadDisabledInConfig": "Offline bulk downloads are disabled for {source} in this app configuration.",
"@mapCache_bulkDownloadDisabledInConfig": {
"placeholders": {
"source": {
"type": "String"
}
}
},
"mapCache_summarySource": "Source: {source}",
"@mapCache_summarySource": {
"placeholders": {
"source": {
"type": "String"
}
}
},
"mapCache_summaryCachedTilesForSource": "Cached tiles for source: {count}",
"@mapCache_summaryCachedTilesForSource": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"mapCache_summaryCachedInSelection": "Cached in selected area/zoom: {count}",
"@mapCache_summaryCachedInSelection": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"mapCache_summaryApproxCacheSize": "Approx cache size: {size}",
"@mapCache_summaryApproxCacheSize": {
"placeholders": {
"size": {
"type": "String"
}
}
},
"mapCache_boundsLabel": "С {north}, Ю {south}, В {east}, З {west}", "mapCache_boundsLabel": "С {north}, Ю {south}, В {east}, З {west}",
"time_justNow": "Только что", "time_justNow": "Только что",
"time_minutesAgo": "{minutes} мин назад", "time_minutesAgo": "{minutes} мин назад",
@@ -686,6 +751,43 @@
"telemetry_voltageValue": "{volts}В", "telemetry_voltageValue": "{volts}В",
"telemetry_currentValue": "{amps}А", "telemetry_currentValue": "{amps}А",
"telemetry_temperatureValue": "{celsius}°C / {fahrenheit}°F", "telemetry_temperatureValue": "{celsius}°C / {fahrenheit}°F",
"telemetry_digitalInputLabel": "Цифровой вход",
"telemetry_digitalOutputLabel": "Цифровой выход",
"telemetry_analogInputLabel": "Аналоговый вход",
"telemetry_analogOutputLabel": "Аналоговый выход",
"telemetry_genericLabel": "Общий датчик",
"telemetry_luminosityLabel": "Освещённость",
"telemetry_presenceLabel": "Присутствие",
"telemetry_humidityLabel": "Влажность",
"telemetry_accelerometerLabel": "Акселерометр",
"telemetry_pressureLabel": "Давление",
"telemetry_altitudeLabel": "Высота",
"telemetry_frequencyLabel": "Частота",
"telemetry_percentageLabel": "Процент",
"telemetry_concentrationLabel": "Концентрация",
"telemetry_powerLabel": "Мощность",
"telemetry_distanceLabel": "Расстояние",
"telemetry_energyLabel": "Энергия",
"telemetry_directionLabel": "Направление",
"telemetry_timeLabel": "Время",
"telemetry_gyrometerLabel": "Гирометр",
"telemetry_colourLabel": "Цвет",
"telemetry_gpsLabel": "GPS",
"telemetry_switchLabel": "Переключатель",
"telemetry_polylineLabel": "Полилиния",
"telemetry_altitudeValue": "{meters} м",
"telemetry_frequencyValue": "{hertz} Гц",
"telemetry_pressureValue": "{hpa} гПа",
"telemetry_luminosityValue": "{lux} лк",
"telemetry_powerValue": "{watts} Вт",
"telemetry_distanceValue": "{meters} м",
"telemetry_energyValue": "{kilowattHours} кВт⋅ч",
"telemetry_directionValue": "{degrees}°",
"telemetry_concentrationValue": "{ppm} ppm",
"telemetry_percentageValue": "{percent}%",
"telemetry_analogValue": "{value}",
"telemetry_autoFetchQuantity": "Количество запросов",
"telemetry_error": "Не удалось получить данные",
"neighbors_receivedData": "Полученные данные о соседях", "neighbors_receivedData": "Полученные данные о соседях",
"neighbors_requestTimedOut": "Время ожидания данных о соседях истекло.", "neighbors_requestTimedOut": "Время ожидания данных о соседях истекло.",
"neighbors_errorLoading": "Ошибка загрузки соседей: {error}", "neighbors_errorLoading": "Ошибка загрузки соседей: {error}",
@@ -1617,5 +1719,192 @@
"repeater_cliHelpStatsCore": "(Только для серийного оборудования) Отображает основные статистические данные прошивки.", "repeater_cliHelpStatsCore": "(Только для серийного оборудования) Отображает основные статистические данные прошивки.",
"settings_companionDebugLogSubtitle": "Команды, ответы и необработанные данные, используемые для протоколов BLE, TCP и USB.", "settings_companionDebugLogSubtitle": "Команды, ответы и необработанные данные, используемые для протоколов BLE, TCP и USB.",
"repeater_chanUtil": "Использование канала", "repeater_chanUtil": "Использование канала",
"settings_companionDebugLog": "Журнал отладки (для сопутствующего приложения)" "settings_companionDebugLog": "Журнал отладки (для сопутствующего приложения)",
"@routing_lastWorked": {
"placeholders": {
"when": {
"type": "String"
}
}
},
"@routing_deliveryCounts": {
"placeholders": {
"successes": {
"type": "int"
},
"failures": {
"type": "int"
}
}
},
"@pathEditor_hopCounter": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"@pathEditor_invalidTokens": {
"placeholders": {
"tokens": {
"type": "String"
}
}
},
"@channels_communityShortId": {
"placeholders": {
"id": {
"type": "String"
}
}
},
"messageStatus_pending": "Отправка",
"common_undo": "Отменить",
"messageStatus_delivered": "Доставлено",
"messageStatus_sent": "Отправлено",
"messageStatus_failed": "Не удалось отправить",
"messageStatus_repeated": "Услышал несколько раз",
"contacts_moreOptions": "Больше вариантов",
"contacts_searchOpen": "Найти контакты",
"contacts_searchClose": "Закрыть поиск",
"routing_title": "Маршрутизация",
"routing_modeAuto": "Авто",
"routing_modeFlood": "Наводнение",
"routing_modeManual": "Инструкция",
"routing_modeAutoHint": "Автоматически выбирает наиболее известный путь, и если такой путь неизвестен, использует алгоритм поиска пути.",
"routing_modeFloodHint": "Передача сигнала через все ретрансляторы. Самый надежный способ, но требует больше времени на передачу.",
"routing_modeManualHint": "Всегда следует точно по указанному вами маршруту.",
"routing_currentRoute": "Текущий маршрут",
"routing_directNoHops": "Прямое соединение – без использования ретрансляторов",
"routing_noPathYet": "Пока нет пути. Следующее сообщение будет отправлено до тех пор, пока не будет обнаружен маршрут.",
"routing_floodBroadcast": "Транслируется через все ретрансляторы",
"routing_editPath": "Изменить путь",
"routing_forgetPath": "Забудьте о маршруте",
"routing_knownPaths": "Известные маршруты",
"routing_knownPathsHint": "Создайте маршрут для переключения на этот пункт.",
"routing_inUse": "В эксплуатации",
"routing_qualityStrong": "Сильный первый скачок",
"routing_qualityGood": "Хорошее начало",
"routing_qualityFair": "Первый хороший урожай",
"routing_qualityWorked": "Осуществлено",
"routing_qualityFlood": "Узнал из новостей, распространяющихся в интернете.",
"routing_qualityUntested": "Непроверенный",
"routing_neverWorked": "никогда не было подтверждено",
"routing_floodDelivery": "Доставка при затоплении",
"pathEditor_title": "Создать маршрут",
"pathEditor_hopCounter": "{count} из 64 хмеля",
"pathEditor_noHops": "На данный момент хмель еще не добавлен. Чтобы добавить его, нажмите на соответствующие кнопки ниже в нужном порядке, или сохраните рецепт без хмеля, чтобы отправить его напрямую.",
"pathEditor_addHops": "Добавляйте хмель в соответствии с указанным порядком.",
"pathEditor_searchRepeaters": "Поиск повторителей",
"pathEditor_advancedHex": "Продвинутый уровень: прямой путь в шестнадцатеричном формате",
"pathEditor_hexLabel": "Префиксы шестнадцатеричной системы",
"pathEditor_hexHelper": "Два шестнадцатеричных символа на каждом шаге, разделенные запятыми.",
"pathEditor_invalidTokens": "Неверно: {tokens}",
"routing_lastWorked": "worked {when}",
"routing_deliveryCounts": "{successes} delivered, {failures} failed",
"pathEditor_tooManyHops": "Максимальное количество ингредиентов – 64",
"pathEditor_usePath": "Используйте этот путь",
"pathEditor_removeHop": "Удалить хмель",
"pathEditor_unknownHop": "Неизвестный ретранслятор",
"map_zoomIn": "Увеличить масштаб",
"map_zoomOut": "Увеличить масштаб",
"map_centerMap": "Карта центра",
"chrome_bluetoothRequiresChromium": "Для работы Web Bluetooth требуется браузер на основе Chromium.",
"channels_communityShortId": "Идентификатор: {id}...",
"pathTrace_legendGpsConfirmed": "GPS подтверждено",
"pathTrace_legendInferred": "Выведенная позиция",
"@pathMap_hopOf": {
"placeholders": {
"current": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"@pathMap_observedPaths": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"@pathMap_alternate": {
"placeholders": {
"index": {
"type": "int"
}
}
},
"@pathMap_hopCount": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"@pathMap_gpsCount": {
"placeholders": {
"confirmed": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"@pathMap_sharedNodeCount": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"@pathMap_partialAnimation": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"map_searchHint": "Поиск по имени или ID узла",
"map_online": "Онлайн",
"scanner_bluetoothWebUnsupported": "Bluetooth недоступен в браузере. Подключитесь через USB.",
"map_activity": "Активность",
"map_recent": "Недавно",
"map_stale": "Устаревший",
"map_visible": "Видимый",
"map_hidden": "Скрытый",
"map_centerOnNode": "Центрировать на узле",
"map_details": "Детали",
"map_noGps": "Без GPS",
"map_noResults": "Не найдено соответствующих узлов",
"pathMap_viewSingle": "Одиночный",
"pathMap_viewCombined": "Объединённые",
"pathMap_play": "Воспроизвести",
"pathMap_pause": "Пауза",
"pathMap_replay": "Повтор",
"pathMap_stepBack": "Предыдущий хоп",
"pathMap_stepForward": "Следующий хоп",
"pathMap_animationOn": "Показать анимацию пакета",
"pathMap_animationOff": "Скрыть анимацию пакета",
"pathMap_hopOf": "Хоп {current} из {total}",
"pathMap_observedPaths": "Наблюдаемые маршруты: {count}",
"pathMap_primary": "Основной",
"pathMap_alternate": "Альт {index}",
"pathMap_hopCount": "{count, plural, one{{count} хоп} few{{count} хопа} many{{count} хопов} other{{count} хопов}}",
"pathMap_legendShared": "Общий сегмент",
"pathMap_legendEstimated": "Расчётный сегмент",
"pathMap_sharedNodeCount": "Используется в {count} маршрутах",
"pathMap_partialAnimation": "{count, plural, one{{count} хоп не имеет координат — показанный путь неполный} few{{count} хопа не имеют координат — показанный путь неполный} many{{count} хопов не имеют координат — показанный путь неполный} other{{count} хопов не имеют координат — показанный путь неполный}}",
"pathMap_showAllPaths": "Показать всё",
"pathMap_hidePath": "Скрыть путь",
"pathMap_collapsePanel": "Скрыть панель",
"pathMap_showPath": "Показать маршрут",
"pathMap_expandPanel": "Расширить панель",
"pathMap_noLocation": "Нет координат",
"pathMap_followPacket": "Следить за пакетом",
"pathMap_unfollowPacket": "Не следить за пакетом",
"pathMap_gpsCount": "{confirmed}/{total} GPS"
} }
+290 -1
View File
@@ -33,6 +33,8 @@
"common_remove": "Odstrániť", "common_remove": "Odstrániť",
"common_enable": "Povolit", "common_enable": "Povolit",
"common_disable": "Zakázať", "common_disable": "Zakázať",
"common_autoRefresh": "Automatické obnovenie",
"common_interval": "Časový interval",
"common_reboot": "Restartovať", "common_reboot": "Restartovať",
"common_loading": "Načítavanie...", "common_loading": "Načítavanie...",
"common_notAvailable": "—", "common_notAvailable": "—",
@@ -238,6 +240,19 @@
"appSettings_last6Hours": "Posledné 6 hodín", "appSettings_last6Hours": "Posledné 6 hodín",
"appSettings_last24Hours": "Posledných 24 hodín", "appSettings_last24Hours": "Posledných 24 hodín",
"appSettings_lastWeek": "Minul týždeň", "appSettings_lastWeek": "Minul týždeň",
"appSettings_rasterTileSource": "Zdroj rastrových dlaždíc",
"appSettings_stadiaEndpoint": "Koncový bod Stadia",
"appSettings_stadiaApiKey": "Kľúč API Stadia",
"appSettings_stadiaApiKeyRequired": "Vyžaduje sa na používanie Stadia Maps",
"appSettings_stadiaApiKeyConfigured": "Nakonfigurované: {maskedKey}",
"@appSettings_stadiaApiKeyConfigured": {
"placeholders": {
"maskedKey": {
"type": "String"
}
}
},
"appSettings_stadiaApiKeyDialogDescription": "Zadajte svoj kľúč API pre Stadia Maps. Aplikácia ho používa na požiadavky na rastrové dlaždice.",
"appSettings_offlineMapCache": "Offline Mapa Pamäť", "appSettings_offlineMapCache": "Offline Mapa Pamäť",
"appSettings_noAreaSelected": "Neoznačila sa žiadna oblasť", "appSettings_noAreaSelected": "Neoznačila sa žiadna oblasť",
"appSettings_areaSelectedZoom": "Vyberená oblasť (zoom {minZoom}-{maxZoom})", "appSettings_areaSelectedZoom": "Vyberená oblasť (zoom {minZoom}-{maxZoom})",
@@ -767,6 +782,56 @@
} }
} }
}, },
"mapCache_cachedTilesLabel": "Cached tiles",
"mapCache_cachedTileSummaryLabel": "Cached tile summary",
"mapCache_bulkDownloadDisabledForSource": "Offline bulk downloads are disabled for {source}.",
"@mapCache_bulkDownloadDisabledForSource": {
"placeholders": {
"source": {
"type": "String"
}
}
},
"mapCache_bulkDownloadDisabledInConfig": "Offline bulk downloads are disabled for {source} in this app configuration.",
"@mapCache_bulkDownloadDisabledInConfig": {
"placeholders": {
"source": {
"type": "String"
}
}
},
"mapCache_summarySource": "Source: {source}",
"@mapCache_summarySource": {
"placeholders": {
"source": {
"type": "String"
}
}
},
"mapCache_summaryCachedTilesForSource": "Cached tiles for source: {count}",
"@mapCache_summaryCachedTilesForSource": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"mapCache_summaryCachedInSelection": "Cached in selected area/zoom: {count}",
"@mapCache_summaryCachedInSelection": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"mapCache_summaryApproxCacheSize": "Approx cache size: {size}",
"@mapCache_summaryApproxCacheSize": {
"placeholders": {
"size": {
"type": "String"
}
}
},
"mapCache_boundsLabel": "N {north}, S {south}, E {east}, W {west}", "mapCache_boundsLabel": "N {north}, S {south}, E {east}, W {west}",
"@mapCache_boundsLabel": { "@mapCache_boundsLabel": {
"placeholders": { "placeholders": {
@@ -1280,6 +1345,43 @@
} }
} }
}, },
"telemetry_digitalInputLabel": "Digitálny vstup",
"telemetry_digitalOutputLabel": "Digitálny výstup",
"telemetry_analogInputLabel": "Analógový vstup",
"telemetry_analogOutputLabel": "Analógový výstup",
"telemetry_genericLabel": "Všeobecný senzor",
"telemetry_luminosityLabel": "Osvetlenie",
"telemetry_presenceLabel": "Prítomnosť",
"telemetry_humidityLabel": "Vlhkosť",
"telemetry_accelerometerLabel": "Akcelerometer",
"telemetry_pressureLabel": "Tlak",
"telemetry_altitudeLabel": "Nadmorská výška",
"telemetry_frequencyLabel": "Frekvencia",
"telemetry_percentageLabel": "Percento",
"telemetry_concentrationLabel": "Koncentrácia",
"telemetry_powerLabel": "Výkon",
"telemetry_distanceLabel": "Vzdialenosť",
"telemetry_energyLabel": "Energia",
"telemetry_directionLabel": "Smer",
"telemetry_timeLabel": "Čas",
"telemetry_gyrometerLabel": "Gyrometer",
"telemetry_colourLabel": "Farba",
"telemetry_gpsLabel": "GPS",
"telemetry_switchLabel": "Prepínač",
"telemetry_polylineLabel": "Lomená čiara",
"telemetry_altitudeValue": "{meters} m",
"telemetry_frequencyValue": "{hertz} Hz",
"telemetry_pressureValue": "{hpa} hPa",
"telemetry_luminosityValue": "{lux} lx",
"telemetry_powerValue": "{watts} W",
"telemetry_distanceValue": "{meters} m",
"telemetry_energyValue": "{kilowattHours} kWh",
"telemetry_directionValue": "{degrees}°",
"telemetry_concentrationValue": "{ppm} ppm",
"telemetry_percentageValue": "{percent}%",
"telemetry_analogValue": "{value}",
"telemetry_autoFetchQuantity": "Počet požiadaviek",
"telemetry_error": "Nepodarilo sa získať údaje",
"telemetry_noData": "Nejsú dostupné žiadne údaje z telemetrie.", "telemetry_noData": "Nejsú dostupné žiadne údaje z telemetrie.",
"telemetry_channelTitle": "Kanál {channel}", "telemetry_channelTitle": "Kanál {channel}",
"@telemetry_channelTitle": { "@telemetry_channelTitle": {
@@ -2314,5 +2416,192 @@
"settings_companionDebugLogSubtitle": "Príkazy, odpovede a surové dáta pre protokoly BLE/TCP/USB", "settings_companionDebugLogSubtitle": "Príkazy, odpovede a surové dáta pre protokoly BLE/TCP/USB",
"settings_companionDebugLog": "Logovanie pre ladenie (sprievodný log)", "settings_companionDebugLog": "Logovanie pre ladenie (sprievodný log)",
"chat_newMessages": "Nové správy", "chat_newMessages": "Nové správy",
"repeater_chanUtil": "Využitie kanálu" "repeater_chanUtil": "Využitie kanálu",
"@routing_lastWorked": {
"placeholders": {
"when": {
"type": "String"
}
}
},
"@routing_deliveryCounts": {
"placeholders": {
"successes": {
"type": "int"
},
"failures": {
"type": "int"
}
}
},
"@pathEditor_hopCounter": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"@pathEditor_invalidTokens": {
"placeholders": {
"tokens": {
"type": "String"
}
}
},
"@channels_communityShortId": {
"placeholders": {
"id": {
"type": "String"
}
}
},
"messageStatus_sent": "Odoslané",
"messageStatus_delivered": "Doručené",
"messageStatus_pending": "Odoslanie",
"common_undo": "Zrušiť",
"messageStatus_failed": "Neúspešné odeslanie",
"messageStatus_repeated": "Slyšal som to opakovane",
"contacts_moreOptions": "Ďalšie možnosti",
"contacts_searchOpen": "Vyhľadajte kontakty",
"contacts_searchClose": "Zavrieť vyhľadávanie",
"routing_title": "Navigácia",
"routing_modeAuto": "Auto",
"routing_modeFlood": "Povodňová vlna",
"routing_modeManual": "Ručná príručka",
"routing_modeAutoHint": "Automaticky vyberá najznámejší trasa, a ak žiadna nie je známa, použije náhodnú trasu.",
"routing_modeFloodHint": "Prenos prostredníctvom všetkých opakovačov. Najspoľahlivejší spôsob, ale vyžaduje viac času vysielania.",
"routing_modeManualHint": "Vždy dodáva presne podľa zadaného trasy.",
"routing_currentRoute": "Aktuálna trasa",
"routing_directNoHops": "Priamo bez prechodných trás",
"routing_noPathYet": "Zatiaľ neexistuje žiadna cesta. Nasledujúce správy budú pokračovať, kým sa nenájde trasa.",
"routing_floodBroadcast": "Prenos prostredníctvom každého opakovača",
"routing_editPath": "Upraviť trasu",
"routing_forgetPath": "Zabudnite na trasu",
"routing_knownPaths": "Známe cesty",
"routing_knownPathsHint": "Kliknite na cestu, aby ste sa k nej presunuli.",
"routing_inUse": "V prevádzke",
"routing_qualityStrong": "Silný prvý krok",
"routing_qualityGood": "Úspešný prvý krok",
"routing_qualityFair": "Prvá, spravodlivá fáza",
"routing_qualityWorked": "Dosiahnutý úspech",
"routing_qualityFlood": "Zistil som to z informácií, ktoré som získal v dôsledku povodňovej situácie.",
"routing_qualityUntested": "Neotestované",
"routing_neverWorked": "nikedy nebolo potvrdené",
"routing_floodDelivery": "Doručenie v prípade povodní",
"pathEditor_title": "Vytvorenie cesty",
"pathEditor_hopCounter": "{count} z 64 chmelových zŕš",
"pathEditor_noHops": "Zatiaľ žiadne chmel. Kliknite na opakované, aby ste ich pridali postupne, alebo uložte bez chmelu, aby ste ho mohli poslať priamo.",
"pathEditor_addHops": "Pridávajte chmel podľa zadaného poriadku.",
"pathEditor_searchRepeaters": "Hľadať opakované",
"pathEditor_advancedHex": "Pokročilé: pôvodná hexová cesta",
"pathEditor_hexLabel": "Prefiksy pre hexadecimálne čísla",
"pathEditor_hexHelper": "Dve hexové čísla na každý krok, oddelené čiarkami",
"routing_lastWorked": "worked {when}",
"pathEditor_invalidTokens": "Neplatné: {tokens}",
"routing_deliveryCounts": "{successes} delivered, {failures} failed",
"pathEditor_tooManyHops": "Maximálne 64 krokov",
"pathEditor_usePath": "Použite túto cestu",
"pathEditor_removeHop": "Odstráňte chmel",
"pathEditor_unknownHop": "Neznáme zariadenie na opakované vysielanie",
"map_zoomIn": "Zväčšiť",
"map_zoomOut": "Zmenť zamer zblízka",
"map_centerMap": "Mapa centra",
"chrome_bluetoothRequiresChromium": "Web Bluetooth vyžaduje prehliadač Chromium.",
"channels_communityShortId": "ID: {id}...",
"pathTrace_legendGpsConfirmed": "GPS potvrdilo",
"pathTrace_legendInferred": "Odvodená poloha",
"@pathMap_hopOf": {
"placeholders": {
"current": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"@pathMap_observedPaths": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"@pathMap_alternate": {
"placeholders": {
"index": {
"type": "int"
}
}
},
"@pathMap_hopCount": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"@pathMap_gpsCount": {
"placeholders": {
"confirmed": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"@pathMap_sharedNodeCount": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"@pathMap_partialAnimation": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"map_online": "Online",
"scanner_bluetoothWebUnsupported": "Funkcia Bluetooth nie je dostupná v prehliadači. Prepojte sa pomocou USB.",
"map_searchHint": "Vyhľadajte podľa názvu alebo ID uzla",
"map_activity": "Aktivita",
"map_recent": "Nedávne",
"map_stale": "Neaktuálne",
"map_hidden": "Skrytý",
"map_visible": "Viditeľný",
"map_centerOnNode": "Nacentrovať na uzol",
"map_details": "Podrobnosti",
"map_noGps": "Bez GPS",
"map_noResults": "Nenašli sa žiadne zodpovedajúce uzly.",
"pathMap_viewSingle": "Jednotlivý",
"pathMap_viewCombined": "Spojené",
"pathMap_play": "Prehrať",
"pathMap_pause": "Pozastaviť",
"pathMap_replay": "Prehrať znova",
"pathMap_stepBack": "Predchádzajúci skok",
"pathMap_stepForward": "Nasledujúci skok",
"pathMap_animationOn": "Zobraziť animáciu paketu",
"pathMap_animationOff": "Skryť animáciu paketu",
"pathMap_hopOf": "Skok {current} z {total}",
"pathMap_observedPaths": "Pozorované cesty: {count}",
"pathMap_primary": "Primárna",
"pathMap_alternate": "Alternatívny {index}",
"pathMap_hopCount": "{count, plural, =1{1 skok} few{{count} skoky} other{{count} skokov}}",
"pathMap_legendShared": "Spoločný segment",
"pathMap_legendEstimated": "Odhadovaný segment",
"pathMap_sharedNodeCount": "Používané {count} cestami",
"pathMap_partialAnimation": "{count, plural, =1{1 skok nemá polohu — zobrazená trasa je neúplná} few{{count} skoky nemajú polohu — zobrazená trasa je neúplná} other{{count} skokov nemá polohu — zobrazená trasa je neúplná}}",
"pathMap_showAllPaths": "Zobraziť všetky",
"pathMap_hidePath": "Skryť cestu",
"pathMap_showPath": "Zobraziť trasu",
"pathMap_collapsePanel": "Zatvoriť panel",
"pathMap_expandPanel": "Rozbaliť panel",
"pathMap_noLocation": "Bez polohy",
"pathMap_followPacket": "Uzamknúť pohľad na paket",
"pathMap_unfollowPacket": "Odomknúť pohľad od paketu",
"pathMap_gpsCount": "{confirmed}/{total} GPS"
} }
+290 -1
View File
@@ -33,6 +33,8 @@
"common_remove": "Izbrisati", "common_remove": "Izbrisati",
"common_enable": "Omogoči", "common_enable": "Omogoči",
"common_disable": "Izklopiti", "common_disable": "Izklopiti",
"common_autoRefresh": "Samodejno osveževanje",
"common_interval": "Časovni interval",
"common_reboot": "Ponoviti", "common_reboot": "Ponoviti",
"common_loading": "Naložanje...", "common_loading": "Naložanje...",
"common_notAvailable": "—", "common_notAvailable": "—",
@@ -238,6 +240,19 @@
"appSettings_last6Hours": "Zadnjih 6 ur", "appSettings_last6Hours": "Zadnjih 6 ur",
"appSettings_last24Hours": "Zadnjih 24 ur", "appSettings_last24Hours": "Zadnjih 24 ur",
"appSettings_lastWeek": "Prejšnji teden", "appSettings_lastWeek": "Prejšnji teden",
"appSettings_rasterTileSource": "Vir rastrskih ploščic",
"appSettings_stadiaEndpoint": "Končna točka Stadia",
"appSettings_stadiaApiKey": "Ključ API Stadia",
"appSettings_stadiaApiKeyRequired": "Obvezno za uporabo Stadia Maps",
"appSettings_stadiaApiKeyConfigured": "Nastavljeno: {maskedKey}",
"@appSettings_stadiaApiKeyConfigured": {
"placeholders": {
"maskedKey": {
"type": "String"
}
}
},
"appSettings_stadiaApiKeyDialogDescription": "Vnesite svoj ključ API za Stadia Maps. Aplikacija ga uporablja za zahteve rastrskih ploščic.",
"appSettings_offlineMapCache": "Shramba zemljevidov brez povezave", "appSettings_offlineMapCache": "Shramba zemljevidov brez povezave",
"appSettings_noAreaSelected": "Območje ni izbrano", "appSettings_noAreaSelected": "Območje ni izbrano",
"appSettings_areaSelectedZoom": "Izbrano območje (povečava {minZoom}-{maxZoom})", "appSettings_areaSelectedZoom": "Izbrano območje (povečava {minZoom}-{maxZoom})",
@@ -767,6 +782,56 @@
} }
} }
}, },
"mapCache_cachedTilesLabel": "Cached tiles",
"mapCache_cachedTileSummaryLabel": "Cached tile summary",
"mapCache_bulkDownloadDisabledForSource": "Offline bulk downloads are disabled for {source}.",
"@mapCache_bulkDownloadDisabledForSource": {
"placeholders": {
"source": {
"type": "String"
}
}
},
"mapCache_bulkDownloadDisabledInConfig": "Offline bulk downloads are disabled for {source} in this app configuration.",
"@mapCache_bulkDownloadDisabledInConfig": {
"placeholders": {
"source": {
"type": "String"
}
}
},
"mapCache_summarySource": "Source: {source}",
"@mapCache_summarySource": {
"placeholders": {
"source": {
"type": "String"
}
}
},
"mapCache_summaryCachedTilesForSource": "Cached tiles for source: {count}",
"@mapCache_summaryCachedTilesForSource": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"mapCache_summaryCachedInSelection": "Cached in selected area/zoom: {count}",
"@mapCache_summaryCachedInSelection": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"mapCache_summaryApproxCacheSize": "Approx cache size: {size}",
"@mapCache_summaryApproxCacheSize": {
"placeholders": {
"size": {
"type": "String"
}
}
},
"mapCache_boundsLabel": "N {north}, S {south}, E {east}, W {west}", "mapCache_boundsLabel": "N {north}, S {south}, E {east}, W {west}",
"@mapCache_boundsLabel": { "@mapCache_boundsLabel": {
"placeholders": { "placeholders": {
@@ -1280,6 +1345,43 @@
} }
} }
}, },
"telemetry_digitalInputLabel": "Digitalni vhod",
"telemetry_digitalOutputLabel": "Digitalni izhod",
"telemetry_analogInputLabel": "Analogni vhod",
"telemetry_analogOutputLabel": "Analogni izhod",
"telemetry_genericLabel": "Splošni senzor",
"telemetry_luminosityLabel": "Osvetljenost",
"telemetry_presenceLabel": "Prisotnost",
"telemetry_humidityLabel": "Vlažnost",
"telemetry_accelerometerLabel": "Merilnik pospeška",
"telemetry_pressureLabel": "Tlak",
"telemetry_altitudeLabel": "Nadmorska višina",
"telemetry_frequencyLabel": "Frekvenca",
"telemetry_percentageLabel": "Odstotek",
"telemetry_concentrationLabel": "Koncentracija",
"telemetry_powerLabel": "Moč",
"telemetry_distanceLabel": "Razdalja",
"telemetry_energyLabel": "Energija",
"telemetry_directionLabel": "Smer",
"telemetry_timeLabel": "Čas",
"telemetry_gyrometerLabel": "Žiroskop",
"telemetry_colourLabel": "Barva",
"telemetry_gpsLabel": "GPS",
"telemetry_switchLabel": "Stikalo",
"telemetry_polylineLabel": "Polilinija",
"telemetry_altitudeValue": "{meters} m",
"telemetry_frequencyValue": "{hertz} Hz",
"telemetry_pressureValue": "{hpa} hPa",
"telemetry_luminosityValue": "{lux} lx",
"telemetry_powerValue": "{watts} W",
"telemetry_distanceValue": "{meters} m",
"telemetry_energyValue": "{kilowattHours} kWh",
"telemetry_directionValue": "{degrees}°",
"telemetry_concentrationValue": "{ppm} ppm",
"telemetry_percentageValue": "{percent}%",
"telemetry_analogValue": "{value}",
"telemetry_autoFetchQuantity": "Število zahtev",
"telemetry_error": "Podatkov ni bilo mogoče pridobiti",
"telemetry_noData": "Niso na voljo podatki o telemetriji.", "telemetry_noData": "Niso na voljo podatki o telemetriji.",
"telemetry_channelTitle": "Kanal {channel}", "telemetry_channelTitle": "Kanal {channel}",
"@telemetry_channelTitle": { "@telemetry_channelTitle": {
@@ -2314,5 +2416,192 @@
"chat_markAsUnread": "Označiti kot neneobdelano", "chat_markAsUnread": "Označiti kot neneobdelano",
"chat_newMessages": "Nove novice", "chat_newMessages": "Nove novice",
"settings_companionDebugLogSubtitle": "Navodila, odgovori in surova podatka za BLE/TCP/USB.", "settings_companionDebugLogSubtitle": "Navodila, odgovori in surova podatka za BLE/TCP/USB.",
"repeater_chanUtil": "Uporaba kanala" "repeater_chanUtil": "Uporaba kanala",
"@routing_lastWorked": {
"placeholders": {
"when": {
"type": "String"
}
}
},
"@routing_deliveryCounts": {
"placeholders": {
"successes": {
"type": "int"
},
"failures": {
"type": "int"
}
}
},
"@pathEditor_hopCounter": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"@pathEditor_invalidTokens": {
"placeholders": {
"tokens": {
"type": "String"
}
}
},
"@channels_communityShortId": {
"placeholders": {
"id": {
"type": "String"
}
}
},
"common_undo": "Preobrn",
"messageStatus_delivered": "Dostavljeno",
"messageStatus_sent": "Pošljeno",
"messageStatus_pending": "Pošiljanje",
"messageStatus_failed": "Uspešno ni bilo mogo, da se sporočilo pošlje",
"messageStatus_repeated": "Slišal sem večkrat",
"contacts_moreOptions": "Več možnosti",
"contacts_searchOpen": "Iskanje kontaktov",
"contacts_searchClose": "Izklopi iskanje",
"routing_title": "Navigacija",
"routing_modeAuto": "Avto",
"routing_modeFlood": "Poplavo",
"routing_modeManual": "Navodilo",
"routing_modeAutoHint": "Samodejno izbere najbolj poznano pot, in sicer, ko ni na voljo nobena.",
"routing_modeFloodHint": "Prenosi preko vseh repetitorjev. Najzanesljivejši način, vendar zahteva več časa.",
"routing_modeManualHint": "Vedno sledi natančni poti, ki jo ste določili.",
"routing_currentRoute": "Trenutna pot",
"routing_directNoHops": "Neposredno brez prehodov",
"routing_noPathYet": "Žep trenutno ni mogoče najti. Naslednje sporočilo bo posredovano, dokler ne bo ugotovljeno, kje je pot.",
"routing_floodBroadcast": "Prenos preko vseh repetitiv",
"routing_editPath": "Uredi pot",
"routing_forgetPath": "Pozabi na pot",
"routing_knownPaths": "Poznati poti",
"routing_knownPathsHint": "Kliknite na pot, da jo izberete.",
"routing_inUse": "V uporabi",
"routing_qualityStrong": "Močan prvi korak",
"routing_qualityGood": "Prva uspešna faza",
"routing_qualityFair": "Prva, uspešna faza",
"routing_qualityWorked": "Izpolnil",
"routing_qualityFlood": "Slišano preko poplave",
"routing_qualityUntested": "Ne preizkušen",
"routing_lastWorked": "delal/a {when}",
"routing_neverWorked": "nikoli ni bilo potrjeno",
"routing_floodDelivery": "Dostava zaradi poplave",
"pathEditor_title": "Izgradnja poti",
"pathEditor_hopCounter": "{count} od 64 različnih sort hropa",
"pathEditor_noHops": "Še niso dodani hmelji. Za dodajanje hmelja v vrstnem redu kliknite na povezavo spodaj, ali pa shranite brez dodanega hmelja, da ga lahko posredujete neposredno.",
"pathEditor_addHops": "Dodajte suho travo v skladu s postopkom.",
"pathEditor_searchRepeaters": "Iskanje ponovitev",
"pathEditor_advancedHex": "Napredno: surovi šestnajstni pot",
"pathEditor_hexLabel": "Predfiks za heksadecimalno šifro",
"pathEditor_hexHelper": "Dva šestbitna znaka na vsak skok, ločena z vejico",
"pathEditor_invalidTokens": "Neveljaven: {tokens}",
"pathEditor_tooManyHops": "Največ 64 hopov",
"pathEditor_usePath": "Uporabite to poto",
"pathEditor_removeHop": "Odstranite hmelj",
"pathEditor_unknownHop": "Neznani ponovitelj",
"map_zoomIn": "Povečaj",
"routing_deliveryCounts": "{successes} delivered, {failures} failed",
"map_zoomOut": "Povečajte pogled",
"map_centerMap": "Krajšarska karta",
"chrome_bluetoothRequiresChromium": "Web Bluetooth zahteva brskalnik Chromium.",
"channels_communityShortId": "ID: {id}...",
"pathTrace_legendGpsConfirmed": "GPS potrdilo",
"pathTrace_legendInferred": "Izpeljana lokacija",
"@pathMap_hopOf": {
"placeholders": {
"current": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"@pathMap_observedPaths": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"@pathMap_alternate": {
"placeholders": {
"index": {
"type": "int"
}
}
},
"@pathMap_hopCount": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"@pathMap_gpsCount": {
"placeholders": {
"confirmed": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"@pathMap_sharedNodeCount": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"@pathMap_partialAnimation": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"scanner_bluetoothWebUnsupported": "Funkcija Bluetooth v brskalniku ni na voljo. Povežite se preko USB-ja namesto tega.",
"map_searchHint": "Iščite ime ali ID vozlišča",
"map_online": "V omrežju",
"map_activity": "Dejavnost",
"map_recent": "Nedavni",
"map_stale": "Zastarelo",
"map_visible": "Vidno",
"map_hidden": "Skrit",
"map_centerOnNode": "Centriraj na vozlišče",
"map_details": "Podrobnosti",
"map_noGps": "Brez GPS",
"map_noResults": "Ni ujemajočih se vozlišč",
"pathMap_viewSingle": "Posamično",
"pathMap_viewCombined": "Skupno",
"pathMap_play": "Predvajaj",
"pathMap_pause": "Premor",
"pathMap_replay": "Ponovitev",
"pathMap_stepBack": "Prejšnji skok",
"pathMap_stepForward": "Naslednji skok",
"pathMap_animationOn": "Prikaži animacijo paketa",
"pathMap_animationOff": "Skrij animacijo paketa",
"pathMap_hopOf": "Skok {current} od {total}",
"pathMap_observedPaths": "Opazovane poti: {count}",
"pathMap_primary": "Primarna",
"pathMap_alternate": "Alternativa {index}",
"pathMap_legendShared": "Deljen segment",
"pathMap_legendEstimated": "Ocenjen segment",
"pathMap_sharedNodeCount": "Uporablja {count} poti",
"pathMap_partialAnimation": "{count, plural, =1{1 skok nima lokacije — prikazana pot je delna} =2{2 skoka nimata lokacije — prikazana pot je delna} few{{count} skoki nimajo lokacije — prikazana pot je delna} other{{count} skokov nima lokacije — prikazana pot je delna}}",
"pathMap_hopCount": "{count, plural, =1{1 skok} =2{2 skoka} few{{count} skoki} other{{count} skokov}}",
"pathMap_showAllPaths": "Pokaži vse",
"pathMap_hidePath": "Skrij pot",
"pathMap_showPath": "Pokaži pot",
"pathMap_collapsePanel": "Strni ploščo",
"pathMap_expandPanel": "Razširi ploščo",
"pathMap_noLocation": "Brez lokacije",
"pathMap_followPacket": "Zakleni pogled na paket",
"pathMap_unfollowPacket": "Odkleni pogled od paketa",
"pathMap_gpsCount": "{confirmed}/{total} GPS"
} }
+290 -1
View File
@@ -33,6 +33,8 @@
"common_remove": "Ta bort", "common_remove": "Ta bort",
"common_enable": "Aktivera", "common_enable": "Aktivera",
"common_disable": "Inaktivera", "common_disable": "Inaktivera",
"common_autoRefresh": "Automatisk uppdatering",
"common_interval": "Intervall",
"common_reboot": "Start om", "common_reboot": "Start om",
"common_loading": "Laddar...", "common_loading": "Laddar...",
"common_notAvailable": "—", "common_notAvailable": "—",
@@ -238,6 +240,19 @@
"appSettings_last6Hours": "De senaste 6 timmarna", "appSettings_last6Hours": "De senaste 6 timmarna",
"appSettings_last24Hours": "De senaste 24 timmarna", "appSettings_last24Hours": "De senaste 24 timmarna",
"appSettings_lastWeek": "Förra veckan", "appSettings_lastWeek": "Förra veckan",
"appSettings_rasterTileSource": "Källa för rasterplattor",
"appSettings_stadiaEndpoint": "Stadia-slutpunkt",
"appSettings_stadiaApiKey": "Stadia API-nyckel",
"appSettings_stadiaApiKeyRequired": "Krävs för att använda Stadia Maps",
"appSettings_stadiaApiKeyConfigured": "Konfigurerad: {maskedKey}",
"@appSettings_stadiaApiKeyConfigured": {
"placeholders": {
"maskedKey": {
"type": "String"
}
}
},
"appSettings_stadiaApiKeyDialogDescription": "Ange din Stadia Maps API-nyckel. Appen använder den för förfrågningar om rasterplattor.",
"appSettings_offlineMapCache": "Offline Kartcache", "appSettings_offlineMapCache": "Offline Kartcache",
"appSettings_noAreaSelected": "Ingen area markerad", "appSettings_noAreaSelected": "Ingen area markerad",
"appSettings_areaSelectedZoom": "Område markerat (zoom {minZoom}-{maxZoom})", "appSettings_areaSelectedZoom": "Område markerat (zoom {minZoom}-{maxZoom})",
@@ -767,6 +782,56 @@
} }
} }
}, },
"mapCache_cachedTilesLabel": "Cached tiles",
"mapCache_cachedTileSummaryLabel": "Cached tile summary",
"mapCache_bulkDownloadDisabledForSource": "Offline bulk downloads are disabled for {source}.",
"@mapCache_bulkDownloadDisabledForSource": {
"placeholders": {
"source": {
"type": "String"
}
}
},
"mapCache_bulkDownloadDisabledInConfig": "Offline bulk downloads are disabled for {source} in this app configuration.",
"@mapCache_bulkDownloadDisabledInConfig": {
"placeholders": {
"source": {
"type": "String"
}
}
},
"mapCache_summarySource": "Source: {source}",
"@mapCache_summarySource": {
"placeholders": {
"source": {
"type": "String"
}
}
},
"mapCache_summaryCachedTilesForSource": "Cached tiles for source: {count}",
"@mapCache_summaryCachedTilesForSource": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"mapCache_summaryCachedInSelection": "Cached in selected area/zoom: {count}",
"@mapCache_summaryCachedInSelection": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"mapCache_summaryApproxCacheSize": "Approx cache size: {size}",
"@mapCache_summaryApproxCacheSize": {
"placeholders": {
"size": {
"type": "String"
}
}
},
"mapCache_boundsLabel": "N {north}, S {south}, E {east}, W {west}", "mapCache_boundsLabel": "N {north}, S {south}, E {east}, W {west}",
"@mapCache_boundsLabel": { "@mapCache_boundsLabel": {
"placeholders": { "placeholders": {
@@ -1280,6 +1345,43 @@
} }
} }
}, },
"telemetry_digitalInputLabel": "Digital ingång",
"telemetry_digitalOutputLabel": "Digital utgång",
"telemetry_analogInputLabel": "Analog ingång",
"telemetry_analogOutputLabel": "Analog utgång",
"telemetry_genericLabel": "Allmän sensor",
"telemetry_luminosityLabel": "Ljusstyrka",
"telemetry_presenceLabel": "Närvaro",
"telemetry_humidityLabel": "Luftfuktighet",
"telemetry_accelerometerLabel": "Accelerometer",
"telemetry_pressureLabel": "Tryck",
"telemetry_altitudeLabel": "Höjd",
"telemetry_frequencyLabel": "Frekvens",
"telemetry_percentageLabel": "Procent",
"telemetry_concentrationLabel": "Koncentration",
"telemetry_powerLabel": "Effekt",
"telemetry_distanceLabel": "Avstånd",
"telemetry_energyLabel": "Energi",
"telemetry_directionLabel": "Riktning",
"telemetry_timeLabel": "Tid",
"telemetry_gyrometerLabel": "Gyrometer",
"telemetry_colourLabel": "Färg",
"telemetry_gpsLabel": "GPS",
"telemetry_switchLabel": "Brytare",
"telemetry_polylineLabel": "Polylinje",
"telemetry_altitudeValue": "{meters} m",
"telemetry_frequencyValue": "{hertz} Hz",
"telemetry_pressureValue": "{hpa} hPa",
"telemetry_luminosityValue": "{lux} lx",
"telemetry_powerValue": "{watts} W",
"telemetry_distanceValue": "{meters} m",
"telemetry_energyValue": "{kilowattHours} kWh",
"telemetry_directionValue": "{degrees}°",
"telemetry_concentrationValue": "{ppm} ppm",
"telemetry_percentageValue": "{percent}%",
"telemetry_analogValue": "{value}",
"telemetry_autoFetchQuantity": "Antal förfrågningar",
"telemetry_error": "Det gick inte att hämta data",
"telemetry_noData": "Inga telemetridata tillgängliga.", "telemetry_noData": "Inga telemetridata tillgängliga.",
"telemetry_channelTitle": "Kanal {channel}", "telemetry_channelTitle": "Kanal {channel}",
"@telemetry_channelTitle": { "@telemetry_channelTitle": {
@@ -2314,5 +2416,192 @@
"settings_companionDebugLog": "Följande felsökningslogg", "settings_companionDebugLog": "Följande felsökningslogg",
"chat_newMessages": "Nya meddelanden", "chat_newMessages": "Nya meddelanden",
"settings_companionDebugLogSubtitle": "BLE/TCP/USB-kommandon, svar och rådata", "settings_companionDebugLogSubtitle": "BLE/TCP/USB-kommandon, svar och rådata",
"repeater_chanUtil": "Användning av kanal" "repeater_chanUtil": "Användning av kanal",
"@routing_lastWorked": {
"placeholders": {
"when": {
"type": "String"
}
}
},
"@routing_deliveryCounts": {
"placeholders": {
"successes": {
"type": "int"
},
"failures": {
"type": "int"
}
}
},
"@pathEditor_hopCounter": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"@pathEditor_invalidTokens": {
"placeholders": {
"tokens": {
"type": "String"
}
}
},
"@channels_communityShortId": {
"placeholders": {
"id": {
"type": "String"
}
}
},
"messageStatus_sent": "Sen",
"messageStatus_delivered": "Levererad",
"common_undo": "Ångra",
"messageStatus_pending": "Skicka",
"messageStatus_failed": "Misslyckades med att skicka",
"messageStatus_repeated": "Hördes upprepade gånger",
"contacts_moreOptions": "Fler alternativ",
"contacts_searchOpen": "Sök efter kontakter",
"contacts_searchClose": "Avancerad sökning",
"routing_title": "Ruttplanering",
"routing_modeAuto": "Bil",
"routing_modeFlood": "Översvämning",
"routing_modeManual": "Instruktioner",
"routing_modeAutoHint": "Väljer automatiskt den bästa kända vägen, och använder en \"flooding\"-strategi om ingen väg är känd.",
"routing_modeFloodHint": "Sändningar via alla repetrar. Det mest pålitliga alternativet, men kräver mer sändtid.",
"routing_modeManualHint": "Skickar alltid den exakta väg du har angivit.",
"routing_currentRoute": "Nuvarande rutt",
"routing_directNoHops": "Direkt utan mellanliggande routrar",
"routing_noPathYet": "Ingen väg hittad ännu. Nästa meddelande skickas tills en rutt har upptäckts.",
"routing_floodBroadcast": "Sändas via alla repetrar",
"routing_editPath": "Redigera sökväg",
"routing_forgetPath": "Glöm vägen",
"routing_knownPaths": "Kända vägar",
"routing_knownPathsHint": "Välj en väg för att byta till den.",
"routing_inUse": "I användning",
"routing_qualityStrong": "En stark start",
"routing_qualityGood": "Bra första steg",
"routing_qualityFair": "Bra första hopp",
"routing_qualityWorked": "Har levererat",
"routing_qualityFlood": "Fått information via nyhetsflöde",
"routing_qualityUntested": "Ej testat",
"routing_lastWorked": "arbetade {when}",
"routing_neverWorked": "aldrig bekräftat",
"routing_floodDelivery": "Leverans vid översvämningsområde",
"pathEditor_title": "Skapa väg",
"pathEditor_hopCounter": "{count} av 64 humlor",
"pathEditor_noHops": "Inga humle än. Använd knapparna nedan för att lägga till dem i rätt ordning, eller spara utan humle för att skicka direkt.",
"pathEditor_addHops": "Tillsätt humlen i rätt ordning.",
"pathEditor_searchRepeaters": "Sök efter återupptagna samtal",
"pathEditor_advancedHex": "Avancerat: rå hex-sökväg",
"pathEditor_hexLabel": "Hex-prefikser",
"pathEditor_hexHelper": "Två hex-tecken per steg, separerade med kommatecken.",
"pathEditor_invalidTokens": "Ogiltigt: {tokens}",
"pathEditor_tooManyHops": "Maximalt 64 humlörter",
"pathEditor_usePath": "Använd denna väg",
"pathEditor_removeHop": "Ta bort humlen",
"pathEditor_unknownHop": "Okänd förstärkare",
"map_zoomIn": "Zooma in",
"routing_deliveryCounts": "{successes} delivered, {failures} failed",
"map_zoomOut": "Zooma ut",
"map_centerMap": "Kartöversikt",
"chrome_bluetoothRequiresChromium": "Web Bluetooth kräver en Chromium-baserad webbläsare.",
"channels_communityShortId": "ID: {id}...",
"pathTrace_legendGpsConfirmed": "GPS-verifierat",
"pathTrace_legendInferred": "Antagen position",
"@pathMap_hopOf": {
"placeholders": {
"current": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"@pathMap_observedPaths": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"@pathMap_alternate": {
"placeholders": {
"index": {
"type": "int"
}
}
},
"@pathMap_hopCount": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"@pathMap_gpsCount": {
"placeholders": {
"confirmed": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"@pathMap_sharedNodeCount": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"@pathMap_partialAnimation": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"map_online": "Online",
"scanner_bluetoothWebUnsupported": "Bluetooth är inte tillgängligt i webbläsaren. Anslut istället via USB.",
"map_activity": "Aktivitet",
"map_searchHint": "Sök efter nodens namn eller ID",
"map_recent": "Nyligen",
"map_stale": "Inaktuell",
"map_visible": "Synlig",
"map_hidden": "Dold",
"map_centerOnNode": "Centrera på nod",
"map_details": "Detaljer",
"map_noGps": "Ingen GPS",
"map_noResults": "Inga matchande noder",
"pathMap_viewSingle": "Enkel",
"pathMap_viewCombined": "Kombinerat",
"pathMap_play": "Spela",
"pathMap_pause": "Pausa",
"pathMap_replay": "Återspela",
"pathMap_stepBack": "Föregående hopp",
"pathMap_stepForward": "Nästa hopp",
"pathMap_animationOn": "Visa paketanimering",
"pathMap_animationOff": "Dölj paketanimering",
"pathMap_hopOf": "Hopp {current} av {total}",
"pathMap_observedPaths": "Observerade vägar: {count}",
"pathMap_primary": "Primär",
"pathMap_alternate": "Alternativ {index}",
"pathMap_hopCount": "{count, plural, =1{1 hopp} other{{count} hopp}}",
"pathMap_legendShared": "Delat segment",
"pathMap_legendEstimated": "Uppskattat segment",
"pathMap_sharedNodeCount": "Används av {count} vägar",
"pathMap_partialAnimation": "{count, plural, =1{1 hopp saknar position — den visade vägen är ofullständig} other{{count} hopp saknar position — den visade vägen är ofullständig}}",
"pathMap_showAllPaths": "Visa allt",
"pathMap_hidePath": "Dölj väg",
"pathMap_showPath": "Visa väg",
"pathMap_collapsePanel": "Fäll ihop panel",
"pathMap_expandPanel": "Expandera panel",
"pathMap_noLocation": "Ingen position",
"pathMap_unfollowPacket": "Lås upp vy från paket",
"pathMap_followPacket": "Lås vy till paket",
"pathMap_gpsCount": "{confirmed}/{total} GPS"
} }
+290 -1
View File
@@ -34,6 +34,8 @@
"common_remove": "Прибрати", "common_remove": "Прибрати",
"common_enable": "Увімкнути", "common_enable": "Увімкнути",
"common_disable": "Вимкнути", "common_disable": "Вимкнути",
"common_autoRefresh": "Автооновлення",
"common_interval": "Інтервал",
"common_reboot": "Перезавантажити", "common_reboot": "Перезавантажити",
"common_loading": "Завантаження...", "common_loading": "Завантаження...",
"common_notAvailable": "—", "common_notAvailable": "—",
@@ -240,6 +242,19 @@
"appSettings_last6Hours": "Останні 6 годин", "appSettings_last6Hours": "Останні 6 годин",
"appSettings_last24Hours": "Останні 24 години", "appSettings_last24Hours": "Останні 24 години",
"appSettings_lastWeek": "Минулий тиждень", "appSettings_lastWeek": "Минулий тиждень",
"appSettings_rasterTileSource": "Джерело растрових тайлів",
"appSettings_stadiaEndpoint": "Кінцева точка Stadia",
"appSettings_stadiaApiKey": "Ключ API Stadia",
"appSettings_stadiaApiKeyRequired": "Потрібно для використання Stadia Maps",
"appSettings_stadiaApiKeyConfigured": "Налаштовано: {maskedKey}",
"@appSettings_stadiaApiKeyConfigured": {
"placeholders": {
"maskedKey": {
"type": "String"
}
}
},
"appSettings_stadiaApiKeyDialogDescription": "Введіть свій ключ API Stadia Maps. Програма використовує його для запитів растрових тайлів.",
"appSettings_offlineMapCache": "Офлайн-кеш карти", "appSettings_offlineMapCache": "Офлайн-кеш карти",
"appSettings_noAreaSelected": "Область не вибрано", "appSettings_noAreaSelected": "Область не вибрано",
"appSettings_areaSelectedZoom": "Вибрана область (зум {minZoom}-{maxZoom})", "appSettings_areaSelectedZoom": "Вибрана область (зум {minZoom}-{maxZoom})",
@@ -777,6 +792,56 @@
} }
} }
}, },
"mapCache_cachedTilesLabel": "Cached tiles",
"mapCache_cachedTileSummaryLabel": "Cached tile summary",
"mapCache_bulkDownloadDisabledForSource": "Offline bulk downloads are disabled for {source}.",
"@mapCache_bulkDownloadDisabledForSource": {
"placeholders": {
"source": {
"type": "String"
}
}
},
"mapCache_bulkDownloadDisabledInConfig": "Offline bulk downloads are disabled for {source} in this app configuration.",
"@mapCache_bulkDownloadDisabledInConfig": {
"placeholders": {
"source": {
"type": "String"
}
}
},
"mapCache_summarySource": "Source: {source}",
"@mapCache_summarySource": {
"placeholders": {
"source": {
"type": "String"
}
}
},
"mapCache_summaryCachedTilesForSource": "Cached tiles for source: {count}",
"@mapCache_summaryCachedTilesForSource": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"mapCache_summaryCachedInSelection": "Cached in selected area/zoom: {count}",
"@mapCache_summaryCachedInSelection": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"mapCache_summaryApproxCacheSize": "Approx cache size: {size}",
"@mapCache_summaryApproxCacheSize": {
"placeholders": {
"size": {
"type": "String"
}
}
},
"mapCache_boundsLabel": "Пн {north}, Пд {south}, Сх {east}, Зх {west}", "mapCache_boundsLabel": "Пн {north}, Пд {south}, Сх {east}, Зх {west}",
"@mapCache_boundsLabel": { "@mapCache_boundsLabel": {
"placeholders": { "placeholders": {
@@ -1291,6 +1356,43 @@
} }
} }
}, },
"telemetry_digitalInputLabel": "Цифровий вхід",
"telemetry_digitalOutputLabel": "Цифровий вихід",
"telemetry_analogInputLabel": "Аналоговий вхід",
"telemetry_analogOutputLabel": "Аналоговий вихід",
"telemetry_genericLabel": "Загальний датчик",
"telemetry_luminosityLabel": "Освітленість",
"telemetry_presenceLabel": "Присутність",
"telemetry_humidityLabel": "Вологість",
"telemetry_accelerometerLabel": "Акселерометр",
"telemetry_pressureLabel": "Тиск",
"telemetry_altitudeLabel": "Висота",
"telemetry_frequencyLabel": "Частота",
"telemetry_percentageLabel": "Відсоток",
"telemetry_concentrationLabel": "Концентрація",
"telemetry_powerLabel": "Потужність",
"telemetry_distanceLabel": "Відстань",
"telemetry_energyLabel": "Енергія",
"telemetry_directionLabel": "Напрямок",
"telemetry_timeLabel": "Час",
"telemetry_gyrometerLabel": "Гірометр",
"telemetry_colourLabel": "Колір",
"telemetry_gpsLabel": "GPS",
"telemetry_switchLabel": "Перемикач",
"telemetry_polylineLabel": "Полілінія",
"telemetry_altitudeValue": "{meters} м",
"telemetry_frequencyValue": "{hertz} Гц",
"telemetry_pressureValue": "{hpa} гПа",
"telemetry_luminosityValue": "{lux} лк",
"telemetry_powerValue": "{watts} Вт",
"telemetry_distanceValue": "{meters} м",
"telemetry_energyValue": "{kilowattHours} кВт⋅год",
"telemetry_directionValue": "{degrees}°",
"telemetry_concentrationValue": "{ppm} ppm",
"telemetry_percentageValue": "{percent}%",
"telemetry_analogValue": "{value}",
"telemetry_autoFetchQuantity": "Кількість запитів",
"telemetry_error": "Не вдалося отримати дані",
"telemetry_noData": "Дані телеметрії недоступні.", "telemetry_noData": "Дані телеметрії недоступні.",
"telemetry_channelTitle": "Канал {channel}", "telemetry_channelTitle": "Канал {channel}",
"@telemetry_channelTitle": { "@telemetry_channelTitle": {
@@ -2294,5 +2396,192 @@
"settings_companionDebugLogSubtitle": "Команди, відповіді та необроблена інформація для протоколів BLE/TCP/USB", "settings_companionDebugLogSubtitle": "Команди, відповіді та необроблена інформація для протоколів BLE/TCP/USB",
"chat_newMessages": "Нові повідомлення", "chat_newMessages": "Нові повідомлення",
"chat_markAsUnread": "Позначити як непрочитане", "chat_markAsUnread": "Позначити як непрочитане",
"repeater_chanUtil": "Використання каналу" "repeater_chanUtil": "Використання каналу",
"@routing_lastWorked": {
"placeholders": {
"when": {
"type": "String"
}
}
},
"@routing_deliveryCounts": {
"placeholders": {
"successes": {
"type": "int"
},
"failures": {
"type": "int"
}
}
},
"@pathEditor_hopCounter": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"@pathEditor_invalidTokens": {
"placeholders": {
"tokens": {
"type": "String"
}
}
},
"@channels_communityShortId": {
"placeholders": {
"id": {
"type": "String"
}
}
},
"messageStatus_delivered": "Доставлено",
"messageStatus_sent": "Надіслано",
"common_undo": "Скасувати",
"messageStatus_pending": "Надсилання",
"messageStatus_failed": "Не вдалося надіслати",
"messageStatus_repeated": "Почув неодноразово",
"contacts_moreOptions": "Більше можливостей",
"contacts_searchOpen": "Пошук контактів",
"contacts_searchClose": "Закрити пошук",
"routing_title": "Маршрутизація",
"routing_modeAuto": "Автомобіль",
"routing_modeFlood": "Повені",
"routing_modeManual": "Інструкція",
"routing_modeAutoHint": "Автоматично обирає найкращий відомий шлях, та у разі відсутності відомого шляху, використовує алгоритм \"занурення\".",
"routing_modeFloodHint": "Передавання через усі ретранслятори. Найбільш надійний спосіб, але потребує більше часу.",
"routing_modeManualHint": "Завжди доставляє точно за вказаним вами маршрутом.",
"routing_currentRoute": "Поточний маршрут",
"routing_directNoHops": "Пряме з'єднання – без проміжних ретрансляторів",
"routing_noPathYet": "Поки що немає жодного шляху. Повідомлення продовжуються надходити, поки не буде знайдено маршрут.",
"routing_floodBroadcast": "Поширення через усі ретранслятори",
"routing_editPath": "Редагувати шлях",
"routing_forgetPath": "Забудь про шлях",
"routing_knownPaths": "Відомі маршрути",
"routing_knownPathsHint": "Виберіть опцію, щоб переключитися на неї.",
"routing_inUse": "У робочому стані",
"routing_qualityStrong": "Сильний перший стрибок",
"routing_qualityGood": "Чудова перша спроба",
"routing_qualityFair": "Перший, але вдалий, крок",
"routing_qualityWorked": "Доставлено",
"routing_qualityFlood": "Дізнався через новини",
"routing_qualityUntested": "Не протестовано",
"routing_neverWorked": "ніколи не підтверджено",
"routing_floodDelivery": "Доставка під час повені",
"pathEditor_title": "Створити маршрут",
"pathEditor_hopCounter": "{count} з 64 штук хмелю",
"pathEditor_noHops": "Ще не додано хміль. Натисніть на відповідні кнопки, щоб додати його в потрібному порядку, або збережіть рецепт без хмілю, щоб відправити його безпосередньо.",
"pathEditor_addHops": "Додавайте хміль у наступній послідовності.",
"pathEditor_searchRepeaters": "Пошук повторювачів",
"pathEditor_advancedHex": "Просунутий рівень: пряма шлях у форматі шестнадцяткової системи.",
"pathEditor_hexLabel": "Префікси для шестнадцяткової системи числення",
"pathEditor_hexHelper": "Два шестизначні символи на кожний крок, розділені комами",
"pathEditor_invalidTokens": "Неправильно: {tokens}",
"routing_lastWorked": "worked {when}",
"routing_deliveryCounts": "{successes} delivered, {failures} failed",
"pathEditor_tooManyHops": "Максимум 64 хмелеві колоди",
"pathEditor_usePath": "Використовуйте цей шлях",
"pathEditor_removeHop": "Видалити хміль",
"pathEditor_unknownHop": "Невідомий ретранслятор",
"map_zoomIn": "Увійти в режим збільшення",
"map_zoomOut": "Видалити зум",
"map_centerMap": "Карта центру",
"chrome_bluetoothRequiresChromium": "Web Bluetooth вимагає браузера на основі Chromium",
"channels_communityShortId": "ID: {id}...",
"pathTrace_legendGpsConfirmed": "GPS підтверджено",
"pathTrace_legendInferred": "Висновок щодо положення",
"@pathMap_hopOf": {
"placeholders": {
"current": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"@pathMap_observedPaths": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"@pathMap_alternate": {
"placeholders": {
"index": {
"type": "int"
}
}
},
"@pathMap_hopCount": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"@pathMap_gpsCount": {
"placeholders": {
"confirmed": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"@pathMap_sharedNodeCount": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"@pathMap_partialAnimation": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"scanner_bluetoothWebUnsupported": "Bluetooth недоступний у браузері. Підключіться через USB.",
"map_searchHint": "Назва або ID вузла",
"map_activity": "Активність",
"map_online": "Онлайн",
"map_recent": "Нещодавні",
"map_stale": "Застаріло",
"map_visible": "Видимий",
"map_hidden": "Прихований",
"map_centerOnNode": "Центрувати на вузлі",
"map_details": "Деталі",
"map_noGps": "Без GPS",
"map_noResults": "Не знайдено відповідних вузлів",
"pathMap_viewSingle": "Один",
"pathMap_viewCombined": "Об'єднаний",
"pathMap_play": "Відтворити",
"pathMap_pause": "Призупинити",
"pathMap_replay": "Повтор",
"pathMap_stepForward": "Наступний перехід",
"pathMap_stepBack": "Попередній перехід",
"pathMap_animationOn": "Відобразити анімацію пакета",
"pathMap_animationOff": "Приховати анімацію пакета",
"pathMap_hopOf": "Перехід {current} з {total}",
"pathMap_observedPaths": "Зафіксовані маршрути: {count}",
"pathMap_primary": "Основний",
"pathMap_alternate": "Альт. {index}",
"pathMap_hopCount": "{count, plural, =1{1 перехід} few{{count} переходи} many{{count} переходів} other{{count} переходів}}",
"pathMap_legendShared": "Об'єднаний сегмент",
"pathMap_legendEstimated": "Орієнтовний сегмент",
"pathMap_sharedNodeCount": "Використовується {count} шляхами",
"pathMap_partialAnimation": "{count, plural, =1{1 перехід не має геопозиції — показаний шлях є частковим} few{{count} переходи не мають геопозиції — показаний шлях є частковим} many{{count} переходів не мають геопозиції — показаний шлях є частковим} other{{count} переходів не мають геопозиції — показаний шлях є частковим}}",
"pathMap_showAllPaths": "Показати все",
"pathMap_hidePath": "Приховати шлях",
"pathMap_showPath": "Показати шлях",
"pathMap_collapsePanel": "Згорнути панель",
"pathMap_expandPanel": "Розгорнути панель",
"pathMap_noLocation": "Без геопозиції",
"pathMap_followPacket": "Прив'язати вигляд до пакету",
"pathMap_unfollowPacket": "Відв'язати вигляд від пакету",
"pathMap_gpsCount": "{confirmed}/{total} GPS"
} }
+290 -1
View File
@@ -34,6 +34,8 @@
"common_remove": "移除", "common_remove": "移除",
"common_enable": "启用", "common_enable": "启用",
"common_disable": "禁用", "common_disable": "禁用",
"common_autoRefresh": "自动刷新",
"common_interval": "间隔",
"common_reboot": "重启", "common_reboot": "重启",
"common_loading": "正在加载...", "common_loading": "正在加载...",
"common_notAvailable": "—", "common_notAvailable": "—",
@@ -252,6 +254,19 @@
"appSettings_last6Hours": "过去6小时", "appSettings_last6Hours": "过去6小时",
"appSettings_last24Hours": "过去24小时", "appSettings_last24Hours": "过去24小时",
"appSettings_lastWeek": "上周", "appSettings_lastWeek": "上周",
"appSettings_rasterTileSource": "栅格瓦片源",
"appSettings_stadiaEndpoint": "Stadia 端点",
"appSettings_stadiaApiKey": "Stadia API 密钥",
"appSettings_stadiaApiKeyRequired": "使用 Stadia Maps 时必需",
"appSettings_stadiaApiKeyConfigured": "已配置:{maskedKey}",
"@appSettings_stadiaApiKeyConfigured": {
"placeholders": {
"maskedKey": {
"type": "String"
}
}
},
"appSettings_stadiaApiKeyDialogDescription": "请输入你的 Stadia Maps API 密钥。该应用会使用它来请求栅格瓦片。",
"appSettings_offlineMapCache": "离线地图缓存", "appSettings_offlineMapCache": "离线地图缓存",
"appSettings_noAreaSelected": "未选择任何区域", "appSettings_noAreaSelected": "未选择任何区域",
"appSettings_areaSelectedZoom": "已选择区域(缩放 {minZoom} - {maxZoom}", "appSettings_areaSelectedZoom": "已选择区域(缩放 {minZoom} - {maxZoom}",
@@ -794,6 +809,56 @@
} }
} }
}, },
"mapCache_cachedTilesLabel": "Cached tiles",
"mapCache_cachedTileSummaryLabel": "Cached tile summary",
"mapCache_bulkDownloadDisabledForSource": "Offline bulk downloads are disabled for {source}.",
"@mapCache_bulkDownloadDisabledForSource": {
"placeholders": {
"source": {
"type": "String"
}
}
},
"mapCache_bulkDownloadDisabledInConfig": "Offline bulk downloads are disabled for {source} in this app configuration.",
"@mapCache_bulkDownloadDisabledInConfig": {
"placeholders": {
"source": {
"type": "String"
}
}
},
"mapCache_summarySource": "Source: {source}",
"@mapCache_summarySource": {
"placeholders": {
"source": {
"type": "String"
}
}
},
"mapCache_summaryCachedTilesForSource": "Cached tiles for source: {count}",
"@mapCache_summaryCachedTilesForSource": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"mapCache_summaryCachedInSelection": "Cached in selected area/zoom: {count}",
"@mapCache_summaryCachedInSelection": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"mapCache_summaryApproxCacheSize": "Approx cache size: {size}",
"@mapCache_summaryApproxCacheSize": {
"placeholders": {
"size": {
"type": "String"
}
}
},
"mapCache_boundsLabel": "北 {north}, 南 {south}, 东 {east}, 西 {west}", "mapCache_boundsLabel": "北 {north}, 南 {south}, 东 {east}, 西 {west}",
"@mapCache_boundsLabel": { "@mapCache_boundsLabel": {
"placeholders": { "placeholders": {
@@ -1310,6 +1375,43 @@
} }
} }
}, },
"telemetry_digitalInputLabel": "数字输入",
"telemetry_digitalOutputLabel": "数字输出",
"telemetry_analogInputLabel": "模拟输入",
"telemetry_analogOutputLabel": "模拟输出",
"telemetry_genericLabel": "通用传感器",
"telemetry_luminosityLabel": "照度",
"telemetry_presenceLabel": "存在检测",
"telemetry_humidityLabel": "湿度",
"telemetry_accelerometerLabel": "加速度计",
"telemetry_pressureLabel": "气压",
"telemetry_altitudeLabel": "高度",
"telemetry_frequencyLabel": "频率",
"telemetry_percentageLabel": "百分比",
"telemetry_concentrationLabel": "浓度",
"telemetry_powerLabel": "功率",
"telemetry_distanceLabel": "距离",
"telemetry_energyLabel": "能量",
"telemetry_directionLabel": "方向",
"telemetry_timeLabel": "时间",
"telemetry_gyrometerLabel": "陀螺仪",
"telemetry_colourLabel": "颜色",
"telemetry_gpsLabel": "GPS",
"telemetry_switchLabel": "开关",
"telemetry_polylineLabel": "折线",
"telemetry_altitudeValue": "{meters} m",
"telemetry_frequencyValue": "{hertz} Hz",
"telemetry_pressureValue": "{hpa} hPa",
"telemetry_luminosityValue": "{lux} lx",
"telemetry_powerValue": "{watts} W",
"telemetry_distanceValue": "{meters} m",
"telemetry_energyValue": "{kilowattHours} kWh",
"telemetry_directionValue": "{degrees}°",
"telemetry_concentrationValue": "{ppm} ppm",
"telemetry_percentageValue": "{percent}%",
"telemetry_analogValue": "{value}",
"telemetry_autoFetchQuantity": "请求次数",
"telemetry_error": "无法获取数据",
"telemetry_noData": "暂无遥测数据", "telemetry_noData": "暂无遥测数据",
"telemetry_channelTitle": "频道 {channel}", "telemetry_channelTitle": "频道 {channel}",
"@telemetry_channelTitle": { "@telemetry_channelTitle": {
@@ -2319,5 +2421,192 @@
"settings_companionDebugLog": "调试日志", "settings_companionDebugLog": "调试日志",
"chat_newMessages": "新的消息", "chat_newMessages": "新的消息",
"settings_companionDebugLogSubtitle": "BLE/TCP/USB 协议、响应和原始数据", "settings_companionDebugLogSubtitle": "BLE/TCP/USB 协议、响应和原始数据",
"repeater_chanUtil": "频道利用率" "repeater_chanUtil": "频道利用率",
"@routing_lastWorked": {
"placeholders": {
"when": {
"type": "String"
}
}
},
"@routing_deliveryCounts": {
"placeholders": {
"successes": {
"type": "int"
},
"failures": {
"type": "int"
}
}
},
"@pathEditor_hopCounter": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"@pathEditor_invalidTokens": {
"placeholders": {
"tokens": {
"type": "String"
}
}
},
"@channels_communityShortId": {
"placeholders": {
"id": {
"type": "String"
}
}
},
"messageStatus_sent": "发送",
"common_undo": "撤销",
"messageStatus_delivered": "已送达",
"messageStatus_pending": "发送",
"messageStatus_failed": "发送失败",
"messageStatus_repeated": "多次听到",
"contacts_moreOptions": "更多选择",
"contacts_searchOpen": "搜索联系人",
"contacts_searchClose": "高级搜索",
"routing_title": "路由",
"routing_modeAuto": "汽车",
"routing_modeFlood": "洪水",
"routing_modeManual": "手册",
"routing_modeAutoHint": "自动选择已知最佳路径,当没有已知路径时,则进行“洪水”搜索。",
"routing_modeFloodHint": "通过所有中继站进行广播。 这种方式最可靠,但占用更多的时间。",
"routing_modeManualHint": "总是按照您设置的路径进行导航。",
"routing_currentRoute": "当前路线",
"routing_directNoHops": "直接连接— 无中继跳",
"routing_noPathYet": "目前还没有找到路径。直到找到路径,才会收到后续消息。",
"routing_floodBroadcast": "通过所有中继器进行广播",
"routing_editPath": "编辑路径",
"routing_forgetPath": "忘记原路",
"routing_knownPaths": "已知的路径",
"routing_knownPathsHint": "点击该路径以切换到它。",
"routing_inUse": "使用中",
"routing_qualityStrong": "强劲的初始阶段",
"routing_qualityGood": "不错的开端",
"routing_qualityFair": "第一次尝试,结果良好",
"routing_qualityWorked": "已完成",
"routing_qualityFlood": "通过新闻报道",
"routing_qualityUntested": "未经测试",
"routing_lastWorked": "工作于 {when}",
"routing_neverWorked": "从未得到证实",
"routing_floodDelivery": "洪水配送",
"pathEditor_title": "构建路径",
"pathEditor_noHops": "目前还没有添加任何啤酒花。点击下面的“添加”按钮,按顺序添加,或者直接保存,不添加任何啤酒花。",
"pathEditor_addHops": "按照顺序添加啤酒花",
"pathEditor_searchRepeaters": "重复搜索",
"pathEditor_advancedHex": "高级:原始十六进制路径",
"pathEditor_hexLabel": "十六进制前缀",
"pathEditor_hexHelper": "每次跳跃,使用两个十六进制字符,用逗号分隔。",
"pathEditor_invalidTokens": "无效:{tokens}",
"pathEditor_tooManyHops": "最多 64 个跳跃",
"pathEditor_usePath": "请使用此路径",
"pathEditor_removeHop": "去除啤酒花",
"routing_deliveryCounts": "{successes} delivered, {failures} failed",
"pathEditor_hopCounter": "{count} of 64 hops",
"pathEditor_unknownHop": "未知的重复器",
"map_zoomIn": "放大",
"map_zoomOut": "放大",
"map_centerMap": "中心地图",
"chrome_bluetoothRequiresChromium": "Web Bluetooth 需要 Chromium 浏览器",
"channels_communityShortId": "ID{id}...",
"pathTrace_legendGpsConfirmed": "通过GPS确认",
"pathTrace_legendInferred": "推测的位置",
"@pathMap_hopOf": {
"placeholders": {
"current": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"@pathMap_observedPaths": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"@pathMap_alternate": {
"placeholders": {
"index": {
"type": "int"
}
}
},
"@pathMap_hopCount": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"@pathMap_gpsCount": {
"placeholders": {
"confirmed": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"@pathMap_sharedNodeCount": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"@pathMap_partialAnimation": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"map_online": "在线",
"scanner_bluetoothWebUnsupported": "浏览器不支持蓝牙,请改用 USB 连接。",
"map_activity": "活动",
"map_searchHint": "搜索节点名称或ID",
"map_recent": "最近",
"map_visible": "可见",
"map_stale": "过时",
"map_hidden": "已隐藏",
"map_centerOnNode": "以节点为中心",
"map_details": "详细信息",
"map_noGps": "无 GPS",
"map_noResults": "未找到匹配的节点",
"pathMap_viewSingle": "单条",
"pathMap_viewCombined": "综合",
"pathMap_play": "播放",
"pathMap_pause": "暂停",
"pathMap_replay": "重播",
"pathMap_stepBack": "上一跳",
"pathMap_stepForward": "下一跳",
"pathMap_animationOn": "显示数据包动画",
"pathMap_animationOff": "隐藏数据包动画",
"pathMap_hopOf": "第 {current} 跳,共 {total} 跳",
"pathMap_observedPaths": "观测到的路径:{count}",
"pathMap_primary": "主路径",
"pathMap_alternate": "备用 {index}",
"pathMap_hopCount": "{count, plural, =1{1 跳} other{{count} 跳}}",
"pathMap_legendShared": "共享路段",
"pathMap_legendEstimated": "估算路段",
"pathMap_sharedNodeCount": "已被 {count} 条路径使用",
"pathMap_showAllPaths": "显示全部",
"pathMap_hidePath": "隐藏路径",
"pathMap_showPath": "显示路径",
"pathMap_collapsePanel": "收起面板",
"pathMap_expandPanel": "展开面板",
"pathMap_noLocation": "无位置",
"pathMap_followPacket": "锁定视图跟随数据包",
"pathMap_unfollowPacket": "解锁视图跟随",
"pathMap_gpsCount": "{confirmed}/{total} GPS",
"pathMap_partialAnimation": "{count, plural, =1{1 跳无位置信息 — 显示的路径不完整} other{{count} 跳无位置信息 — 显示的路径不完整}}"
} }
+12 -17
View File
@@ -24,11 +24,21 @@ import 'services/translation_service.dart';
import 'services/ui_view_state_service.dart'; import 'services/ui_view_state_service.dart';
import 'services/timeout_prediction_service.dart'; import 'services/timeout_prediction_service.dart';
import 'storage/prefs_manager.dart'; import 'storage/prefs_manager.dart';
import 'theme/mesh_theme.dart';
import 'utils/app_logger.dart'; import 'utils/app_logger.dart';
void main() async { void main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
// On desktop, debugPrint is not suppressed in release builds and every
// call is a synchronous stdout write. The connector logs heavily on hot
// paths (frame handling, queue/channel sync), which shows up as syscall
// overhead on low-end Linux machines (issue #202). The in-app debug log
// screens are unaffected they store entries themselves.
if (kReleaseMode) {
debugPrint = (String? message, {int? wrapWidth}) {};
}
// Initialize SharedPreferences cache // Initialize SharedPreferences cache
await PrefsManager.initialize(); await PrefsManager.initialize();
@@ -193,23 +203,8 @@ class MeshCoreApp extends StatelessWidget {
locale: _localeFromSetting( locale: _localeFromSetting(
settingsService.settings.languageOverride, settingsService.settings.languageOverride,
), ),
theme: ThemeData( theme: MeshTheme.light(),
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue), darkTheme: MeshTheme.dark(),
useMaterial3: true,
snackBarTheme: const SnackBarThemeData(
behavior: SnackBarBehavior.floating,
),
),
darkTheme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.blue,
brightness: Brightness.dark,
),
useMaterial3: true,
snackBarTheme: const SnackBarThemeData(
behavior: SnackBarBehavior.floating,
),
),
themeMode: _themeModeFromSetting( themeMode: _themeModeFromSetting(
settingsService.settings.themeMode, settingsService.settings.themeMode,
), ),
+4 -4
View File
@@ -144,7 +144,7 @@ class AppSettings {
this.mapKeyPrefix = '', this.mapKeyPrefix = '',
this.mapShowMarkers = true, this.mapShowMarkers = true,
this.mapShowGuessedLocations = true, this.mapShowGuessedLocations = true,
this.enableMessageTracing = false, this.enableMessageTracing = true,
this.mapCacheBounds, this.mapCacheBounds,
this.mapCacheMinZoom = 10, this.mapCacheMinZoom = 10,
this.mapCacheMaxZoom = 15, this.mapCacheMaxZoom = 15,
@@ -155,7 +155,7 @@ class AppSettings {
this.notifyOnNewMessage = true, this.notifyOnNewMessage = true,
this.notifyOnNewChannelMessage = true, this.notifyOnNewChannelMessage = true,
this.notifyOnNewAdvert = true, this.notifyOnNewAdvert = true,
this.autoRouteRotationEnabled = false, this.autoRouteRotationEnabled = true,
this.maxRouteWeight = 5.0, this.maxRouteWeight = 5.0,
this.initialRouteWeight = 3.0, this.initialRouteWeight = 3.0,
this.routeWeightSuccessIncrement = 0.5, this.routeWeightSuccessIncrement = 0.5,
@@ -273,7 +273,7 @@ class AppSettings {
mapShowMarkers: json['map_show_markers'] as bool? ?? true, mapShowMarkers: json['map_show_markers'] as bool? ?? true,
mapShowGuessedLocations: mapShowGuessedLocations:
json['map_show_guessed_locations'] as bool? ?? true, json['map_show_guessed_locations'] as bool? ?? true,
enableMessageTracing: json['enable_message_tracing'] as bool? ?? false, enableMessageTracing: json['enable_message_tracing'] 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()),
), ),
@@ -288,7 +288,7 @@ class AppSettings {
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: autoRouteRotationEnabled:
json['auto_route_rotation_enabled'] as bool? ?? false, json['auto_route_rotation_enabled'] as bool? ?? true,
maxRouteWeight: (json['max_route_weight'] as num?)?.toDouble() ?? 5.0, maxRouteWeight: (json['max_route_weight'] as num?)?.toDouble() ?? 5.0,
initialRouteWeight: initialRouteWeight:
(json['initial_route_weight'] as num?)?.toDouble() ?? 3.0, (json['initial_route_weight'] as num?)?.toDouble() ?? 3.0,
+69
View File
@@ -0,0 +1,69 @@
import 'dart:ui';
import 'package:latlong2/latlong.dart';
import 'path_history.dart';
/// One observed route rendered on the path map the live traced path
/// (primary) or an alternate from the contact's path history — resolved to
/// map coordinates with per-hop confidence flags.
class DisplayPath {
final String id;
final String label;
final Color color;
final bool isPrimary;
/// Outbound hop bytes, including hops that could not be placed on the map.
final List<int> hopBytes;
/// Resolved map points: self, each locatable hop, then the target when its
/// position is known. Hops with no position are skipped here but still
/// counted in [unresolvedHops].
final List<LatLng> points;
/// Display name for each entry of [points].
final List<String> pointLabels;
/// Whether each entry of [points] is a GPS-grade position (vs inferred).
final List<bool> pointConfirmed;
/// Per segment (length points-1): true when either endpoint is inferred or
/// unlocatable hops were skipped in between rendered dashed.
final List<bool> segmentEstimated;
/// Per segment: the transmission ordinal of the segment's destination,
/// used to highlight the matching hop-list row during animation.
final List<int> rowForSegment;
/// Total transmissions on the full route (including unlocatable hops).
final int totalTransmissions;
/// True when the route ends with a chat-target endpoint row.
final bool hasTargetEndpoint;
final int gpsConfirmedHops;
final int unresolvedHops;
final double distanceMeters;
/// History metadata; null for the live traced (primary) path.
final PathRecord? record;
const DisplayPath({
required this.id,
required this.label,
required this.color,
required this.isPrimary,
required this.hopBytes,
required this.points,
required this.pointLabels,
required this.pointConfirmed,
required this.segmentEstimated,
required this.rowForSegment,
required this.totalTransmissions,
required this.hasTargetEndpoint,
required this.gpsConfirmedHops,
required this.unresolvedHops,
required this.distanceMeters,
this.record,
});
}
+177
View File
@@ -0,0 +1,177 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/scheduler.dart';
import 'package:latlong2/latlong.dart';
/// Timeline state for the packet-flow animation on the path map.
///
/// The packet travels each segment over [segmentMs] (scaled by [speed]),
/// then dwells at the reached hop for [dwellMs] so the hop visibly lights up.
/// Overlay layers listen to this controller directly; [activeSegment] only
/// fires when the segment index changes so list highlights rebuild cheaply.
class PathPlaybackController extends ChangeNotifier {
static const double segmentMs = 1100;
static const double dwellMs = 380;
static const List<double> speedSteps = [0.5, 1.0, 2.0];
late final Ticker _ticker;
List<LatLng> _points = const [];
double _timelineMs = 0;
Duration _lastTick = Duration.zero;
bool _playing = false;
bool _started = false;
double _speed = 1.0;
/// Segment currently being traveled (clamped to the last segment), or -1
/// while the animation has not been started listeners use this for
/// hop-list highlighting without rebuilding every tick.
final ValueNotifier<int> activeSegment = ValueNotifier(-1);
PathPlaybackController(TickerProvider vsync) {
_ticker = vsync.createTicker(_onTick);
}
List<LatLng> get points => _points;
bool get hasPath => _points.length >= 2;
int get segmentCount => hasPath ? _points.length - 1 : 0;
bool get playing => _playing;
double get speed => _speed;
/// True once the user has started or stepped the animation; the packet
/// overlay renders only in this state.
bool get started => _started;
double get _slotMs => segmentMs + dwellMs;
double get _totalMs => segmentCount * _slotMs;
bool get isComplete => hasPath && _timelineMs >= _totalMs;
int get currentSegment {
if (!hasPath) return 0;
return (_timelineMs / _slotMs).floor().clamp(0, segmentCount - 1);
}
/// Travel progress through [currentSegment]; 1.0 while dwelling at its end.
double get segmentProgress {
if (!hasPath) return 0;
final within = _timelineMs - currentSegment * _slotMs;
return (within / segmentMs).clamp(0.0, 1.0);
}
/// Dwell progress (0..1) at the reached hop, or null while traveling.
double? get dwellProgress {
if (!hasPath || isComplete) return null;
final within = _timelineMs - currentSegment * _slotMs;
if (within < segmentMs) return null;
return ((within - segmentMs) / dwellMs).clamp(0.0, 1.0);
}
/// Index of the point the packet has most recently reached.
int get reachedPointIndex {
if (!hasPath) return 0;
if (isComplete) return _points.length - 1;
return segmentProgress >= 1.0 ? currentSegment + 1 : currentSegment;
}
LatLng get position {
if (!hasPath) return const LatLng(0, 0);
final seg = currentSegment;
final a = _points[seg];
final b = _points[seg + 1];
final t = segmentProgress;
return LatLng(
a.latitude + (b.latitude - a.latitude) * t,
a.longitude + (b.longitude - a.longitude) * t,
);
}
/// Replaces the path and resets the animation to the start.
void setPath(List<LatLng> points) {
_ticker.stop();
_points = List.unmodifiable(points);
_timelineMs = 0;
_playing = false;
_started = false;
activeSegment.value = -1;
notifyListeners();
}
void play() {
if (!hasPath) return;
if (isComplete) _timelineMs = 0;
_started = true;
_playing = true;
activeSegment.value = currentSegment;
if (!_ticker.isActive) {
_lastTick = Duration.zero;
_ticker.start();
}
notifyListeners();
}
void pause() {
_ticker.stop();
_playing = false;
notifyListeners();
}
void togglePlay() => _playing ? pause() : play();
void replay() {
if (!hasPath) return;
_timelineMs = 0;
activeSegment.value = 0;
play();
}
/// Stops playback and hides the packet overlay.
void stop() {
_ticker.stop();
_playing = false;
_started = false;
_timelineMs = 0;
activeSegment.value = -1;
notifyListeners();
}
void stepForward() => _jumpToPoint(reachedPointIndex + 1);
void stepBack() => _jumpToPoint(reachedPointIndex - 1);
void cycleSpeed() {
final index = speedSteps.indexOf(_speed);
_speed = speedSteps[(index + 1) % speedSteps.length];
notifyListeners();
}
void _jumpToPoint(int index) {
if (!hasPath) return;
_ticker.stop();
_playing = false;
_started = true;
final clamped = index.clamp(0, _points.length - 1);
// Land at the start of the dwell window so the hop pulse plays.
_timelineMs = clamped == 0 ? 0 : (clamped - 1) * _slotMs + segmentMs;
activeSegment.value = currentSegment;
notifyListeners();
}
void _onTick(Duration elapsed) {
final dtMs = (elapsed - _lastTick).inMicroseconds / 1000.0;
_lastTick = elapsed;
_timelineMs = (_timelineMs + dtMs * _speed).clamp(0.0, _totalMs);
if (_timelineMs >= _totalMs) {
_ticker.stop();
_playing = false;
}
if (activeSegment.value != currentSegment) {
activeSegment.value = currentSegment;
}
notifyListeners();
}
@override
void dispose() {
_ticker.dispose();
activeSegment.dispose();
super.dispose();
}
}
+78 -26
View File
@@ -4,6 +4,7 @@ import 'package:provider/provider.dart';
import '../l10n/l10n.dart'; import '../l10n/l10n.dart';
import '../services/app_debug_log_service.dart'; import '../services/app_debug_log_service.dart';
import '../theme/mesh_theme.dart';
import '../widgets/adaptive_app_bar_title.dart'; import '../widgets/adaptive_app_bar_title.dart';
import '../helpers/snack_bar_builder.dart'; import '../helpers/snack_bar_builder.dart';
@@ -58,25 +59,57 @@ 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, color: MeshPalette.line),
itemBuilder: (context, index) { itemBuilder: (context, index) {
final entry = entries[index]; final entry = entries[index];
return ListTile( return Container(
dense: true, color: MeshPalette.bg,
leading: _buildLevelIcon(entry.level), padding: const EdgeInsets.symmetric(
title: Text( horizontal: 16,
'[${entry.tag}] ${entry.message}', vertical: 8,
style: const TextStyle(
fontSize: 12,
fontFamily: 'monospace',
),
), ),
subtitle: Text( child: Row(
entry.formattedTime, crossAxisAlignment: CrossAxisAlignment.start,
style: TextStyle( children: [
fontSize: 10, _buildLevelIcon(context, entry.level),
color: Colors.grey[600], const SizedBox(width: 10),
), Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text.rich(
TextSpan(
children: [
TextSpan(
text: '[${entry.tag}] ',
style: MeshTheme.mono(
fontSize: 11.5,
color: _levelColor(entry.level),
),
),
TextSpan(
text: entry.message,
style: MeshTheme.mono(
fontSize: 11.5,
color: MeshPalette.ink2,
),
),
],
),
),
const SizedBox(height: 2),
Text(
entry.formattedTime,
style: MeshTheme.mono(
fontSize: 9.5,
color: MeshPalette.ink4,
),
),
],
),
),
],
), ),
); );
}, },
@@ -85,25 +118,25 @@ class AppDebugLogScreen extends StatelessWidget {
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon( const Icon(
Icons.bug_report_outlined, Icons.bug_report_outlined,
size: 64, size: 64,
color: Colors.grey[400], color: MeshPalette.ink3,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
context.l10n.debugLog_noEntries, context.l10n.debugLog_noEntries,
style: TextStyle( style: const TextStyle(
fontSize: 16, fontSize: 16,
color: Colors.grey[600], color: MeshPalette.ink3,
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
context.l10n.debugLog_enableInSettings, context.l10n.debugLog_enableInSettings,
style: TextStyle( style: const TextStyle(
fontSize: 12, fontSize: 12,
color: Colors.grey[500], color: MeshPalette.ink3,
), ),
), ),
], ],
@@ -115,18 +148,37 @@ class AppDebugLogScreen extends StatelessWidget {
); );
} }
Widget _buildLevelIcon(AppDebugLogLevel level) { Color _levelColor(AppDebugLogLevel level) {
switch (level) { switch (level) {
case AppDebugLogLevel.info: case AppDebugLogLevel.info:
return const Icon(Icons.info_outline, size: 18, color: Colors.blue); return MeshPalette.blue;
case AppDebugLogLevel.warning:
return MeshPalette.warn;
case AppDebugLogLevel.error:
return MeshPalette.alert;
}
}
Widget _buildLevelIcon(BuildContext context, AppDebugLogLevel level) {
switch (level) {
case AppDebugLogLevel.info:
return const Icon(
Icons.info_outline,
size: 18,
color: MeshPalette.blue,
);
case AppDebugLogLevel.warning: case AppDebugLogLevel.warning:
return const Icon( return const Icon(
Icons.warning_amber_outlined, Icons.warning_amber_outlined,
size: 18, size: 18,
color: Colors.orange, color: MeshPalette.warn,
); );
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: MeshPalette.alert,
);
} }
} }
} }
File diff suppressed because it is too large Load Diff
+113 -19
View File
@@ -4,6 +4,7 @@ import 'package:flutter/services.dart';
import '../l10n/l10n.dart'; import '../l10n/l10n.dart';
import '../services/ble_debug_log_service.dart'; import '../services/ble_debug_log_service.dart';
import '../connector/meshcore_protocol.dart'; import '../connector/meshcore_protocol.dart';
import '../theme/mesh_theme.dart';
import '../widgets/adaptive_app_bar_title.dart'; import '../widgets/adaptive_app_bar_title.dart';
import '../helpers/snack_bar_builder.dart'; import '../helpers/snack_bar_builder.dart';
@@ -32,6 +33,7 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: AdaptiveAppBarTitle(context.l10n.debugLog_bleTitle), title: AdaptiveAppBarTitle(context.l10n.debugLog_bleTitle),
centerTitle: true,
actions: [ actions: [
IconButton( IconButton(
tooltip: context.l10n.debugLog_copyLog, tooltip: context.l10n.debugLog_copyLog,
@@ -101,23 +103,14 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
itemCount: showingFrames itemCount: showingFrames
? entries.length ? entries.length
: rawEntries.length, : rawEntries.length,
separatorBuilder: (_, _) => const Divider(height: 1), separatorBuilder: (_, _) =>
const Divider(height: 1, color: MeshPalette.line),
itemBuilder: (context, index) { itemBuilder: (context, index) {
if (showingFrames) { if (showingFrames) {
final entry = entries[index]; final entry = entries[index];
final time = final time =
'${entry.timestamp.hour.toString().padLeft(2, '0')}:${entry.timestamp.minute.toString().padLeft(2, '0')}:${entry.timestamp.second.toString().padLeft(2, '0')}'; '${entry.timestamp.hour.toString().padLeft(2, '0')}:${entry.timestamp.minute.toString().padLeft(2, '0')}:${entry.timestamp.second.toString().padLeft(2, '0')}';
return ListTile( return GestureDetector(
dense: true,
title: Text(entry.description),
subtitle: Text('${entry.hexPreview}\n$time'),
isThreeLine: true,
leading: Icon(
entry.outgoing
? Icons.upload
: Icons.download,
size: 18,
),
onLongPress: () async { onLongPress: () async {
await Clipboard.setData( await Clipboard.setData(
ClipboardData( ClipboardData(
@@ -131,6 +124,60 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
), ),
); );
}, },
child: Container(
color: MeshPalette.bg,
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
child: Row(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Icon(
entry.outgoing
? Icons.upload
: Icons.download,
size: 18,
color: entry.outgoing
? MeshPalette.blue
: MeshPalette.signal,
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Text(
entry.description,
style: MeshTheme.mono(
fontSize: 11.5,
color: MeshPalette.ink,
),
),
const SizedBox(height: 2),
Text(
entry.hexPreview,
style: MeshTheme.mono(
fontSize: 10,
color: MeshPalette.ink3,
),
),
const SizedBox(height: 2),
Text(
time,
style: MeshTheme.mono(
fontSize: 9.5,
color: MeshPalette.ink4,
),
),
],
),
),
],
),
),
); );
} }
@@ -138,18 +185,65 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
final info = _decodeRawPacket(entry.payload); final info = _decodeRawPacket(entry.payload);
final time = final time =
'${entry.timestamp.hour.toString().padLeft(2, '0')}:${entry.timestamp.minute.toString().padLeft(2, '0')}:${entry.timestamp.second.toString().padLeft(2, '0')}'; '${entry.timestamp.hour.toString().padLeft(2, '0')}:${entry.timestamp.minute.toString().padLeft(2, '0')}:${entry.timestamp.second.toString().padLeft(2, '0')}';
return ListTile( return GestureDetector(
dense: true,
title: Text(info.title),
subtitle: Text('${info.summary}\n$time'),
isThreeLine: true,
leading: const Icon(Icons.download, size: 18),
onTap: () => _showRawDialog(context, info), onTap: () => _showRawDialog(context, info),
child: Container(
color: MeshPalette.bg,
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(
Icons.download,
size: 18,
color: MeshPalette.signal,
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Text(
info.title,
style: MeshTheme.mono(
fontSize: 11.5,
color: MeshPalette.ink,
),
),
const SizedBox(height: 2),
Text(
info.summary,
style: MeshTheme.mono(
fontSize: 10,
color: MeshPalette.ink3,
),
),
const SizedBox(height: 2),
Text(
time,
style: MeshTheme.mono(
fontSize: 9.5,
color: MeshPalette.ink4,
),
),
],
),
),
],
),
),
); );
}, },
) )
: Center( : Center(
child: Text(context.l10n.debugLog_noBleActivity), child: Text(
context.l10n.debugLog_noBleActivity,
style: const TextStyle(color: MeshPalette.ink3),
),
), ),
), ),
], ],
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+482 -1024
View File
File diff suppressed because it is too large Load Diff
+86 -70
View File
@@ -1,5 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../l10n/l10n.dart'; import '../l10n/l10n.dart';
import '../theme/mesh_theme.dart';
import '../widgets/mesh_ui.dart';
class ChromeRequiredScreen extends StatelessWidget { class ChromeRequiredScreen extends StatelessWidget {
const ChromeRequiredScreen({super.key}); const ChromeRequiredScreen({super.key});
@@ -7,81 +9,95 @@ class ChromeRequiredScreen extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = context.l10n; final l10n = context.l10n;
final theme = Theme.of(context); final scheme = Theme.of(context).colorScheme;
final isDark = theme.brightness == Brightness.dark;
return Scaffold( return Scaffold(
body: Container( body: SafeArea(
width: double.infinity, child: Center(
padding: const EdgeInsets.symmetric(horizontal: 32), child: SingleChildScrollView(
decoration: BoxDecoration( padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 40),
gradient: LinearGradient( child: Column(
begin: Alignment.topLeft, mainAxisAlignment: MainAxisAlignment.center,
end: Alignment.bottomRight, children: [
colors: isDark // Icon in tinted circle
? [const Color(0xFF1A1A1A), const Color(0xFF0D0D0D)] Container(
: [const Color(0xFFF5F7FA), const Color(0xFFE4E7EB)], width: 88,
), height: 88,
), decoration: BoxDecoration(
child: Column( shape: BoxShape.circle,
mainAxisAlignment: MainAxisAlignment.center, color: scheme.tertiary.withValues(alpha: 0.10),
children: [ border: Border.all(
Container( color: scheme.tertiary.withValues(alpha: 0.25),
padding: const EdgeInsets.all(24), width: 1.5,
decoration: BoxDecoration(
color: Colors.orange.withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: const Icon(
Icons.browser_not_supported_rounded,
size: 80,
color: Colors.orange,
),
),
const SizedBox(height: 32),
Text(
l10n.scanner_chromeRequired,
textAlign: TextAlign.center,
style: theme.textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
color: isDark ? Colors.white : Colors.black87,
),
),
const SizedBox(height: 16),
Text(
l10n.scanner_chromeRequiredMessage,
textAlign: TextAlign.center,
style: theme.textTheme.bodyLarge?.copyWith(
color: isDark ? Colors.white70 : Colors.black54,
height: 1.5,
),
),
const SizedBox(height: 48),
// We can't really "fix" it for them other than telling them to use Chrome
// but we can provide a nice visual.
Container(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
decoration: BoxDecoration(
color: Colors.blue.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(30),
border: Border.all(color: Colors.blue.withValues(alpha: 0.3)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.info_outline, size: 20, color: Colors.blue),
const SizedBox(width: 12),
Text(
"Web Bluetooth requires a Chromium browser",
style: theme.textTheme.bodyMedium?.copyWith(
color: Colors.blue,
fontWeight: FontWeight.w500,
), ),
), ),
], child: Icon(
), Icons.browser_not_supported_rounded,
size: 42,
color: scheme.tertiary,
),
),
const SizedBox(height: 28),
// Title
Text(
l10n.scanner_chromeRequired,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
color: scheme.onSurface,
letterSpacing: -0.3,
),
),
const SizedBox(height: 12),
// Body text
Text(
l10n.scanner_chromeRequiredMessage,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 14,
color: scheme.onSurfaceVariant,
height: 1.55,
),
),
const SizedBox(height: 32),
// Info chip
MeshCard(
margin: EdgeInsets.zero,
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
color: scheme.secondaryContainer.withValues(alpha: 0.35),
borderColor: scheme.outline.withValues(alpha: 0.3),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.info_outline,
size: 18,
color: scheme.secondary,
),
const SizedBox(width: 10),
Flexible(
child: Text(
l10n.chrome_bluetoothRequiresChromium,
style: MeshTheme.mono(
fontSize: 12,
color: scheme.onSecondaryContainer,
fontWeight: FontWeight.w500,
),
textAlign: TextAlign.center,
),
),
],
),
),
],
), ),
], ),
), ),
), ),
); );
+229 -75
View File
@@ -1,14 +1,18 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
import '../connector/meshcore_connector.dart'; import '../connector/meshcore_connector.dart';
import '../helpers/snack_bar_builder.dart';
import '../l10n/l10n.dart'; import '../l10n/l10n.dart';
import '../models/community.dart'; import '../models/community.dart';
import '../storage/community_store.dart'; import '../storage/community_store.dart';
import '../theme/mesh_theme.dart';
import '../widgets/adaptive_app_bar_title.dart'; import '../widgets/adaptive_app_bar_title.dart';
import '../widgets/mesh_ui.dart';
import '../widgets/qr_scanner_widget.dart'; import '../widgets/qr_scanner_widget.dart';
import '../helpers/snack_bar_builder.dart';
/// Screen for scanning community QR codes to join communities. /// Screen for scanning community QR codes to join communities.
/// ///
@@ -35,16 +39,87 @@ class _CommunityQrScannerScreenState extends State<CommunityQrScannerScreen> {
centerTitle: true, centerTitle: true,
), ),
body: _isProcessing body: _isProcessing
? const Center(child: CircularProgressIndicator()) ? Container(
color: Theme.of(context).colorScheme.surface,
child: const Center(child: CircularProgressIndicator()),
)
: QrScannerWidget( : QrScannerWidget(
onScanned: (data) => _handleScannedData(context, data), onScanned: (data) => _handleScannedData(context, data),
validator: Community.isValidQrData, validator: Community.isValidQrData,
onValidationFailed: (_) => _showInvalidQrError(context), onValidationFailed: (_) => _showInvalidQrError(context),
instructions: context.l10n.community_scanInstructions, instructions: context.l10n.community_scanInstructions,
overlay: _buildThemedOverlay(context),
), ),
); );
} }
Widget _buildThemedOverlay(BuildContext context) {
return Stack(
fit: StackFit.expand,
children: [
// Dark semi-transparent background with cutout
ColorFiltered(
colorFilter: ColorFilter.mode(
Colors.black.withValues(alpha: 0.5),
BlendMode.srcOut,
),
child: Stack(
fit: StackFit.expand,
children: [
Container(
decoration: const BoxDecoration(
color: Colors.black,
backgroundBlendMode: BlendMode.dstOut,
),
),
Center(
child: Container(
height: 250,
width: 250,
decoration: BoxDecoration(
color: Colors.red,
borderRadius: BorderRadius.circular(16),
),
),
),
],
),
),
// Corner brackets on top
const ScannerCornerOverlay(
scanWindowSize: 250,
borderColor: MeshPalette.blue,
borderWidth: 2,
cornerLength: 24,
),
// Instructions pill below the scan window
Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 250 + 24),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 10,
),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.72),
borderRadius: BorderRadius.circular(MeshRadii.pill),
),
child: Text(
context.l10n.community_scanInstructions,
style: const TextStyle(color: MeshPalette.ink2, fontSize: 13),
textAlign: TextAlign.center,
),
),
],
),
),
],
);
}
Future<void> _handleScannedData(BuildContext context, String data) async { Future<void> _handleScannedData(BuildContext context, String data) async {
if (_isProcessing) return; if (_isProcessing) return;
@@ -80,7 +155,7 @@ class _CommunityQrScannerScreenState extends State<CommunityQrScannerScreen> {
showDismissibleSnackBar( showDismissibleSnackBar(
context, context,
content: Text(context.l10n.community_invalidQrCode), content: Text(context.l10n.community_invalidQrCode),
backgroundColor: Colors.red, backgroundColor: MeshPalette.alert,
); );
} }
} finally { } finally {
@@ -96,29 +171,74 @@ class _CommunityQrScannerScreenState extends State<CommunityQrScannerScreen> {
showDismissibleSnackBar( showDismissibleSnackBar(
context, context,
content: Text(context.l10n.community_invalidQrCode), content: Text(context.l10n.community_invalidQrCode),
backgroundColor: Colors.orange, backgroundColor: MeshPalette.warn,
duration: const Duration(seconds: 2), duration: const Duration(seconds: 2),
); );
} }
void _showAlreadyMemberDialog(BuildContext context, Community community) { void _showAlreadyMemberDialog(BuildContext context, Community community) {
showDialog( showMeshSheet(
context: context, context,
builder: (dialogContext) => AlertDialog( builder: (sheetContext) {
title: Text(context.l10n.community_alreadyMember), final sheetScheme = Theme.of(sheetContext).colorScheme;
content: Text( return Column(
context.l10n.community_alreadyMemberMessage(community.name), mainAxisSize: MainAxisSize.min,
), crossAxisAlignment: CrossAxisAlignment.stretch,
actions: [ children: [
TextButton( BottomSheetHeader(title: context.l10n.community_alreadyMember),
onPressed: () { Padding(
Navigator.pop(dialogContext); padding: const EdgeInsets.fromLTRB(20, 0, 20, 4),
Navigator.pop(context); child: Text(
}, context.l10n.community_alreadyMemberMessage(community.name),
child: Text(context.l10n.common_ok), style: TextStyle(color: sheetScheme.onSurfaceVariant),
), ),
], ),
), MeshCard(
child: Row(
children: [
const Icon(
Icons.groups,
color: MeshPalette.magenta,
size: 32,
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
community.name,
style: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 15,
),
),
Text(
'ID: ${community.shortCommunityId}...',
style: MeshTheme.mono(
fontSize: 11.5,
color: sheetScheme.onSurfaceVariant,
),
),
],
),
),
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: FilledButton(
onPressed: () {
Navigator.pop(sheetContext);
Navigator.pop(context);
},
child: Text(context.l10n.common_ok),
),
),
],
);
},
); );
} }
@@ -127,77 +247,111 @@ class _CommunityQrScannerScreenState extends State<CommunityQrScannerScreen> {
Community community, Community community,
) async { ) async {
bool addPublicChannel = true; bool addPublicChannel = true;
final completer = Completer<bool>();
final result = await showDialog<bool>( await showMeshSheet<void>(
context: context, context,
builder: (dialogContext) => StatefulBuilder( builder: (sheetContext) => StatefulBuilder(
builder: (dialogContext, setDialogState) => AlertDialog( builder: (sheetContext, setSheetState) {
title: Text(context.l10n.community_joinTitle), final joinScheme = Theme.of(sheetContext).colorScheme;
content: Column( return Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Text(context.l10n.community_joinConfirmation(community.name)), BottomSheetHeader(title: context.l10n.community_joinTitle),
const SizedBox(height: 16), Padding(
Row( padding: const EdgeInsets.fromLTRB(20, 0, 20, 4),
children: [ child: Text(
Icon( context.l10n.community_joinConfirmation(community.name),
Icons.groups, style: TextStyle(color: joinScheme.onSurfaceVariant),
color: Theme.of(dialogContext).colorScheme.primary, ),
), ),
const SizedBox(width: 12), MeshCard(
Expanded( child: Row(
child: Column( children: [
crossAxisAlignment: CrossAxisAlignment.start, AvatarCircle(
children: [ name: community.name,
Text( icon: Icons.groups,
community.name, color: MeshPalette.magenta,
style: const TextStyle(fontWeight: FontWeight.bold), size: 44,
), ),
Text( const SizedBox(width: 14),
'ID: ${community.shortCommunityId}...', Expanded(
style: TextStyle( child: Column(
fontSize: 12, crossAxisAlignment: CrossAxisAlignment.start,
color: Colors.grey[600], children: [
), Text(
), community.name,
], style: const TextStyle(
), fontWeight: FontWeight.w600,
), fontSize: 15,
], ),
),
Text(
'ID: ${community.shortCommunityId}...',
style: MeshTheme.mono(
fontSize: 11.5,
color: joinScheme.onSurfaceVariant,
),
),
],
),
),
],
),
), ),
const SizedBox(height: 16),
const Divider(),
const SizedBox(height: 8),
CheckboxListTile( CheckboxListTile(
value: addPublicChannel, value: addPublicChannel,
onChanged: (value) { onChanged: (value) {
setDialogState(() { setSheetState(() {
addPublicChannel = value ?? true; addPublicChannel = value ?? true;
}); });
}, },
title: Text(context.l10n.community_addPublicChannel), title: Text(context.l10n.community_addPublicChannel),
subtitle: Text(context.l10n.community_addPublicChannelHint), subtitle: Text(context.l10n.community_addPublicChannelHint),
controlAffinity: ListTileControlAffinity.leading, controlAffinity: ListTileControlAffinity.leading,
contentPadding: EdgeInsets.zero, contentPadding: const EdgeInsets.symmetric(horizontal: 16),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: () {
completer.complete(false);
Navigator.pop(sheetContext);
},
child: Text(context.l10n.common_cancel),
),
),
const SizedBox(width: 12),
Expanded(
child: FilledButton(
onPressed: () {
completer.complete(true);
Navigator.pop(sheetContext);
},
child: Text(context.l10n.community_join),
),
),
],
),
), ),
], ],
), );
actions: [ },
TextButton(
onPressed: () => Navigator.pop(dialogContext, false),
child: Text(context.l10n.common_cancel),
),
FilledButton(
onPressed: () => Navigator.pop(dialogContext, true),
child: Text(context.l10n.community_join),
),
],
),
), ),
); );
if (result == true && context.mounted) { // If sheet was dismissed without a button press, treat as cancel
if (!completer.isCompleted) {
completer.complete(false);
}
final result = await completer.future;
if (result && context.mounted) {
await _joinCommunity(context, community, addPublicChannel); await _joinCommunity(context, community, addPublicChannel);
} else if (context.mounted) { } else if (context.mounted) {
// User cancelled - go back // User cancelled - go back
@@ -231,7 +385,7 @@ class _CommunityQrScannerScreenState extends State<CommunityQrScannerScreen> {
showDismissibleSnackBar( showDismissibleSnackBar(
context, context,
content: Text(context.l10n.community_joined(community.name)), content: Text(context.l10n.community_joined(community.name)),
backgroundColor: Colors.green, backgroundColor: MeshPalette.signal,
); );
// Return to previous screen // Return to previous screen
+114 -32
View File
@@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
import 'package:meshcore_open/connector/meshcore_connector.dart'; import 'package:meshcore_open/connector/meshcore_connector.dart';
import 'package:meshcore_open/models/companion_radio_stats.dart'; import 'package:meshcore_open/models/companion_radio_stats.dart';
import 'package:meshcore_open/l10n/l10n.dart'; import 'package:meshcore_open/l10n/l10n.dart';
import 'package:meshcore_open/theme/mesh_theme.dart';
import 'package:meshcore_open/widgets/mesh_ui.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
class CompanionRadioStatsScreen extends StatefulWidget { class CompanionRadioStatsScreen extends StatefulWidget {
@@ -49,6 +51,25 @@ class _CompanionRadioStatsScreenState extends State<CompanionRadioStatsScreen> {
super.dispose(); super.dispose();
} }
Widget _tile(String text, IconData icon, Color color) {
final scheme = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
child: Row(
children: [
Icon(icon, size: 16, color: color),
const SizedBox(width: 10),
Expanded(
child: Text(
text,
style: MeshTheme.mono(fontSize: 13, color: scheme.onSurface),
),
),
],
),
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = context.l10n; final l10n = context.l10n;
@@ -85,44 +106,105 @@ class _CompanionRadioStatsScreenState extends State<CompanionRadioStatsScreen> {
valueListenable: connector.radioStatsNotifier, valueListenable: connector.radioStatsNotifier,
builder: (context, stats, _) { builder: (context, stats, _) {
return ListView( return ListView(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.symmetric(vertical: 8),
children: [ children: [
if (stats != null) ...[ if (stats != null) ...[
Text( const SectionHeader(
l10n.radioStats_noiseFloor(stats.noiseFloorDbm), 'Signal',
style: tt.titleMedium, padding: EdgeInsets.fromLTRB(16, 16, 16, 8),
), ),
const SizedBox(height: 4), MeshCard(
Text(l10n.radioStats_lastRssi(stats.lastRssiDbm)), margin: const EdgeInsets.symmetric(
Text( horizontal: 16,
l10n.radioStats_lastSnr( vertical: 4,
stats.lastSnrDb.toStringAsFixed(1), ),
padding: const EdgeInsets.all(4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_tile(
l10n.radioStats_noiseFloor(stats.noiseFloorDbm),
Icons.noise_aware,
scheme.onSurfaceVariant,
),
const Divider(height: 1),
_tile(
l10n.radioStats_lastRssi(stats.lastRssiDbm),
Icons.wifi_tethering,
scheme.onSurfaceVariant,
),
const Divider(height: 1),
_tile(
l10n.radioStats_lastSnr(
stats.lastSnrDb.toStringAsFixed(1),
),
Icons.signal_cellular_alt,
MeshTheme.snrColor(stats.lastSnrDb, blocked: false),
),
],
), ),
), ),
Text(l10n.radioStats_txAir(stats.txAirSecs)), const SectionHeader(
Text(l10n.radioStats_rxAir(stats.rxAirSecs)), 'Airtime',
const SizedBox(height: 16), padding: EdgeInsets.fromLTRB(16, 16, 16, 8),
] else ),
Text(l10n.radioStats_waiting), MeshCard(
const SizedBox(height: 16), margin: const EdgeInsets.symmetric(
SizedBox( horizontal: 16,
height: 200, vertical: 4,
child: CustomPaint( ),
painter: _NoiseChartPainter( padding: const EdgeInsets.all(4),
samples: List<double>.from(_noiseHistory), child: Column(
colorScheme: scheme, crossAxisAlignment: CrossAxisAlignment.start,
textTheme: tt, children: [
_tile(
l10n.radioStats_txAir(stats.txAirSecs),
Icons.upload,
MeshPalette.blue,
),
const Divider(height: 1),
_tile(
l10n.radioStats_rxAir(stats.rxAirSecs),
Icons.download,
MeshPalette.blue,
),
],
),
),
] else ...[
const SizedBox(height: 80),
Center(
child: CircularProgressIndicator(
color: Theme.of(context).colorScheme.primary,
),
),
const SizedBox(height: 8),
Center(
child: Text(
l10n.radioStats_waiting,
style: TextStyle(color: scheme.onSurfaceVariant),
),
),
],
SectionHeader(
l10n.radioStats_chartCaption,
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: SizedBox(
height: 200,
child: CustomPaint(
painter: _NoiseChartPainter(
samples: List<double>.from(_noiseHistory),
colorScheme: scheme,
textTheme: tt,
),
child: const SizedBox.expand(),
), ),
child: const SizedBox.expand(),
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text(
l10n.radioStats_chartCaption,
style: tt.bodySmall?.copyWith(
color: scheme.onSurfaceVariant,
),
),
], ],
); );
}, },
@@ -210,10 +292,10 @@ class _NoiseChartPainter extends CustomPainter {
} }
final span = maxV - minV; final span = maxV - minV;
for (var i = 0; i <= 2; i++) { for (var i = 0; i <= 4; i++) {
final v = maxV - span * i / 2; final v = maxV - span * i / 4;
final tp = _yAxisLabel(v); final tp = _yAxisLabel(v);
final y = chart.top + (chart.height * i / 2) - tp.height / 2; final y = chart.top + (chart.height * i / 4) - tp.height / 2;
tp.paint(canvas, Offset(4, y)); tp.paint(canvas, Offset(4, y));
} }
+433 -242
View File
@@ -16,6 +16,7 @@ import '../models/contact.dart';
import '../l10n/contact_localization.dart'; import '../l10n/contact_localization.dart';
import '../models/contact_group.dart'; import '../models/contact_group.dart';
import '../services/ui_view_state_service.dart'; import '../services/ui_view_state_service.dart';
import '../theme/mesh_theme.dart';
import '../utils/contact_search.dart'; import '../utils/contact_search.dart';
import '../storage/contact_group_store.dart'; import '../storage/contact_group_store.dart';
import '../utils/dialog_utils.dart'; import '../utils/dialog_utils.dart';
@@ -24,6 +25,7 @@ import '../utils/emoji_utils.dart';
import '../utils/route_transitions.dart'; import '../utils/route_transitions.dart';
import '../widgets/list_filter_widget.dart'; import '../widgets/list_filter_widget.dart';
import '../widgets/empty_state.dart'; import '../widgets/empty_state.dart';
import '../widgets/mesh_ui.dart';
import '../widgets/quick_switch_bar.dart'; import '../widgets/quick_switch_bar.dart';
import '../widgets/repeater_login_dialog.dart'; import '../widgets/repeater_login_dialog.dart';
import '../widgets/room_login_dialog.dart'; import '../widgets/room_login_dialog.dart';
@@ -59,7 +61,7 @@ class _ContactsScreenState extends State<ContactsScreen>
String _loadedGroupScopeKeyHex = ''; String _loadedGroupScopeKeyHex = '';
Timer? _searchDebounce; Timer? _searchDebounce;
final Set<ContactOperationType> _pendingOperations = {}; final List<ContactOperationType> _pendingOperations = [];
StreamSubscription<Uint8List>? _frameSubscription; StreamSubscription<Uint8List>? _frameSubscription;
@@ -185,59 +187,52 @@ class _ContactsScreenState extends State<ContactsScreen>
Clipboard.setData(ClipboardData(text: "meshcore://$hexString")); Clipboard.setData(ClipboardData(text: "meshcore://$hexString"));
} }
// Generic OK/ERR acks carry no command correlation, so consume only
// the oldest pending operation per ack instead of clearing all.
if (code == respCodeOk) { if (code == respCodeOk) {
// Show a snackbar indicating success
if (!mounted) return; if (!mounted) return;
if (_pendingOperations.isEmpty) return;
if (_pendingOperations.contains(ContactOperationType.import)) { final op = _pendingOperations.removeAt(0);
showDismissibleSnackBar( switch (op) {
context, case ContactOperationType.import:
content: Text(context.l10n.contacts_contactImported), showDismissibleSnackBar(
); context,
content: Text(context.l10n.contacts_contactImported),
);
case ContactOperationType.zeroHopShare:
showDismissibleSnackBar(
context,
content: Text(context.l10n.contacts_zeroHopContactAdvertSent),
);
case ContactOperationType.export:
showDismissibleSnackBar(
context,
content: Text(context.l10n.contacts_contactAdvertCopied),
);
} }
if (_pendingOperations.contains(ContactOperationType.zeroHopShare)) {
showDismissibleSnackBar(
context,
content: Text(context.l10n.contacts_zeroHopContactAdvertSent),
);
}
if (_pendingOperations.contains(ContactOperationType.export)) {
showDismissibleSnackBar(
context,
content: Text(context.l10n.contacts_contactAdvertCopied),
);
}
_pendingOperations.clear();
} }
if (code == respCodeErr) { if (code == respCodeErr) {
// Show a snackbar indicating failure
if (!mounted) return; if (!mounted) return;
if (_pendingOperations.isEmpty) return;
if (_pendingOperations.contains(ContactOperationType.import)) { final op = _pendingOperations.removeAt(0);
showDismissibleSnackBar( switch (op) {
context, case ContactOperationType.import:
content: Text(context.l10n.contacts_contactImportFailed), showDismissibleSnackBar(
); context,
content: Text(context.l10n.contacts_contactImportFailed),
);
case ContactOperationType.zeroHopShare:
showDismissibleSnackBar(
context,
content: Text(context.l10n.contacts_zeroHopContactAdvertFailed),
);
case ContactOperationType.export:
showDismissibleSnackBar(
context,
content: Text(context.l10n.contacts_contactAdvertCopyFailed),
);
} }
if (_pendingOperations.contains(ContactOperationType.zeroHopShare)) {
showDismissibleSnackBar(
context,
content: Text(context.l10n.contacts_zeroHopContactAdvertFailed),
);
}
if (_pendingOperations.contains(ContactOperationType.export)) {
showDismissibleSnackBar(
context,
content: Text(context.l10n.contacts_contactAdvertCopyFailed),
);
}
_pendingOperations.clear();
} }
} catch (e) { } catch (e) {
appLogger.error( appLogger.error(
@@ -252,17 +247,37 @@ class _ContactsScreenState extends State<ContactsScreen>
final connector = Provider.of<MeshCoreConnector>(context, listen: false); final connector = Provider.of<MeshCoreConnector>(context, listen: false);
final exportContactFrame = buildExportContactFrame(pubKey); final exportContactFrame = buildExportContactFrame(pubKey);
_pendingOperations.add(ContactOperationType.export); _pendingOperations.add(ContactOperationType.export);
await connector.sendFrame(exportContactFrame, expectsGenericAck: true); try {
await connector.sendFrame(exportContactFrame, expectsGenericAck: true);
} catch (e) {
_pendingOperations.remove(ContactOperationType.export);
if (mounted) {
showDismissibleSnackBar(
context,
content: Text(context.l10n.contacts_contactAdvertCopyFailed),
);
}
}
} }
Future<void> _contactZeroHop(Uint8List pubKey) async { Future<void> _contactZeroHop(Uint8List pubKey) async {
final connector = Provider.of<MeshCoreConnector>(context, listen: false); final connector = Provider.of<MeshCoreConnector>(context, listen: false);
final exportContactZeroHopFrame = buildZeroHopContact(pubKey); final exportContactZeroHopFrame = buildZeroHopContact(pubKey);
_pendingOperations.add(ContactOperationType.zeroHopShare); _pendingOperations.add(ContactOperationType.zeroHopShare);
await connector.sendFrame( try {
exportContactZeroHopFrame, await connector.sendFrame(
expectsGenericAck: true, exportContactZeroHopFrame,
); expectsGenericAck: true,
);
} catch (e) {
_pendingOperations.remove(ContactOperationType.zeroHopShare);
if (mounted) {
showDismissibleSnackBar(
context,
content: Text(context.l10n.contacts_zeroHopContactAdvertFailed),
);
}
}
} }
Future<void> _contactImport() async { Future<void> _contactImport() async {
@@ -288,11 +303,10 @@ class _ContactsScreenState extends State<ContactsScreen>
return; return;
} }
final hexString = text.substring('meshcore://'.length); final hexString = text.substring('meshcore://'.length);
final Uint8List importContactFrame;
try { try {
final bytes = hex2Uint8List(hexString); final bytes = hex2Uint8List(hexString);
final importContactFrame = buildImportContactFrame(bytes); importContactFrame = buildImportContactFrame(bytes);
_pendingOperations.add(ContactOperationType.import);
connector.importContact(importContactFrame);
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
showDismissibleSnackBar( showDismissibleSnackBar(
@@ -300,6 +314,19 @@ class _ContactsScreenState extends State<ContactsScreen>
content: Text(context.l10n.contacts_invalidAdvertFormat), content: Text(context.l10n.contacts_invalidAdvertFormat),
); );
} }
return;
}
_pendingOperations.add(ContactOperationType.import);
try {
await connector.sendFrame(importContactFrame, expectsGenericAck: true);
} catch (e) {
_pendingOperations.remove(ContactOperationType.import);
if (mounted) {
showDismissibleSnackBar(
context,
content: Text(context.l10n.contacts_contactImportFailed),
);
}
} }
} }
@@ -322,7 +349,34 @@ class _ContactsScreenState extends State<ContactsScreen>
bottom: const SyncProgressAppBarBottom(), bottom: const SyncProgressAppBarBottom(),
actions: [ actions: [
PopupMenuButton( PopupMenuButton(
itemBuilder: (context) => [ tooltip: context.l10n.contacts_moreOptions,
itemBuilder: (context) => <PopupMenuEntry<dynamic>>[
PopupMenuItem(
child: Row(
children: [
const Icon(Icons.person_add_rounded),
const SizedBox(width: 8),
Text(context.l10n.discoveredContacts_Title),
],
),
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const DiscoveryScreen(),
),
),
),
PopupMenuItem(
child: Row(
children: [
const Icon(Icons.paste),
const SizedBox(width: 8),
Text(context.l10n.contacts_addContactFromClipboard),
],
),
onTap: () => _contactImport(),
),
const PopupMenuDivider(),
PopupMenuItem( PopupMenuItem(
child: Row( child: Row(
children: [ children: [
@@ -365,46 +419,20 @@ class _ContactsScreenState extends State<ContactsScreen>
), ),
onTap: () => _contactExport(Uint8List.fromList([])), onTap: () => _contactExport(Uint8List.fromList([])),
), ),
const PopupMenuDivider(),
PopupMenuItem( PopupMenuItem(
child: Row( child: Row(
children: [ children: [
const Icon(Icons.paste), Icon(
const SizedBox(width: 8), Icons.logout,
Text(context.l10n.contacts_addContactFromClipboard), color: Theme.of(context).colorScheme.error,
], ),
),
onTap: () => _contactImport(),
),
],
icon: const Icon(Icons.connect_without_contact),
),
PopupMenuButton(
itemBuilder: (context) => [
PopupMenuItem(
child: Row(
children: [
const Icon(Icons.logout, color: Colors.red),
const SizedBox(width: 8), const SizedBox(width: 8),
Text(context.l10n.common_disconnect), Text(context.l10n.common_disconnect),
], ],
), ),
onTap: () => _disconnect(context, connector), onTap: () => _disconnect(context, connector),
), ),
PopupMenuItem(
child: Row(
children: [
const Icon(Icons.person_add_rounded),
const SizedBox(width: 8),
Text(context.l10n.discoveredContacts_Title),
],
),
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const DiscoveryScreen(),
),
),
),
PopupMenuItem( PopupMenuItem(
child: Row( child: Row(
children: [ children: [
@@ -426,6 +454,10 @@ class _ContactsScreenState extends State<ContactsScreen>
], ],
), ),
body: _buildContactsBody(context, connector), body: _buildContactsBody(context, connector),
floatingActionButton: FloatingActionButton(
onPressed: () => _showAddContactSheet(context),
child: const Icon(Icons.person_add),
),
bottomNavigationBar: SafeArea( bottomNavigationBar: SafeArea(
top: false, top: false,
child: QuickSwitchBar( child: QuickSwitchBar(
@@ -440,6 +472,42 @@ class _ContactsScreenState extends State<ContactsScreen>
); );
} }
void _showAddContactSheet(BuildContext context) {
showMeshSheet(
context,
builder: (sheetContext) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
BottomSheetHeader(title: context.l10n.contacts_title),
ListTile(
leading: const Icon(Icons.paste),
title: Text(context.l10n.contacts_addContactFromClipboard),
onTap: () {
Navigator.pop(sheetContext);
_contactImport();
},
),
ListTile(
leading: const Icon(Icons.person_add_rounded),
title: Text(context.l10n.discoveredContacts_Title),
onTap: () {
Navigator.pop(sheetContext);
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const DiscoveryScreen(),
),
);
},
),
const SizedBox(height: 8),
],
),
),
);
}
Future<void> _disconnect( Future<void> _disconnect(
BuildContext context, BuildContext context,
MeshCoreConnector connector, MeshCoreConnector connector,
@@ -571,7 +639,11 @@ class _ContactsScreenState extends State<ContactsScreen>
const SizedBox(width: 8), const SizedBox(width: 8),
IconButton( IconButton(
tooltip: menuContext.l10n.contacts_deleteGroup, tooltip: menuContext.l10n.contacts_deleteGroup,
icon: const Icon(Icons.delete, size: 20, color: Colors.red), icon: Icon(
Icons.delete,
size: 20,
color: Theme.of(context).colorScheme.error,
),
onPressed: canManageGroups onPressed: canManageGroups
? () => _closeDropdownAndRun( ? () => _closeDropdownAndRun(
menuContext, menuContext,
@@ -589,16 +661,25 @@ class _ContactsScreenState extends State<ContactsScreen>
], ],
child: SizedBox( child: SizedBox(
height: 48, height: 48,
child: Padding( child: DecoratedBox(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), decoration: BoxDecoration(
child: Row( border: Border.all(color: Theme.of(context).colorScheme.outline),
children: [ borderRadius: BorderRadius.circular(12),
Expanded( ),
child: Text(selectedGroupName, overflow: TextOverflow.ellipsis), child: Padding(
), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
const SizedBox(width: 8), child: Row(
const Icon(Icons.arrow_drop_down), children: [
], Expanded(
child: Text(
selectedGroupName,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
const Icon(Icons.arrow_drop_down),
],
),
), ),
), ),
), ),
@@ -624,6 +705,14 @@ class _ContactsScreenState extends State<ContactsScreen>
icon: Icons.people_outline, icon: Icons.people_outline,
title: context.l10n.contacts_noContacts, title: context.l10n.contacts_noContacts,
subtitle: context.l10n.contacts_contactsWillAppear, subtitle: context.l10n.contacts_contactsWillAppear,
action: FilledButton.icon(
onPressed: () => Navigator.push(
context,
MaterialPageRoute(builder: (context) => const DiscoveryScreen()),
),
icon: const Icon(Icons.person_add_rounded),
label: Text(context.l10n.discoveredContacts_Title),
),
); );
} }
@@ -759,6 +848,9 @@ class _ContactsScreenState extends State<ContactsScreen>
width: 48, width: 48,
height: 48, height: 48,
child: IconButton( child: IconButton(
tooltip: viewState.contactsSearchExpanded
? context.l10n.contacts_searchClose
: context.l10n.contacts_searchOpen,
onPressed: () { onPressed: () {
if (viewState.contactsSearchExpanded) { if (viewState.contactsSearchExpanded) {
_collapseContactsSearch(viewState); _collapseContactsSearch(viewState);
@@ -791,32 +883,37 @@ class _ContactsScreenState extends State<ContactsScreen>
), ),
), ),
Expanded( Expanded(
child: filteredAndSorted.isEmpty child: RefreshIndicator(
? Center( onRefresh: () => connector.getContacts(),
child: Column( child: filteredAndSorted.isEmpty
mainAxisAlignment: MainAxisAlignment.center, ? LayoutBuilder(
children: [ builder: (context, constraints) => ListView(
Icon(Icons.search_off, size: 64, color: Colors.grey[400]), physics: const AlwaysScrollableScrollPhysics(),
const SizedBox(height: 16), children: [
Text( ConstrainedBox(
viewState.contactsShowUnreadOnly constraints: BoxConstraints(
? context.l10n.contacts_noUnreadContacts minHeight: constraints.maxHeight,
: context.l10n.contacts_noContactsFound, ),
style: TextStyle(fontSize: 16, color: Colors.grey[600]), child: EmptyState(
), icon: Icons.search_off,
], title: viewState.contactsShowUnreadOnly
), ? context.l10n.contacts_noUnreadContacts
) : context.l10n.contacts_noContactsFound,
: RefreshIndicator( ),
onRefresh: () => connector.getContacts(), ),
child: ListView.builder( ],
),
)
: ListView.builder(
padding: const EdgeInsets.only(bottom: 88),
itemCount: filteredAndSorted.length, itemCount: filteredAndSorted.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final contact = filteredAndSorted[index]; final contact = filteredAndSorted[index];
final unreadCount = connector.getUnreadCountForContact( final unreadCount = connector.getUnreadCountForContact(
contact, contact,
); );
return _ContactTile( return _ContactTileEntrance(
index: index,
contact: contact, contact: contact,
lastSeen: _resolveLastSeen(contact), lastSeen: _resolveLastSeen(contact),
unreadCount: unreadCount, unreadCount: unreadCount,
@@ -827,7 +924,7 @@ class _ContactsScreenState extends State<ContactsScreen>
); );
}, },
), ),
), ),
), ),
], ],
); );
@@ -1048,7 +1145,7 @@ class _ContactsScreenState extends State<ContactsScreen>
}, },
child: Text( child: Text(
context.l10n.common_delete, context.l10n.common_delete,
style: const TextStyle(color: Colors.red), style: TextStyle(color: Theme.of(context).colorScheme.error),
), ),
), ),
], ],
@@ -1250,17 +1347,22 @@ class _ContactsScreenState extends State<ContactsScreen>
final isRoom = contact.type == advTypeRoom; final isRoom = contact.type == advTypeRoom;
final isFavorite = contact.isFavorite; final isFavorite = contact.isFavorite;
showModalBottomSheet( showMeshSheet(
context: context, context,
builder: (sheetContext) => SafeArea( builder: (sheetContext) => SafeArea(
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
BottomSheetHeader(
title: contact.name,
subtitle: contact.typeLabel(context.l10n),
),
if (isRepeater) ...[ if (isRepeater) ...[
ListTile( ListTile(
leading: const Icon(Icons.radar, color: Colors.green), leading: Icon(Icons.radar, color: MeshPalette.signal),
title: Text(context.l10n.contacts_ping), title: Text(context.l10n.contacts_ping),
onTap: () { onTap: () {
Navigator.pop(sheetContext);
final hw = context final hw = context
.read<MeshCoreConnector>() .read<MeshCoreConnector>()
.pathHashByteWidth; .pathHashByteWidth;
@@ -1278,7 +1380,7 @@ class _ContactsScreenState extends State<ContactsScreen>
}, },
), ),
ListTile( ListTile(
leading: const Icon(Icons.cell_tower, color: Colors.orange), leading: Icon(Icons.cell_tower, color: MeshPalette.warn),
title: Text(context.l10n.contacts_manageRepeater), title: Text(context.l10n.contacts_manageRepeater),
onTap: () { onTap: () {
Navigator.pop(sheetContext); Navigator.pop(sheetContext);
@@ -1287,9 +1389,10 @@ class _ContactsScreenState extends State<ContactsScreen>
), ),
] else if (isRoom) ...[ ] else if (isRoom) ...[
ListTile( ListTile(
leading: const Icon(Icons.radar, color: Colors.green), leading: Icon(Icons.radar, color: MeshPalette.signal),
title: Text(context.l10n.contacts_pathTrace), title: Text(context.l10n.contacts_pathTrace),
onTap: () { onTap: () {
Navigator.pop(sheetContext);
final hw = context final hw = context
.read<MeshCoreConnector>() .read<MeshCoreConnector>()
.pathHashByteWidth; .pathHashByteWidth;
@@ -1312,7 +1415,7 @@ class _ContactsScreenState extends State<ContactsScreen>
}, },
), ),
ListTile( ListTile(
leading: const Icon(Icons.room, color: Colors.blue), leading: Icon(Icons.meeting_room, color: MeshPalette.blue),
title: Text(context.l10n.contacts_roomLogin), title: Text(context.l10n.contacts_roomLogin),
onTap: () { onTap: () {
Navigator.pop(sheetContext); Navigator.pop(sheetContext);
@@ -1320,10 +1423,7 @@ class _ContactsScreenState extends State<ContactsScreen>
}, },
), ),
ListTile( ListTile(
leading: const Icon( leading: Icon(Icons.room_preferences, color: MeshPalette.warn),
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);
@@ -1337,9 +1437,10 @@ class _ContactsScreenState extends State<ContactsScreen>
] else ...[ ] else ...[
if (contact.pathLength > 0) if (contact.pathLength > 0)
ListTile( ListTile(
leading: const Icon(Icons.radar, color: Colors.green), leading: Icon(Icons.radar, color: MeshPalette.signal),
title: Text(context.l10n.contacts_chatTraceRoute), title: Text(context.l10n.contacts_chatTraceRoute),
onTap: () { onTap: () {
Navigator.pop(sheetContext);
final hw = context final hw = context
.read<MeshCoreConnector>() .read<MeshCoreConnector>()
.pathHashByteWidth; .pathHashByteWidth;
@@ -1359,19 +1460,11 @@ class _ContactsScreenState extends State<ContactsScreen>
); );
}, },
), ),
ListTile(
leading: const Icon(Icons.chat),
title: Text(context.l10n.contacts_openChat),
onTap: () {
Navigator.pop(sheetContext);
_openChat(context, contact);
},
),
], ],
ListTile( ListTile(
leading: Icon( leading: Icon(
isFavorite ? Icons.star : Icons.star_border, isFavorite ? Icons.star : Icons.star_border,
color: Colors.amber[700], color: MeshPalette.warn,
), ),
title: Text( title: Text(
isFavorite isFavorite
@@ -1403,16 +1496,20 @@ class _ContactsScreenState extends State<ContactsScreen>
}, },
), ),
ListTile( ListTile(
leading: const Icon(Icons.delete, color: Colors.red), leading: Icon(
Icons.delete,
color: Theme.of(context).colorScheme.error,
),
title: Text( title: Text(
context.l10n.contacts_deleteContact, context.l10n.contacts_deleteContact,
style: const TextStyle(color: Colors.red), style: TextStyle(color: Theme.of(context).colorScheme.error),
), ),
onTap: () { onTap: () {
Navigator.pop(sheetContext); Navigator.pop(sheetContext);
_confirmDelete(context, connector, contact); _confirmDelete(context, connector, contact);
}, },
), ),
const SizedBox(height: 8),
], ],
), ),
), ),
@@ -1441,7 +1538,7 @@ class _ContactsScreenState extends State<ContactsScreen>
}, },
child: Text( child: Text(
context.l10n.common_delete, context.l10n.common_delete,
style: const TextStyle(color: Colors.red), style: TextStyle(color: Theme.of(context).colorScheme.error),
), ),
), ),
], ],
@@ -1467,118 +1564,176 @@ class _ContactTile extends StatelessWidget {
required this.onLongPress, required this.onLongPress,
}); });
@override /// Node-type avatar color per design language.
Widget build(BuildContext context) { Color _avatarColor() {
return GestureDetector( switch (contact.type) {
onSecondaryTapUp: PlatformInfo.isDesktop ? (_) => onLongPress() : null, case advTypeRepeater:
child: ListTile( return MeshPalette.warn;
leading: CircleAvatar( case advTypeRoom:
backgroundColor: _getTypeColor(contact.type), return MeshPalette.magenta;
child: _buildContactAvatar(contact), case advTypeSensor:
), return const Color(0xFF4ACCC4); // teal
title: Text(contact.name, maxLines: 1, overflow: TextOverflow.ellipsis), default:
subtitle: Column( return MeshPalette
crossAxisAlignment: CrossAxisAlignment.start, .blue; // chat AvatarCircle handles deterministic hue
children: [
Text(
contact.pathLabel(context.l10n),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
Text(
contact.shortPubKeyHex,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 12),
),
],
),
// Clamp text scaling in trailing section to prevent overflow while
// maintaining accessibility. Primary content (title/subtitle) scales normally.
trailing: MediaQuery(
data: MediaQuery.of(context).copyWith(
textScaler: TextScaler.linear(
MediaQuery.textScalerOf(context).scale(1.0).clamp(1.0, 1.3),
),
),
child: SizedBox(
width: 120,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
if (unreadCount > 0) ...[
UnreadBadge(count: unreadCount),
const SizedBox(height: 4),
],
Text(
_formatLastSeen(context, lastSeen),
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.right,
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
if (isFavorite)
Icon(Icons.star, size: 14, color: Colors.amber[700]),
if (isFavorite && contact.hasLocation)
const SizedBox(width: 2),
if (contact.hasLocation)
Icon(
Icons.location_on,
size: 14,
color: Colors.grey[400],
),
],
),
],
),
),
),
onTap: onTap,
onLongPress: onLongPress,
),
);
}
Widget _buildContactAvatar(Contact contact) {
final emoji = firstEmoji(contact.name);
if (emoji != null) {
return Text(emoji, style: const TextStyle(fontSize: 18));
} }
return Icon(_getTypeIcon(contact.type), color: Colors.white, size: 20);
} }
IconData _getTypeIcon(int type) { /// Node-type avatar icon. Returns null for chat nodes so AvatarCircle shows initials.
switch (type) { IconData? _avatarIcon() {
case advTypeChat: switch (contact.type) {
return Icons.chat;
case advTypeRepeater: case advTypeRepeater:
return Icons.cell_tower; return Icons.cell_tower;
case advTypeRoom: case advTypeRoom:
return Icons.group; return Icons.meeting_room;
case advTypeSensor: case advTypeSensor:
return Icons.sensors; return Icons.sensors;
default: default:
return Icons.device_unknown; return null; // chat uses initials
} }
} }
Color _getTypeColor(int type) { @override
switch (type) { Widget build(BuildContext context) {
case advTypeChat: final scheme = Theme.of(context).colorScheme;
return Colors.blue; final emoji = firstEmoji(contact.name);
case advTypeRepeater: final isChat = contact.type == advTypeChat;
return Colors.orange; final pathLen = contact.pathBytesForDisplay.length;
case advTypeRoom: final isDirect = contact.pathLength >= 0;
return Colors.purple; final hasPath = pathLen > 0 || contact.pathLength == 0;
case advTypeSensor:
return Colors.green; return GestureDetector(
default: onSecondaryTapUp: PlatformInfo.isDesktop ? (_) => onLongPress() : null,
return Colors.grey; child: MeshCard(
} onTap: onTap,
onLongPress: onLongPress,
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
child: Row(
children: [
// Avatar
if (emoji != null)
Container(
width: 42,
height: 42,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: scheme.surfaceContainerHigh,
border: Border.all(color: scheme.outlineVariant),
),
alignment: Alignment.center,
child: Text(emoji, style: const TextStyle(fontSize: 20)),
)
else
AvatarCircle(
name: contact.name,
size: 42,
color: isChat ? null : _avatarColor(),
icon: _avatarIcon(),
),
const SizedBox(width: 12),
// Main content
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// Name row + route chip
Row(
children: [
Expanded(
child: Text(
contact.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontWeight: unreadCount > 0
? FontWeight.w700
: FontWeight.w500,
fontSize: 15,
color: scheme.onSurface,
),
),
),
if (isFavorite) ...[
const SizedBox(width: 4),
Icon(Icons.star, size: 13, color: MeshPalette.warn),
],
if (contact.hasLocation) ...[
const SizedBox(width: 4),
Icon(
Icons.location_on,
size: 13,
color: scheme.onSurfaceVariant.withValues(
alpha: 0.55,
),
),
],
],
),
const SizedBox(height: 3),
// Path / subtitle row
Row(
children: [
Expanded(
child: Text(
contact.pathLabel(context.l10n),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 12,
color: scheme.onSurfaceVariant,
),
),
),
if (hasPath) ...[
const SizedBox(width: 6),
RouteChip(
isDirect: isDirect,
hops: isDirect ? contact.pathLength : null,
),
],
],
),
],
),
),
const SizedBox(width: 10),
// Trailing: time + unread badge
// Clamp text scale to prevent overflow in trailing section.
MediaQuery(
data: MediaQuery.of(context).copyWith(
textScaler: TextScaler.linear(
MediaQuery.textScalerOf(context).scale(1.0).clamp(1.0, 1.3),
),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisSize: MainAxisSize.min,
children: [
if (unreadCount > 0) ...[
UnreadBadge(count: unreadCount),
const SizedBox(height: 4),
],
Text(
_formatLastSeen(context, lastSeen),
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.right,
style: MeshTheme.mono(
fontSize: 11,
color: unreadCount > 0
? MeshPalette.blue
: scheme.onSurfaceVariant,
),
),
],
),
),
],
),
),
);
} }
String _formatLastSeen(BuildContext context, DateTime lastSeen) { String _formatLastSeen(BuildContext context, DateTime lastSeen) {
@@ -1603,3 +1758,39 @@ class _ContactTile extends StatelessWidget {
: context.l10n.contacts_lastSeenDaysAgo(days); : context.l10n.contacts_lastSeenDaysAgo(days);
} }
} }
// Wrap each contact tile with staggered entrance.
class _ContactTileEntrance extends StatelessWidget {
final int index;
final Contact contact;
final DateTime lastSeen;
final int unreadCount;
final bool isFavorite;
final VoidCallback onTap;
final VoidCallback onLongPress;
const _ContactTileEntrance({
required this.index,
required this.contact,
required this.lastSeen,
required this.unreadCount,
required this.isFavorite,
required this.onTap,
required this.onLongPress,
});
@override
Widget build(BuildContext context) {
return ListEntrance(
index: index,
child: _ContactTile(
contact: contact,
lastSeen: lastSeen,
unreadCount: unreadCount,
isFavorite: isFavorite,
onTap: onTap,
onLongPress: onLongPress,
),
);
}
}
+210 -128
View File
@@ -7,11 +7,14 @@ import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart'; import '../connector/meshcore_connector.dart';
import '../connector/meshcore_protocol.dart'; import '../connector/meshcore_protocol.dart';
import '../l10n/l10n.dart'; import '../l10n/l10n.dart';
import '../l10n/contact_localization.dart';
import '../models/contact.dart'; import '../models/contact.dart';
import '../theme/mesh_theme.dart';
import '../utils/contact_search.dart'; import '../utils/contact_search.dart';
import '../utils/platform_info.dart'; import '../utils/platform_info.dart';
import '../widgets/app_bar.dart'; import '../widgets/app_bar.dart';
import '../widgets/list_filter_widget.dart'; import '../widgets/list_filter_widget.dart';
import '../widgets/mesh_ui.dart';
import '../helpers/snack_bar_builder.dart'; import '../helpers/snack_bar_builder.dart';
enum DiscoverySortOption { lastSeen, name, type } enum DiscoverySortOption { lastSeen, name, type }
@@ -46,6 +49,34 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
: contact.lastSeen; : contact.lastSeen;
} }
/// Node-type avatar color per design language.
Color _avatarColor(int type) {
switch (type) {
case advTypeRepeater:
return MeshPalette.warn;
case advTypeRoom:
return MeshPalette.magenta;
case advTypeSensor:
return const Color(0xFF4ACCC4); // teal
default:
return MeshPalette.blue;
}
}
/// Node-type avatar icon; null = show initials for chat nodes.
IconData? _avatarIcon(int type) {
switch (type) {
case advTypeRepeater:
return Icons.cell_tower;
case advTypeRoom:
return Icons.meeting_room;
case advTypeSensor:
return Icons.sensors;
default:
return null;
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = context.l10n; final l10n = context.l10n;
@@ -71,7 +102,10 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
PopupMenuItem( PopupMenuItem(
child: Row( child: Row(
children: [ children: [
const Icon(Icons.delete, color: Colors.red), Icon(
Icons.delete,
color: Theme.of(context).colorScheme.error,
),
const SizedBox(width: 8), const SizedBox(width: 8),
Text(context.l10n.discoveredContacts_deleteContactAll), Text(context.l10n.discoveredContacts_deleteContactAll),
], ],
@@ -89,103 +123,185 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
children: [ children: [
_buildFilters(filteredAndSorted, connector), _buildFilters(filteredAndSorted, connector),
Expanded( Expanded(
child: discoveredContacts.isEmpty child: AnimatedSwitcher(
? Center(child: Text(l10n.contacts_noContacts)) duration: const Duration(milliseconds: 220),
: filteredAndSorted.isEmpty child: discoveredContacts.isEmpty
? Center(child: Text(l10n.discoveredContacts_noMatching)) ? Center(
: ListView.builder( key: const ValueKey('empty_all'),
itemCount: filteredAndSorted.length, child: Text(l10n.contacts_noContacts),
itemBuilder: (context, index) { )
final contact = filteredAndSorted[index]; : filteredAndSorted.isEmpty
final tile = ListTile( ? Center(
leading: CircleAvatar( key: const ValueKey('empty_filtered'),
backgroundColor: _getTypeColor(contact.type), child: Text(l10n.discoveredContacts_noMatching),
child: Icon( )
_getTypeIcon(contact.type), : ListView.builder(
color: Colors.white, key: const ValueKey('list'),
size: 20, padding: const EdgeInsets.only(bottom: 24),
), itemCount: filteredAndSorted.length,
), itemBuilder: (context, index) {
title: Text( final contact = filteredAndSorted[index];
final tile = _buildDiscoveryTile(
context,
contact,
connector,
index,
);
if (PlatformInfo.isDesktop) {
return GestureDetector(
onSecondaryTapUp: (_) =>
_showContactContextMenu(contact, connector),
child: tile,
);
}
return tile;
},
),
),
),
],
),
);
}
Widget _buildDiscoveryTile(
BuildContext context,
Contact contact,
MeshCoreConnector connector,
int index,
) {
final scheme = Theme.of(context).colorScheme;
final isChat = contact.type == advTypeChat;
return ListEntrance(
index: index,
child: MeshCard(
onTap: () async {
try {
final imported = await connector.importDiscoveredContact(contact);
if (!context.mounted) return;
if (!imported) {
showDismissibleSnackBar(
context,
content: Text(context.l10n.contacts_contactImportFailed),
);
return;
}
showDismissibleSnackBar(
context,
content: Text(context.l10n.discoveredContacts_contactAdded),
action: SnackBarAction(
label: context.l10n.common_undo,
onPressed: () => connector.removeContact(contact),
),
);
} catch (_) {
if (!context.mounted) return;
showDismissibleSnackBar(
context,
content: Text(context.l10n.contacts_contactImportFailed),
);
}
},
onLongPress: () => _showContactContextMenu(contact, connector),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
child: Row(
children: [
AvatarCircle(
name: contact.name,
size: 42,
color: isChat ? null : _avatarColor(contact.type),
icon: _avatarIcon(contact.type),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// Name + type chip
Row(
children: [
Expanded(
child: Text(
contact.name, contact.name,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontWeight: FontWeight.w500,
fontSize: 15,
),
), ),
subtitle: Text( ),
const SizedBox(width: 6),
StatusChip(
label: contact.typeLabel(context.l10n).toUpperCase(),
color: _avatarColor(contact.type),
icon: _avatarIcon(contact.type),
),
],
),
const SizedBox(height: 3),
// Short pub key
Row(
children: [
Expanded(
child: Text(
contact.shortPubKeyHex, contact.shortPubKeyHex,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), style: MeshTheme.mono(
// Clamp text scaling in trailing section to prevent overflow while fontSize: 11,
// maintaining accessibility. Primary content (title/subtitle) scales normally. color: scheme.onSurfaceVariant,
trailing: MediaQuery(
data: MediaQuery.of(context).copyWith(
textScaler: TextScaler.linear(
MediaQuery.textScalerOf(
context,
).scale(1.0).clamp(1.0, 1.3),
),
),
child: SizedBox(
width: 120,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
_formatLastSeen(
context,
_resolveLastSeen(contact),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.right,
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
if (contact.hasLocation)
Icon(
Icons.location_on,
size: 14,
color: Colors.grey[400],
),
if (contact.rawPacket != null)
const SizedBox(width: 2),
if (contact.rawPacket != null)
Icon(
Icons.cell_tower,
size: 14,
color: Colors.grey[400],
),
],
),
],
),
), ),
), ),
onTap: () { ),
connector.importDiscoveredContact(contact); if (contact.hasLocation) ...[
}, const SizedBox(width: 6),
onLongPress: () => Icon(
_showContactContextMenu(contact, connector), Icons.location_on,
); size: 13,
if (PlatformInfo.isDesktop) { color: scheme.onSurfaceVariant.withValues(
return GestureDetector( alpha: 0.55,
onSecondaryTapUp: (_) => ),
_showContactContextMenu(contact, connector), ),
child: tile, ],
); if (contact.rawPacket != null) ...[
} const SizedBox(width: 4),
return tile; Icon(
}, Icons.cell_tower,
size: 13,
color: scheme.onSurfaceVariant.withValues(
alpha: 0.55,
),
),
],
],
), ),
), ],
], ),
),
const SizedBox(width: 10),
// Last seen time
MediaQuery(
data: MediaQuery.of(context).copyWith(
textScaler: TextScaler.linear(
MediaQuery.textScalerOf(context).scale(1.0).clamp(1.0, 1.3),
),
),
child: Text(
_formatLastSeen(context, _resolveLastSeen(contact)),
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.right,
style: MeshTheme.mono(
fontSize: 11,
color: scheme.onSurfaceVariant,
),
),
),
],
),
), ),
); );
} }
@@ -194,19 +310,17 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
Contact contact, Contact contact,
MeshCoreConnector connector, MeshCoreConnector connector,
) async { ) async {
final action = await showModalBottomSheet<String>( final action = await showMeshSheet<String>(
context: context, context,
showDragHandle: true,
builder: (sheetContext) { builder: (sheetContext) {
final l10n = context.l10n; final l10n = context.l10n;
return SafeArea( return SafeArea(
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
ListTile( BottomSheetHeader(
leading: const Icon(Icons.add_reaction_sharp), title: contact.name,
title: Text(l10n.discoveredContacts_addContact), subtitle: contact.typeLabel(l10n),
onTap: () => Navigator.of(sheetContext).pop('import_contact'),
), ),
ListTile( ListTile(
leading: const Icon(Icons.copy), leading: const Icon(Icons.copy),
@@ -218,6 +332,7 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
title: Text(l10n.discoveredContacts_deleteContact), title: Text(l10n.discoveredContacts_deleteContact),
onTap: () => Navigator.of(sheetContext).pop('delete_contact'), onTap: () => Navigator.of(sheetContext).pop('delete_contact'),
), ),
const SizedBox(height: 8),
], ],
), ),
); );
@@ -227,9 +342,6 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
if (!mounted || action == null) return; if (!mounted || action == null) return;
switch (action) { switch (action) {
case 'import_contact':
connector.importDiscoveredContact(contact);
break;
case 'copy_contact': case 'copy_contact':
if (contact.rawPacket == null) return; if (contact.rawPacket == null) return;
final hexString = pubKeyToHex(contact.rawPacket!); final hexString = pubKeyToHex(contact.rawPacket!);
@@ -429,36 +541,6 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
} }
} }
IconData _getTypeIcon(int type) {
switch (type) {
case advTypeChat:
return Icons.chat;
case advTypeRepeater:
return Icons.cell_tower;
case advTypeRoom:
return Icons.group;
case advTypeSensor:
return Icons.sensors;
default:
return Icons.device_unknown;
}
}
Color _getTypeColor(int type) {
switch (type) {
case advTypeChat:
return Colors.blue;
case advTypeRepeater:
return Colors.orange;
case advTypeRoom:
return Colors.purple;
case advTypeSensor:
return Colors.green;
default:
return Colors.grey;
}
}
String _formatLastSeen(BuildContext context, DateTime lastSeen) { String _formatLastSeen(BuildContext context, DateTime lastSeen) {
final now = DateTime.now(); final now = DateTime.now();
final diff = now.difference(lastSeen); final diff = now.difference(lastSeen);
File diff suppressed because it is too large Load Diff
+204 -159
View File
@@ -10,6 +10,8 @@ import '../services/app_settings_service.dart';
import '../services/map_tile_cache_service.dart'; import '../services/map_tile_cache_service.dart';
import '../widgets/adaptive_app_bar_title.dart'; import '../widgets/adaptive_app_bar_title.dart';
import '../helpers/snack_bar_builder.dart'; import '../helpers/snack_bar_builder.dart';
import '../theme/mesh_theme.dart';
import '../widgets/mesh_ui.dart';
class MapCacheScreen extends StatefulWidget { class MapCacheScreen extends StatefulWidget {
const MapCacheScreen({super.key}); const MapCacheScreen({super.key});
@@ -81,27 +83,34 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
return Positioned( return Positioned(
top: 12, top: 12,
left: 12, left: 12,
child: Card( child: DecoratedBox(
elevation: 4, decoration: BoxDecoration(
child: Column( color: MeshPalette.bg1.withValues(alpha: 0.90),
mainAxisSize: MainAxisSize.min, borderRadius: BorderRadius.circular(MeshRadii.md),
children: [ border: Border.all(color: MeshPalette.line2),
IconButton( ),
icon: const Icon(Icons.add), child: ClipRRect(
tooltip: 'Zoom in', borderRadius: BorderRadius.circular(MeshRadii.md),
onPressed: () => _zoomMapBy(1), child: Column(
), mainAxisSize: MainAxisSize.min,
IconButton( children: [
icon: const Icon(Icons.remove), IconButton(
tooltip: 'Zoom out', icon: const Icon(Icons.add),
onPressed: () => _zoomMapBy(-1), tooltip: context.l10n.map_zoomIn,
), onPressed: () => _zoomMapBy(1),
IconButton( ),
icon: const Icon(Icons.my_location), IconButton(
tooltip: 'Center map', icon: const Icon(Icons.remove),
onPressed: _resetMapView, tooltip: context.l10n.map_zoomOut,
), onPressed: () => _zoomMapBy(-1),
], ),
IconButton(
icon: const Icon(Icons.my_location),
tooltip: context.l10n.map_centerMap,
onPressed: _resetMapView,
),
],
),
), ),
), ),
); );
@@ -199,7 +208,9 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
showDismissibleSnackBar( showDismissibleSnackBar(
context, context,
content: Text( content: Text(
'Offline bulk downloads are disabled for ${cacheService.source.label} in this app configuration.', context.l10n.mapCache_bulkDownloadDisabledInConfig(
cacheService.source.label,
),
), ),
); );
return; return;
@@ -339,11 +350,12 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
visibleBounds: _visibleBounds, visibleBounds: _visibleBounds,
); );
final cacheSummary = final cacheSummary =
'Source: ${source.label}\n' '${context.l10n.mapCache_summarySource(source.label)}\n'
'Cached tiles for source: ${activeCachedTiles.length}\n' '${context.l10n.mapCache_summaryCachedTilesForSource(activeCachedTiles.length)}\n'
'Cached in selected area/zoom: $cachedInSelection\n' '${context.l10n.mapCache_summaryCachedInSelection(cachedInSelection)}\n'
'Approx cache size: ${_formatBytes(_cachedTileBytes)}'; '${context.l10n.mapCache_summaryApproxCacheSize(_formatBytes(_cachedTileBytes))}';
final l10n = context.l10n; final l10n = context.l10n;
final scheme = Theme.of(context).colorScheme;
final isDesktop = _isDesktopPlatform(defaultTargetPlatform); final isDesktop = _isDesktopPlatform(defaultTargetPlatform);
final progressValue = _estimatedTiles == 0 final progressValue = _estimatedTiles == 0
? 0.0 ? 0.0
@@ -395,14 +407,7 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
}, },
), ),
children: [ children: [
TileLayer( tileCache.buildTileLayer(context),
urlTemplate: tileCache.urlTemplate,
tileProvider: tileCache.tileProvider,
tileBuilder: tileCache.tileBuilder,
userAgentPackageName:
MapTileCacheService.userAgentPackageName,
maxZoom: 19,
),
if (selectedBounds != null) if (selectedBounds != null)
PolygonLayer( PolygonLayer(
polygons: [ polygons: [
@@ -435,14 +440,25 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
Positioned( Positioned(
top: 12, top: 12,
right: 12, right: 12,
child: Card( child: DecoratedBox(
decoration: BoxDecoration(
color: MeshPalette.bg1.withValues(alpha: 0.93),
borderRadius: BorderRadius.circular(MeshRadii.md),
border: Border.all(color: MeshPalette.line2),
),
child: Padding( child: Padding(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 6,
),
child: Text( child: Text(
selectedBounds == null selectedBounds == null
? l10n.mapCache_noAreaSelected ? l10n.mapCache_noAreaSelected
: _formatBounds(selectedBounds, l10n), : _formatBounds(selectedBounds, l10n),
style: const TextStyle(fontSize: 12), style: MeshTheme.mono(
fontSize: 11,
color: MeshPalette.ink2,
),
), ),
), ),
), ),
@@ -452,135 +468,164 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
), ),
SafeArea( SafeArea(
top: false, top: false,
child: Padding( child: DecoratedBox(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16), decoration: BoxDecoration(
child: Column( color: scheme.surfaceContainerLow,
crossAxisAlignment: CrossAxisAlignment.start, border: Border(top: BorderSide(color: scheme.outlineVariant)),
mainAxisSize: MainAxisSize.min, ),
children: [ child: Padding(
Text( padding: const EdgeInsets.fromLTRB(16, 4, 16, 16),
l10n.mapCache_cacheArea, child: Column(
style: const TextStyle( crossAxisAlignment: CrossAxisAlignment.start,
fontWeight: FontWeight.bold, mainAxisSize: MainAxisSize.min,
fontSize: 16, children: [
SectionHeader(
l10n.mapCache_cacheArea,
padding: const EdgeInsets.fromLTRB(0, 12, 0, 8),
), ),
), Row(
const SizedBox(height: 8), children: [
Row( Expanded(
children: [ child: ElevatedButton.icon(
Expanded( icon: const Icon(Icons.crop_free),
child: ElevatedButton.icon( label: Text(l10n.mapCache_useCurrentView),
icon: const Icon(Icons.crop_free), onPressed: _isDownloading
label: Text(l10n.mapCache_useCurrentView), ? null
onPressed: _isDownloading ? null : _setBoundsFromView, : _setBoundsFromView,
),
), ),
), const SizedBox(width: 12),
const SizedBox(width: 12), TextButton(
TextButton( onPressed: _isDownloading || selectedBounds == null
onPressed: _isDownloading || selectedBounds == null
? null
: _clearBounds,
child: Text(l10n.common_clear),
),
],
),
const SizedBox(height: 12),
Text(
l10n.mapCache_zoomRange,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
RangeSlider(
values: RangeValues(
_minZoom.toDouble(),
_maxZoom.toDouble(),
),
min: 3,
max: 18,
divisions: 15,
labels: RangeLabels('$_minZoom', '$_maxZoom'),
onChanged: _isDownloading
? null
: (values) {
setState(() {
_minZoom = values.start.round();
_maxZoom = values.end.round();
});
},
onChangeEnd: _isDownloading
? null
: (_) {
_saveZoomRange();
},
),
Text(l10n.mapCache_estimatedTiles(_estimatedTiles)),
const SizedBox(height: 12),
TextFormField(
key: ValueKey(
'$cacheSummary|${activeCachedTiles.length}|$cachedInSelection|$_cachedTileBytes',
),
initialValue: cacheSummary,
readOnly: true,
minLines: 4,
maxLines: 4,
decoration: InputDecoration(
labelText: _isLoadingCachedTiles
? 'Cached tiles'
: 'Cached tile summary',
border: const OutlineInputBorder(),
),
),
if (!source.allowsBulkDownload) ...[
const SizedBox(height: 8),
Text(
'Offline bulk downloads are disabled for ${source.label}.',
style: TextStyle(color: Colors.orange[800]),
),
],
if (_isDownloading) ...[
const SizedBox(height: 8),
LinearProgressIndicator(value: progressValue),
const SizedBox(height: 4),
Text(
l10n.mapCache_downloadedTiles(
_completedTiles,
_estimatedTiles,
),
),
],
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: ElevatedButton.icon(
icon: const Icon(Icons.download),
label: Text(l10n.mapCache_downloadTilesButton),
onPressed:
_isDownloading ||
selectedBounds == null ||
!source.allowsBulkDownload
? null ? null
: _startDownload, : _clearBounds,
child: Text(l10n.common_clear),
), ),
],
),
const SizedBox(height: 12),
SectionHeader(
l10n.mapCache_zoomRange,
padding: const EdgeInsets.fromLTRB(0, 8, 0, 0),
),
RangeSlider(
values: RangeValues(
_minZoom.toDouble(),
_maxZoom.toDouble(),
), ),
const SizedBox(width: 12), min: 3,
OutlinedButton( max: 18,
onPressed: _isDownloading ? null : _clearCache, divisions: 15,
child: Text(l10n.mapCache_clearCacheButton), labels: RangeLabels('$_minZoom', '$_maxZoom'),
), onChanged: _isDownloading
], ? null
), : (values) {
if (_failedTiles > 0 && !_isDownloading) setState(() {
Padding( _minZoom = values.start.round();
padding: const EdgeInsets.only(top: 8), _maxZoom = values.end.round();
child: Text( });
l10n.mapCache_failedDownloads(_failedTiles), },
style: TextStyle(color: Colors.orange[700]), onChangeEnd: _isDownloading
? null
: (_) {
_saveZoomRange();
},
),
Text(
l10n.mapCache_estimatedTiles(_estimatedTiles),
style: MeshTheme.mono(
fontSize: 12,
color: scheme.onSurfaceVariant,
), ),
), ),
], const SizedBox(height: 12),
TextFormField(
key: ValueKey(
'$cacheSummary|${activeCachedTiles.length}|$cachedInSelection|$_cachedTileBytes',
),
initialValue: cacheSummary,
readOnly: true,
minLines: 4,
maxLines: 4,
decoration: InputDecoration(
labelText: _isLoadingCachedTiles
? l10n.mapCache_cachedTilesLabel
: l10n.mapCache_cachedTileSummaryLabel,
border: const OutlineInputBorder(),
),
),
if (!source.allowsBulkDownload) ...[
const SizedBox(height: 8),
Text(
l10n.mapCache_bulkDownloadDisabledForSource(
source.label,
),
style: MeshTheme.mono(
fontSize: 12,
color: MeshPalette.alert,
),
),
],
if (_isDownloading) ...[
const SizedBox(height: 8),
LinearProgressIndicator(
value: progressValue,
color: MeshPalette.blue,
backgroundColor: scheme.surfaceContainerHighest,
),
const SizedBox(height: 4),
Text(
l10n.mapCache_downloadedTiles(
_completedTiles,
_estimatedTiles,
),
style: MeshTheme.mono(
fontSize: 12,
color: scheme.onSurfaceVariant,
),
),
],
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: ElevatedButton.icon(
icon: const Icon(Icons.download),
label: Text(l10n.mapCache_downloadTilesButton),
onPressed:
_isDownloading ||
selectedBounds == null ||
!source.allowsBulkDownload
? null
: _startDownload,
),
),
const SizedBox(width: 12),
OutlinedButton(
style: OutlinedButton.styleFrom(
foregroundColor: MeshPalette.alert,
side: const BorderSide(
color: MeshPalette.alertLine,
),
),
onPressed: _isDownloading ? null : _clearCache,
child: Text(l10n.mapCache_clearCacheButton),
),
],
),
if (_failedTiles > 0 && !_isDownloading)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
l10n.mapCache_failedDownloads(_failedTiles),
style: MeshTheme.mono(
fontSize: 12,
color: MeshPalette.alert,
),
),
),
],
),
), ),
), ),
), ),
+2212 -742
View File
File diff suppressed because it is too large Load Diff
+99 -143
View File
@@ -9,8 +9,10 @@ import '../models/path_selection.dart';
import '../connector/meshcore_connector.dart'; import '../connector/meshcore_connector.dart';
import '../connector/meshcore_protocol.dart'; import '../connector/meshcore_protocol.dart';
import '../services/repeater_command_service.dart'; import '../services/repeater_command_service.dart';
import '../widgets/path_management_dialog.dart'; import '../theme/mesh_theme.dart';
import '../widgets/snr_indicator.dart'; import '../widgets/empty_state.dart';
import '../widgets/mesh_ui.dart';
import '../widgets/routing_sheet.dart';
import '../helpers/snack_bar_builder.dart'; import '../helpers/snack_bar_builder.dart';
class NeighborsScreen extends StatefulWidget { class NeighborsScreen extends StatefulWidget {
@@ -167,7 +169,7 @@ class _NeighborsScreenState extends State<NeighborsScreen> {
showDismissibleSnackBar( showDismissibleSnackBar(
context, context,
content: Text(context.l10n.neighbors_receivedData), content: Text(context.l10n.neighbors_receivedData),
backgroundColor: Colors.green, backgroundColor: Theme.of(context).colorScheme.tertiary,
); );
_statusTimeout?.cancel(); _statusTimeout?.cancel();
if (!mounted) return; if (!mounted) return;
@@ -227,7 +229,7 @@ class _NeighborsScreenState extends State<NeighborsScreen> {
showDismissibleSnackBar( showDismissibleSnackBar(
context, context,
content: Text(context.l10n.neighbors_requestTimedOut), content: Text(context.l10n.neighbors_requestTimedOut),
backgroundColor: Colors.red, backgroundColor: Theme.of(context).colorScheme.error,
); );
_recordStatusResult(false); _recordStatusResult(false);
}); });
@@ -241,7 +243,7 @@ class _NeighborsScreenState extends State<NeighborsScreen> {
showDismissibleSnackBar( showDismissibleSnackBar(
context, context,
content: Text(context.l10n.neighbors_errorLoading(e.toString())), content: Text(context.l10n.neighbors_errorLoading(e.toString())),
backgroundColor: Colors.red, backgroundColor: Theme.of(context).colorScheme.error,
); );
} }
} }
@@ -279,7 +281,9 @@ class _NeighborsScreenState extends State<NeighborsScreen> {
children: [ children: [
Text( Text(
l10n.neighbors_repeatersNeighbors, l10n.neighbors_repeatersNeighbors,
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
maxLines: 1,
overflow: TextOverflow.ellipsis,
), ),
Text( Text(
repeater.name, repeater.name,
@@ -287,75 +291,18 @@ class _NeighborsScreenState extends State<NeighborsScreen> {
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.normal, fontWeight: FontWeight.normal,
), ),
maxLines: 1,
overflow: TextOverflow.ellipsis,
), ),
], ],
), ),
centerTitle: false, centerTitle: false,
actions: [ actions: [
PopupMenuButton<String>( IconButton(
icon: Icon(isFloodMode ? Icons.waves : Icons.route), icon: Icon(isFloodMode ? Icons.waves : Icons.route),
tooltip: l10n.repeater_routingMode, tooltip: l10n.repeater_routingMode,
onSelected: (mode) async {
if (mode == 'flood') {
await connector.setPathOverride(repeater, pathLen: -1);
} else {
await connector.setPathOverride(repeater, pathLen: null);
}
},
itemBuilder: (context) => [
PopupMenuItem(
value: 'auto',
child: Row(
children: [
Icon(
Icons.auto_mode,
size: 20,
color: !isFloodMode
? Theme.of(context).primaryColor
: null,
),
const SizedBox(width: 8),
Text(
l10n.repeater_autoUseSavedPath,
style: TextStyle(
fontWeight: !isFloodMode
? FontWeight.bold
: FontWeight.normal,
),
),
],
),
),
PopupMenuItem(
value: 'flood',
child: Row(
children: [
Icon(
Icons.waves,
size: 20,
color: isFloodMode
? Theme.of(context).primaryColor
: null,
),
const SizedBox(width: 8),
Text(
l10n.repeater_forceFloodMode,
style: TextStyle(
fontWeight: isFloodMode
? FontWeight.bold
: FontWeight.normal,
),
),
],
),
),
],
),
IconButton(
icon: const Icon(Icons.timeline),
tooltip: l10n.repeater_pathManagement,
onPressed: () => onPressed: () =>
PathManagementDialog.show(context, contact: repeater), ContactRoutingSheet.show(context, contact: repeater),
), ),
IconButton( IconButton(
icon: _isLoading icon: _isLoading
@@ -375,23 +322,16 @@ class _NeighborsScreenState extends State<NeighborsScreen> {
child: RefreshIndicator( child: RefreshIndicator(
onRefresh: _loadNeighbors, onRefresh: _loadNeighbors,
child: ListView( child: ListView(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
children: [ children: [
if (!_isLoaded && if (!_isLoaded &&
!_hasData && !_hasData &&
(_parsedNeighbors == null || _parsedNeighbors!.isEmpty)) (_parsedNeighbors == null || _parsedNeighbors!.isEmpty))
Center( EmptyState(icon: Icons.wifi_find, title: l10n.neighbors_noData),
child: Text(
l10n.neighbors_noData,
style: TextStyle(fontSize: 16, color: Colors.grey),
),
),
if (_isLoaded || if (_isLoaded ||
_hasData && _hasData &&
!(_parsedNeighbors == null || _parsedNeighbors!.isEmpty)) !(_parsedNeighbors == null || _parsedNeighbors!.isEmpty))
_buildNeighborsInfoCard( _buildNeighborsList(connector),
"${l10n.repeater_neighbors} - $_neighborCount",
),
], ],
), ),
), ),
@@ -399,81 +339,97 @@ class _NeighborsScreenState extends State<NeighborsScreen> {
); );
} }
Widget _buildNeighborsInfoCard(String title) { Widget _buildNeighborsList(MeshCoreConnector connector) {
final connector = Provider.of<MeshCoreConnector>(context, listen: false); final l10n = context.l10n;
return Card( return Column(
child: Padding( crossAxisAlignment: CrossAxisAlignment.start,
padding: const EdgeInsets.all(16), children: [
child: Column( SectionHeader(
crossAxisAlignment: CrossAxisAlignment.start, '${l10n.repeater_neighbors}$_neighborCount',
children: [ padding: const EdgeInsets.fromLTRB(4, 8, 4, 10),
Row( ),
for (var i = 0; i < _parsedNeighbors!.length; i++)
ListEntrance(
index: i,
child: _buildNeighborRow(_parsedNeighbors![i], connector.currentSf),
),
],
);
}
Widget _buildNeighborRow(Map<String, dynamic> data, int? spreadingFactor) {
final l10n = context.l10n;
final scheme = Theme.of(context).colorScheme;
final Contact? contact = data['contact'] as Contact?;
final double snr = data['snr'] as double;
final int lastHeardSeconds = data['lastHeard'] as int;
final name = contact != null
? contact.name
: l10n.neighbors_unknownContact(
'<${pubKeyToHex(data['publicKey'] as Uint8List)}>',
);
final snrColor = MeshTheme.snrColor(snr, blocked: false);
final heardLabel = l10n.neighbors_heardAgo(
fmtDuration(lastHeardSeconds + 0.0),
);
return MeshCard(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
margin: const EdgeInsets.symmetric(vertical: 4),
child: Row(
children: [
AvatarCircle(
name: name,
size: 40,
color: contact != null ? MeshPalette.warn : scheme.onSurfaceVariant,
icon: contact != null ? Icons.cell_tower : Icons.device_unknown,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon(
Icons.info_outline,
color: Theme.of(context).textTheme.headlineSmall?.color,
),
const SizedBox(width: 8),
Text( Text(
title, name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle( style: const TextStyle(
fontSize: 18, fontWeight: FontWeight.w500,
fontWeight: FontWeight.bold, fontSize: 15,
),
),
const SizedBox(height: 2),
Text(
heardLabel,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 12,
color: Theme.of(context).colorScheme.onSurfaceVariant,
), ),
), ),
], ],
), ),
const Divider(), ),
for (final entry in _parsedNeighbors!.asMap().entries) const SizedBox(width: 10),
_buildInfoRow( Column(
entry.value['contact'] != null crossAxisAlignment: CrossAxisAlignment.end,
? entry.value['contact'].name mainAxisSize: MainAxisSize.min,
: context.l10n.neighbors_unknownContact( children: [
"<${pubKeyToHex(entry.value['publicKey'])}>", SignalBars(snr: snr, height: 16),
), const SizedBox(height: 4),
context.l10n.neighbors_heardAgo( Text(
fmtDuration(entry.value['lastHeard'] + 0.0), '${snr.toStringAsFixed(1)} dB',
style: MeshTheme.mono(
fontSize: 11,
fontWeight: FontWeight.w600,
color: snrColor,
), ),
entry.value['snr'],
connector.currentSf!,
), ),
], ],
),
),
);
}
Widget _buildInfoRow(
String label,
String value,
double snr,
int spreadingFactor,
) {
final snrUi = snrUiFromSNR(snr, spreadingFactor);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 3),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: ListTile(
contentPadding: EdgeInsets.zero,
title: Text(
label,
style: const TextStyle(fontWeight: FontWeight.w500),
),
subtitle: Text(value),
trailing: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(snrUi.icon, color: snrUi.color, size: 18.0),
Text(
snrUi.text,
style: TextStyle(fontSize: 10, color: snrUi.color),
),
],
),
),
), ),
], ],
), ),
File diff suppressed because it is too large Load Diff
+259 -276
View File
@@ -1,14 +1,15 @@
import 'dart:async'; import 'dart:async';
import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../l10n/l10n.dart'; import '../l10n/l10n.dart';
import '../models/contact.dart'; import '../models/contact.dart';
import '../connector/meshcore_connector.dart'; import '../connector/meshcore_connector.dart';
import '../connector/meshcore_protocol.dart'; import '../connector/meshcore_protocol.dart';
import '../theme/mesh_theme.dart';
import '../widgets/debug_frame_viewer.dart'; import '../widgets/debug_frame_viewer.dart';
import '../services/repeater_command_service.dart'; import '../services/repeater_command_service.dart';
import '../widgets/path_management_dialog.dart'; import '../widgets/routing_sheet.dart';
import '../helpers/snack_bar_builder.dart'; import '../helpers/snack_bar_builder.dart';
class RepeaterCliScreen extends StatefulWidget { class RepeaterCliScreen extends StatefulWidget {
@@ -34,7 +35,6 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
StreamSubscription<Uint8List>? _frameSubscription; StreamSubscription<Uint8List>? _frameSubscription;
RepeaterCommandService? _commandService; RepeaterCommandService? _commandService;
// Common commands for quick access
late final List<Map<String, String>> _quickCommands = [ late final List<Map<String, String>> _quickCommands = [
{'labelKey': 'advertise', 'command': 'advert'}, {'labelKey': 'advertise', 'command': 'advert'},
{'labelKey': 'getName', 'command': 'get name'}, {'labelKey': 'getName', 'command': 'get name'},
@@ -67,12 +67,8 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
void _setupMessageListener() { void _setupMessageListener() {
final connector = Provider.of<MeshCoreConnector>(context, listen: false); final connector = Provider.of<MeshCoreConnector>(context, listen: false);
// Listen for incoming text messages from the repeater
_frameSubscription = connector.receivedFrames.listen((frame) { _frameSubscription = connector.receivedFrames.listen((frame) {
if (frame.isEmpty) return; if (frame.isEmpty) return;
// Check if it's a text message response
if (frame[0] == respCodeContactMsgRecv || if (frame[0] == respCodeContactMsgRecv ||
frame[0] == respCodeContactMsgRecvV3) { frame[0] == respCodeContactMsgRecvV3) {
_handleTextMessageResponse(frame); _handleTextMessageResponse(frame);
@@ -102,12 +98,7 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
final parsed = parseContactMessageText(frame); final parsed = parseContactMessageText(frame);
if (parsed == null) return; if (parsed == null) return;
if (!_matchesRepeaterPrefix(parsed.senderPrefix)) return; if (!_matchesRepeaterPrefix(parsed.senderPrefix)) return;
// Notify command service of response (for retry handling)
_commandService?.handleResponse(widget.repeater, parsed.text); _commandService?.handleResponse(widget.repeater, parsed.text);
// Note: The command service will handle the response via the Future
// We don't need to add it to history here anymore as _sendCommand will do it
} }
bool _matchesRepeaterPrefix(Uint8List prefix) { bool _matchesRepeaterPrefix(Uint8List prefix) {
@@ -131,7 +122,6 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
}); });
}); });
// Show debug info if requested
if (showDebug && mounted) { if (showDebug && mounted) {
final frame = buildSendCliCommandFrame( final frame = buildSendCliCommandFrame(
widget.repeater.publicKey, widget.repeater.publicKey,
@@ -144,7 +134,6 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
); );
} }
// Send CLI command to repeater with retry
try { try {
if (_commandService != null) { if (_commandService != null) {
final connector = Provider.of<MeshCoreConnector>( final connector = Provider.of<MeshCoreConnector>(
@@ -157,7 +146,6 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
command, command,
retries: 1, retries: 1,
); );
if (mounted) { if (mounted) {
setState(() { setState(() {
_commandHistory.add({ _commandHistory.add({
@@ -184,7 +172,6 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
_historyIndex = -1; _historyIndex = -1;
_commandFocusNode.requestFocus(); _commandFocusNode.requestFocus();
// Auto-scroll to bottom
Future.delayed(const Duration(milliseconds: 100), () { Future.delayed(const Duration(milliseconds: 100), () {
if (_scrollController.hasClients) { if (_scrollController.hasClients) {
_scrollController.animateTo( _scrollController.animateTo(
@@ -239,161 +226,6 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
}); });
} }
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final connector = context.watch<MeshCoreConnector>();
final repeater = _resolveRepeater(connector);
final isFloodMode = repeater.pathOverride == -1;
return Scaffold(
appBar: AppBar(
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(l10n.repeater_cliTitle),
Text(
repeater.name,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.normal,
),
),
],
),
centerTitle: false,
actions: [
PopupMenuButton<String>(
icon: Icon(isFloodMode ? Icons.waves : Icons.route),
tooltip: l10n.repeater_routingMode,
onSelected: (mode) async {
if (mode == 'flood') {
await connector.setPathOverride(repeater, pathLen: -1);
} else {
await connector.setPathOverride(repeater, pathLen: null);
}
},
itemBuilder: (context) => [
PopupMenuItem(
value: 'auto',
child: Row(
children: [
Icon(
Icons.auto_mode,
size: 20,
color: !isFloodMode
? Theme.of(context).primaryColor
: null,
),
const SizedBox(width: 8),
Text(
l10n.repeater_autoUseSavedPath,
style: TextStyle(
fontWeight: !isFloodMode
? FontWeight.bold
: FontWeight.normal,
),
),
],
),
),
PopupMenuItem(
value: 'flood',
child: Row(
children: [
Icon(
Icons.waves,
size: 20,
color: isFloodMode
? Theme.of(context).primaryColor
: null,
),
const SizedBox(width: 8),
Text(
l10n.repeater_forceFloodMode,
style: TextStyle(
fontWeight: isFloodMode
? FontWeight.bold
: FontWeight.normal,
),
),
],
),
),
],
),
IconButton(
icon: const Icon(Icons.timeline),
tooltip: l10n.repeater_pathManagement,
onPressed: () =>
PathManagementDialog.show(context, contact: repeater),
),
IconButton(
icon: const Icon(Icons.bug_report),
tooltip: l10n.repeater_debugNextCommand,
onPressed: () {
// Set a flag or just send next command with debug
if (_commandController.text.trim().isNotEmpty) {
_sendCommand(showDebug: true);
} else {
showDismissibleSnackBar(
context,
content: Text(l10n.repeater_enterCommandFirst),
);
}
},
),
IconButton(
icon: const Icon(Icons.help_outline),
tooltip: l10n.repeater_commandHelp,
onPressed: () => _showCommandHelp(context),
),
IconButton(
icon: const Icon(Icons.clear_all),
tooltip: l10n.repeater_clearHistory,
onPressed: _commandHistory.isEmpty ? null : _clearHistory,
),
],
),
body: Column(
children: [
_buildQuickCommandsBar(),
const Divider(height: 1),
Expanded(
child: _commandHistory.isEmpty
? _buildEmptyState()
: _buildCommandHistory(),
),
const Divider(height: 1),
_buildCommandInput(),
],
),
);
}
Widget _buildQuickCommandsBar() {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: _quickCommands.map((cmd) {
final label = _quickCommandLabel(cmd['labelKey']!);
return Padding(
padding: const EdgeInsets.only(right: 8),
child: ActionChip(
label: Text(label),
onPressed: () => _useQuickCommand(cmd['command']!),
avatar: const Icon(Icons.play_arrow, size: 16),
),
);
}).toList(),
),
),
);
}
String _quickCommandLabel(String key) { String _quickCommandLabel(String key) {
final l10n = context.l10n; final l10n = context.l10n;
switch (key) { switch (key) {
@@ -420,22 +252,234 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
} }
} }
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final scheme = Theme.of(context).colorScheme;
final connector = context.watch<MeshCoreConnector>();
final repeater = _resolveRepeater(connector);
final isFloodMode = repeater.pathOverride == -1;
return Scaffold(
backgroundColor: MeshPalette.bg,
appBar: AppBar(
backgroundColor: MeshPalette.bg1,
title: Text(l10n.repeater_cliTitle),
centerTitle: true,
actions: [
IconButton(
icon: Icon(isFloodMode ? Icons.waves : Icons.route),
tooltip: l10n.repeater_routingMode,
onPressed: () =>
ContactRoutingSheet.show(context, contact: repeater),
),
IconButton(
icon: const Icon(Icons.help_outline),
tooltip: l10n.repeater_commandHelp,
onPressed: () => _showCommandHelp(context),
),
IconButton(
icon: const Icon(Icons.clear_all),
tooltip: l10n.repeater_clearHistory,
onPressed: _commandHistory.isEmpty ? null : _clearHistory,
),
PopupMenuButton<String>(
icon: const Icon(Icons.more_vert),
onSelected: (value) {
if (value == 'debug') {
if (_commandController.text.trim().isNotEmpty) {
_sendCommand(showDebug: true);
} else {
showDismissibleSnackBar(
context,
content: Text(l10n.repeater_enterCommandFirst),
);
}
}
},
itemBuilder: (context) => [
PopupMenuItem(
value: 'debug',
child: Row(
children: [
const Icon(Icons.bug_report),
const SizedBox(width: 8),
Text(l10n.repeater_debugNextCommand),
],
),
),
],
),
],
),
body: Column(
children: [
// Quick commands bar
Container(
color: MeshPalette.bg1,
padding: const EdgeInsets.fromLTRB(8, 6, 8, 6),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: _quickCommands.map((cmd) {
final label = _quickCommandLabel(cmd['labelKey']!);
return Padding(
padding: const EdgeInsets.only(right: 6),
child: ActionChip(
label: Text(
label,
style: MeshTheme.mono(
fontSize: 11,
fontWeight: FontWeight.w600,
color: MeshPalette.blue,
),
),
backgroundColor: MeshPalette.blueBg,
side: const BorderSide(color: MeshPalette.blueLine),
visualDensity: VisualDensity.compact,
onPressed: () => _useQuickCommand(cmd['command']!),
),
);
}).toList(),
),
),
),
Divider(height: 1, color: MeshPalette.line),
// Output area
Expanded(
child: _commandHistory.isEmpty
? _buildEmptyState()
: _buildCommandHistory(),
),
Divider(height: 1, color: MeshPalette.line),
// Command input
Container(
color: MeshPalette.bg1,
padding: const EdgeInsets.fromLTRB(8, 8, 8, 8),
child: SafeArea(
child: Row(
children: [
IconButton(
icon: Icon(
Icons.arrow_upward,
size: 18,
color: scheme.onSurfaceVariant,
),
tooltip: l10n.repeater_previousCommand,
onPressed: () => _navigateHistory(true),
visualDensity: VisualDensity.compact,
),
IconButton(
icon: Icon(
Icons.arrow_downward,
size: 18,
color: scheme.onSurfaceVariant,
),
tooltip: l10n.repeater_nextCommand,
onPressed: () => _navigateHistory(false),
visualDensity: VisualDensity.compact,
),
const SizedBox(width: 4),
Expanded(
child: TextField(
controller: _commandController,
focusNode: _commandFocusNode,
style: MeshTheme.mono(
fontSize: 13,
color: MeshPalette.ink,
),
decoration: InputDecoration(
hintText: context.l10n.repeater_enterCommandHint,
hintStyle: MeshTheme.mono(
fontSize: 13,
color: MeshPalette.ink4,
),
prefixText: '> ',
prefixStyle: MeshTheme.mono(
fontSize: 13,
color: MeshPalette.blue,
fontWeight: FontWeight.w700,
),
filled: true,
fillColor: MeshPalette.bg2,
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(MeshRadii.pill),
borderSide: const BorderSide(
color: MeshPalette.line2,
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(MeshRadii.pill),
borderSide: const BorderSide(
color: MeshPalette.line2,
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(MeshRadii.pill),
borderSide: const BorderSide(
color: MeshPalette.blue,
width: 1.5,
),
),
),
textInputAction: TextInputAction.send,
onSubmitted: (_) => _sendCommand(),
),
),
const SizedBox(width: 6),
Material(
color: MeshPalette.blue.withValues(alpha: 0.15),
shape: const CircleBorder(
side: BorderSide(color: MeshPalette.blueLine),
),
child: InkWell(
customBorder: const CircleBorder(),
onTap: () {
HapticFeedback.lightImpact();
_sendCommand();
},
child: const Padding(
padding: EdgeInsets.all(10),
child: Icon(
Icons.send,
size: 18,
color: MeshPalette.blue,
),
),
),
),
],
),
),
),
],
),
);
}
Widget _buildEmptyState() { Widget _buildEmptyState() {
final l10n = context.l10n; final l10n = context.l10n;
return Center( return Center(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon(Icons.terminal, size: 64, color: Colors.grey[400]), const Icon(Icons.terminal, size: 48, color: MeshPalette.ink4),
const SizedBox(height: 16), const SizedBox(height: 12),
Text( Text(
l10n.repeater_noCommandsSent, l10n.repeater_noCommandsSent,
style: TextStyle(fontSize: 16, color: Colors.grey[600]), style: MeshTheme.mono(fontSize: 13, color: MeshPalette.ink3),
), ),
const SizedBox(height: 8), const SizedBox(height: 4),
Text( Text(
l10n.repeater_typeCommandOrUseQuick, l10n.repeater_typeCommandOrUseQuick,
style: TextStyle(fontSize: 14, color: Colors.grey[500]), style: const TextStyle(fontSize: 12, color: MeshPalette.ink4),
), ),
], ],
), ),
@@ -445,49 +489,37 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
Widget _buildCommandHistory() { Widget _buildCommandHistory() {
return ListView.builder( return ListView.builder(
controller: _scrollController, controller: _scrollController,
padding: const EdgeInsets.all(16), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
itemCount: _commandHistory.length, itemCount: _commandHistory.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final entry = _commandHistory[index]; final entry = _commandHistory[index];
final isCommand = entry['type'] == 'command'; final isCommand = entry['type'] == 'command';
return Padding( return Padding(
padding: const EdgeInsets.only(bottom: 12), padding: const EdgeInsets.only(bottom: 2),
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Container( // Gutter prefix
padding: const EdgeInsets.all(6), SizedBox(
decoration: BoxDecoration( width: 20,
color: isCommand child: Text(
? Theme.of(context).colorScheme.primaryContainer isCommand ? '>' : ' ',
: Theme.of(context).colorScheme.secondaryContainer, style: MeshTheme.mono(
borderRadius: BorderRadius.circular(4), fontSize: 12,
), fontWeight: FontWeight.w700,
child: Icon( color: isCommand ? MeshPalette.blue : MeshPalette.ink3,
isCommand ? Icons.chevron_right : Icons.arrow_back, ),
size: 16,
color: isCommand
? Theme.of(context).colorScheme.onPrimaryContainer
: Theme.of(context).colorScheme.onSecondaryContainer,
), ),
), ),
const SizedBox(width: 12), const SizedBox(width: 6),
Expanded( Expanded(
child: Column( child: SelectableText(
crossAxisAlignment: CrossAxisAlignment.start, entry['text']!,
children: [ style: MeshTheme.mono(
SelectableText( fontSize: 12.5,
entry['text']!, color: isCommand ? MeshPalette.blue : MeshPalette.ink,
style: TextStyle( ),
fontFamily: 'monospace',
fontSize: 13,
color: isCommand
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.onSurface,
),
),
],
), ),
), ),
], ],
@@ -497,54 +529,6 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
); );
} }
Widget _buildCommandInput() {
final l10n = context.l10n;
return Container(
padding: const EdgeInsets.all(12),
color: Theme.of(context).colorScheme.surface,
child: SafeArea(
child: Row(
children: [
IconButton(
icon: const Icon(Icons.arrow_upward, size: 20),
tooltip: l10n.repeater_previousCommand,
onPressed: () => _navigateHistory(true),
),
IconButton(
icon: const Icon(Icons.arrow_downward, size: 20),
tooltip: l10n.repeater_nextCommand,
onPressed: () => _navigateHistory(false),
),
const SizedBox(width: 8),
Expanded(
child: TextField(
controller: _commandController,
focusNode: _commandFocusNode,
decoration: InputDecoration(
hintText: l10n.repeater_enterCommandHint,
border: const OutlineInputBorder(),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
prefixText: '> ',
),
style: const TextStyle(fontFamily: 'monospace'),
textInputAction: TextInputAction.send,
onSubmitted: (_) => _sendCommand(),
),
),
const SizedBox(width: 8),
IconButton.filled(
icon: const Icon(Icons.send),
onPressed: _sendCommand,
),
],
),
),
);
}
void _applyHelpCommand(String command) { void _applyHelpCommand(String command) {
_commandController.text = command; _commandController.text = command;
_commandController.selection = TextSelection.fromPosition( _commandController.selection = TextSelection.fromPosition(
@@ -1165,16 +1149,20 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
List<_CommandHelpEntry> commands, { List<_CommandHelpEntry> commands, {
String? note, String? note,
}) { }) {
final scheme = Theme.of(context).colorScheme;
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
title, title,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16), style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 15),
), ),
if (note != null) ...[ if (note != null) ...[
const SizedBox(height: 6), const SizedBox(height: 4),
Text(note, style: const TextStyle(fontSize: 12)), Text(
note,
style: TextStyle(fontSize: 11, color: scheme.onSurfaceVariant),
),
], ],
const SizedBox(height: 8), const SizedBox(height: 8),
...commands.map((entry) => _buildHelpCommandCard(context, entry)), ...commands.map((entry) => _buildHelpCommandCard(context, entry)),
@@ -1183,39 +1171,35 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
} }
Widget _buildHelpCommandCard(BuildContext context, _CommandHelpEntry entry) { Widget _buildHelpCommandCard(BuildContext context, _CommandHelpEntry entry) {
final colorScheme = Theme.of(context).colorScheme; final scheme = Theme.of(context).colorScheme;
return Card( return Card(
elevation: 0, elevation: 0,
margin: const EdgeInsets.only(bottom: 8), margin: const EdgeInsets.only(bottom: 6),
color: colorScheme.surfaceContainerHighest, color: scheme.surfaceContainerHighest,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(MeshRadii.sm),
side: BorderSide(color: colorScheme.outlineVariant), side: BorderSide(color: scheme.outlineVariant),
), ),
child: InkWell( child: InkWell(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(MeshRadii.sm),
onTap: () => _applyHelpCommand(entry.command), onTap: () => _applyHelpCommand(entry.command),
child: Padding( child: Padding(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(10),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
entry.command, entry.command,
style: TextStyle( style: MeshTheme.mono(
fontFamily: 'monospace', fontSize: 12,
fontSize: 13, fontWeight: FontWeight.w600,
fontWeight: FontWeight.bold, color: MeshPalette.blue,
color: colorScheme.onSurfaceVariant,
), ),
), ),
const SizedBox(height: 6), const SizedBox(height: 4),
Text( Text(
entry.description, entry.description,
style: TextStyle( style: TextStyle(fontSize: 12, color: scheme.onSurfaceVariant),
fontSize: 12,
color: colorScheme.onSurfaceVariant,
),
), ),
], ],
), ),
@@ -1228,6 +1212,5 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
class _CommandHelpEntry { class _CommandHelpEntry {
final String command; final String command;
final String description; final String description;
const _CommandHelpEntry({required this.command, required this.description}); const _CommandHelpEntry({required this.command, required this.description});
} }
+211 -212
View File
@@ -1,10 +1,13 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:meshcore_open/connector/meshcore_protocol.dart'; import 'package:meshcore_open/connector/meshcore_protocol.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../l10n/l10n.dart'; import '../l10n/l10n.dart';
import '../models/contact.dart'; import '../models/contact.dart';
import '../l10n/contact_localization.dart'; import '../l10n/contact_localization.dart';
import '../services/app_settings_service.dart'; import '../services/app_settings_service.dart';
import '../theme/mesh_theme.dart';
import '../widgets/mesh_ui.dart';
import 'repeater_status_screen.dart'; import 'repeater_status_screen.dart';
import 'repeater_cli_screen.dart'; import 'repeater_cli_screen.dart';
import 'repeater_settings_screen.dart'; import 'repeater_settings_screen.dart';
@@ -26,175 +29,157 @@ class RepeaterHubScreen extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = context.l10n; final l10n = context.l10n;
final scheme = Theme.of(context).colorScheme;
final settingsService = context.watch<AppSettingsService>(); final settingsService = context.watch<AppSettingsService>();
final chemistry = settingsService.batteryChemistryForRepeater( final chemistry = settingsService.batteryChemistryForRepeater(
repeater.publicKeyHex, repeater.publicKeyHex,
); );
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Column( title: Text(
crossAxisAlignment: CrossAxisAlignment.start, repeater.type == advTypeRepeater
mainAxisSize: MainAxisSize.min, ? (isAdmin ? l10n.repeater_management : l10n.repeater_guest)
children: [ : (isAdmin ? l10n.room_management : l10n.room_guest),
if (isAdmin)
Text(
repeater.type == advTypeRepeater
? l10n.repeater_management
: l10n.room_management,
),
if (!isAdmin)
Text(
repeater.type == advTypeRepeater
? l10n.repeater_guest
: l10n.room_guest,
),
Text(
repeater.name,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.normal,
),
),
],
), ),
centerTitle: false, centerTitle: true,
), ),
body: SafeArea( body: SafeArea(
top: false, top: false,
child: ListView( child: ListView(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.only(bottom: 24),
children: [ children: [
// Repeater info card // Identity card
Card( Padding(
child: Padding( padding: const EdgeInsets.fromLTRB(16, 20, 16, 4),
padding: const EdgeInsets.all(16), child: MeshCard(
child: Column( margin: EdgeInsets.zero,
padding: const EdgeInsets.all(20),
child: Row(
children: [ children: [
CircleAvatar( AvatarCircle(
radius: 40, name: repeater.name,
backgroundColor: Colors.orange, size: 52,
child: const Icon( color: MeshPalette.warn,
Icons.cell_tower, icon: Icons.cell_tower,
size: 40,
color: Colors.white,
),
), ),
const SizedBox(height: 16), const SizedBox(width: 16),
Text( Expanded(
repeater.name, child: Column(
style: const TextStyle( crossAxisAlignment: CrossAxisAlignment.start,
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
repeater.shortPubKeyHex,
style: TextStyle(fontSize: 14, color: Colors.grey[600]),
),
const SizedBox(height: 8),
Text(
repeater.pathLabel(context.l10n),
style: TextStyle(fontSize: 14, color: Colors.grey[600]),
),
if (repeater.hasLocation) ...[
const SizedBox(height: 4),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon(
Icons.location_on,
size: 14,
color: Colors.grey[600],
),
const SizedBox(width: 4),
Text( Text(
'${repeater.latitude?.toStringAsFixed(4)}, ${repeater.longitude?.toStringAsFixed(4)}', repeater.name,
style: TextStyle( style: Theme.of(context).textTheme.titleMedium
fontSize: 12, ?.copyWith(fontWeight: FontWeight.w700),
color: Colors.grey[600], maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Text(
repeater.shortPubKeyHex,
style: MeshTheme.mono(
fontSize: 11,
color: scheme.onSurfaceVariant,
), ),
), ),
const SizedBox(height: 4),
Text(
repeater.pathLabel(l10n),
style: Theme.of(context).textTheme.bodySmall
?.copyWith(color: scheme.onSurfaceVariant),
),
if (repeater.hasLocation) ...[
const SizedBox(height: 2),
Row(
children: [
Icon(
Icons.location_on,
size: 12,
color: scheme.onSurfaceVariant,
),
const SizedBox(width: 3),
Expanded(
child: Text(
'${repeater.latitude?.toStringAsFixed(4)}, '
'${repeater.longitude?.toStringAsFixed(4)}',
style: MeshTheme.mono(
fontSize: 10,
color: scheme.onSurfaceVariant,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
],
], ],
), ),
], ),
StatusChip(
label: isAdmin ? 'ADMIN' : 'GUEST',
color: isAdmin
? MeshPalette.blue
: scheme.onSurfaceVariant,
),
], ],
), ),
), ),
), ),
const SizedBox(height: 24),
if (isAdmin) // Battery chemistry (admin only)
Card( if (isAdmin) ...[
child: Padding( SectionHeader(l10n.appSettings_batteryChemistry),
padding: const EdgeInsets.fromLTRB(16, 16, 16, 12), MeshCard(
child: Column( margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
crossAxisAlignment: CrossAxisAlignment.start, padding: const EdgeInsets.fromLTRB(14, 10, 14, 14),
children: [ child: DropdownButtonFormField<String>(
Row( initialValue: chemistry,
children: [ isExpanded: true,
const Icon(Icons.battery_full), decoration: InputDecoration(
const SizedBox(width: 10), prefixIcon: const Icon(Icons.battery_full, size: 18),
Expanded( labelText: l10n.appSettings_batteryChemistry,
child: Text(
l10n.appSettings_batteryChemistry,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
),
],
),
const SizedBox(height: 12),
DropdownButtonFormField<String>(
initialValue: chemistry,
isExpanded: true,
decoration: const InputDecoration(
border: UnderlineInputBorder(),
isDense: true,
),
onChanged: (value) {
if (value == null) return;
settingsService.setBatteryChemistryForRepeater(
repeater.publicKeyHex,
value,
);
},
items: [
DropdownMenuItem(
value: 'nmc',
child: Text(l10n.appSettings_batteryNmc),
),
DropdownMenuItem(
value: 'lifepo4',
child: Text(l10n.appSettings_batteryLifepo4),
),
DropdownMenuItem(
value: 'lipo',
child: Text(l10n.appSettings_batteryLipo),
),
],
),
],
), ),
onChanged: (value) {
if (value == null) return;
settingsService.setBatteryChemistryForRepeater(
repeater.publicKeyHex,
value,
);
},
items: [
DropdownMenuItem(
value: 'nmc',
child: Text(l10n.appSettings_batteryNmc),
),
DropdownMenuItem(
value: 'lifepo4',
child: Text(l10n.appSettings_batteryLifepo4),
),
DropdownMenuItem(
value: 'lipo',
child: Text(l10n.appSettings_batteryLipo),
),
],
), ),
), ),
const SizedBox(height: 24), ],
Text(
// Tools
SectionHeader(
isAdmin isAdmin
? l10n.repeater_managementTools ? l10n.repeater_managementTools
: l10n.repeater_guestTools, : l10n.repeater_guestTools,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
), ),
const SizedBox(height: 16),
// Status button _HubActionTile(
_buildManagementCard( index: 0,
context,
icon: Icons.analytics, icon: Icons.analytics,
title: l10n.repeater_status, title: l10n.repeater_status,
subtitle: l10n.repeater_statusSubtitle, subtitle: l10n.repeater_statusSubtitle,
color: Colors.blue, accentColor: MeshPalette.blue,
onTap: () { onTap: () {
HapticFeedback.selectionClick();
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
@@ -206,15 +191,15 @@ class RepeaterHubScreen extends StatelessWidget {
); );
}, },
), ),
const SizedBox(height: 16),
// Telemetry button _HubActionTile(
_buildManagementCard( index: 1,
context,
icon: Icons.bar_chart_sharp, icon: Icons.bar_chart_sharp,
title: l10n.repeater_telemetry, title: l10n.repeater_telemetry,
subtitle: l10n.repeater_telemetrySubtitle, subtitle: l10n.repeater_telemetrySubtitle,
color: Colors.teal, accentColor: MeshPalette.magenta,
onTap: () { onTap: () {
HapticFeedback.selectionClick();
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
@@ -223,16 +208,34 @@ class RepeaterHubScreen extends StatelessWidget {
); );
}, },
), ),
if (isAdmin) const SizedBox(height: 12),
// CLI button _HubActionTile(
if (isAdmin) index: 2,
_buildManagementCard( icon: Icons.group,
context, title: l10n.repeater_neighbors,
subtitle: l10n.repeater_neighborsSubtitle,
accentColor: MeshPalette.signal,
onTap: () {
HapticFeedback.selectionClick();
Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
NeighborsScreen(repeater: repeater, password: password),
),
);
},
),
if (isAdmin) ...[
_HubActionTile(
index: 3,
icon: Icons.terminal, icon: Icons.terminal,
title: l10n.repeater_cli, title: l10n.repeater_cli,
subtitle: l10n.repeater_cliSubtitle, subtitle: l10n.repeater_cliSubtitle,
color: Colors.green, accentColor: MeshPalette.warn,
onTap: () { onTap: () {
HapticFeedback.selectionClick();
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
@@ -244,34 +247,14 @@ class RepeaterHubScreen extends StatelessWidget {
); );
}, },
), ),
const SizedBox(height: 12), _HubActionTile(
// Neighbors button index: 4,
_buildManagementCard(
context,
icon: Icons.group,
title: l10n.repeater_neighbors,
subtitle: l10n.repeater_neighborsSubtitle,
color: Colors.orange,
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
NeighborsScreen(repeater: repeater, password: password),
),
);
},
),
if (isAdmin) const SizedBox(height: 12),
// Settings button
if (isAdmin)
_buildManagementCard(
context,
icon: Icons.settings, icon: Icons.settings,
title: l10n.repeater_settings, title: l10n.repeater_settings,
subtitle: l10n.repeater_settingsSubtitle, subtitle: l10n.repeater_settingsSubtitle,
color: Colors.deepOrange, accentColor: MeshPalette.alert,
onTap: () { onTap: () {
HapticFeedback.selectionClick();
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
@@ -283,60 +266,76 @@ class RepeaterHubScreen extends StatelessWidget {
); );
}, },
), ),
],
], ],
), ),
), ),
); );
} }
}
Widget _buildManagementCard( class _HubActionTile extends StatelessWidget {
BuildContext context, { final int index;
required IconData icon, final IconData icon;
required String title, final String title;
required String subtitle, final String subtitle;
required Color color, final Color accentColor;
required VoidCallback onTap, final VoidCallback onTap;
}) {
return Card( const _HubActionTile({
elevation: 2, required this.index,
child: InkWell( required this.icon,
required this.title,
required this.subtitle,
required this.accentColor,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return ListEntrance(
index: index,
child: MeshCard(
onTap: onTap, onTap: onTap,
borderRadius: BorderRadius.circular(12), child: Row(
child: Padding( children: [
padding: const EdgeInsets.all(16), Container(
child: Row( width: 44,
children: [ height: 44,
Container( decoration: BoxDecoration(
padding: const EdgeInsets.all(12), color: accentColor.withValues(alpha: 0.12),
decoration: BoxDecoration( borderRadius: BorderRadius.circular(MeshRadii.md),
color: color.withValues(alpha: 0.1), border: Border.all(color: accentColor.withValues(alpha: 0.3)),
borderRadius: BorderRadius.circular(12),
),
child: Icon(icon, color: color, size: 32),
), ),
const SizedBox(width: 16), alignment: Alignment.center,
Expanded( child: Icon(icon, size: 22, color: accentColor),
child: Column( ),
crossAxisAlignment: CrossAxisAlignment.start, const SizedBox(width: 14),
children: [ Expanded(
Text( child: Column(
title, crossAxisAlignment: CrossAxisAlignment.start,
style: const TextStyle( children: [
fontSize: 18, Text(
fontWeight: FontWeight.bold, title,
), style: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 15,
), ),
const SizedBox(height: 4), ),
Text( const SizedBox(height: 2),
subtitle, Text(
style: TextStyle(fontSize: 14, color: Colors.grey[600]), subtitle,
style: TextStyle(
fontSize: 12.5,
color: scheme.onSurfaceVariant,
), ),
], ),
), ],
), ),
Icon(Icons.chevron_right, color: Colors.grey[400]), ),
], Icon(Icons.chevron_right, color: scheme.onSurfaceVariant, size: 20),
), ],
), ),
), ),
); );
File diff suppressed because it is too large Load Diff
+229 -293
View File
@@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../l10n/l10n.dart'; import '../l10n/l10n.dart';
import '../models/contact.dart'; import '../models/contact.dart';
@@ -10,8 +11,10 @@ import '../connector/meshcore_connector.dart';
import '../connector/meshcore_protocol.dart'; import '../connector/meshcore_protocol.dart';
import '../services/app_settings_service.dart'; import '../services/app_settings_service.dart';
import '../services/repeater_command_service.dart'; import '../services/repeater_command_service.dart';
import '../theme/mesh_theme.dart';
import '../utils/battery_utils.dart'; import '../utils/battery_utils.dart';
import '../widgets/path_management_dialog.dart'; import '../widgets/mesh_ui.dart';
import '../widgets/routing_sheet.dart';
import '../helpers/snack_bar_builder.dart'; import '../helpers/snack_bar_builder.dart';
class RepeaterStatusScreen extends StatefulWidget { class RepeaterStatusScreen extends StatefulWidget {
@@ -64,8 +67,6 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
final connector = Provider.of<MeshCoreConnector>(context, listen: false); final connector = Provider.of<MeshCoreConnector>(context, listen: false);
_commandService = RepeaterCommandService(connector); _commandService = RepeaterCommandService(connector);
_setupMessageListener(); _setupMessageListener();
// Defer until after the first frame so any notifyListeners() triggered
// during preparePathForContactSend doesn't fire mid-build.
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _loadStatus(); if (mounted) _loadStatus();
}); });
@@ -81,12 +82,8 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
void _setupMessageListener() { void _setupMessageListener() {
final connector = Provider.of<MeshCoreConnector>(context, listen: false); final connector = Provider.of<MeshCoreConnector>(context, listen: false);
// Listen for incoming text messages from the repeater
_frameSubscription = connector.receivedFrames.listen((frame) { _frameSubscription = connector.receivedFrames.listen((frame) {
if (frame.isEmpty) return; if (frame.isEmpty) return;
// Check if it's a text message response
if (frame[0] == pushCodeStatusResponse) { if (frame[0] == pushCodeStatusResponse) {
_handleStatusResponse(frame); _handleStatusResponse(frame);
} else if (frame[0] == respCodeContactMsgRecv || } else if (frame[0] == respCodeContactMsgRecv ||
@@ -118,11 +115,7 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
final parsed = parseContactMessageText(frame); final parsed = parseContactMessageText(frame);
if (parsed == null) return; if (parsed == null) return;
if (!_matchesRepeaterPrefix(parsed.senderPrefix)) return; if (!_matchesRepeaterPrefix(parsed.senderPrefix)) return;
// Notify command service of response (for retry handling)
_commandService?.handleResponse(widget.repeater, parsed.text); _commandService?.handleResponse(widget.repeater, parsed.text);
// Parse status responses
_parseStatusResponse(parsed.text); _parseStatusResponse(parsed.text);
_recordStatusResult(true); _recordStatusResult(true);
} }
@@ -131,7 +124,6 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
if (frame.length < 8) return; if (frame.length < 8) return;
final prefix = frame.sublist(2, 8); final prefix = frame.sublist(2, 8);
if (!_matchesRepeaterPrefix(prefix)) return; if (!_matchesRepeaterPrefix(prefix)) return;
if (frame.length < _statusResponseBytes) return; if (frame.length < _statusResponseBytes) return;
final data = ByteData.sublistView( final data = ByteData.sublistView(
@@ -254,14 +246,9 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
_dupFlood = _asInt(data['dup_flood']); _dupFlood = _asInt(data['dup_flood']);
_dupDirect = _asInt(data['dup_direct']); _dupDirect = _asInt(data['dup_direct']);
} }
} catch (_) { } catch (_) {}
// Ignore parse failures for non-JSON responses.
}
}
if (mounted) {
setState(() {});
} }
if (mounted) setState(() {});
} }
Future<void> _loadStatus() async { Future<void> _loadStatus() async {
@@ -302,9 +289,7 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
var messageBytes = frame.length >= _statusResponseBytes var messageBytes = frame.length >= _statusResponseBytes
? frame.length ? frame.length
: _statusResponseBytes; : _statusResponseBytes;
if (messageBytes < maxFrameSize) { if (messageBytes < maxFrameSize) messageBytes = maxFrameSize;
messageBytes = maxFrameSize;
}
final timeoutMs = connector.calculateTimeout( final timeoutMs = connector.calculateTimeout(
pathLength: pathLengthValue, pathLength: pathLengthValue,
messageBytes: messageBytes, messageBytes: messageBytes,
@@ -312,26 +297,21 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
_statusTimeout?.cancel(); _statusTimeout?.cancel();
_statusTimeout = Timer(Duration(milliseconds: timeoutMs), () { _statusTimeout = Timer(Duration(milliseconds: timeoutMs), () {
if (!mounted) return; if (!mounted) return;
setState(() { setState(() => _isLoading = false);
_isLoading = false;
});
showDismissibleSnackBar( showDismissibleSnackBar(
context, context,
content: Text(context.l10n.repeater_statusRequestTimeout), content: Text(context.l10n.repeater_statusRequestTimeout),
backgroundColor: Colors.red, backgroundColor: Theme.of(context).colorScheme.error,
); );
_recordStatusResult(false); _recordStatusResult(false);
}); });
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
setState(() { setState(() => _isLoading = false);
_isLoading = false;
});
showDismissibleSnackBar( showDismissibleSnackBar(
context, context,
content: Text(context.l10n.repeater_errorLoadingStatus(e.toString())), content: Text(context.l10n.repeater_errorLoadingStatus(e.toString())),
backgroundColor: Colors.red, backgroundColor: Theme.of(context).colorScheme.error,
); );
} }
_recordStatusResult(false); _recordStatusResult(false);
@@ -347,268 +327,6 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
_pendingStatusSelection = null; _pendingStatusSelection = null;
} }
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final connector = context.watch<MeshCoreConnector>();
final repeater = _resolveRepeater(connector);
final isFloodMode = repeater.pathOverride == -1;
return Scaffold(
appBar: AppBar(
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(l10n.repeater_statusTitle),
Text(
repeater.name,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.normal,
),
),
],
),
centerTitle: false,
actions: [
PopupMenuButton<String>(
icon: Icon(isFloodMode ? Icons.waves : Icons.route),
tooltip: l10n.repeater_routingMode,
onSelected: (mode) async {
if (mode == 'flood') {
await connector.setPathOverride(repeater, pathLen: -1);
} else {
await connector.setPathOverride(repeater, pathLen: null);
}
},
itemBuilder: (context) => [
PopupMenuItem(
value: 'auto',
child: Row(
children: [
Icon(
Icons.auto_mode,
size: 20,
color: !isFloodMode
? Theme.of(context).primaryColor
: null,
),
const SizedBox(width: 8),
Text(
l10n.repeater_autoUseSavedPath,
style: TextStyle(
fontWeight: !isFloodMode
? FontWeight.bold
: FontWeight.normal,
),
),
],
),
),
PopupMenuItem(
value: 'flood',
child: Row(
children: [
Icon(
Icons.waves,
size: 20,
color: isFloodMode
? Theme.of(context).primaryColor
: null,
),
const SizedBox(width: 8),
Text(
l10n.repeater_forceFloodMode,
style: TextStyle(
fontWeight: isFloodMode
? FontWeight.bold
: FontWeight.normal,
),
),
],
),
),
],
),
IconButton(
icon: const Icon(Icons.timeline),
tooltip: l10n.repeater_pathManagement,
onPressed: () =>
PathManagementDialog.show(context, contact: repeater),
),
IconButton(
icon: _isLoading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.refresh),
onPressed: _isLoading ? null : _loadStatus,
tooltip: l10n.repeater_refresh,
),
],
),
body: SafeArea(
top: false,
child: RefreshIndicator(
onRefresh: _loadStatus,
child: ListView(
padding: const EdgeInsets.all(16),
children: [
_buildSystemInfoCard(),
const SizedBox(height: 16),
_buildRadioStatsCard(),
const SizedBox(height: 16),
_buildPacketStatsCard(),
],
),
),
),
);
}
Widget _buildSystemInfoCard() {
final l10n = context.l10n;
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.info_outline,
color: Theme.of(context).textTheme.headlineSmall?.color,
),
const SizedBox(width: 8),
Text(
l10n.repeater_systemInformation,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
],
),
const Divider(),
_buildInfoRow(l10n.repeater_battery, _batteryText()),
_buildInfoRow(l10n.repeater_clockAtLogin, _clockText()),
_buildInfoRow(l10n.repeater_uptime, _formatDuration(_uptimeSecs)),
_buildInfoRow(l10n.repeater_queueLength, _formatValue(_queueLen)),
_buildInfoRow(l10n.repeater_debugFlags, _formatValue(_debugFlags)),
],
),
),
);
}
Widget _buildRadioStatsCard() {
final l10n = context.l10n;
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.radio,
color: Theme.of(context).textTheme.headlineSmall?.color,
),
const SizedBox(width: 8),
Text(
l10n.repeater_radioStatistics,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
],
),
const Divider(),
_buildInfoRow(
l10n.repeater_lastRssi,
_formatValue(_lastRssi, suffix: ' dB'),
),
_buildInfoRow(l10n.repeater_lastSnr, _formatSnr(_lastSnr)),
_buildInfoRow(
l10n.repeater_noiseFloor,
_formatValue(_noiseFloor, suffix: ' dB'),
),
_buildInfoRow(l10n.repeater_txAirtime, _formatDuration(_txAirSecs)),
_buildInfoRow(l10n.repeater_rxAirtime, _formatDuration(_rxAirSecs)),
],
),
),
);
}
Widget _buildPacketStatsCard() {
final l10n = context.l10n;
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.analytics,
color: Theme.of(context).textTheme.headlineSmall?.color,
),
const SizedBox(width: 8),
Text(
l10n.repeater_packetStatistics,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
],
),
const Divider(),
_buildInfoRow(l10n.repeater_sent, _packetTxText()),
_buildInfoRow(l10n.repeater_received, _packetRxText()),
_buildInfoRow(l10n.repeater_duplicates, _duplicateText()),
_buildInfoRow(l10n.repeater_chanUtil, _chanUtilText()),
],
),
),
);
}
Widget _buildInfoRow(String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 130,
child: Text(
label,
style: TextStyle(
color: Colors.grey[600],
fontWeight: FontWeight.w500,
),
),
),
Expanded(
child: Text(
value,
style: const TextStyle(fontWeight: FontWeight.w400),
),
),
],
),
);
}
int? _asInt(dynamic value) { int? _asInt(dynamic value) {
if (value == null) return null; if (value == null) return null;
if (value is int) return value; if (value is int) return value;
@@ -715,4 +433,222 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
if (snr == null) return ''; if (snr == null) return '';
return snr.toStringAsFixed(2); return snr.toStringAsFixed(2);
} }
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final connector = context.watch<MeshCoreConnector>();
final repeater = _resolveRepeater(connector);
final isFloodMode = repeater.pathOverride == -1;
return Scaffold(
appBar: AppBar(
title: Text(l10n.repeater_statusTitle),
centerTitle: true,
actions: [
IconButton(
icon: Icon(isFloodMode ? Icons.waves : Icons.route),
tooltip: l10n.repeater_routingMode,
onPressed: () =>
ContactRoutingSheet.show(context, contact: repeater),
),
IconButton(
icon: _isLoading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.refresh),
onPressed: _isLoading ? null : _loadStatus,
tooltip: l10n.repeater_refresh,
),
],
),
body: SafeArea(
top: false,
child: RefreshIndicator(
onRefresh: _loadStatus,
child: _isLoading && _batteryMv == null
? const Center(child: CircularProgressIndicator())
: _buildBody(l10n, repeater.name),
),
),
);
}
Widget _buildBody(dynamic l10n, String name) {
final scheme = Theme.of(context).colorScheme;
return ListView(
padding: const EdgeInsets.only(bottom: 24),
children: [
// System
SectionHeader(l10n.repeater_systemInformation),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: _buildStatGrid([
_StatItem(
icon: Icons.battery_std,
label: l10n.repeater_battery,
value: _batteryText(),
color: _batteryColor(),
),
_StatItem(
icon: Icons.timer_outlined,
label: l10n.repeater_uptime,
value: _formatDuration(_uptimeSecs),
color: MeshPalette.blue,
),
_StatItem(
icon: Icons.schedule,
label: l10n.repeater_clockAtLogin,
value: _clockText(),
color: scheme.onSurfaceVariant,
),
_StatItem(
icon: Icons.inbox,
label: l10n.repeater_queueLength,
value: _formatValue(_queueLen),
color: scheme.onSurfaceVariant,
),
_StatItem(
icon: Icons.bug_report_outlined,
label: l10n.repeater_debugFlags,
value: _formatValue(_debugFlags),
color: _debugFlags != null && _debugFlags! > 0
? MeshPalette.warn
: scheme.onSurfaceVariant,
),
]),
),
// Radio
SectionHeader(l10n.repeater_radioStatistics),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: _buildStatGrid([
_StatItem(
icon: Icons.signal_cellular_alt,
label: l10n.repeater_lastRssi,
value: _formatValue(_lastRssi, suffix: ' dB'),
color: MeshPalette.blue,
),
_StatItem(
icon: Icons.waves,
label: l10n.repeater_lastSnr,
value: _formatSnr(_lastSnr),
color: MeshTheme.snrColor(_lastSnr, blocked: false),
),
_StatItem(
icon: Icons.noise_control_off,
label: l10n.repeater_noiseFloor,
value: _formatValue(_noiseFloor, suffix: ' dB'),
color: scheme.onSurfaceVariant,
),
_StatItem(
icon: Icons.upload,
label: l10n.repeater_txAirtime,
value: _formatDuration(_txAirSecs),
color: MeshPalette.warn,
),
_StatItem(
icon: Icons.download,
label: l10n.repeater_rxAirtime,
value: _formatDuration(_rxAirSecs),
color: MeshPalette.signal,
),
]),
),
// Packets
SectionHeader(l10n.repeater_packetStatistics),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: _buildStatGrid([
_StatItem(
icon: Icons.send,
label: l10n.repeater_sent,
value: _packetTxText(),
color: MeshPalette.blue,
),
_StatItem(
icon: Icons.call_received,
label: l10n.repeater_received,
value: _packetRxText(),
color: MeshPalette.signal,
),
_StatItem(
icon: Icons.content_copy,
label: l10n.repeater_duplicates,
value: _duplicateText(),
color: scheme.onSurfaceVariant,
),
_StatItem(
icon: Icons.percent,
label: l10n.repeater_chanUtil,
value: _chanUtilText(),
color: _chanUtil != null && _chanUtil! > 80
? MeshPalette.alert
: _chanUtil != null && _chanUtil! > 50
? MeshPalette.warn
: MeshPalette.signal,
),
]),
),
const SizedBox(height: 8),
],
);
}
Color _batteryColor() {
final connector = context.watch<MeshCoreConnector>();
final batteryMv =
connector.getRepeaterBatteryMillivolts(widget.repeater.publicKeyHex) ??
_batteryMv;
if (batteryMv == null) {
return Theme.of(context).colorScheme.onSurfaceVariant;
}
final percent = estimateBatteryPercentFromMillivolts(
batteryMv,
_batteryChemistry(),
);
if (percent < 20) return MeshPalette.alert;
if (percent < 40) return MeshPalette.warn;
return MeshPalette.signal;
}
Widget _buildStatGrid(List<_StatItem> items) {
return GridView.count(
crossAxisCount: 2,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
mainAxisSpacing: 8,
crossAxisSpacing: 8,
childAspectRatio: 2.2,
children: items
.map(
(item) => StatTile(
icon: item.icon,
label: item.label,
value: item.value,
color: item.color,
),
)
.toList(),
);
}
}
class _StatItem {
final IconData icon;
final String label;
final String value;
final Color color;
const _StatItem({
required this.icon,
required this.label,
required this.value,
required this.color,
});
} }
+219 -154
View File
@@ -1,5 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../utils/platform_info.dart'; import '../utils/platform_info.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';
@@ -7,9 +8,12 @@ import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart'; import '../connector/meshcore_connector.dart';
import '../l10n/l10n.dart'; import '../l10n/l10n.dart';
import '../services/linux_ble_error_classifier.dart'; import '../services/linux_ble_error_classifier.dart';
import '../theme/mesh_theme.dart';
import '../utils/app_logger.dart'; import '../utils/app_logger.dart';
import '../widgets/adaptive_app_bar_title.dart'; import '../widgets/adaptive_app_bar_title.dart';
import '../widgets/device_tile.dart'; import '../widgets/device_tile.dart';
import '../widgets/empty_state.dart';
import '../widgets/mesh_ui.dart';
import '../helpers/snack_bar_builder.dart'; import '../helpers/snack_bar_builder.dart';
import 'channels_screen.dart'; import 'channels_screen.dart';
import 'tcp_screen.dart'; import 'tcp_screen.dart';
@@ -25,6 +29,7 @@ class ScannerScreen extends StatefulWidget {
class _ScannerScreenState extends State<ScannerScreen> { class _ScannerScreenState extends State<ScannerScreen> {
bool _changedNavigation = false; bool _changedNavigation = false;
String? _connectingDeviceId;
late final MeshCoreConnector _connector; late final MeshCoreConnector _connector;
late final VoidCallback _connectionListener; late final VoidCallback _connectionListener;
BluetoothAdapterState _bluetoothState = BluetoothAdapterState.unknown; BluetoothAdapterState _bluetoothState = BluetoothAdapterState.unknown;
@@ -101,6 +106,32 @@ class _ScannerScreenState extends State<ScannerScreen> {
title: AdaptiveAppBarTitle(context.l10n.scanner_title), title: AdaptiveAppBarTitle(context.l10n.scanner_title),
centerTitle: true, centerTitle: true,
automaticallyImplyLeading: false, automaticallyImplyLeading: false,
actions: [
if (PlatformInfo.supportsUsbSerial)
IconButton(
icon: const Icon(Icons.usb),
tooltip: context.l10n.connectionChoiceUsbLabel,
onPressed: () {
appLogger.info(
'USB selected, opening UsbScreen',
tag: 'ScannerScreen',
);
Navigator.of(
context,
).push(MaterialPageRoute(builder: (_) => const UsbScreen()));
},
),
if (!PlatformInfo.isWeb)
IconButton(
icon: const Icon(Icons.lan),
tooltip: context.l10n.connectionChoiceTcpLabel,
onPressed: () {
Navigator.of(
context,
).push(MaterialPageRoute(builder: (_) => const TcpScreen()));
},
),
],
), ),
body: SafeArea( body: SafeArea(
top: false, top: false,
@@ -108,12 +139,21 @@ class _ScannerScreenState extends State<ScannerScreen> {
builder: (context, connector, child) { builder: (context, connector, child) {
return Column( return Column(
children: [ children: [
// Bluetooth off warning // Bluetooth off warning slides in/out with AnimatedSize
if (_bluetoothState == BluetoothAdapterState.off) AnimatedSize(
_bluetoothOffWarning(context), duration: const Duration(milliseconds: 250),
curve: Curves.easeInOut,
child: _bluetoothState == BluetoothAdapterState.off
? _BluetoothOffBanner(
onEnable: PlatformInfo.isAndroid
? () => FlutterBluePlus.turnOn()
: null,
)
: const SizedBox.shrink(),
),
// Status bar // Connection status header
_buildStatusBar(context, connector), _ConnectionStatusHeader(connector: connector),
// Device list // Device list
Expanded(child: _buildDeviceList(context, connector)), Expanded(child: _buildDeviceList(context, connector)),
@@ -122,84 +162,43 @@ class _ScannerScreenState extends State<ScannerScreen> {
}, },
), ),
), ),
bottomNavigationBar: Consumer<MeshCoreConnector>( floatingActionButton: Consumer<MeshCoreConnector>(
builder: (context, connector, child) { builder: (context, connector, child) {
final isScanning = final isScanning =
connector.state == MeshCoreConnectionState.scanning; connector.state == MeshCoreConnectionState.scanning;
final isBluetoothOff = _bluetoothState == BluetoothAdapterState.off; final isBluetoothOff = _bluetoothState == BluetoothAdapterState.off;
final usbSupported = PlatformInfo.supportsUsbSerial;
final tcpSupported = !PlatformInfo.isWeb;
return SafeArea( return FloatingActionButton.extended(
top: false, heroTag: 'scanner_ble_action',
minimum: const EdgeInsets.fromLTRB(16, 8, 16, 16), onPressed: isBluetoothOff
child: FittedBox( ? null
fit: BoxFit.scaleDown, : () {
alignment: Alignment.centerRight, HapticFeedback.lightImpact();
child: Row( _toggleScan(connector);
mainAxisAlignment: MainAxisAlignment.end, },
children: [ icon: AnimatedSwitcher(
if (usbSupported) duration: const Duration(milliseconds: 220),
FloatingActionButton.extended( transitionBuilder: (child, anim) =>
onPressed: () { ScaleTransition(scale: anim, child: child),
appLogger.info( child: isScanning
'USB selected, opening UsbScreen', ? SizedBox(
tag: 'ScannerScreen', key: const ValueKey('scanning'),
); width: 20,
Navigator.of(context).push( height: 20,
MaterialPageRoute(builder: (_) => const UsbScreen()), child: CircularProgressIndicator(
); strokeWidth: 2,
}, color: Theme.of(context).colorScheme.onPrimary,
heroTag: 'scanner_usb_action', ),
icon: const Icon(Icons.usb), )
label: Text(context.l10n.connectionChoiceUsbLabel), : const Icon(
Icons.bluetooth_searching,
key: ValueKey('idle'),
), ),
if (usbSupported) const SizedBox(width: 12), ),
if (tcpSupported) label: Text(
FloatingActionButton.extended( isScanning
onPressed: () { ? context.l10n.scanner_stop
Navigator.of(context).push( : context.l10n.scanner_scan,
MaterialPageRoute(builder: (_) => const TcpScreen()),
);
},
heroTag: 'scanner_tcp_action',
icon: const Icon(Icons.lan),
label: Text(context.l10n.connectionChoiceTcpLabel),
),
if (tcpSupported) const SizedBox(width: 12),
FloatingActionButton.extended(
heroTag: 'scanner_ble_action',
onPressed: isBluetoothOff
? null
: () {
if (isScanning) {
connector.stopScan();
} else {
unawaited(
connector.startScan().catchError((e) {
appLogger.warn(
'startScan error: $e',
tag: 'ScannerScreen',
);
}),
);
}
},
icon: isScanning
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.bluetooth_searching),
label: Text(
isScanning
? context.l10n.scanner_stop
: context.l10n.scanner_scan,
),
),
],
),
), ),
); );
}, },
@@ -207,79 +206,70 @@ class _ScannerScreenState extends State<ScannerScreen> {
); );
} }
Widget _buildStatusBar(BuildContext context, MeshCoreConnector connector) { void _toggleScan(MeshCoreConnector connector) {
String statusText; if (PlatformInfo.isWeb) {
Color statusColor; // flutter_blue_plus has no web backend, so a BLE scan silently no-ops in
// the browser. Tell the user instead of leaving them staring at a button.
final l10n = context.l10n; showDismissibleSnackBar(
switch (connector.state) { context,
case MeshCoreConnectionState.scanning: content: Text(context.l10n.scanner_bluetoothWebUnsupported),
statusText = l10n.scanner_scanning; );
statusColor = Colors.blue; return;
break; }
case MeshCoreConnectionState.connecting: if (connector.state == MeshCoreConnectionState.scanning) {
statusText = l10n.scanner_connecting; connector.stopScan();
statusColor = Colors.orange; } else {
break; unawaited(
case MeshCoreConnectionState.connected: connector.startScan().catchError((e) {
statusText = l10n.scanner_connectedTo(connector.deviceDisplayName); appLogger.warn('startScan error: $e', tag: 'ScannerScreen');
statusColor = Colors.green; }),
break; );
case MeshCoreConnectionState.disconnecting:
statusText = l10n.scanner_disconnecting;
statusColor = Colors.orange;
break;
case MeshCoreConnectionState.disconnected:
statusText = l10n.scanner_notConnected;
statusColor = Colors.grey;
break;
} }
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
color: statusColor.withValues(alpha: 0.1),
child: Row(
children: [
Icon(Icons.circle, size: 12, color: statusColor),
const SizedBox(width: 8),
Text(
statusText,
style: TextStyle(color: statusColor, fontWeight: FontWeight.w500),
),
],
),
);
} }
Widget _buildDeviceList(BuildContext context, MeshCoreConnector connector) { Widget _buildDeviceList(BuildContext context, MeshCoreConnector connector) {
if (connector.scanResults.isEmpty) { if (connector.scanResults.isEmpty) {
return Center( final isBluetoothOff = _bluetoothState == BluetoothAdapterState.off;
child: Column( final isScanning = connector.state == MeshCoreConnectionState.scanning;
mainAxisAlignment: MainAxisAlignment.center, return EmptyState(
children: [ icon: isBluetoothOff ? Icons.bluetooth_disabled : Icons.bluetooth,
Icon(Icons.bluetooth, size: 64, color: Colors.grey[400]), title: isBluetoothOff
const SizedBox(height: 16), ? context.l10n.scanner_bluetoothOff
Text( : isScanning
connector.state == MeshCoreConnectionState.scanning ? context.l10n.scanner_searchingDevices
? context.l10n.scanner_searchingDevices : context.l10n.scanner_tapToScan,
: context.l10n.scanner_tapToScan, subtitle: isBluetoothOff
style: TextStyle(fontSize: 16, color: Colors.grey[600]), ? context.l10n.scanner_bluetoothOffMessage
), : null,
], action: (isBluetoothOff || isScanning)
), ? null
: FilledButton.icon(
onPressed: () {
HapticFeedback.lightImpact();
_toggleScan(connector);
},
icon: const Icon(Icons.bluetooth_searching),
label: Text(context.l10n.scanner_scan),
),
); );
} }
return ListView.separated( final isConnecting = connector.state == MeshCoreConnectionState.connecting;
padding: const EdgeInsets.all(8), return ListView.builder(
padding: const EdgeInsets.fromLTRB(0, 8, 0, 96),
itemCount: connector.scanResults.length, itemCount: connector.scanResults.length,
separatorBuilder: (context, index) => const Divider(),
itemBuilder: (context, index) { itemBuilder: (context, index) {
final result = connector.scanResults[index]; final result = connector.scanResults[index];
return DeviceTile( final deviceId = result.device.remoteId.toString();
scanResult: result, return ListEntrance(
onTap: () => _connectToDevice(context, connector, result), index: index,
child: DeviceTile(
scanResult: result,
isConnecting: isConnecting && _connectingDeviceId == deviceId,
onTap: isConnecting
? null
: () => _connectToDevice(context, connector, result),
),
); );
}, },
); );
@@ -293,6 +283,9 @@ class _ScannerScreenState extends State<ScannerScreen> {
final name = result.device.platformName.isNotEmpty final name = result.device.platformName.isNotEmpty
? result.device.platformName ? result.device.platformName
: result.advertisementData.advName; : result.advertisementData.advName;
setState(() {
_connectingDeviceId = result.device.remoteId.toString();
});
try { try {
await connector.connect( await connector.connect(
result.device, result.device,
@@ -321,9 +314,15 @@ class _ScannerScreenState extends State<ScannerScreen> {
showDismissibleSnackBar( showDismissibleSnackBar(
context, context,
content: Text(context.l10n.scanner_connectionFailed(e.toString())), content: Text(context.l10n.scanner_connectionFailed(e.toString())),
backgroundColor: Colors.red, backgroundColor: Theme.of(context).colorScheme.error,
); );
} }
} finally {
if (mounted) {
setState(() {
_connectingDeviceId = null;
});
}
} }
} }
@@ -412,47 +411,113 @@ class _ScannerScreenState extends State<ScannerScreen> {
); );
return pin; return pin;
} }
}
Widget _bluetoothOffWarning(BuildContext context) { // Private sub-widgets
final errorColor = Theme.of(context).colorScheme.error;
return Container( /// Bluetooth-off warning banner styled as an alert MeshCard.
width: double.infinity, class _BluetoothOffBanner extends StatelessWidget {
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), final VoidCallback? onEnable;
color: errorColor.withValues(alpha: 0.15),
const _BluetoothOffBanner({this.onEnable});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return MeshCard(
color: scheme.error.withValues(alpha: 0.08),
borderColor: scheme.error.withValues(alpha: 0.35),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
child: Row( child: Row(
children: [ children: [
Icon(Icons.bluetooth_disabled, size: 24, color: errorColor), Icon(Icons.bluetooth_disabled, size: 20, color: scheme.error),
const SizedBox(width: 12), const SizedBox(width: 10),
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [ children: [
Text( Text(
context.l10n.scanner_bluetoothOff, context.l10n.scanner_bluetoothOff,
style: TextStyle( style: TextStyle(
color: errorColor, color: scheme.error,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
fontSize: 14, fontSize: 13.5,
), ),
), ),
const SizedBox(height: 4), const SizedBox(height: 2),
Text( Text(
context.l10n.scanner_bluetoothOffMessage, context.l10n.scanner_bluetoothOffMessage,
style: TextStyle( style: TextStyle(
color: errorColor.withValues(alpha: 0.85), color: scheme.error.withValues(alpha: 0.8),
fontSize: 12, fontSize: 12,
), ),
), ),
], ],
), ),
), ),
if (PlatformInfo.isAndroid) if (onEnable != null) ...[
const SizedBox(width: 8),
TextButton( TextButton(
onPressed: () => FlutterBluePlus.turnOn(), onPressed: onEnable,
child: Text(context.l10n.scanner_enableBluetooth), child: Text(context.l10n.scanner_enableBluetooth),
), ),
],
], ],
), ),
); );
} }
} }
/// Connection status header with AnimatedSwitcher between states.
class _ConnectionStatusHeader extends StatelessWidget {
final MeshCoreConnector connector;
const _ConnectionStatusHeader({required this.connector});
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final scheme = Theme.of(context).colorScheme;
final (String label, Color color, bool pulse) = switch (connector.state) {
MeshCoreConnectionState.scanning => (
l10n.scanner_scanning,
MeshPalette.blue,
true,
),
MeshCoreConnectionState.connecting => (
l10n.scanner_connecting,
MeshPalette.warn,
true,
),
MeshCoreConnectionState.connected => (
l10n.scanner_connectedTo(connector.deviceDisplayName),
MeshPalette.signal,
false,
),
MeshCoreConnectionState.disconnecting => (
l10n.scanner_disconnecting,
MeshPalette.warn,
true,
),
MeshCoreConnectionState.disconnected => (
l10n.scanner_notConnected,
scheme.onSurfaceVariant,
false,
),
};
return Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: Align(
key: ValueKey(connector.state),
alignment: Alignment.centerLeft,
child: StatusChip(label: label, color: color, pulse: pulse),
),
),
);
}
}
File diff suppressed because it is too large Load Diff
+105 -74
View File
@@ -1,13 +1,16 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart'; import '../connector/meshcore_connector.dart';
import '../l10n/l10n.dart'; import '../l10n/l10n.dart';
import '../services/app_settings_service.dart'; import '../services/app_settings_service.dart';
import '../theme/mesh_theme.dart';
import '../utils/platform_info.dart'; import '../utils/platform_info.dart';
import '../widgets/adaptive_app_bar_title.dart'; import '../widgets/adaptive_app_bar_title.dart';
import '../widgets/mesh_ui.dart';
import '../helpers/snack_bar_builder.dart'; import '../helpers/snack_bar_builder.dart';
import 'channels_screen.dart'; import 'channels_screen.dart';
import 'usb_screen.dart'; import 'usb_screen.dart';
@@ -95,13 +98,32 @@ class _TcpScreenState extends State<TcpScreen> {
final isConnecting = final isConnecting =
connector.state == MeshCoreConnectionState.connecting && connector.state == MeshCoreConnectionState.connecting &&
connector.activeTransport == MeshCoreTransportType.tcp; connector.activeTransport == MeshCoreTransportType.tcp;
// Connect is only available from a fully disconnected state
// scanning, connecting, or an active session must settle first.
final isButtonDisabled = final isButtonDisabled =
isConnecting || connector.state != MeshCoreConnectionState.disconnected;
connector.state == MeshCoreConnectionState.scanning; return ListView(
return Column( padding: const EdgeInsets.only(bottom: 32),
children: [ children: [
_buildStatusBar(context, connector), // Status header
Padding( Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: Align(
key: ValueKey(connector.state),
alignment: Alignment.centerLeft,
child: _buildStatusChip(context, connector),
),
),
),
// Transport switcher
_buildTransportLinks(context),
// Connection form
const SectionHeader('TCP / IP'),
MeshCard(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
@@ -111,7 +133,6 @@ class _TcpScreenState extends State<TcpScreen> {
decoration: InputDecoration( decoration: InputDecoration(
labelText: context.l10n.tcpHostLabel, labelText: context.l10n.tcpHostLabel,
hintText: context.l10n.tcpHostHint, hintText: context.l10n.tcpHostHint,
border: const OutlineInputBorder(),
), ),
enabled: !isConnecting, enabled: !isConnecting,
keyboardType: TextInputType.url, keyboardType: TextInputType.url,
@@ -122,7 +143,6 @@ class _TcpScreenState extends State<TcpScreen> {
decoration: InputDecoration( decoration: InputDecoration(
labelText: context.l10n.tcpPortLabel, labelText: context.l10n.tcpPortLabel,
hintText: context.l10n.tcpPortHint, hintText: context.l10n.tcpPortHint,
border: const OutlineInputBorder(),
), ),
enabled: !isConnecting, enabled: !isConnecting,
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
@@ -130,7 +150,12 @@ class _TcpScreenState extends State<TcpScreen> {
const SizedBox(height: 16), const SizedBox(height: 16),
FilledButton.icon( FilledButton.icon(
key: const Key('tcp_connect_button'), key: const Key('tcp_connect_button'),
onPressed: isButtonDisabled ? null : _connectTcp, onPressed: isButtonDisabled
? null
: () {
HapticFeedback.lightImpact();
_connectTcp();
},
icon: isConnecting icon: isConnecting
? const SizedBox( ? const SizedBox(
width: 18, width: 18,
@@ -149,94 +174,100 @@ class _TcpScreenState extends State<TcpScreen> {
], ],
), ),
), ),
// Last used endpoint
if (connector.activeTcpEndpoint != null &&
connector.isTcpTransportConnected) ...[
const SectionHeader('CONNECTED TO'),
MeshCard(
padding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 10,
),
child: Row(
children: [
Icon(
Icons.lan,
size: 16,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 10),
Expanded(
child: Text(
connector.activeTcpEndpoint!,
style: MeshTheme.mono(
fontSize: 13,
color: Theme.of(context).colorScheme.onSurface,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
],
], ],
); );
}, },
), ),
), ),
bottomNavigationBar: SafeArea(
top: false,
minimum: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerRight,
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
if (PlatformInfo.supportsUsbSerial)
FloatingActionButton.extended(
onPressed: () {
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (_) => const UsbScreen()),
);
},
heroTag: 'tcp_usb_action',
extendedPadding: const EdgeInsets.symmetric(horizontal: 12),
icon: const Icon(Icons.usb),
label: Text(context.l10n.connectionChoiceUsbLabel),
),
if (PlatformInfo.supportsUsbSerial) const SizedBox(width: 12),
FloatingActionButton.extended(
onPressed: () {
Navigator.of(context).maybePop();
},
heroTag: 'tcp_ble_action',
extendedPadding: const EdgeInsets.symmetric(horizontal: 12),
icon: const Icon(Icons.bluetooth),
label: Text(context.l10n.connectionChoiceBluetoothLabel),
),
],
),
),
),
); );
} }
Widget _buildStatusBar(BuildContext context, MeshCoreConnector connector) { Widget _buildStatusChip(BuildContext context, MeshCoreConnector connector) {
final l10n = context.l10n; final l10n = context.l10n;
String statusText;
Color statusColor;
if (connector.isTcpTransportConnected) { if (connector.isTcpTransportConnected) {
statusText = l10n.scanner_connectedTo( return StatusChip(
connector.activeTcpEndpoint ?? 'TCP', label: l10n.scanner_connectedTo(connector.activeTcpEndpoint ?? 'TCP'),
color: MeshPalette.signal,
); );
statusColor = Colors.green;
} else if (connector.state == MeshCoreConnectionState.connecting && } else if (connector.state == MeshCoreConnectionState.connecting &&
connector.activeTransport == MeshCoreTransportType.tcp) { connector.activeTransport == MeshCoreTransportType.tcp) {
statusText = l10n.tcpStatus_connectingTo( return StatusChip(
'${_hostController.text}:${_portController.text}', label: l10n.tcpStatus_connectingTo(
'${_hostController.text}:${_portController.text}',
),
color: MeshPalette.warn,
pulse: true,
); );
statusColor = Colors.orange;
} else if (connector.state == MeshCoreConnectionState.disconnecting && } else if (connector.state == MeshCoreConnectionState.disconnecting &&
connector.activeTransport == MeshCoreTransportType.tcp) { connector.activeTransport == MeshCoreTransportType.tcp) {
statusText = l10n.scanner_disconnecting; return StatusChip(
statusColor = Colors.orange; label: l10n.scanner_disconnecting,
color: MeshPalette.warn,
pulse: true,
);
} else { } else {
statusText = l10n.tcpStatus_notConnected; return StatusChip(
statusColor = Colors.grey; label: l10n.tcpStatus_notConnected,
color: Theme.of(context).colorScheme.onSurfaceVariant,
);
} }
}
return Container( Widget _buildTransportLinks(BuildContext context) {
width: double.infinity, return Padding(
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
color: statusColor.withValues(alpha: 0.1), child: Wrap(
child: Row( spacing: 12,
runSpacing: 8,
children: [ children: [
Icon(Icons.circle, size: 12, color: statusColor), if (PlatformInfo.supportsUsbSerial)
const SizedBox(width: 8), OutlinedButton.icon(
Expanded( onPressed: () {
child: FittedBox( Navigator.of(context).pushReplacement(
fit: BoxFit.scaleDown, MaterialPageRoute(builder: (_) => const UsbScreen()),
alignment: Alignment.centerLeft, );
child: Text( },
statusText, icon: const Icon(Icons.usb),
style: TextStyle( label: Text(context.l10n.connectionChoiceUsbLabel),
color: statusColor,
fontWeight: FontWeight.w500,
),
),
), ),
OutlinedButton.icon(
onPressed: () => Navigator.of(context).maybePop(),
icon: const Icon(Icons.bluetooth),
label: Text(context.l10n.connectionChoiceBluetoothLabel),
), ),
], ],
), ),
@@ -274,7 +305,7 @@ class _TcpScreenState extends State<TcpScreen> {
showDismissibleSnackBar( showDismissibleSnackBar(
context, context,
content: Text(message), content: Text(message),
backgroundColor: Colors.red, backgroundColor: Theme.of(context).colorScheme.error,
); );
} }
+630 -145
View File
@@ -1,21 +1,26 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../l10n/l10n.dart'; import '../l10n/l10n.dart';
import '../models/contact.dart'; import '../models/contact.dart';
import '../models/path_selection.dart'; import '../models/path_selection.dart';
import '../models/app_settings.dart'; import '../models/app_settings.dart';
import '../storage/prefs_manager.dart';
import '../connector/meshcore_connector.dart'; import '../connector/meshcore_connector.dart';
import '../connector/meshcore_protocol.dart'; import '../connector/meshcore_protocol.dart';
import '../services/app_settings_service.dart'; import '../services/app_settings_service.dart';
import '../services/repeater_command_service.dart'; import '../services/repeater_command_service.dart';
import '../utils/app_logger.dart'; import '../utils/app_logger.dart';
import '../widgets/path_management_dialog.dart'; import '../widgets/routing_sheet.dart';
import '../helpers/cayenne_lpp.dart'; import '../helpers/cayenne_lpp.dart';
import '../utils/battery_utils.dart'; import '../utils/battery_utils.dart';
import '../helpers/snack_bar_builder.dart'; import '../helpers/snack_bar_builder.dart';
import '../widgets/sync_progress_overlay.dart'; import '../widgets/sync_progress_overlay.dart';
import '../widgets/telemetry_location_map.dart';
import '../theme/mesh_theme.dart';
import '../widgets/mesh_ui.dart';
class TelemetryScreen extends StatefulWidget { class TelemetryScreen extends StatefulWidget {
final Contact contact; final Contact contact;
@@ -27,6 +32,13 @@ class TelemetryScreen extends StatefulWidget {
} }
class _TelemetryScreenState extends State<TelemetryScreen> { class _TelemetryScreenState extends State<TelemetryScreen> {
static const int _autoRefreshDefaultIntervalSeconds = 20;
static const int _autoRefreshDefaultQuantity = 10;
static const int _autoRefreshMinIntervalSeconds = 10;
static const int _autoRefreshMaxIntervalSeconds = 300;
static const int _autoRefreshMinQuantity = 1;
static const int _autoRefreshMaxQuantity = 10;
int _tagData = 0; int _tagData = 0;
bool _isLoading = false; bool _isLoading = false;
@@ -37,6 +49,17 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
RepeaterCommandService? _commandService; RepeaterCommandService? _commandService;
PathSelection? _pendingStatusSelection; PathSelection? _pendingStatusSelection;
List<Map<String, dynamic>>? _parsedTelemetry; List<Map<String, dynamic>>? _parsedTelemetry;
final TextEditingController _autoRefreshIntervalController =
TextEditingController(text: '$_autoRefreshDefaultIntervalSeconds');
final TextEditingController _autoRefreshQuantityController =
TextEditingController(text: '$_autoRefreshDefaultQuantity');
Timer? _autoRefreshTimer;
bool _isAutoRefreshEnabled = false;
bool _activeTelemetryRequestIsAutoRefresh = false;
bool _autoRefreshLastAttemptFailed = false;
int _autoRefreshCurrentAttempt = 0;
int _autoRefreshTotalAttempts = 0;
int _autoRefreshIntervalSeconds = _autoRefreshDefaultIntervalSeconds;
int _tripTime = 0; int _tripTime = 0;
@@ -63,6 +86,7 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
super.initState(); super.initState();
final connector = Provider.of<MeshCoreConnector>(context, listen: false); final connector = Provider.of<MeshCoreConnector>(context, listen: false);
_commandService = RepeaterCommandService(connector); _commandService = RepeaterCommandService(connector);
_loadAutoRefreshSettings();
_setupMessageListener(); _setupMessageListener();
_loadTelemetry(); _loadTelemetry();
_hasData = false; _hasData = false;
@@ -82,17 +106,26 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
_tagData = reader.readUInt32LE(); _tagData = reader.readUInt32LE();
_tripTime = reader.readUInt32LE(); _tripTime = reader.readUInt32LE();
_statusTimeout?.cancel(); _statusTimeout?.cancel();
final isAutoRefreshRequest = _activeTelemetryRequestIsAutoRefresh;
_statusTimeout = Timer(Duration(milliseconds: _tripTime), () { _statusTimeout = Timer(Duration(milliseconds: _tripTime), () {
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_isLoading = false; _isLoading = false;
_isLoaded = false; _isLoaded = false;
if (isAutoRefreshRequest && _isAutoRefreshEnabled) {
_autoRefreshLastAttemptFailed = true;
}
}); });
showDismissibleSnackBar( if (!isAutoRefreshRequest) {
context, showDismissibleSnackBar(
content: Text(context.l10n.telemetry_requestTimeout), context,
backgroundColor: Colors.red, content: Text(context.l10n.telemetry_requestTimeout),
); backgroundColor: Theme.of(context).colorScheme.error,
);
}
if (isAutoRefreshRequest && _isAutoRefreshEnabled) {
_scheduleNextAutoRefreshAttempt();
}
_recordTelemetryResult(false); _recordTelemetryResult(false);
}); });
} }
@@ -134,15 +167,21 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
); );
} }
if (!mounted) return; if (!mounted) return;
final isAutoRefreshRequest = _activeTelemetryRequestIsAutoRefresh;
setState(() { setState(() {
_parsedTelemetry = parsedTelemetry; _parsedTelemetry = parsedTelemetry;
if (isAutoRefreshRequest) {
_autoRefreshLastAttemptFailed = false;
}
_activeTelemetryRequestIsAutoRefresh = false;
}); });
showDismissibleSnackBar( if (!isAutoRefreshRequest) {
context, showDismissibleSnackBar(
content: Text(context.l10n.telemetry_receivedData), context,
backgroundColor: Colors.green, content: Text(context.l10n.telemetry_receivedData),
); );
}
_statusTimeout?.cancel(); _statusTimeout?.cancel();
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
@@ -150,14 +189,18 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
_isLoaded = true; _isLoaded = true;
_hasData = true; _hasData = true;
}); });
if (isAutoRefreshRequest) {
_scheduleNextAutoRefreshAttempt();
}
} }
Future<void> _loadTelemetry() async { Future<void> _loadTelemetry({bool isAutoRefresh = false}) async {
if (_commandService == null) return; if (_commandService == null) return;
setState(() { setState(() {
_isLoading = true; _isLoading = true;
_isLoaded = false; _isLoaded = false;
_activeTelemetryRequestIsAutoRefresh = isAutoRefresh;
}); });
try { try {
final connector = Provider.of<MeshCoreConnector>(context, listen: false); final connector = Provider.of<MeshCoreConnector>(context, listen: false);
@@ -169,7 +212,7 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
if (widget.contact.type != advTypeChat) { if (widget.contact.type != advTypeChat) {
frame = buildSendBinaryReq( frame = buildSendBinaryReq(
widget.contact.publicKey, widget.contact.publicKey,
payload: Uint8List.fromList([reqTypeGetTelemetry]), payload: buildTelemetryBinaryPayload(),
); );
} else { } else {
frame = buildSendTelemetryReq(widget.contact.publicKey); frame = buildSendTelemetryReq(widget.contact.publicKey);
@@ -180,17 +223,76 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
setState(() { setState(() {
_isLoading = false; _isLoading = false;
_isLoaded = false; _isLoaded = false;
if (isAutoRefresh) {
_autoRefreshLastAttemptFailed = true;
}
_activeTelemetryRequestIsAutoRefresh = false;
}); });
if (isAutoRefresh) {
_scheduleNextAutoRefreshAttempt();
}
showDismissibleSnackBar( if (!isAutoRefresh) {
context, showDismissibleSnackBar(
content: Text(context.l10n.telemetry_errorLoading(e.toString())), context,
backgroundColor: Colors.red, content: Text(context.l10n.telemetry_errorLoading(e.toString())),
); backgroundColor: Theme.of(context).colorScheme.error,
);
}
} }
} }
} }
void _loadAutoRefreshSettings() {
final prefs = PrefsManager.instance;
final contactKey = widget.contact.publicKeyHex;
final interval =
(prefs.getInt(_autoRefreshIntervalKey(contactKey)) ??
_autoRefreshDefaultIntervalSeconds)
.clamp(
_autoRefreshMinIntervalSeconds,
_autoRefreshMaxIntervalSeconds,
)
.toInt();
final quantity =
(prefs.getInt(_autoRefreshQuantityKey(contactKey)) ??
_autoRefreshDefaultQuantity)
.clamp(_autoRefreshMinQuantity, _autoRefreshMaxQuantity)
.toInt();
_autoRefreshIntervalSeconds = interval;
_autoRefreshIntervalController.text = interval.toString();
_autoRefreshQuantityController.text = quantity.toString();
}
Future<void> _saveAutoRefreshSettings() async {
final contactKey = widget.contact.publicKeyHex;
final interval = _clampControllerValue(
controller: _autoRefreshIntervalController,
min: _autoRefreshMinIntervalSeconds,
max: _autoRefreshMaxIntervalSeconds,
fallback: _autoRefreshIntervalSeconds,
);
final quantity = _clampControllerValue(
controller: _autoRefreshQuantityController,
min: _autoRefreshMinQuantity,
max: _autoRefreshMaxQuantity,
fallback: _autoRefreshDefaultQuantity,
);
final prefs = PrefsManager.instance;
await prefs.setInt(_autoRefreshIntervalKey(contactKey), interval);
await prefs.setInt(_autoRefreshQuantityKey(contactKey), quantity);
}
String _autoRefreshIntervalKey(String contactKey) {
return 'telemetry_auto_refresh_interval_$contactKey';
}
String _autoRefreshQuantityKey(String contactKey) {
return 'telemetry_auto_refresh_quantity_$contactKey';
}
void _recordTelemetryResult(bool success) { void _recordTelemetryResult(bool success) {
final selection = _pendingStatusSelection; final selection = _pendingStatusSelection;
if (selection == null) return; if (selection == null) return;
@@ -206,19 +308,28 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
@override @override
void dispose() { void dispose() {
unawaited(_saveAutoRefreshSettings());
_frameSubscription?.cancel(); _frameSubscription?.cancel();
_commandService?.dispose(); _commandService?.dispose();
_statusTimeout?.cancel(); _statusTimeout?.cancel();
_autoRefreshTimer?.cancel();
_autoRefreshIntervalController.dispose();
_autoRefreshQuantityController.dispose();
super.dispose(); super.dispose();
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = context.l10n; final l10n = context.l10n;
final scheme = Theme.of(context).colorScheme;
final connector = context.watch<MeshCoreConnector>(); final connector = context.watch<MeshCoreConnector>();
final settings = context.watch<AppSettingsService>().settings; final settings = context.watch<AppSettingsService>().settings;
final isImperialUnits = settings.unitSystem == UnitSystem.imperial; final isImperialUnits = settings.unitSystem == UnitSystem.imperial;
final isFloodMode = widget.contact.pathOverride == -1; final contact = connector.contacts.firstWhere(
(c) => c.publicKeyHex == widget.contact.publicKeyHex,
orElse: () => widget.contact,
);
final isFloodMode = contact.pathOverride == -1;
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
@@ -242,70 +353,11 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
centerTitle: false, centerTitle: false,
bottom: const SyncProgressAppBarBottom(), bottom: const SyncProgressAppBarBottom(),
actions: [ actions: [
PopupMenuButton<String>( IconButton(
icon: Icon(isFloodMode ? Icons.waves : Icons.route), icon: Icon(isFloodMode ? Icons.waves : Icons.route),
tooltip: l10n.repeater_routingMode, tooltip: l10n.repeater_routingMode,
onSelected: (mode) async {
if (mode == 'flood') {
await connector.setPathOverride(widget.contact, pathLen: -1);
} else {
await connector.setPathOverride(widget.contact, pathLen: null);
}
},
itemBuilder: (context) => [
PopupMenuItem(
value: 'auto',
child: Row(
children: [
Icon(
Icons.auto_mode,
size: 20,
color: !isFloodMode
? Theme.of(context).primaryColor
: null,
),
const SizedBox(width: 8),
Text(
l10n.repeater_autoUseSavedPath,
style: TextStyle(
fontWeight: !isFloodMode
? FontWeight.bold
: FontWeight.normal,
),
),
],
),
),
PopupMenuItem(
value: 'flood',
child: Row(
children: [
Icon(
Icons.waves,
size: 20,
color: isFloodMode
? Theme.of(context).primaryColor
: null,
),
const SizedBox(width: 8),
Text(
l10n.repeater_forceFloodMode,
style: TextStyle(
fontWeight: isFloodMode
? FontWeight.bold
: FontWeight.normal,
),
),
],
),
),
],
),
IconButton(
icon: const Icon(Icons.timeline),
tooltip: l10n.repeater_pathManagement,
onPressed: () => onPressed: () =>
PathManagementDialog.show(context, contact: widget.contact), ContactRoutingSheet.show(context, contact: widget.contact),
), ),
IconButton( IconButton(
icon: _isLoading icon: _isLoading
@@ -315,7 +367,9 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
child: CircularProgressIndicator(strokeWidth: 2), child: CircularProgressIndicator(strokeWidth: 2),
) )
: const Icon(Icons.refresh), : const Icon(Icons.refresh),
onPressed: _isLoading ? null : _loadTelemetry, onPressed: (_isLoading || _isAutoRefreshEnabled)
? null
: () => _loadTelemetry(),
tooltip: l10n.repeater_refresh, tooltip: l10n.repeater_refresh,
), ),
], ],
@@ -323,7 +377,8 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
body: SafeArea( body: SafeArea(
top: false, top: false,
child: RefreshIndicator( child: RefreshIndicator(
onRefresh: _loadTelemetry, onRefresh: () =>
_isAutoRefreshEnabled ? Future.value() : _loadTelemetry(),
child: ListView( child: ListView(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
children: [ children: [
@@ -333,7 +388,10 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
Center( Center(
child: Text( child: Text(
l10n.telemetry_noData, l10n.telemetry_noData,
style: const TextStyle(fontSize: 16, color: Colors.grey), style: TextStyle(
fontSize: 16,
color: scheme.onSurfaceVariant,
),
), ),
), ),
if ((_isLoaded || _hasData) && if ((_isLoaded || _hasData) &&
@@ -346,6 +404,7 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
entry['channel'], entry['channel'],
isImperialUnits, isImperialUnits,
), ),
_buildAutoRefreshCard(),
], ],
), ),
), ),
@@ -359,85 +418,504 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
int channel, int channel,
bool isImperialUnits, bool isImperialUnits,
) { ) {
final l10n = context.l10n; return Column(
return Card( crossAxisAlignment: CrossAxisAlignment.start,
child: Padding( children: [
padding: const EdgeInsets.all(16), SectionHeader(title, padding: const EdgeInsets.fromLTRB(16, 16, 16, 8)),
child: Column( MeshCard(
crossAxisAlignment: CrossAxisAlignment.start, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
children: [ child: Column(
Row( crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Icon( for (final entry in channelData.entries)
Icons.info_outline, _buildTelemetryField(entry, channel, isImperialUnits),
color: Theme.of(context).textTheme.headlineSmall?.color, ],
), ),
const SizedBox(width: 8),
Text(
title,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
],
),
const Divider(),
for (final entry in channelData.entries)
if (entry.key == 'voltage' && channel == 1)
_buildInfoRow(
l10n.telemetry_batteryLabel,
_batteryText(entry.value),
)
else if (entry.key == 'voltage')
_buildInfoRow(
l10n.telemetry_voltageLabel,
l10n.telemetry_voltageValue(entry.value.toString()),
)
else if (entry.key == 'temperature' && channel == 1)
_buildInfoRow(
l10n.telemetry_mcuTemperatureLabel,
_temperatureText(entry.value, isImperialUnits),
)
else if (entry.key == 'temperature')
_buildInfoRow(
l10n.telemetry_temperatureLabel,
_temperatureText(entry.value, isImperialUnits),
)
else if (entry.key == 'current' && channel == 1)
_buildInfoRow(
l10n.telemetry_currentLabel,
l10n.telemetry_currentValue(entry.value.toString()),
)
else
_buildInfoRow(entry.key, entry.value.toString()),
],
), ),
), ],
); );
} }
Widget _buildTelemetryField(
MapEntry<String, dynamic> entry,
int channel,
bool isImperialUnits,
) {
if (entry.key == 'gps') {
return _buildGpsInfo(entry.value);
}
final display = _formatTelemetryField(
entry.key,
entry.value,
channel,
isImperialUnits,
);
return _buildInfoRow(display.label, display.value);
}
_TelemetryFieldDisplay _formatTelemetryField(
String key,
dynamic value,
int channel,
bool isImperialUnits,
) {
final l10n = context.l10n;
final text = _telemetryValueText(value);
switch (key) {
case 'digitalInput':
return _TelemetryFieldDisplay(l10n.telemetry_digitalInputLabel, text);
case 'digitalOutput':
return _TelemetryFieldDisplay(l10n.telemetry_digitalOutputLabel, text);
case 'analogInput':
return _TelemetryFieldDisplay(
l10n.telemetry_analogInputLabel,
l10n.telemetry_analogValue(text),
);
case 'analogOutput':
return _TelemetryFieldDisplay(
l10n.telemetry_analogOutputLabel,
l10n.telemetry_analogValue(text),
);
case 'generic':
return _TelemetryFieldDisplay(l10n.telemetry_genericLabel, text);
case 'luminosity':
return _TelemetryFieldDisplay(
l10n.telemetry_luminosityLabel,
l10n.telemetry_luminosityValue(text),
);
case 'presence':
return _TelemetryFieldDisplay(l10n.telemetry_presenceLabel, text);
case 'temperature':
return _TelemetryFieldDisplay(
channel == 1
? l10n.telemetry_mcuTemperatureLabel
: l10n.telemetry_temperatureLabel,
_temperatureText(value, isImperialUnits),
);
case 'humidity':
return _TelemetryFieldDisplay(l10n.telemetry_humidityLabel, text);
case 'accelerometer':
return _TelemetryFieldDisplay(
l10n.telemetry_accelerometerLabel,
_telemetryAxisText(value),
);
case 'pressure':
return _TelemetryFieldDisplay(
l10n.telemetry_pressureLabel,
l10n.telemetry_pressureValue(text),
);
case 'altitude':
return _TelemetryFieldDisplay(
l10n.telemetry_altitudeLabel,
l10n.telemetry_altitudeValue(text),
);
case 'voltage':
return _TelemetryFieldDisplay(
channel == 1
? l10n.telemetry_batteryLabel
: l10n.telemetry_voltageLabel,
channel == 1
? _batteryText(value)
: l10n.telemetry_voltageValue(text),
);
case 'current':
return _TelemetryFieldDisplay(
l10n.telemetry_currentLabel,
l10n.telemetry_currentValue(text),
);
case 'frequency':
return _TelemetryFieldDisplay(
l10n.telemetry_frequencyLabel,
l10n.telemetry_frequencyValue(text),
);
case 'percentage':
return _TelemetryFieldDisplay(
l10n.telemetry_percentageLabel,
l10n.telemetry_percentageValue(text),
);
case 'concentration':
return _TelemetryFieldDisplay(
l10n.telemetry_concentrationLabel,
l10n.telemetry_concentrationValue(text),
);
case 'power':
return _TelemetryFieldDisplay(
l10n.telemetry_powerLabel,
l10n.telemetry_powerValue(text),
);
case 'distance':
return _TelemetryFieldDisplay(
l10n.telemetry_distanceLabel,
l10n.telemetry_distanceValue(text),
);
case 'energy':
return _TelemetryFieldDisplay(
l10n.telemetry_energyLabel,
l10n.telemetry_energyValue(text),
);
case 'direction':
return _TelemetryFieldDisplay(
l10n.telemetry_directionLabel,
l10n.telemetry_directionValue(text),
);
case 'time':
return _TelemetryFieldDisplay(
l10n.telemetry_timeLabel,
_telemetryTimeText(value),
);
case 'gyrometer':
return _TelemetryFieldDisplay(
l10n.telemetry_gyrometerLabel,
_telemetryAxisText(value),
);
case 'colour':
return _TelemetryFieldDisplay(
l10n.telemetry_colourLabel,
_telemetryColorText(value),
);
case 'switch':
return _TelemetryFieldDisplay(l10n.telemetry_switchLabel, text);
case 'polyline':
return _TelemetryFieldDisplay(
l10n.telemetry_polylineLabel,
_telemetryMapText(value),
);
default:
return _TelemetryFieldDisplay(key, text);
}
}
Widget _buildAutoRefreshCard() {
final l10n = context.l10n;
final counterText = _autoRefreshCounterText();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SectionHeader(
l10n.common_autoRefresh,
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
),
MeshCard(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildAutoRefreshNumberField(
controller: _autoRefreshIntervalController,
label: l10n.common_interval,
min: _autoRefreshMinIntervalSeconds,
max: _autoRefreshMaxIntervalSeconds,
fallback: _autoRefreshIntervalSeconds,
),
const SizedBox(height: 12),
_buildAutoRefreshNumberField(
controller: _autoRefreshQuantityController,
label: l10n.telemetry_autoFetchQuantity,
min: _autoRefreshMinQuantity,
max: _autoRefreshMaxQuantity,
fallback: _autoRefreshDefaultQuantity,
),
if (counterText != null) ...[
const SizedBox(height: 12),
Text(
counterText,
textAlign: TextAlign.center,
style: TextStyle(
color: _autoRefreshLastAttemptFailed
? Theme.of(context).colorScheme.error
: null,
fontWeight: FontWeight.w600,
),
),
],
const SizedBox(height: 12),
FilledButton(
onPressed: _isLoading && !_isAutoRefreshEnabled
? null
: _toggleAutoRefresh,
child: _isAutoRefreshEnabled
? SizedBox(
width: double.infinity,
height: 20,
child: Stack(
alignment: Alignment.center,
children: [
Center(child: Text(l10n.common_disable)),
Positioned(
right: 0,
child: SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Theme.of(
context,
).colorScheme.onPrimary,
),
),
),
],
),
)
: Text(l10n.common_enable),
),
],
),
),
],
);
}
Widget _buildAutoRefreshNumberField({
required TextEditingController controller,
required String label,
required int min,
required int max,
required int fallback,
}) {
return TextField(
controller: controller,
enabled: !_isAutoRefreshEnabled,
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
decoration: InputDecoration(
labelText: label,
border: const OutlineInputBorder(),
isDense: true,
),
onEditingComplete: () {
_clampControllerValue(
controller: controller,
min: min,
max: max,
fallback: fallback,
);
unawaited(_saveAutoRefreshSettings());
FocusScope.of(context).unfocus();
},
onSubmitted: (_) => unawaited(_saveAutoRefreshSettings()),
onTapOutside: (_) {
unawaited(_saveAutoRefreshSettings());
FocusScope.of(context).unfocus();
},
);
}
String? _autoRefreshCounterText() {
if (!_isAutoRefreshEnabled && _autoRefreshCurrentAttempt == 0) return null;
final counter = '$_autoRefreshCurrentAttempt/$_autoRefreshTotalAttempts';
if (_autoRefreshLastAttemptFailed) {
return '${context.l10n.telemetry_error}: $counter';
}
return counter;
}
void _toggleAutoRefresh() {
if (_isAutoRefreshEnabled) {
_stopAutoRefresh();
return;
}
_startAutoRefresh();
}
void _startAutoRefresh() {
final interval = _clampControllerValue(
controller: _autoRefreshIntervalController,
min: _autoRefreshMinIntervalSeconds,
max: _autoRefreshMaxIntervalSeconds,
fallback: _autoRefreshIntervalSeconds,
);
final quantity = _clampControllerValue(
controller: _autoRefreshQuantityController,
min: _autoRefreshMinQuantity,
max: _autoRefreshMaxQuantity,
fallback: _autoRefreshDefaultQuantity,
);
unawaited(_saveAutoRefreshSettings());
setState(() {
_isAutoRefreshEnabled = true;
_autoRefreshIntervalSeconds = interval;
_autoRefreshTotalAttempts = quantity;
_autoRefreshCurrentAttempt = 0;
_autoRefreshLastAttemptFailed = false;
});
_runAutoRefreshAttempt();
}
void _stopAutoRefresh() {
_autoRefreshTimer?.cancel();
_autoRefreshTimer = null;
if (!mounted) return;
setState(() {
_isAutoRefreshEnabled = false;
});
}
Future<void> _runAutoRefreshAttempt() async {
if (!_isAutoRefreshEnabled || !mounted) return;
if (_autoRefreshCurrentAttempt >= _autoRefreshTotalAttempts) {
_stopAutoRefresh();
return;
}
setState(() {
_autoRefreshCurrentAttempt += 1;
});
await _loadTelemetry(isAutoRefresh: true);
}
void _scheduleNextAutoRefreshAttempt() {
if (!_isAutoRefreshEnabled || !mounted) return;
_autoRefreshTimer?.cancel();
if (_autoRefreshCurrentAttempt >= _autoRefreshTotalAttempts) {
_stopAutoRefresh();
return;
}
// Start the interval only after the current request has finished: after a
// telemetry response, timeout, or send error. This keeps slow replies from
// shortening the intended pause between requests.
_autoRefreshTimer = Timer(
Duration(seconds: _autoRefreshIntervalSeconds),
_runAutoRefreshAttempt,
);
}
int _clampControllerValue({
required TextEditingController controller,
required int min,
required int max,
required int fallback,
}) {
final parsed = int.tryParse(controller.text);
final value = (parsed ?? fallback).clamp(min, max).toInt();
controller.text = value.toString();
controller.selection = TextSelection.collapsed(
offset: controller.text.length,
);
return value;
}
Widget _buildGpsInfo(dynamic value) {
final latitude = _readGpsValue(value, 'latitude');
final longitude = _readGpsValue(value, 'longitude');
final altitude = _readGpsValue(value, 'altitude');
final isValidPosition = _isValidGpsPosition(latitude, longitude);
final gpsText = isValidPosition
? [
latitude!.toStringAsFixed(5),
longitude!.toStringAsFixed(5),
if (altitude != null) '${altitude.toStringAsFixed(1)} m',
].join(', ')
: value.toString();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildInfoRow(context.l10n.telemetry_gpsLabel, gpsText),
if (isValidPosition)
TelemetryLocationMap(
// The map renders only after bounds validation, keeping malformed
// Cayenne payloads from creating an invalid FlutterMap center.
latitude: latitude!,
longitude: longitude!,
label: widget.contact.name,
contactType: widget.contact.type,
contactPublicKeyHex: widget.contact.publicKeyHex,
),
],
);
}
double? _readGpsValue(dynamic value, String key) {
if (value is! Map) return null;
final rawValue = value[key];
if (rawValue is num) return rawValue.toDouble();
return null;
}
bool _isValidGpsPosition(double? latitude, double? longitude) {
if (latitude == null || longitude == null) return false;
const double epsilon = 1e-6;
return (latitude.abs() > epsilon || longitude.abs() > epsilon) &&
latitude >= -90.0 &&
latitude <= 90.0 &&
longitude >= -180.0 &&
longitude <= 180.0;
}
String _telemetryValueText(dynamic value) {
if (value == null) return context.l10n.common_notAvailable;
if (value is double) {
return value.toStringAsFixed(value.truncateToDouble() == value ? 0 : 2);
}
if (value is num) {
return value.toString();
}
return value.toString();
}
String _telemetryAxisText(dynamic value) {
if (value is! Map) return _telemetryValueText(value);
final x = _telemetryValueText(value['x']);
final y = _telemetryValueText(value['y']);
final z = _telemetryValueText(value['z']);
return 'X: $x, Y: $y, Z: $z';
}
String _telemetryColorText(dynamic value) {
if (value is! Map) return _telemetryValueText(value);
final red = _telemetryValueText(value['red']);
final green = _telemetryValueText(value['green']);
final blue = _telemetryValueText(value['blue']);
return 'R: $red, G: $green, B: $blue';
}
String _telemetryMapText(dynamic value) {
if (value is! Map) return _telemetryValueText(value);
return value.entries
.map((entry) => '${entry.key}: ${entry.value}')
.join(', ');
}
String _telemetryTimeText(dynamic value) {
if (value is! num || value <= 0) return _telemetryValueText(value);
final dateTime = DateTime.fromMillisecondsSinceEpoch(
value.toInt() * 1000,
isUtc: true,
).toLocal();
final localizations = MaterialLocalizations.of(context);
final time = localizations.formatTimeOfDay(
TimeOfDay.fromDateTime(dateTime),
);
return '${localizations.formatFullDate(dateTime)} $time';
}
Widget _buildInfoRow(String label, String value) { Widget _buildInfoRow(String label, String value) {
final scheme = Theme.of(context).colorScheme;
return Padding( return Padding(
padding: const EdgeInsets.symmetric(vertical: 6), padding: const EdgeInsets.symmetric(vertical: 6),
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
SizedBox( Expanded(
width: 130,
child: Text( child: Text(
label, label,
style: TextStyle( style: TextStyle(
color: Colors.grey[600], color: scheme.onSurfaceVariant,
fontSize: 13,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
), ),
), ),
), ),
Expanded( const SizedBox(width: 8),
child: Text( Text(
value, value,
style: const TextStyle(fontWeight: FontWeight.w400), style: MeshTheme.mono(fontSize: 13, color: scheme.onSurface),
), textAlign: TextAlign.end,
), ),
], ],
), ),
@@ -485,3 +963,10 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
return '${tempC.toStringAsFixed(1)}°C'; return '${tempC.toStringAsFixed(1)}°C';
} }
} }
class _TelemetryFieldDisplay {
final String label;
final String value;
const _TelemetryFieldDisplay(this.label, this.value);
}
+150 -142
View File
@@ -6,13 +6,15 @@ import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart'; import '../connector/meshcore_connector.dart';
import '../l10n/l10n.dart'; import '../l10n/l10n.dart';
import '../theme/mesh_theme.dart';
import '../utils/app_logger.dart'; import '../utils/app_logger.dart';
import '../utils/platform_info.dart'; import '../utils/platform_info.dart';
import '../utils/usb_port_labels.dart'; import '../utils/usb_port_labels.dart';
import '../widgets/adaptive_app_bar_title.dart'; import '../widgets/adaptive_app_bar_title.dart';
import '../widgets/empty_state.dart';
import '../widgets/mesh_ui.dart';
import '../helpers/snack_bar_builder.dart'; import '../helpers/snack_bar_builder.dart';
import 'channels_screen.dart'; import 'channels_screen.dart';
import 'scanner_screen.dart';
import 'tcp_screen.dart'; import 'tcp_screen.dart';
class UsbScreen extends StatefulWidget { class UsbScreen extends StatefulWidget {
@@ -98,138 +100,124 @@ class _UsbScreenState extends State<UsbScreen> {
child: Consumer<MeshCoreConnector>( child: Consumer<MeshCoreConnector>(
builder: (context, connector, child) { builder: (context, connector, child) {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
_buildStatusBar(context, connector), // Status header
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: Align(
key: ValueKey('${connector.state}_$_isLoadingPorts'),
alignment: Alignment.centerLeft,
child: _buildStatusChip(context, connector),
),
),
),
// Transport switcher
_buildTransportLinks(context),
// Port list
Expanded(child: _buildPortList(context, connector)), Expanded(child: _buildPortList(context, connector)),
], ],
); );
}, },
), ),
), ),
bottomNavigationBar: Consumer<MeshCoreConnector>( bottomNavigationBar: _supportsHotPlug
builder: (context, connector, child) { ? null
final isLoading = _isLoadingPorts; : SafeArea(
final showBle = true; top: false,
final showTcp = !PlatformInfo.isWeb; minimum: const EdgeInsets.fromLTRB(16, 8, 16, 16),
return SafeArea(
top: false,
minimum: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerRight,
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.end, mainAxisAlignment: MainAxisAlignment.end,
children: [ children: [
if (showTcp) FloatingActionButton.extended(
FloatingActionButton.extended( onPressed: _isLoadingPorts ? null : _loadPorts,
onPressed: () { heroTag: 'usb_refresh_action',
Navigator.of(context).pushReplacement( icon: _isLoadingPorts
MaterialPageRoute(builder: (_) => const TcpScreen()), ? const SizedBox(
); width: 20,
}, height: 20,
heroTag: 'usb_tcp_action', child: CircularProgressIndicator(strokeWidth: 2),
extendedPadding: const EdgeInsets.symmetric( )
horizontal: 12, : const Icon(Icons.usb),
), label: Text(context.l10n.scanner_scan),
icon: const Icon(Icons.lan), ),
label: Text(context.l10n.connectionChoiceTcpLabel),
),
if (showTcp && showBle) const SizedBox(width: 12),
if (showBle)
FloatingActionButton.extended(
onPressed: () {
Navigator.of(context).pushReplacement(
MaterialPageRoute(
builder: (_) => const ScannerScreen(),
),
);
},
heroTag: 'usb_ble_action',
extendedPadding: const EdgeInsets.symmetric(
horizontal: 12,
),
icon: const Icon(Icons.bluetooth),
label: Text(context.l10n.connectionChoiceBluetoothLabel),
),
if ((showTcp || showBle) && !_supportsHotPlug)
const SizedBox(width: 12),
if (!_supportsHotPlug)
FloatingActionButton.extended(
onPressed: isLoading ? null : _loadPorts,
heroTag: 'usb_refresh_action',
extendedPadding: const EdgeInsets.symmetric(
horizontal: 12,
),
icon: isLoading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.usb),
label: Text(context.l10n.scanner_scan),
),
], ],
), ),
), ),
);
},
),
); );
} }
Widget _buildStatusBar(BuildContext context, MeshCoreConnector connector) { Widget _buildStatusChip(BuildContext context, MeshCoreConnector connector) {
final l10n = context.l10n; final l10n = context.l10n;
String statusText; final scheme = Theme.of(context).colorScheme;
Color statusColor;
if (_isLoadingPorts) { if (_isLoadingPorts) {
statusText = l10n.usbStatus_searching; return StatusChip(
statusColor = Colors.blue; label: l10n.usbStatus_searching,
color: scheme.primary,
pulse: true,
);
} else if (connector.isUsbTransportConnected) { } else if (connector.isUsbTransportConnected) {
switch (connector.state) { switch (connector.state) {
case MeshCoreConnectionState.connected: case MeshCoreConnectionState.connected:
statusText = l10n.scanner_connectedTo( return StatusChip(
connector.activeUsbPortDisplayLabel ?? 'USB', label: l10n.scanner_connectedTo(
connector.activeUsbPortDisplayLabel ?? 'USB',
),
color: MeshPalette.signal,
); );
statusColor = Colors.green;
case MeshCoreConnectionState.disconnecting: case MeshCoreConnectionState.disconnecting:
statusText = l10n.scanner_disconnecting; return StatusChip(
statusColor = Colors.orange; label: l10n.scanner_disconnecting,
color: MeshPalette.warn,
pulse: true,
);
default: default:
statusText = l10n.usbStatus_notConnected; return StatusChip(
statusColor = Colors.grey; label: l10n.usbStatus_notConnected,
color: scheme.onSurfaceVariant,
);
} }
} else if (connector.state == MeshCoreConnectionState.connecting && } else if (connector.state == MeshCoreConnectionState.connecting &&
connector.activeTransport == MeshCoreTransportType.usb) { connector.activeTransport == MeshCoreTransportType.usb) {
statusText = l10n.usbStatus_connecting; return StatusChip(
statusColor = Colors.orange; label: l10n.usbStatus_connecting,
color: MeshPalette.warn,
pulse: true,
);
} else { } else {
statusText = l10n.usbStatus_notConnected; return StatusChip(
statusColor = Colors.grey; label: l10n.usbStatus_notConnected,
color: scheme.onSurfaceVariant,
);
} }
}
return Container( Widget _buildTransportLinks(BuildContext context) {
width: double.infinity, return Padding(
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
color: statusColor.withValues(alpha: 0.1), child: Wrap(
child: Row( spacing: 12,
runSpacing: 8,
children: [ children: [
Icon(Icons.circle, size: 12, color: statusColor), if (!PlatformInfo.isWeb)
const SizedBox(width: 8), OutlinedButton.icon(
Expanded( onPressed: () {
child: FittedBox( Navigator.of(context).pushReplacement(
fit: BoxFit.scaleDown, MaterialPageRoute(builder: (_) => const TcpScreen()),
alignment: Alignment.centerLeft, );
child: Text( },
statusText, icon: const Icon(Icons.lan),
style: TextStyle( label: Text(context.l10n.connectionChoiceTcpLabel),
color: statusColor,
fontWeight: FontWeight.w500,
),
),
), ),
OutlinedButton.icon(
onPressed: () => Navigator.of(context).maybePop(),
icon: const Icon(Icons.bluetooth),
label: Text(context.l10n.connectionChoiceBluetoothLabel),
), ),
], ],
), ),
@@ -240,46 +228,20 @@ class _UsbScreenState extends State<UsbScreen> {
final l10n = context.l10n; final l10n = context.l10n;
if (_isLoadingPorts) { if (_isLoadingPorts) {
return Center( return EmptyState(icon: Icons.usb, title: l10n.usbStatus_searching);
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.usb, size: 64, color: Colors.grey[400]),
const SizedBox(height: 16),
Text(
l10n.usbStatus_searching,
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
),
],
),
);
} }
if (_ports.isEmpty) { if (_ports.isEmpty) {
return Center( return EmptyState(icon: Icons.usb, title: l10n.usbScreenEmptyState);
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.usb, size: 64, color: Colors.grey[400]),
const SizedBox(height: 16),
Text(
l10n.usbScreenEmptyState,
textAlign: TextAlign.center,
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
),
],
),
);
} }
final isConnecting = final isConnecting =
connector.state == MeshCoreConnectionState.connecting && connector.state == MeshCoreConnectionState.connecting &&
connector.activeTransport == MeshCoreTransportType.usb; connector.activeTransport == MeshCoreTransportType.usb;
return ListView.separated( return ListView.builder(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.only(bottom: 32),
itemCount: _ports.length, itemCount: _ports.length,
separatorBuilder: (context, index) => const Divider(),
itemBuilder: (context, index) { itemBuilder: (context, index) {
final port = _ports[index]; final port = _ports[index];
final displayName = friendlyUsbPortName(port); final displayName = friendlyUsbPortName(port);
@@ -287,18 +249,50 @@ class _UsbScreenState extends State<UsbScreen> {
final showRawName = final showRawName =
rawName != displayName && !rawName.startsWith('web:'); rawName != displayName && !rawName.startsWith('web:');
return ListTile( return ListEntrance(
leading: const Icon(Icons.usb), index: index,
title: Text( child: MeshCard(
displayName, padding: EdgeInsets.zero,
style: const TextStyle(fontWeight: FontWeight.w500), child: ListTile(
onTap: isConnecting
? null
: () {
HapticFeedback.selectionClick();
_connectPort(port);
},
leading: AvatarCircle(
name: displayName,
size: 40,
icon: Icons.usb,
color: Theme.of(context).colorScheme.primary,
),
title: Text(
displayName,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.onSurface,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: showRawName
? Text(
rawName,
style: MeshTheme.mono(
fontSize: 11,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
)
: null,
trailing: Icon(
Icons.chevron_right,
size: 18,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
), ),
subtitle: showRawName ? Text(rawName) : null,
trailing: ElevatedButton(
onPressed: isConnecting ? null : () => _connectPort(port),
child: Text(l10n.common_connect),
),
onTap: isConnecting ? null : () => _connectPort(port),
); );
}, },
); );
@@ -384,13 +378,27 @@ class _UsbScreenState extends State<UsbScreen> {
void _showError(Object error) { void _showError(Object error) {
if (!mounted) return; if (!mounted) return;
// Cancelling the browser's serial port picker is a normal user action, not
// an error don't show a scary red toast (and never leak the raw
// DOMException text).
if (_isUserCancelledPortPicker(error)) return;
showDismissibleSnackBar( showDismissibleSnackBar(
context, context,
content: Text(_friendlyErrorMessage(error)), content: Text(_friendlyErrorMessage(error)),
backgroundColor: Colors.red, backgroundColor: Theme.of(context).colorScheme.error,
); );
} }
bool _isUserCancelledPortPicker(Object error) {
if (error is StateError &&
error.message.contains('No USB serial device selected')) {
return true;
}
final text = error.toString();
return text.contains('No port selected by the user') ||
text.contains("Failed to execute 'requestPort'");
}
String _friendlyErrorMessage(Object error) { String _friendlyErrorMessage(Object error) {
final l10n = context.l10n; final l10n = context.l10n;
+79 -103
View File
@@ -1,7 +1,7 @@
import 'dart:math' as math; import 'dart:math' as math;
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/material.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_map/flutter_map.dart';
@@ -15,7 +15,9 @@ enum MapRasterSourcePreset {
stamenTerrain('stamen_terrain'), stamenTerrain('stamen_terrain'),
alidadeSmoothDark('alidade_smooth_dark'), alidadeSmoothDark('alidade_smooth_dark'),
outdoors('outdoors'), outdoors('outdoors'),
osmBright('osm_bright'); osmBright('osm_bright'),
outdoorsDark('outdoors_dark'),
osmBrightDark('osm_bright_dark');
const MapRasterSourcePreset(this.id); const MapRasterSourcePreset(this.id);
@@ -129,19 +131,24 @@ class MapRasterSourceCatalog {
isStadia: true, isStadia: true,
allowsBulkDownload: true, allowsBulkDownload: true,
); );
static const MapRasterSourceDefinition outdoorsDark =
MapRasterSourceDefinition(
id: 'outdoors',
label: 'Outdoors Dark',
description: 'Dark version of the Outdoors map style',
isStadia: true,
allowsBulkDownload: true,
);
static const MapRasterSourceDefinition osmBrightDark =
MapRasterSourceDefinition(
id: 'osm_bright',
label: 'OSM Bright Dark',
description: 'Dark version of the OSM Bright map style',
isStadia: true,
allowsBulkDownload: true,
);
static const List<MapRasterSourceDefinition> presets = [ static MapRasterSourceDefinition fromPreset(MapRasterSourcePreset preset) {
osmAuto,
osmStandard,
osmDark,
stamenTerrain,
alidadeSmoothDark,
outdoors,
osmBright,
];
static MapRasterSourceDefinition fromSettings(AppSettings settings) {
final preset = MapRasterSourcePreset.fromId(settings.mapRasterSourceId);
switch (preset) { switch (preset) {
case MapRasterSourcePreset.osmAuto: case MapRasterSourcePreset.osmAuto:
return osmAuto; return osmAuto;
@@ -153,45 +160,19 @@ class MapRasterSourceCatalog {
return alidadeSmoothDark; return alidadeSmoothDark;
case MapRasterSourcePreset.outdoors: case MapRasterSourcePreset.outdoors:
return outdoors; return outdoors;
case MapRasterSourcePreset.outdoorsDark:
return outdoorsDark;
case MapRasterSourcePreset.osmBright: case MapRasterSourcePreset.osmBright:
return osmBright; return osmBright;
case MapRasterSourcePreset.osmBrightDark:
return osmBrightDark;
case MapRasterSourcePreset.stamenTerrain: case MapRasterSourcePreset.stamenTerrain:
return stamenTerrain; return stamenTerrain;
} }
} }
static MapRasterSourceDefinition resolveFromSettings( static MapRasterSourceDefinition fromSettings(AppSettings settings) {
AppSettings settings, { return fromPreset(MapRasterSourcePreset.fromId(settings.mapRasterSourceId));
Brightness? platformBrightness,
}) {
final selected = fromSettings(settings);
if (selected.id != osmAuto.id) return selected;
return _prefersDarkTheme(
settings.themeMode,
platformBrightness: platformBrightness,
)
? osmDark
: osmStandard;
}
static bool _prefersDarkTheme(
String themeMode, {
Brightness? platformBrightness,
}) {
switch (themeMode) {
case 'dark':
return true;
case 'light':
return false;
default:
return (platformBrightness ??
WidgetsBinding
.instance
.platformDispatcher
.platformBrightness) ==
Brightness.dark;
}
} }
} }
@@ -325,18 +306,37 @@ class MapTileCacheService extends ChangeNotifier {
} }
MapRasterSourceDefinition get source => MapRasterSourceDefinition get source =>
MapRasterSourceCatalog.resolveFromSettings(appSettingsService.settings); MapRasterSourceCatalog.fromSettings(appSettingsService.settings);
MapRasterEndpointDefinition get endpoint => MapRasterEndpointDefinition get endpoint =>
MapRasterEndpointCatalog.fromSettings(appSettingsService.settings); MapRasterEndpointCatalog.fromSettings(appSettingsService.settings);
String get urlTemplate => _buildUrlTemplate(appSettingsService.settings); String get urlTemplate => _buildUrlTemplate(appSettingsService.settings);
TileBuilder? get tileBuilder => TileBuilder? get tileBuilder => null;
isInvertedOsmDarkSource ? _osmDarkTileBuilder : null;
bool get isInvertedOsmDarkSource => static const ColorFilter _darkMapFilter = ColorFilter.matrix([
source.id == MapRasterSourceCatalog.osmDark.id; -0.0850,
-0.2861,
-0.0289,
0,
120,
-0.0957,
-0.3218,
-0.0325,
0,
140,
-0.1169,
-0.3934,
-0.0397,
0,
170,
0,
0,
0,
1,
0,
]);
CacheManager get _concreteCacheManager => cacheManager as CacheManager; CacheManager get _concreteCacheManager => cacheManager as CacheManager;
@@ -344,6 +344,34 @@ class MapTileCacheService extends ChangeNotifier {
'User-Agent': 'flutter_map ($userAgentPackageName)', 'User-Agent': 'flutter_map ($userAgentPackageName)',
}; };
Widget buildTileLayer(BuildContext context, {double opacity = 1}) {
final source = this.source;
Widget layer = TileLayer(
urlTemplate: urlTemplate,
tileProvider: tileProvider,
tileBuilder: tileBuilder,
userAgentPackageName: userAgentPackageName,
maxZoom: 19,
);
final shouldApplyDarkFilter =
source == MapRasterSourceCatalog.osmDark ||
source == MapRasterSourceCatalog.outdoorsDark ||
source == MapRasterSourceCatalog.osmBrightDark ||
(source == MapRasterSourceCatalog.osmAuto &&
Theme.of(context).brightness == Brightness.dark);
if (shouldApplyDarkFilter) {
layer = ColorFiltered(colorFilter: _darkMapFilter, child: layer);
}
if (opacity < 1) {
layer = Opacity(opacity: opacity, child: layer);
}
return layer;
}
Future<void> clearCache() async { Future<void> clearCache() async {
await cacheManager.emptyCache(); await cacheManager.emptyCache();
} }
@@ -681,58 +709,6 @@ class MapTileCacheService extends ChangeNotifier {
} }
} }
Widget _osmDarkTileBuilder(BuildContext _, Widget tileWidget, TileImage tile) {
return ColorFiltered(
colorFilter: const ColorFilter.matrix(<double>[
1.33,
0,
0,
0,
0,
0,
1.33,
0,
0,
0,
0,
0,
1.33,
0,
0,
0,
0,
0,
1,
0,
]),
child: ColorFiltered(
colorFilter: const ColorFilter.matrix(<double>[
0.5740000009536743,
-1.4299999475479126,
-0.14399999380111694,
0,
255,
-0.4259999990463257,
-0.429999977350235,
-0.14399999380111694,
0,
255,
-0.4259999990463257,
-1.4299999475479126,
0.8559999465942383,
0,
255,
0,
0,
0,
1,
0,
]),
child: tileWidget,
),
);
}
class CachedNetworkTileProvider extends TileProvider { class CachedNetworkTileProvider extends TileProvider {
final BaseCacheManager cacheManager; final BaseCacheManager cacheManager;
+65 -8
View File
@@ -39,7 +39,12 @@ class RetryServiceConfig {
final void Function(Message) updateMessage; final void Function(Message) updateMessage;
final Function(Contact)? clearContactPath; final Function(Contact)? clearContactPath;
final Function(Contact, Uint8List, int)? setContactPath; final Function(Contact, Uint8List, int)? setContactPath;
final int Function(int pathLength, int messageBytes, {String? contactKey})? final int Function(
int pathLength,
int messageBytes, {
String? contactKey,
int? deviceTimeoutMs,
})?
calculateTimeout; calculateTimeout;
final Uint8List? Function()? getSelfPublicKey; final Uint8List? Function()? getSelfPublicKey;
final String Function(Contact, String)? prepareContactOutboundText; final String Function(Contact, String)? prepareContactOutboundText;
@@ -74,6 +79,12 @@ class RetryServiceConfig {
class MessageRetryService extends ChangeNotifier { class MessageRetryService extends ChangeNotifier {
static const int maxAckHistorySize = 100; static const int maxAckHistorySize = 100;
/// Global cap on concurrent in-flight messages across ALL contacts.
/// The firmware's expected_ack_table is a single 8-entry circular buffer
/// shared globally; cap at 6 to leave two slots of headroom.
static const int _maxGlobalInFlight = 6;
int _maxRetries = 5; int _maxRetries = 5;
int get maxRetries => _maxRetries; int get maxRetries => _maxRetries;
@@ -170,8 +181,9 @@ class MessageRetryService extends ChangeNotifier {
_config?.addMessage(contact.publicKeyHex, message); _config?.addMessage(contact.publicKeyHex, message);
// Queue per contact only one message in-flight at a time to avoid // Queue per contact one message in-flight per contact at a time, and
// overflowing the firmware's 8-entry expected_ack_table. // bounded globally by _maxGlobalInFlight across all contacts so we never
// overflow the firmware's 8-entry global expected_ack_table.
final contactKey = contact.publicKeyHex; final contactKey = contact.publicKeyHex;
_sendQueue[contactKey] ??= []; _sendQueue[contactKey] ??= [];
_sendQueue[contactKey]!.add(messageId); _sendQueue[contactKey]!.add(messageId);
@@ -184,6 +196,11 @@ class MessageRetryService extends ChangeNotifier {
} }
void _sendNextForContact(String contactKey) { void _sendNextForContact(String contactKey) {
// Enforce the global in-flight cap before starting a new send.
// The firmware's expected_ack_table is a single 8-entry circular buffer
// shared across all contacts; exceeding it silently evicts an older slot.
if (_activeMessages.length >= _maxGlobalInFlight) return;
final queue = _sendQueue[contactKey]; final queue = _sendQueue[contactKey];
if (queue == null) return; if (queue == null) return;
@@ -210,8 +227,23 @@ class MessageRetryService extends ChangeNotifier {
void _onMessageResolved(String messageId, String contactKey) { void _onMessageResolved(String messageId, String contactKey) {
if (_resolvedMessages.contains(messageId)) return; if (_resolvedMessages.contains(messageId)) return;
_resolvedMessages.add(messageId); _resolvedMessages.add(messageId);
_activeMessages.remove(messageId); // If cleanup already removed this message from the active set, it has
// already pumped the queues; avoid double-pumping.
if (!_activeMessages.remove(messageId)) return;
_pumpQueues(contactKey);
}
void _pumpQueues(String contactKey) {
// Pump this contact's queue first, then any other contacts that are waiting.
_sendNextForContact(contactKey); _sendNextForContact(contactKey);
for (final key in _sendQueue.keys) {
if (key == contactKey) continue;
if (_activeMessages.length >= _maxGlobalInFlight) break;
final queue = _sendQueue[key];
if (queue != null && queue.isNotEmpty) {
_sendNextForContact(key);
}
}
} }
PathSelection? _selectPathForAttempt(Message message, Contact contact) { PathSelection? _selectPathForAttempt(Message message, Contact contact) {
@@ -352,6 +384,10 @@ class MessageRetryService extends ChangeNotifier {
} }
bool updateMessageFromSent(int ackHash, int timeoutMs) { bool updateMessageFromSent(int ackHash, int timeoutMs) {
// Firmware sets expected_ack = 0 for CLI/command sends (TXT_TYPE_CLI_DATA).
// No ACK will ever be issued for these, so arming a retry timer is wrong.
if (ackHash == 0) return false;
final config = _config; final config = _config;
if (config == null) return false; if (config == null) return false;
@@ -404,13 +440,18 @@ class MessageRetryService extends ChangeNotifier {
// Calculate timeout: prefer ML prediction, then device-provided, then physics fallback // Calculate timeout: prefer ML prediction, then device-provided, then physics fallback
final pathLengthValue = message.pathLength ?? contact.pathLength; final pathLengthValue = message.pathLength ?? contact.pathLength;
final outboundTextForTimeout =
config.prepareContactOutboundText?.call(contact, message.text) ??
message.text;
final messageBytesForTimeout = utf8.encode(outboundTextForTimeout).length;
int actualTimeout = timeoutMs; int actualTimeout = timeoutMs;
if (config.calculateTimeout != null) { if (config.calculateTimeout != null) {
actualTimeout = config.calculateTimeout!( actualTimeout = config.calculateTimeout!(
pathLengthValue, pathLengthValue,
message.text.length, messageBytesForTimeout,
contactKey: contact.publicKeyHex, contactKey: contact.publicKeyHex,
deviceTimeoutMs: timeoutMs > 0 ? timeoutMs : null,
); );
} }
@@ -449,17 +490,28 @@ class MessageRetryService extends ChangeNotifier {
}); });
} }
void untrack(String messageId) {
_timeoutTimers[messageId]?.cancel();
_cleanupMessage(messageId);
}
void _cleanupMessage(String messageId) { void _cleanupMessage(String messageId) {
_moveAckHashesToHistory(messageId); _moveAckHashesToHistory(messageId);
_ackHashToMessageId.removeWhere( _ackHashToMessageId.removeWhere(
(_, mapping) => mapping.messageId == messageId, (_, mapping) => mapping.messageId == messageId,
); );
_expectedHashToMessageId.removeWhere((_, msgId) => msgId == messageId); _expectedHashToMessageId.removeWhere((_, msgId) => msgId == messageId);
final contactKey = _pendingContacts[messageId]?.publicKeyHex;
_pendingMessages.remove(messageId); _pendingMessages.remove(messageId);
_pendingContacts.remove(messageId); _pendingContacts.remove(messageId);
_attemptPathHistory.remove(messageId); _attemptPathHistory.remove(messageId);
_timeoutTimers.remove(messageId); _timeoutTimers.remove(messageId);
_resolvedMessages.remove(messageId); _resolvedMessages.remove(messageId);
// Cancellation (and other cleanup paths) must release the active in-flight
// slot and pump waiting queues so the global cap does not stall forever.
if (_activeMessages.remove(messageId) && contactKey != null) {
_pumpQueues(contactKey);
}
} }
void _handleTimeout(String messageId) { void _handleTimeout(String messageId) {
@@ -612,7 +664,6 @@ class MessageRetryService extends ChangeNotifier {
for (final expectedHash in expectedHashes) { for (final expectedHash in expectedHashes) {
if (expectedHash == ackHash) { if (expectedHash == ackHash) {
matchedMessageId = messageId; matchedMessageId = messageId;
matchedAttemptIndex = expectedHashes.indexOf(expectedHash);
break; break;
} }
} }
@@ -664,10 +715,16 @@ class MessageRetryService extends ChangeNotifier {
if (config?.onDeliveryObserved != null && if (config?.onDeliveryObserved != null &&
tripTimeMs > 0 && tripTimeMs > 0 &&
message.pathLength != null) { message.pathLength != null) {
config!.onDeliveryObserved!( final outboundTextForObserved =
config!.prepareContactOutboundText?.call(contact, message.text) ??
message.text;
final messageBytesForObserved = utf8
.encode(outboundTextForObserved)
.length;
config.onDeliveryObserved!(
contact.publicKeyHex, contact.publicKeyHex,
message.pathLength!, message.pathLength!,
message.text.length, messageBytesForObserved,
tripTimeMs, tripTimeMs,
); );
} }
+42 -8
View File
@@ -114,6 +114,36 @@ class NotificationService {
return _isInitialized; return _isInitialized;
} }
// Cached "are we allowed to post notifications" result. Null = not yet
// determined. Avoids calling _notifications.show() when it would only throw
// "You must request notifications permissions first" (every web build, and
// Android 13+ before the user grants the permission).
bool? _canNotify;
Future<bool> _ensureCanNotify() async {
if (!await _ensureInitialized()) return false;
final cached = _canNotify;
if (cached != null) return cached;
// flutter_local_notifications has no web backend, so show() always throws.
// Skip silently instead of logging an error per incoming message.
if (kIsWeb) return _canNotify = false;
// On Android 13+ notifications require an explicit grant; reflect the real
// OS state so we don't spam failed show() calls when denied.
final androidPlugin = _notifications
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin
>();
if (androidPlugin != null) {
final enabled = await androidPlugin.areNotificationsEnabled();
return _canNotify = enabled ?? false;
}
// iOS/macOS request permission during initialize(); desktop has no gate.
return _canNotify = true;
}
Future<bool> requestPermissions() async { Future<bool> requestPermissions() async {
if (!_isInitialized) { if (!_isInitialized) {
await initialize(); await initialize();
@@ -126,7 +156,8 @@ class NotificationService {
>(); >();
if (androidPlugin != null) { if (androidPlugin != null) {
final granted = await androidPlugin.requestNotificationsPermission(); final granted = await androidPlugin.requestNotificationsPermission();
return granted ?? false; _canNotify = granted ?? false;
return _canNotify!;
} }
// iOS permissions are requested during initialization // iOS permissions are requested during initialization
@@ -140,7 +171,8 @@ class NotificationService {
badge: true, badge: true,
sound: true, sound: true,
); );
return granted ?? false; _canNotify = granted ?? false;
return _canNotify!;
} }
return true; return true;
@@ -165,7 +197,7 @@ class NotificationService {
String? contactId, String? contactId,
int? badgeCount, int? badgeCount,
}) async { }) async {
if (!await _ensureInitialized()) return; if (!await _ensureCanNotify()) return;
final androidDetails = AndroidNotificationDetails( final androidDetails = AndroidNotificationDetails(
'messages', 'messages',
@@ -215,7 +247,7 @@ class NotificationService {
required String contactType, required String contactType,
String? contactId, String? contactId,
}) async { }) async {
if (!await _ensureInitialized()) return; if (!await _ensureCanNotify()) return;
const androidDetails = AndroidNotificationDetails( const androidDetails = AndroidNotificationDetails(
'adverts', 'adverts',
@@ -248,7 +280,7 @@ class NotificationService {
await _notifications.show( await _notifications.show(
id: contactId != null id: contactId != null
? 'advert:$contactId'.hashCode ? 'advert:$contactId'.hashCode
: DateTime.now().millisecondsSinceEpoch, : DateTime.now().millisecondsSinceEpoch & 0x7FFFFFFF,
title: _l10n.notification_newTypeDiscovered(contactType), title: _l10n.notification_newTypeDiscovered(contactType),
body: contactName, body: contactName,
notificationDetails: notificationDetails, notificationDetails: notificationDetails,
@@ -265,7 +297,7 @@ class NotificationService {
int? channelIndex, int? channelIndex,
int? badgeCount, int? badgeCount,
}) async { }) async {
if (!await _ensureInitialized()) return; if (!await _ensureCanNotify()) return;
final androidDetails = AndroidNotificationDetails( final androidDetails = AndroidNotificationDetails(
'channel_messages', 'channel_messages',
@@ -304,7 +336,9 @@ class NotificationService {
try { try {
await _notifications.show( await _notifications.show(
id: channelIndex?.hashCode ?? DateTime.now().millisecondsSinceEpoch, id:
channelIndex?.hashCode ??
DateTime.now().millisecondsSinceEpoch & 0x7FFFFFFF,
title: channelName, title: channelName,
body: body, body: body,
notificationDetails: notificationDetails, notificationDetails: notificationDetails,
@@ -543,7 +577,7 @@ class NotificationService {
} }
Future<void> _showBatchSummary(List<_PendingNotification> batch) async { Future<void> _showBatchSummary(List<_PendingNotification> batch) async {
if (!await _ensureInitialized()) return; if (!await _ensureCanNotify()) return;
// Group by type // Group by type
final messages = batch final messages = batch
+3 -1
View File
@@ -134,10 +134,12 @@ class PathHistoryService extends ChangeNotifier {
newWeight = (currentWeight + successIncrement).clamp(0.0, maxWeight); newWeight = (currentWeight + successIncrement).clamp(0.0, maxWeight);
} else { } else {
newWeight = currentWeight - failureDecrement; newWeight = currentWeight - failureDecrement;
if (newWeight <= 0) { if (newWeight <= 0 && failureCount >= 3) {
removePathRecord(contactPubKeyHex, selection.pathBytes); removePathRecord(contactPubKeyHex, selection.pathBytes);
return; return;
} }
// Keep the record with a small floor weight until we have enough evidence
newWeight = newWeight.clamp(0.1, maxWeight);
} }
_addPathRecord( _addPathRecord(
+20 -8
View File
@@ -63,12 +63,15 @@ class TimeoutPredictionService extends ChangeNotifier {
required int tripTimeMs, required int tripTimeMs,
int secondsSinceLastRx = 0, int secondsSinceLastRx = 0,
}) { }) {
final isFlood = pathLength < 0;
final observation = DeliveryObservation( final observation = DeliveryObservation(
contactKey: contactKey, contactKey: contactKey,
pathLength: pathLength, // Clamp to 0 for flood so the hop-count slope is learned from direct paths
// only; isFlood carries the flood signal as a separate feature.
pathLength: isFlood ? 0 : pathLength,
messageBytes: messageBytes, messageBytes: messageBytes,
secondsSinceLastRx: secondsSinceLastRx, secondsSinceLastRx: secondsSinceLastRx,
isFlood: pathLength < 0, isFlood: isFlood,
deliveryMs: tripTimeMs, deliveryMs: tripTimeMs,
timestamp: DateTime.now(), timestamp: DateTime.now(),
); );
@@ -76,11 +79,12 @@ class TimeoutPredictionService extends ChangeNotifier {
_observations.add(observation); _observations.add(observation);
if (_observations.length > maxObservations) { if (_observations.length > maxObservations) {
_observations.removeAt(0); _observations.removeAt(0);
_rebuildContactStats();
} else {
_contactStats.putIfAbsent(contactKey, () => _ContactStats());
_contactStats[contactKey]!.add(tripTimeMs.toDouble());
} }
_contactStats.putIfAbsent(contactKey, () => _ContactStats());
_contactStats[contactKey]!.add(tripTimeMs.toDouble());
_observationsSinceLastTrain++; _observationsSinceLastTrain++;
if (_observationsSinceLastTrain >= _retrainInterval && if (_observationsSinceLastTrain >= _retrainInterval &&
_observations.length >= minObservations) { _observations.length >= minObservations) {
@@ -108,11 +112,14 @@ class TimeoutPredictionService extends ChangeNotifier {
try { try {
if (_activeFeatures.isEmpty) return null; if (_activeFeatures.isEmpty) return null;
final flood = pathLength < 0;
final allFeatures = { final allFeatures = {
'pathLength': pathLength.toDouble(), // Clamp to 0 for flood mirrors recordObservation so training and
// prediction see the same pathLength values; isFlood carries the signal.
'pathLength': flood ? 0.0 : pathLength.toDouble(),
'messageBytes': messageBytes.toDouble(), 'messageBytes': messageBytes.toDouble(),
'secSinceRx': secondsSinceLastRx.toDouble(), 'secSinceRx': secondsSinceLastRx.toDouble(),
'isFlood': pathLength < 0 ? 1.0 : 0.0, 'isFlood': flood ? 1.0 : 0.0,
}; };
final row = _activeFeatures.map((f) => allFeatures[f]!).toList(); final row = _activeFeatures.map((f) => allFeatures[f]!).toList();
@@ -164,7 +171,9 @@ class TimeoutPredictionService extends ChangeNotifier {
// (ml_algo's OLS produces all-zero coefficients for singular matrices) // (ml_algo's OLS produces all-zero coefficients for singular matrices)
final allNames = ['pathLength', 'messageBytes', 'secSinceRx', 'isFlood']; final allNames = ['pathLength', 'messageBytes', 'secSinceRx', 'isFlood'];
final allExtractors = <double Function(DeliveryObservation)>[ final allExtractors = <double Function(DeliveryObservation)>[
(o) => o.pathLength.toDouble(), // pathLength is already clamped to >=0 in recordObservation, but guard
// here as well for any observations loaded from older persisted data.
(o) => o.pathLength < 0 ? 0.0 : o.pathLength.toDouble(),
(o) => o.messageBytes.toDouble(), (o) => o.messageBytes.toDouble(),
(o) => o.secondsSinceLastRx.toDouble(), (o) => o.secondsSinceLastRx.toDouble(),
(o) => o.isFlood ? 1.0 : 0.0, (o) => o.isFlood ? 1.0 : 0.0,
@@ -215,6 +224,9 @@ class TimeoutPredictionService extends ChangeNotifier {
@override @override
void dispose() { void dispose() {
if (_persistTimer?.isActive == true) {
_storage?.saveDeliveryObservations(_observations);
}
_persistTimer?.cancel(); _persistTimer?.cancel();
super.dispose(); super.dispose();
} }
+22 -9
View File
@@ -47,6 +47,7 @@ class TranslationService extends ChangeNotifier {
_langDetectInit = initLangDetect(); _langDetectInit = initLangDetect();
} }
bool _disposed = false;
bool _isBusy = false; bool _isBusy = false;
bool _isDownloading = false; bool _isDownloading = false;
bool _cancelDownloadRequested = false; bool _cancelDownloadRequested = false;
@@ -215,7 +216,7 @@ class TranslationService extends ChangeNotifier {
} }
_downloadTotalBytes = totalSize; _downloadTotalBytes = totalSize;
notifyListeners(); _notify();
DownloadedModelFile downloaded; DownloadedModelFile downloaded;
if (supportsRange && if (supportsRange &&
@@ -268,7 +269,7 @@ class TranslationService extends ChangeNotifier {
throw StateError('Model download failed: HTTP ${response.statusCode}'); throw StateError('Model download failed: HTTP ${response.statusCode}');
} }
_downloadTotalBytes ??= response.contentLength; _downloadTotalBytes ??= response.contentLength;
notifyListeners(); _notify();
final trackedStream = _trackDownloadProgress(response.stream); final trackedStream = _trackDownloadProgress(response.stream);
return await _fileStore.writeModelBytes( return await _fileStore.writeModelBytes(
fileName: fileName, fileName: fileName,
@@ -313,7 +314,7 @@ class TranslationService extends ChangeNotifier {
throw const TranslationDownloadCancelled(); throw const TranslationDownloadCancelled();
} }
_downloadFileName = 'Merging chunks...'; _downloadFileName = 'Merging chunks...';
notifyListeners(); _notify();
combineReached = true; combineReached = true;
return await _fileStore.combineChunks( return await _fileStore.combineChunks(
fileName: fileName, fileName: fileName,
@@ -361,7 +362,7 @@ class TranslationService extends ChangeNotifier {
} }
_cancelDownloadRequested = true; _cancelDownloadRequested = true;
_lastError = 'Download stopped.'; _lastError = 'Download stopped.';
notifyListeners(); _notify();
} }
Future<void> removeModel(TranslationModelRecord model) async { Future<void> removeModel(TranslationModelRecord model) async {
@@ -469,7 +470,7 @@ class TranslationService extends ChangeNotifier {
} catch (error) { } catch (error) {
_lastError = error.toString(); _lastError = error.toString();
appLogger.warn('Language detection failed: $error'); appLogger.warn('Language detection failed: $error');
notifyListeners(); _notify();
return null; return null;
} }
} }
@@ -538,7 +539,7 @@ class TranslationService extends ChangeNotifier {
} catch (error) { } catch (error) {
_lastError = error.toString(); _lastError = error.toString();
appLogger.warn('Translation request failed: $error'); appLogger.warn('Translation request failed: $error');
notifyListeners(); _notify();
return null; return null;
} }
} }
@@ -631,6 +632,10 @@ class TranslationService extends ChangeNotifier {
final completer = Completer<T>(); final completer = Completer<T>();
_setBusy(true); _setBusy(true);
_queue = _queue.then((_) async { _queue = _queue.then((_) async {
if (_disposed) {
completer.completeError(StateError('TranslationService disposed.'));
return;
}
try { try {
completer.complete(await action()); completer.complete(await action());
} catch (error, stackTrace) { } catch (error, stackTrace) {
@@ -648,17 +653,24 @@ class TranslationService extends ChangeNotifier {
throw const TranslationDownloadCancelled(); throw const TranslationDownloadCancelled();
} }
_downloadedBytes += chunk.length; _downloadedBytes += chunk.length;
notifyListeners(); _notify();
yield chunk; yield chunk;
} }
} }
void _notify() {
if (_disposed) {
return;
}
notifyListeners();
}
void _setBusy(bool value) { void _setBusy(bool value) {
if (_isBusy == value) { if (_isBusy == value) {
return; return;
} }
_isBusy = value; _isBusy = value;
notifyListeners(); _notify();
} }
void _setDownloading(bool value) { void _setDownloading(bool value) {
@@ -669,11 +681,12 @@ class TranslationService extends ChangeNotifier {
_downloadTotalBytes = null; _downloadTotalBytes = null;
_downloadFileName = null; _downloadFileName = null;
} }
notifyListeners(); _notify();
} }
@override @override
void dispose() { void dispose() {
_disposed = true;
final engine = _engine; final engine = _engine;
_engine = null; _engine = null;
_loadedModelPath = null; _loadedModelPath = null;
@@ -33,12 +33,14 @@ class UsbSerialService {
String? _connectedPortLabel; String? _connectedPortLabel;
FlSerial? _serial; FlSerial? _serial;
AppDebugLogService? _debugLogService; AppDebugLogService? _debugLogService;
Object? _lastError;
UsbSerialStatus get status => _status; UsbSerialStatus get status => _status;
String? get activePortKey => _connectedPortKey; String? get activePortKey => _connectedPortKey;
String? get activePortDisplayLabel => String? get activePortDisplayLabel =>
_connectedPortLabel ?? _connectedPortKey; _connectedPortLabel ?? _connectedPortKey;
Stream<Uint8List> get frameStream => _frameController.stream; Stream<Uint8List> get frameStream => _frameController.stream;
Object? get lastError => _lastError;
bool get _useAndroidUsbHost => bool get _useAndroidUsbHost =>
!kIsWeb && defaultTargetPlatform == TargetPlatform.android; !kIsWeb && defaultTargetPlatform == TargetPlatform.android;
bool get _useDesktopFlSerial => bool get _useDesktopFlSerial =>
@@ -434,6 +436,7 @@ class UsbSerialService {
} }
void _addFrameError(Object error, [StackTrace? stackTrace]) { void _addFrameError(Object error, [StackTrace? stackTrace]) {
_lastError = error;
if (_frameController.isClosed) { if (_frameController.isClosed) {
return; return;
} }
+48 -11
View File
@@ -15,6 +15,18 @@ class UsbSerialService {
static const Map<String, String> _knownUsbNames = <String, String>{ static const Map<String, String> _knownUsbNames = <String, String>{
'2886:1667': 'Seeed Wio Tracker L1', '2886:1667': 'Seeed Wio Tracker L1',
}; };
/// USB-to-UART bridge chips whose hardware auto-reset circuit requires DTR
/// to be held asserted after open (otherwise the MCU resets). Native-USB-CDC
/// boards (nRF52840/Adafruit 0x239A, Espressif native 0x303A, Seeed 0x2886)
/// tie DTR to the bootloader/reset line, so asserting it re-enumerates and
/// drops the device ("The device has been lost"); they must be left alone.
static const Set<int> _uartBridgeVendorIds = <int>{
0x10C4, // Silicon Labs CP210x
0x1A86, // QinHeng CH340 / CH9102
0x0403, // FTDI
0x067B, // Prolific PL2303
};
static final Map<String, String> _deviceNamesByPortKey = <String, String>{}; static final Map<String, String> _deviceNamesByPortKey = <String, String>{};
static final Map<String, String> _baseLabelsByPortKey = <String, String>{}; static final Map<String, String> _baseLabelsByPortKey = <String, String>{};
static final Map<String, JSObject> _authorizedPortsByKey = static final Map<String, JSObject> _authorizedPortsByKey =
@@ -34,12 +46,14 @@ class UsbSerialService {
String _requestPortLabel = 'Choose USB Device'; String _requestPortLabel = 'Choose USB Device';
String _fallbackDeviceName = 'Web Serial Device'; String _fallbackDeviceName = 'Web Serial Device';
AppDebugLogService? _debugLogService; AppDebugLogService? _debugLogService;
Object? _lastError;
UsbSerialStatus get status => _status; UsbSerialStatus get status => _status;
String? get activePortKey => _connectedPortKey; String? get activePortKey => _connectedPortKey;
String? get activePortDisplayLabel => _connectedPortName ?? _connectedPortKey; String? get activePortDisplayLabel => _connectedPortName ?? _connectedPortKey;
Stream<Uint8List> get frameStream => _frameController.stream; Stream<Uint8List> get frameStream => _frameController.stream;
bool get isConnected => _status == UsbSerialStatus.connected; bool get isConnected => _status == UsbSerialStatus.connected;
Object? get lastError => _lastError;
JSObject get _navigator => JSObject.fromInteropObject(web.window.navigator); JSObject get _navigator => JSObject.fromInteropObject(web.window.navigator);
bool get _isSupported => _navigator.has('serial'); bool get _isSupported => _navigator.has('serial');
@@ -74,6 +88,7 @@ class UsbSerialService {
} }
_status = UsbSerialStatus.connecting; _status = UsbSerialStatus.connecting;
_lastError = null;
_frameDecoder.reset(); _frameDecoder.reset();
try { try {
@@ -282,16 +297,30 @@ class UsbSerialService {
..['flowControl'] = 'none'.toJS; ..['flowControl'] = 'none'.toJS;
await port.callMethod<JSPromise<JSAny?>>('open'.toJS, options).toDart; await port.callMethod<JSPromise<JSAny?>>('open'.toJS, options).toDart;
// Prevent ESP32 USB-CDC reset: hold DTR=true, RTS=false after open. // Only UART-bridge chips (CP210x/CH340/FTDI/PL2303) need DTR held high to
try { // avoid the auto-reset circuit firing on open. Native-USB-CDC boards
final signals = JSObject() // (e.g. nRF52840/Adafruit) tie DTR to the reset line toggling it there
..['dataTerminalReady'] = true.toJS // re-enumerates the device and Web Serial reports "The device has been
..['requestToSend'] = false.toJS; // lost". Leave their signals untouched.
await port final vendorId = _portInfo(port)?.usbVendorId;
.callMethod<JSPromise<JSAny?>>('setSignals'.toJS, signals) final isUartBridge =
.toDart; vendorId != null && _uartBridgeVendorIds.contains(vendorId);
} catch (_) { _debugLogService?.info(
// setSignals may not be supported on all browsers/devices. 'Open: vendorId=${vendorId == null ? 'unknown' : '0x${vendorId.toRadixString(16)}'} '
'uartBridge=$isUartBridge (DTR ${isUartBridge ? 'asserted' : 'left default'})',
tag: 'USB Serial',
);
if (isUartBridge) {
try {
final signals = JSObject()
..['dataTerminalReady'] = true.toJS
..['requestToSend'] = false.toJS;
await port
.callMethod<JSPromise<JSAny?>>('setSignals'.toJS, signals)
.toDart;
} catch (_) {
// setSignals may not be supported on all browsers/devices.
}
} }
} }
@@ -384,13 +413,21 @@ class UsbSerialService {
} catch (error, stackTrace) { } catch (error, stackTrace) {
_debugLogService?.error('_pumpReads error: $error', tag: 'USB Serial'); _debugLogService?.error('_pumpReads error: $error', tag: 'USB Serial');
if (_status == UsbSerialStatus.connected) { if (_status == UsbSerialStatus.connected) {
// The transport is dead reflect that in status immediately so a
// concurrent connect handshake fails fast instead of waiting for a
// SELF_INFO that can never arrive.
_status = UsbSerialStatus.disconnected;
_lastError = error;
_addFrameError(error, stackTrace); _addFrameError(error, stackTrace);
} }
} finally { } finally {
_debugLogService?.info('_pumpReads: ended', tag: 'USB Serial'); _debugLogService?.info('_pumpReads: ended', tag: 'USB Serial');
_releaseLock(reader); _releaseLock(reader);
if (_status == UsbSerialStatus.connected && identical(reader, _reader)) { if (_status == UsbSerialStatus.connected && identical(reader, _reader)) {
_addFrameError(StateError('USB serial connection closed')); _status = UsbSerialStatus.disconnected;
final closedError = StateError('USB serial connection closed');
_lastError = closedError;
_addFrameError(closedError);
} }
} }
} }
+237 -69
View File
@@ -1,70 +1,112 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
/// MeshCore redesign palette warm field-journal dark theme with /// MeshCore palette high-contrast slate surfaces with sky-blue accents.
/// phosphor-green signal accents. Mirrors values from the redesign spec.
class MeshPalette { class MeshPalette {
MeshPalette._(); MeshPalette._();
// Surfaces (warm near-black, olive undertone) // Surfaces shared with the map overlays and navigation.
static const bg = Color(0xFF0F1412); static const bg = Color(0xFF0B1220);
static const bg1 = Color(0xFF161C19); static const bg1 = Color(0xFF0F172A);
static const bg2 = Color(0xFF1D2521); static const bg2 = Color(0xFF162033);
static const bg3 = Color(0xFF28322D); static const bg3 = Color(0xFF1E293B);
static const bg4 = Color(0xFF34403A); static const bg4 = Color(0xFF334155);
// Lines // Lines
static const line = Color(0xFF232C28); static const line = Color(0xFF1E293B);
static const line2 = Color(0xFF34403A); static const line2 = Color(0xFF334155);
static const line3 = Color(0xFF48564F); static const line3 = Color(0xFF475569);
// Ink // Ink
static const ink = Color(0xFFEFF3E8); static const ink = Color(0xFFF8FAFC);
static const ink2 = Color(0xFFBAC4B5); static const ink2 = Color(0xFFCBD5E1);
static const ink3 = Color(0xFF7C8B82); static const ink3 = Color(0xFF94A3B8);
static const ink4 = Color(0xFF55635B); static const ink4 = Color(0xFF64748B);
// Signal (phosphor) // Signal-quality green (used only for SNR coloring, not UI chrome)
static const signal = Color(0xFF7BEFA8); static const signal = Color(0xFF22C55E);
static const signalDim = Color(0xFF4DC580); static const signalDim = Color(0xFF16A34A);
static const signalBg = Color(0x177BEFA8); // ~9% alpha
static const signalLine = Color(0x427BEFA8); // ~26%
static const signalGlow = Color(0x597BEFA8); // ~35%
// Warn (ember) // Warn
static const warn = Color(0xFFFFA552); static const warn = Color(0xFFF59E0B);
static const warnDim = Color(0xFFC27E3C); static const warnDim = Color(0xFFD97706);
static const warnBg = Color(0x1CFFA552); static const warnBg = Color(0x1FF59E0B);
static const warnLine = Color(0x4DFFA552); static const warnLine = Color(0x66F59E0B);
// Alert (coral) // Alert
static const alert = Color(0xFFFF6A5C); static const alert = Color(0xFFEF4444);
static const alertBg = Color(0x1CFF6A5C); static const alertBg = Color(0x1FEF4444);
static const alertLine = Color(0x52FF6A5C); static const alertLine = Color(0x66EF4444);
// Blue (dusk sky) // Blue primary map/app accent
static const blue = Color(0xFF7FCBF5); static const blue = Color(0xFF0EA5E9);
static const blueBg = Color(0x1C7FCBF5); static const blueDim = Color(0xFF0284C7);
static const blueLine = Color(0x477FCBF5); static const blueBg = Color(0x290EA5E9);
static const blueLine = Color(0x800EA5E9);
// Magenta // Magenta
static const magenta = Color(0xFFDE7FDB); static const magenta = Color(0xFFDE7FDB);
static const magentaBg = Color(0x1CDE7FDB); static const magentaBg = Color(0x1CDE7FDB);
static const magentaLine = Color(0x47DE7FDB); static const magentaLine = Color(0x47DE7FDB);
// Me bubble (mossy) // Me bubble (dusk blue)
static const me = Color(0xFF1E3527); static const me = Color(0xFF0C4A6E);
static const meBorder = Color(0xFF2D5039); static const meBorder = Color(0xFF0369A1);
static const meInk = Color(0xFFDEF0DC); static const meInk = Color(0xFFF0F9FF);
// Light variant (used when user explicitly picks light theme) // Light variant (used when user explicitly picks light theme)
static const lightBg = Color(0xFFF5F3EC); static const lightBg = Color(0xFFF4F6F8);
static const lightBg1 = Color(0xFFECE9DF); static const lightBg1 = Color(0xFFEAEEF2);
static const lightBg2 = Color(0xFFE2DED2); static const lightBg2 = Color(0xFFDFE5EA);
static const lightLine = Color(0xFFCAC5B4); static const lightLine = Color(0xFFC3CCD4);
static const lightInk = Color(0xFF0F1410); static const lightInk = Color(0xFF10161B);
static const lightInk2 = Color(0xFF3D463E); static const lightInk2 = Color(0xFF3C4853);
static const lightInk3 = Color(0xFF6A756D); static const lightInk3 = Color(0xFF69767F);
static const lightSignal = Color(0xFF1A7A44); static const lightBlue = Color(0xFF2F6EA8);
}
/// High-contrast semantic colors for UI rendered over variable map tiles.
class MapPalette {
MapPalette._();
static const online = Color(0xFF22C55E);
static const offline = Color(0xFF6B7280);
static const stale = Color(0xFFF59E0B);
static const repeater = Color(0xFF2563EB);
static const router = Color(0xFF7C3AED);
static const batteryLow = Color(0xFFEF4444);
static const cluster = Color(0xFFF97316);
static const selected = Color(0xFF0EA5E9);
static const sensor = Color(0xFF0F766E);
static const shared = Color(0xFF0369A1);
static const panelLight = Color(0xF0FFFFFF);
static const panelDark = Color(0xF50B1220);
static const textPrimary = Color(0xFFF8FAFC);
static const textSecondary = Color(0xFFCBD5E1);
static const textMuted = Color(0xFF94A3B8);
static const border = Color(0x5264758B);
static const markerOutline = Colors.white;
static const markerShadow = Color(0xB3000000);
}
/// High-contrast colors for line-of-sight maps and elevation profiles.
class LosPalette {
LosPalette._();
static const terrain = Color(0xFFA3E635);
static const beam = Color(0xFF38BDF8);
static const horizon = Color(0xFFFBBF24);
static const blocked = Color(0xFFEF4444);
static const marginal = Color(0xFFF59E0B);
static const clear = Color(0xFF22C55E);
static const selected = Color(0xFF0EA5E9);
static const chartBackground = Color(0xFF0B1220);
static const panelDark = Color(0xF00F172A);
static const panelLight = Color(0xF5FFFFFF);
static const text = Color(0xFFF8FAFC);
static const textMuted = Color(0xFFCBD5E1);
static const border = Color(0x5264758B);
static const shadow = Color(0x99000000);
} }
/// Named font stacks Flutter falls back to system fonts when the named /// Named font stacks Flutter falls back to system fonts when the named
@@ -75,6 +117,7 @@ class MeshFonts {
static const sans = 'Inter'; static const sans = 'Inter';
static const mono = 'JetBrains Mono'; static const mono = 'JetBrains Mono';
static const display = 'Instrument Serif'; static const display = 'Instrument Serif';
static const emoji = 'Noto Color Emoji';
static const List<String> sansFallback = [ static const List<String> sansFallback = [
'system-ui', 'system-ui',
@@ -96,6 +139,11 @@ class MeshFonts {
'Times New Roman', 'Times New Roman',
'serif', 'serif',
]; ];
static const List<String> emojiFallback = [
'Apple Color Emoji',
'Segoe UI Emoji',
'Noto Emoji',
];
} }
/// Radii used consistently across the app. /// Radii used consistently across the app.
@@ -115,18 +163,22 @@ class MeshTheme {
static ThemeData dark() { static ThemeData dark() {
const scheme = ColorScheme.dark( const scheme = ColorScheme.dark(
primary: MeshPalette.signal, primary: MeshPalette.blue,
onPrimary: Color(0xFF0A1810), onPrimary: Colors.white,
primaryContainer: MeshPalette.signalBg, primaryContainer: Color(0xFF075985),
onPrimaryContainer: MeshPalette.signal, onPrimaryContainer: Colors.white,
secondary: MeshPalette.blue, secondary: MeshPalette.magenta,
onSecondary: Color(0xFF0A1520), onSecondary: Colors.white,
tertiary: MeshPalette.magenta, secondaryContainer: Color(0xFF331A33),
onTertiary: Color(0xFF201020), onSecondaryContainer: Colors.white,
tertiary: MeshPalette.warn,
onTertiary: Color(0xFF0B1220),
tertiaryContainer: Color(0xFF78350F),
onTertiaryContainer: Colors.white,
error: MeshPalette.alert, error: MeshPalette.alert,
onError: Color(0xFF1A0A08), onError: Colors.white,
errorContainer: MeshPalette.alertBg, errorContainer: Color(0xFF7F1D1D),
onErrorContainer: MeshPalette.alert, onErrorContainer: Colors.white,
surface: MeshPalette.bg, surface: MeshPalette.bg,
onSurface: MeshPalette.ink, onSurface: MeshPalette.ink,
surfaceContainerLowest: MeshPalette.bg, surfaceContainerLowest: MeshPalette.bg,
@@ -141,33 +193,39 @@ class MeshTheme {
scrim: Colors.black54, scrim: Colors.black54,
inverseSurface: MeshPalette.ink, inverseSurface: MeshPalette.ink,
onInverseSurface: MeshPalette.bg, onInverseSurface: MeshPalette.bg,
inversePrimary: MeshPalette.signalDim, inversePrimary: MeshPalette.blueDim,
); );
return _build(scheme, Brightness.dark); return _build(scheme, Brightness.dark);
} }
static ThemeData light() { static ThemeData light() {
const scheme = ColorScheme.light( const scheme = ColorScheme.light(
primary: MeshPalette.lightSignal, primary: MeshPalette.lightBlue,
onPrimary: Colors.white, onPrimary: Colors.white,
primaryContainer: Color(0xFFD4E8D8), primaryContainer: Color(0xFFD3E4F5),
onPrimaryContainer: MeshPalette.lightSignal, onPrimaryContainer: Color(0xFF12354F),
secondary: Color(0xFF2F6EA8), secondary: Color(0xFF8C4A8A),
onSecondary: Colors.white, onSecondary: Colors.white,
tertiary: Color(0xFF8C4A8A), secondaryContainer: Color(0xFFEFD6EE),
onSecondaryContainer: Color(0xFF3D1A3C),
tertiary: Color(0xFF9A5B16),
onTertiary: Colors.white, onTertiary: Colors.white,
tertiaryContainer: Color(0xFFF8E3C9),
onTertiaryContainer: Color(0xFF4A2A05),
error: Color(0xFFB53D2F), error: Color(0xFFB53D2F),
onError: Colors.white, onError: Colors.white,
errorContainer: Color(0xFFF6D9D4),
onErrorContainer: Color(0xFF5C1A12),
surface: MeshPalette.lightBg, surface: MeshPalette.lightBg,
onSurface: MeshPalette.lightInk, onSurface: MeshPalette.lightInk,
surfaceContainerLowest: MeshPalette.lightBg, surfaceContainerLowest: MeshPalette.lightBg,
surfaceContainerLow: MeshPalette.lightBg1, surfaceContainerLow: MeshPalette.lightBg1,
surfaceContainer: MeshPalette.lightBg1, surfaceContainer: MeshPalette.lightBg1,
surfaceContainerHigh: MeshPalette.lightBg2, surfaceContainerHigh: MeshPalette.lightBg2,
surfaceContainerHighest: Color(0xFFD5D0C0), surfaceContainerHighest: Color(0xFFD2DAE1),
onSurfaceVariant: MeshPalette.lightInk2, onSurfaceVariant: MeshPalette.lightInk2,
outline: MeshPalette.lightLine, outline: MeshPalette.lightLine,
outlineVariant: Color(0xFFDBD6C6), outlineVariant: Color(0xFFD8DEE5),
); );
return _build(scheme, Brightness.light); return _build(scheme, Brightness.light);
} }
@@ -327,9 +385,9 @@ class MeshTheme {
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
), ),
navigationBarTheme: NavigationBarThemeData( navigationBarTheme: NavigationBarThemeData(
backgroundColor: scheme.surfaceContainerLow, backgroundColor: scheme.surface,
surfaceTintColor: Colors.transparent, surfaceTintColor: Colors.transparent,
indicatorColor: scheme.primary.withValues(alpha: 0.14), indicatorColor: scheme.primary,
indicatorShape: RoundedRectangleBorder( indicatorShape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(MeshRadii.md), borderRadius: BorderRadius.circular(MeshRadii.md),
), ),
@@ -341,13 +399,13 @@ class MeshTheme {
fontSize: 10, fontSize: 10,
fontWeight: selected ? FontWeight.w700 : FontWeight.w500, fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
letterSpacing: 0.1, letterSpacing: 0.1,
color: selected ? scheme.primary : scheme.onSurfaceVariant, color: selected ? scheme.onPrimary : scheme.onSurfaceVariant,
); );
}), }),
iconTheme: WidgetStateProperty.resolveWith((states) { iconTheme: WidgetStateProperty.resolveWith((states) {
final selected = states.contains(WidgetState.selected); final selected = states.contains(WidgetState.selected);
return IconThemeData( return IconThemeData(
color: selected ? scheme.primary : scheme.onSurfaceVariant, color: selected ? scheme.onPrimary : scheme.onSurfaceVariant,
size: 22, size: 22,
); );
}), }),
@@ -386,6 +444,106 @@ class MeshTheme {
), ),
iconTheme: IconThemeData(color: scheme.onSurfaceVariant, size: 22), iconTheme: IconThemeData(color: scheme.onSurfaceVariant, size: 22),
splashFactory: InkSparkle.splashFactory, splashFactory: InkSparkle.splashFactory,
pageTransitionsTheme: const PageTransitionsTheme(
builders: {
TargetPlatform.android: FadeForwardsPageTransitionsBuilder(),
TargetPlatform.iOS: FadeForwardsPageTransitionsBuilder(),
TargetPlatform.linux: FadeForwardsPageTransitionsBuilder(),
TargetPlatform.macOS: FadeForwardsPageTransitionsBuilder(),
TargetPlatform.windows: FadeForwardsPageTransitionsBuilder(),
},
),
segmentedButtonTheme: SegmentedButtonThemeData(
style: SegmentedButton.styleFrom(
selectedBackgroundColor: scheme.primary.withValues(alpha: 0.16),
selectedForegroundColor: scheme.primary,
side: BorderSide(color: scheme.outlineVariant),
textStyle: const TextStyle(
fontFamily: MeshFonts.sans,
fontFamilyFallback: MeshFonts.sansFallback,
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
),
switchTheme: SwitchThemeData(
thumbColor: WidgetStateProperty.resolveWith(
(states) => states.contains(WidgetState.selected)
? scheme.onPrimary
: scheme.onSurfaceVariant,
),
trackColor: WidgetStateProperty.resolveWith(
(states) => states.contains(WidgetState.selected)
? scheme.primary
: scheme.surfaceContainerHighest,
),
trackOutlineColor: WidgetStateProperty.resolveWith(
(states) => states.contains(WidgetState.selected)
? Colors.transparent
: scheme.outline,
),
),
sliderTheme: SliderThemeData(
activeTrackColor: scheme.primary,
inactiveTrackColor: scheme.surfaceContainerHighest,
thumbColor: scheme.primary,
overlayColor: scheme.primary.withValues(alpha: 0.12),
valueIndicatorColor: scheme.surfaceContainerHighest,
valueIndicatorTextStyle: TextStyle(
fontFamily: MeshFonts.mono,
fontFamilyFallback: MeshFonts.monoFallback,
color: scheme.onSurface,
fontSize: 12,
),
trackHeight: 3,
),
tabBarTheme: TabBarThemeData(
labelColor: scheme.primary,
unselectedLabelColor: scheme.onSurfaceVariant,
indicatorColor: scheme.primary,
dividerColor: scheme.outlineVariant,
labelStyle: const TextStyle(
fontFamily: MeshFonts.sans,
fontFamilyFallback: MeshFonts.sansFallback,
fontSize: 13.5,
fontWeight: FontWeight.w700,
),
unselectedLabelStyle: const TextStyle(
fontFamily: MeshFonts.sans,
fontFamilyFallback: MeshFonts.sansFallback,
fontSize: 13.5,
fontWeight: FontWeight.w500,
),
),
progressIndicatorTheme: ProgressIndicatorThemeData(
color: scheme.primary,
linearTrackColor: scheme.surfaceContainerHigh,
circularTrackColor: Colors.transparent,
),
tooltipTheme: TooltipThemeData(
decoration: BoxDecoration(
color: scheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(MeshRadii.sm),
border: Border.all(color: scheme.outline),
),
textStyle: TextStyle(color: scheme.onSurface, fontSize: 12),
),
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
backgroundColor: scheme.primary,
foregroundColor: scheme.onPrimary,
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(MeshRadii.pill),
),
textStyle: const TextStyle(
fontFamily: MeshFonts.sans,
fontFamilyFallback: MeshFonts.sansFallback,
fontWeight: FontWeight.w600,
fontSize: 14,
),
),
),
); );
} }
@@ -436,6 +594,16 @@ class MeshTheme {
); );
} }
/// Color-emoji style with platform fallbacks and stable vertical metrics.
static TextStyle emoji({double fontSize = 28}) {
return TextStyle(
fontFamily: MeshFonts.emoji,
fontFamilyFallback: MeshFonts.emojiFallback,
fontSize: fontSize,
height: 1,
);
}
/// Color-code an SNR value for consistency across the app. /// Color-code an SNR value for consistency across the app.
static Color snrColor(num? snr, {required bool blocked}) { static Color snrColor(num? snr, {required bool blocked}) {
if (blocked) return MeshPalette.alert; if (blocked) return MeshPalette.alert;

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