mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-08-04 23:12:58 +10:00
Merge branch 'dev' into update-pacox-multibyte
This commit is contained in:
@@ -41,7 +41,7 @@ lib/
|
||||
├── models/ # Plain data classes (Contact, Channel, Message, Community, …)
|
||||
├── services/ # ChangeNotifier services + IO services (retry, translation, ML, …)
|
||||
├── 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)
|
||||
├── theme/ # MeshPalette (defined, not yet wired in main.dart)
|
||||
├── l10n/ # ARB localization for 18 locales
|
||||
@@ -194,14 +194,14 @@ enum MeshCoreConnectionState {
|
||||
|
||||
## 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**
|
||||
|
||||
| Package | Version | Purpose |
|
||||
|---------|---------|---------|
|
||||
| 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) |
|
||||
|
||||
**State / Storage**
|
||||
@@ -232,7 +232,7 @@ App version: `8.0.0+11` — Dart SDK constraint: `^3.9.2`
|
||||
|
||||
| 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) |
|
||||
| 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 |
|
||||
@@ -246,7 +246,7 @@ App version: `8.0.0+11` — Dart SDK constraint: `^3.9.2`
|
||||
|
||||
| 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 |
|
||||
|
||||
**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_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**
|
||||
|
||||
@@ -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 |
|
||||
| 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 |
|
||||
| package_info_plus | ^9.0.0 | Reads app version/build number displayed in settings |
|
||||
| share_plus | ^13.1.0 | Shares files (e.g. exported GPX tracks) via the system share sheet |
|
||||
| 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 |
|
||||
| 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) |
|
||||
@@ -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/storage/prefs_manager.dart` | SharedPreferences singleton initialized in `main()` |
|
||||
| `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`) |
|
||||
|
||||
@@ -94,12 +94,12 @@ MeshCore Open is a cross-platform mobile application for communicating with Mesh
|
||||
|---------|---------|
|
||||
| flutter_blue_plus | Bluetooth Low Energy communication |
|
||||
| provider | State management |
|
||||
| sqflite | Local database storage |
|
||||
| shared_preferences | Local key-value storage (scoped per device) |
|
||||
| flutter_map | Interactive map display |
|
||||
| latlong2 | Geographic coordinate handling |
|
||||
| flutter_local_notifications | Background notification support |
|
||||
| smaz | Message compression |
|
||||
| pointycastle | Cryptographic operations |
|
||||
| llamadart | On-device LLM message translation |
|
||||
| intl | Internationalization and date formatting |
|
||||
|
||||
## Getting Started
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||
android.useAndroidX=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
|
||||
|
||||
@@ -198,6 +198,7 @@ Tap the translate button on any received message. On first use, the GGUF model f
|
||||
|
||||
### How It Works
|
||||
- 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)
|
||||
- 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
|
||||
|
||||
@@ -56,6 +56,7 @@ enum MeshCoreConnectionState {
|
||||
- `Lilygo`
|
||||
- `HT-`
|
||||
- `LowMesh_MC_`
|
||||
- `NRF52`
|
||||
2. **Connect** with 15-second timeout (6 seconds on Linux)
|
||||
3. **Request MTU** 185 bytes (non-web only)
|
||||
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 |
|
||||
| 36 | CMD_SEND_TRACE_PATH | Request path trace |
|
||||
| 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 |
|
||||
| 41 | CMD_SET_CUSTOM_VAR | Set a custom variable |
|
||||
| 50 | CMD_SEND_BINARY_REQ | Send binary request |
|
||||
| 56 | CMD_GET_STATS | Request companion radio stats |
|
||||
| 57 | CMD_SEND_ANON_REQ | Send anonymous request |
|
||||
| 58 | CMD_SET_AUTO_ADD_CONFIG | Set 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)
|
||||
|
||||
@@ -145,6 +148,7 @@ On unexpected disconnection, auto-reconnect with exponential backoff:
|
||||
| 17 | RESP_CODE_CHANNEL_MSG_RECV_V3 | Incoming channel message (v3) |
|
||||
| 18 | RESP_CODE_CHANNEL_INFO | Channel definition |
|
||||
| 21 | RESP_CODE_CUSTOM_VARS | Custom variables |
|
||||
| 24 | RESP_CODE_STATS | Companion radio stats |
|
||||
| 25 | RESP_CODE_AUTO_ADD_CONFIG | Auto-add flags |
|
||||
| 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 |
|
||||
|
||||
@@ -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.
|
||||
|
||||
Up to 8 channels (indices 0–7) 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
|
||||
|
||||
@@ -17,7 +17,7 @@ QuickSwitchBar tab 1 (middle) from any main screen.
|
||||
| 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 |
|
||||
| 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
|
||||
|
||||
@@ -26,12 +26,12 @@ QuickSwitchBar tab 1 (middle) from any main screen.
|
||||
- **Search bar** with live text filtering (300ms debounce)
|
||||
- **Sort/filter button**
|
||||
- **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)
|
||||
- Unread badge (if messages are unread)
|
||||
- Drag handle (when manual sort is active)
|
||||
- **"+" 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.
|
||||
|
||||
@@ -59,7 +59,7 @@ Tap the "+" FAB to open a dialog with six options:
|
||||
|
||||
| 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 |
|
||||
| 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
|
||||
|
||||
- **Mobile**: Tap a message bubble to view its routing path
|
||||
- **Desktop**: Long-press/right-click → "Path" (tapping the bubble does nothing on desktop)
|
||||
- **All platforms**: Long-press (or right-click on desktop) a message bubble → "Path"
|
||||
- Opens the Channel Message Path Screen (see [Additional Features](additional-features.md))
|
||||
|
||||
### Context Actions (Long-Press / Right-Click)
|
||||
@@ -109,7 +108,7 @@ Tap a channel card to open the channel chat screen.
|
||||
| Action | Availability | Description |
|
||||
|---|---|---|
|
||||
| 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) |
|
||||
| Copy | All messages | Copies text to clipboard |
|
||||
| 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
|
||||
- **Popup menu** per community:
|
||||
- **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
|
||||
|
||||
|
||||
@@ -18,18 +18,15 @@ From the Contacts screen, tap any Chat-type contact to open the ChatScreen.
|
||||
|
||||
- **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.
|
||||
- **Action buttons**:
|
||||
- **Routing mode** (waves icon): Switch between Auto, Direct, and Flood routing
|
||||
- **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.
|
||||
- Custom path entry follows the device's current hash width, so multibyte paths are entered as comma-separated hex prefixes such as `A1B2,C1C2` when that mode is enabled.
|
||||
- **Info** (info icon): Contact info dialog showing type, path, GPS coordinates, public key, and SMAZ compression toggle
|
||||
- **Action button**:
|
||||
- **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.
|
||||
|
||||
### Message List
|
||||
|
||||
- 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
|
||||
- **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
|
||||
- **Pinch-to-zoom**: Two-finger zoom (0.8x–1.8x) and double-tap to reset
|
||||
- **Jump to bottom**: Floating button appears when scrolled away from the bottom
|
||||
@@ -88,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
|
||||
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). Note: in app code and storage `pathLength` refers to the stored hop count (number of hops) in the message/contact model — not the on-air encoded byte length — so the formula above uses the model hop count.
|
||||
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...)
|
||||
5. **Max retries**: Configurable (default 5, range 2–10)
|
||||
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"
|
||||
@@ -115,8 +112,9 @@ Add emoji reactions to incoming messages (not your own):
|
||||
| Action | Availability | Description |
|
||||
|---|---|---|
|
||||
| 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 |
|
||||
| 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 |
|
||||
| Delete | All messages | Removes locally (not from mesh) |
|
||||
| Retry | Failed outgoing messages | Re-sends the message |
|
||||
|
||||
+17
-18
@@ -6,18 +6,17 @@ The Contacts screen is the primary hub for managing mesh nodes your radio has a
|
||||
|
||||
## How to Access
|
||||
|
||||
- Automatically shown after connecting to a device
|
||||
- QuickSwitchBar tab 0 (leftmost) from Channels or Map screens
|
||||
- QuickSwitchBar tab 0 (leftmost) from Channels or Map screens (Channels is shown first after connecting)
|
||||
- Back navigation from Chat or Settings screens
|
||||
|
||||
## Contact Types
|
||||
|
||||
| Type | Avatar Color | Icon | Description |
|
||||
|---|---|---|---|
|
||||
| Chat | Blue | Chat bubble | Another user's mesh radio |
|
||||
| Repeater | Orange | Cell tower | A mesh repeater/relay node |
|
||||
| Room | Purple | Group | A room server for group chat |
|
||||
| Sensor | Green | Sensors | A sensor device |
|
||||
| Chat | Blue | Initials / emoji | Another user's mesh radio |
|
||||
| Repeater | Amber | Cell tower | A mesh repeater/relay node |
|
||||
| Room | Magenta | Meeting room | A room server for group chat |
|
||||
| Sensor | Teal | Sensors | A sensor device |
|
||||
|
||||
## Contact List
|
||||
|
||||
@@ -73,42 +72,42 @@ Groups are stored per radio identity (scoped by public key).
|
||||
|
||||
| Action | Availability | Description |
|
||||
|---|---|---|
|
||||
| Ping | Repeaters (always) | 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 |
|
||||
| Ping | Repeaters only | Opens PathTraceMapScreen targeting the repeater |
|
||||
| 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 |
|
||||
| Room Login | Rooms only | Login dialog → ChatScreen |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| Delete Contact | All types | Confirmation dialog → removes from device and clears messages |
|
||||
|
||||
## 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
|
||||
- Flood Advert — broadcasts across the full mesh network
|
||||
- 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
|
||||
|
||||
**Three-dot overflow menu**:
|
||||
- *(divider)*
|
||||
- Disconnect — disconnects from the device
|
||||
- Discovered Contacts — opens the DiscoveryScreen
|
||||
- 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
|
||||
|
||||
### 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).
|
||||
|
||||
### 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
|
||||
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
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
### 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
|
||||
|
||||
@@ -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**:
|
||||
- **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 (80–120m) 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**:
|
||||
- **High confidence** (2+ anchors): Displayed at 55% opacity.
|
||||
- **Low confidence** (1 anchor): Displayed at 30% opacity.
|
||||
- **High confidence** (2+ anchors): The marker border uses the node's type color (brighter border).
|
||||
- **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.
|
||||
|
||||
#### 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.
|
||||
- **More opaque** (55%): 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.
|
||||
- **Marker with `not_listed_location` icon**: This is a guessed position, not a confirmed GPS fix.
|
||||
- **Colored border** (type color): Higher confidence — the contact was seen through 2 or more repeaters with known positions.
|
||||
- **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.
|
||||
- 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
|
||||
- **Orange circles** (`~HH`): Inferred positions (no GPS but deducible from contacts)
|
||||
- **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
|
||||
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.
|
||||
|
||||
### What the User Sees
|
||||
A full-screen map with a collapsible control panel containing:
|
||||
- **Elevation profile chart**: Terrain fill (green), LOS beam line (white), radio horizon line (yellow)
|
||||
- **Status**: Clear (green) or blocked (red) with distance and minimum clearance
|
||||
- **Options panel**: Node toggles, endpoint dropdowns, antenna height sliders (0–400 ft), Run LOS button
|
||||
A full-screen map with a draggable bottom sheet containing:
|
||||
- **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 summary**: Clear (green), Marginal (amber, within 5 m of obstruction), or Blocked (red) with distance and clearance/obstruction amount
|
||||
- **Options section** (collapsible): Node toggles, endpoint dropdowns, antenna height sliders (0–400 ft), Run LOS button
|
||||
|
||||
### 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
|
||||
- **Antenna heights** are adjustable for both endpoints
|
||||
- **Map line** between endpoints is colored green (clear) or red (blocked)
|
||||
- Terrain elevation is fetched from the Open-Meteo API (21–81 sample points, cached 24 hours)
|
||||
- **Map line** between endpoints is colored green (clear), amber (marginal), or red (blocked)
|
||||
- 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
|
||||
|
||||
---
|
||||
@@ -149,7 +156,7 @@ Settings → App Settings → Map Display → Offline Map Cache
|
||||
- Map with a blue polygon overlay showing previously selected cache bounds
|
||||
- Bounding box coordinates card
|
||||
- **Cache Area** controls: "Use Current View" and Clear buttons
|
||||
- **Zoom Range** slider (3–18) with estimated tile count
|
||||
- **Zoom Range** range slider (3–18, dual-handle for min and max) with estimated tile count
|
||||
- **Download progress** bar (when downloading)
|
||||
- **Download Tiles** and **Clear Cache** buttons
|
||||
|
||||
|
||||
+10
-10
@@ -5,7 +5,7 @@
|
||||
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**.
|
||||
@@ -24,7 +24,7 @@ Tapping a tab replaces the current screen with a subtle fade + slight horizontal
|
||||
|
||||
## 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)
|
||||
- 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)
|
||||
├─ [BLE connect] → push → ContactsScreen
|
||||
├─ [TCP FAB] → push → TcpScreen
|
||||
│ └─ [TCP connected] → pushReplacement → ContactsScreen
|
||||
└─ [USB FAB] → push → UsbScreen
|
||||
└─ [USB connected] → pushReplacement → ContactsScreen
|
||||
├─ [BLE connect] → push → ChannelsScreen
|
||||
├─ [TCP icon button] → push → TcpScreen
|
||||
│ └─ [TCP connected] → pushReplacement → ChannelsScreen
|
||||
└─ [USB icon button] → push → UsbScreen
|
||||
└─ [USB connected] → pushReplacement → ChannelsScreen
|
||||
|
||||
ContactsScreen (selected=0)
|
||||
├─ [quick-switch 1] → pushReplacement → ChannelsScreen
|
||||
@@ -60,9 +60,9 @@ ChannelsScreen (selected=1)
|
||||
MapScreen (selected=2)
|
||||
├─ [quick-switch 0] → pushReplacement → ContactsScreen
|
||||
├─ [quick-switch 1] → pushReplacement → ChannelsScreen
|
||||
├─ [radar button] → push → PathTraceMapScreen
|
||||
├─ [terrain button] → push → LineOfSightMapScreen
|
||||
└─ [long-press] → share marker / set location
|
||||
├─ [radar menu item] → enters in-map path trace mode (push → PathTraceMapScreen after path is built)
|
||||
├─ [terrain menu item] → push → LineOfSightMapScreen
|
||||
└─ [long-press] → share marker sheet
|
||||
|
||||
Settings (push from any main screen)
|
||||
└─ [App Settings] → push → AppSettingsScreen
|
||||
|
||||
@@ -22,7 +22,7 @@ MeshCore Open provides both **system notifications** (push-style OS alerts) and
|
||||
|
||||
### 3. Advertisement Notifications
|
||||
- **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
|
||||
- **Priority**: Default
|
||||
- **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
|
||||
- **Channels list**: Each channel row shows an unread badge
|
||||
- **Chat screen subtitle**: Shows unread count inline
|
||||
- Badges cap at "99+" for display
|
||||
- Badges cap at "9999+" for display
|
||||
|
||||
### How Unread Counts Work
|
||||
|
||||
|
||||
@@ -34,8 +34,8 @@ The central management screen showing:
|
||||
|---|---|---|
|
||||
| Status | Repeater Status Screen | All users |
|
||||
| Telemetry | Telemetry Screen | All users |
|
||||
| CLI | Repeater CLI Screen | Admin only |
|
||||
| Neighbors | Neighbors Screen | All users |
|
||||
| CLI | Repeater CLI Screen | Admin only |
|
||||
| Settings | Repeater Settings Screen | Admin only |
|
||||
|
||||
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)
|
||||
- Up/down arrows navigate through command history
|
||||
- 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
|
||||
- Clear icon: Wipes the command/response history
|
||||
- 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`
|
||||
|
||||
**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`
|
||||
|
||||
@@ -207,7 +209,7 @@ Nine configuration cards, each with its own per-field refresh button(s):
|
||||
|
||||
**Danger Zone** (red-styled card)
|
||||
- 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
|
||||
- **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
|
||||
|
||||
@@ -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.
|
||||
|
||||
**Bottom FAB Row**: Up to three floating action buttons:
|
||||
- **USB** button - Opens USB connection screen (Android, Windows, Linux, macOS, Chrome web only)
|
||||
- **TCP/IP** button - Opens TCP connection screen (all non-web platforms)
|
||||
**App Bar Actions**: Icon buttons in the top-right corner of the app bar:
|
||||
- **USB** icon button - Opens USB connection screen (Android, Windows, Linux, macOS, Chrome web only)
|
||||
- **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
|
||||
|
||||
### 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
|
||||
|
||||
- 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
|
||||
- Scans for 10 seconds then auto-stops
|
||||
- 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
|
||||
4. Discovers BLE services and locates the Nordic UART Service
|
||||
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
|
||||
|
||||
---
|
||||
@@ -74,7 +76,7 @@ Tap a device tile or its Connect button:
|
||||
|
||||
### 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
|
||||
|
||||
@@ -82,15 +84,15 @@ From the Scanner screen, tap the **USB** FAB button.
|
||||
- A list of detected USB serial ports, each showing:
|
||||
- Friendly display name
|
||||
- Raw port name (subtitle, only shown when it differs from the display name)
|
||||
- "Connect" button
|
||||
- FABs at the bottom to switch to BLE or TCP (these use `pushReplacement`, so back navigation returns to Scanner, not between USB/TCP)
|
||||
- Chevron trailing icon (the entire tile is tappable to connect)
|
||||
- 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
|
||||
|
||||
- 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
|
||||
- Tap a port or its Connect button to connect
|
||||
- On successful connection, navigates to Contacts screen
|
||||
- Tap a port tile to connect
|
||||
- On successful connection, navigates to Channels screen
|
||||
- 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)
|
||||
|
||||
@@ -100,7 +102,7 @@ From the Scanner screen, tap the **USB** FAB button.
|
||||
|
||||
### 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
|
||||
|
||||
@@ -108,7 +110,7 @@ From the Scanner screen, tap the **TCP/IP** FAB button.
|
||||
- **Host address** text field
|
||||
- **Port number** text field
|
||||
- **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
|
||||
|
||||
@@ -119,6 +121,6 @@ From the Scanner screen, tap the **TCP/IP** FAB button.
|
||||
- Validation errors are shown as red snackbars
|
||||
- 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")
|
||||
- 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")
|
||||
- Error messages for timeout, unsupported platform, and connection failures
|
||||
|
||||
+92
-74
@@ -12,12 +12,13 @@ Settings are only accessible while a device is connected.
|
||||
The settings screen is a scrollable list of cards:
|
||||
|
||||
1. [Device Info](#device-info)
|
||||
2. [App Settings](#app-settings) (link to sub-screen)
|
||||
3. [Node Settings](#node-settings)
|
||||
4. [Actions](#actions)
|
||||
5. [Debug](#debug)
|
||||
2. [Node Settings](#node-settings)
|
||||
3. [Location](#location)
|
||||
4. [App Settings](#app-settings) (link to sub-screen)
|
||||
5. [Actions](#actions)
|
||||
6. [Export](#export)
|
||||
7. [About](#about)
|
||||
7. [Debug](#debug)
|
||||
8. [About](#about)
|
||||
|
||||
---
|
||||
|
||||
@@ -40,56 +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 (1–10, default 5, integer steps)
|
||||
- Initial Route Weight (0.5–5.0, default 3.0)
|
||||
- Success Increment (0.1–2.0, default 0.5, 0.1 steps)
|
||||
- Failure Decrement (0.1–2.0, default 0.2, 0.1 steps)
|
||||
- Max Message Retries (2–10, 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
|
||||
- **Offline Map Cache**: Navigate to tile download screen
|
||||
|
||||
### Debug
|
||||
- **App Debug Logging**: Enable the in-app debug log
|
||||
|
||||
---
|
||||
|
||||
## Node Settings
|
||||
|
||||
These settings are sent directly to the connected device firmware.
|
||||
@@ -101,7 +52,7 @@ These settings are sent directly to the connected device firmware.
|
||||
|
||||
### Radio Settings
|
||||
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 300–2500 MHz
|
||||
- **Bandwidth**: Dropdown (7.8 / 10.4 / 15.6 / 20.8 / 31.25 / 41.7 / 62.5 / 125 / 250 / 500 kHz)
|
||||
- **Spreading Factor**: SF5–SF12
|
||||
@@ -109,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)
|
||||
- **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
|
||||
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
|
||||
@@ -125,8 +83,68 @@ Five toggles controlling which node types are auto-added when heard:
|
||||
- Auto-add Sensors
|
||||
- Overwrite Oldest (when contact list is full)
|
||||
|
||||
### Privacy Mode
|
||||
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.
|
||||
### Privacy
|
||||
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 (1–10, default 5, integer steps)
|
||||
- Initial Route Weight (0.5–5.0, default 3.0)
|
||||
- Success Increment (0.1–2.0, default 0.5, 0.1 steps)
|
||||
- Failure Decrement (0.1–2.0, default 0.2, 0.1 steps)
|
||||
- Max Message Retries (2–10, 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
|
||||
- **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
|
||||
|
||||
---
|
||||
|
||||
@@ -136,10 +154,24 @@ One-tap device operations:
|
||||
|
||||
| Action | Description |
|
||||
|---|---|
|
||||
| Send Advertisement | Floods the mesh with your node's advertisement |
|
||||
| Sync Time | Sends current Unix timestamp to the device |
|
||||
| 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.
|
||||
|
||||
---
|
||||
|
||||
@@ -160,20 +192,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
|
||||
|
||||
Shows the standard Flutter about dialog with app name, version, and legal notice.
|
||||
|
||||
@@ -243,6 +243,9 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
// Intentionally global (not per-contact): tracks overall network activity.
|
||||
// Frequent RX from any source indicates a busy network with more collisions.
|
||||
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 _lastContactMsgRxTime = DateTime.fromMillisecondsSinceEpoch(0);
|
||||
DateTime _lastChannelMsgRxTime = DateTime.fromMillisecondsSinceEpoch(0);
|
||||
@@ -363,6 +366,20 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
String? get deviceId => _deviceId;
|
||||
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;
|
||||
String? get activeUsbPort => _usbManager.activePortKey;
|
||||
String? get activeUsbPortDisplayLabel => _usbManager.activePortDisplayLabel;
|
||||
@@ -528,7 +545,7 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
}
|
||||
|
||||
String _batteryChemistryForDevice() {
|
||||
final deviceId = _device?.remoteId.toString();
|
||||
final deviceId = batteryDeviceKey;
|
||||
if (deviceId == null || _appSettingsService == null) return 'nmc';
|
||||
return _appSettingsService!.batteryChemistryForDevice(deviceId);
|
||||
}
|
||||
@@ -966,11 +983,17 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
updateMessage: _updateMessage,
|
||||
clearContactPath: clearContactPath,
|
||||
setContactPath: setContactPath,
|
||||
calculateTimeout: (pathLength, messageBytes, {String? contactKey}) =>
|
||||
calculateTimeout(
|
||||
calculateTimeout:
|
||||
(
|
||||
pathLength,
|
||||
messageBytes, {
|
||||
String? contactKey,
|
||||
int? deviceTimeoutMs,
|
||||
}) => calculateTimeout(
|
||||
pathLength: pathLength,
|
||||
messageBytes: messageBytes,
|
||||
contactKey: contactKey,
|
||||
deviceTimeoutMs: deviceTimeoutMs,
|
||||
),
|
||||
getSelfPublicKey: () => _selfPublicKey,
|
||||
prepareContactOutboundText: prepareContactOutboundText,
|
||||
@@ -986,7 +1009,9 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
recentSelections: recentSelections,
|
||||
),
|
||||
onDeliveryObserved: (contactKey, pathLength, messageBytes, tripTimeMs) {
|
||||
final secSinceRx = DateTime.now().difference(_lastRxTime).inSeconds;
|
||||
final secSinceRx = DateTime.now()
|
||||
.difference(_lastRxBeforeFrame)
|
||||
.inSeconds;
|
||||
_timeoutPredictionService?.recordObservation(
|
||||
contactKey: contactKey,
|
||||
pathLength: pathLength,
|
||||
@@ -1642,6 +1667,20 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
await stopScan();
|
||||
}
|
||||
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(
|
||||
_handleFrame,
|
||||
onError: (error, stackTrace) {
|
||||
@@ -2690,12 +2729,21 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
Uint8List data, {
|
||||
String? channelSendQueueId,
|
||||
bool expectsGenericAck = false,
|
||||
bool waitForGenericAck = false,
|
||||
}) async {
|
||||
if (!isConnected) {
|
||||
throw Exception("Not connected to a MeshCore device");
|
||||
}
|
||||
_bleDebugLogService?.logFrame(data, outgoing: true);
|
||||
|
||||
final pendingAck = _trackPendingGenericAck(
|
||||
data,
|
||||
channelSendQueueId: channelSendQueueId,
|
||||
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
|
||||
@@ -2720,12 +2768,24 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
withoutResponse: canWriteWithoutResponse,
|
||||
);
|
||||
}
|
||||
_trackPendingGenericAck(
|
||||
data,
|
||||
channelSendQueueId: channelSendQueueId,
|
||||
expectsGenericAck: expectsGenericAck,
|
||||
} 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 {
|
||||
if (!isConnected) return;
|
||||
@@ -2981,6 +3041,17 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
}) async {
|
||||
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
|
||||
final reactionInfo = ReactionHelper.parseReaction(text);
|
||||
if (reactionInfo != null) {
|
||||
@@ -3472,14 +3543,16 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> importDiscoveredContact(Contact contact) async {
|
||||
if (!isConnected) return;
|
||||
Future<bool> importDiscoveredContact(Contact contact) async {
|
||||
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.
|
||||
final encodedPathLen = _encodePathLenForCurrentMode(
|
||||
contact.pathLength,
|
||||
contact.path,
|
||||
);
|
||||
if (encodedPathLen == null) return;
|
||||
if (encodedPathLen == null) return false;
|
||||
await sendFrame(
|
||||
buildUpdateContactPathFrame(
|
||||
contact.publicKey,
|
||||
@@ -3492,6 +3565,7 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
lon: contact.longitude,
|
||||
lastModified: contact.lastSeen,
|
||||
),
|
||||
waitForGenericAck: true,
|
||||
);
|
||||
|
||||
// Update the discovered contact to mark it as active (imported)
|
||||
@@ -3517,6 +3591,8 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
),
|
||||
);
|
||||
notifyListeners();
|
||||
unawaited(_persistDiscoveredContacts());
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<void> clearContactPath(Contact contact) async {
|
||||
@@ -3922,6 +3998,7 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
|
||||
void _handleFrame(List<int> data) {
|
||||
if (data.isEmpty) return;
|
||||
_lastRxBeforeFrame = _lastRxTime;
|
||||
_lastRxTime = DateTime.now();
|
||||
|
||||
final frame = Uint8List.fromList(data);
|
||||
@@ -4074,11 +4151,15 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
}
|
||||
|
||||
final failedAck = _pendingGenericAckQueue.removeAt(0);
|
||||
failedAck.completer?.completeError(
|
||||
Exception('Firmware rejected command with error code $errCode'),
|
||||
);
|
||||
if (failedAck.commandCode != cmdSendChannelTxtMsg ||
|
||||
failedAck.channelSendQueueId == null) {
|
||||
return;
|
||||
}
|
||||
_pendingChannelSentQueue.remove(failedAck.channelSendQueueId);
|
||||
_markPendingChannelMessageFailedById(failedAck.channelSendQueueId!);
|
||||
}
|
||||
|
||||
void _handlePathUpdated(Uint8List frame) {
|
||||
@@ -4432,16 +4513,28 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
// Same as max for flood — firmware uses a single formula
|
||||
return 500 + (16 * airtime);
|
||||
} 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.
|
||||
/// 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({
|
||||
required int pathLength,
|
||||
int messageBytes = 100,
|
||||
String? contactKey,
|
||||
int? deviceTimeoutMs,
|
||||
}) {
|
||||
final airtime = _estimateAirtimeMs(messageBytes);
|
||||
final physicsMin = _physicsMinTimeout(pathLength, airtime);
|
||||
@@ -4456,17 +4549,29 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
secondsSinceLastRx: secSinceRx,
|
||||
);
|
||||
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) {
|
||||
// Flood: trust ML, only enforce firmware formula as floor
|
||||
if (mlTimeout < physicsMin) {
|
||||
return physicsMin;
|
||||
// Flood: trust ML, only enforce firmware estimate as floor
|
||||
if (mlTimeout < floor) {
|
||||
return floor.clamp(0, _hardMaxTimeoutMs);
|
||||
}
|
||||
}
|
||||
return mlTimeout.clamp(physicsMin, physicsMax);
|
||||
return mlTimeout.clamp(floor, _hardMaxTimeoutMs);
|
||||
}
|
||||
|
||||
// No ML data — use firmware formula
|
||||
return physicsMax;
|
||||
// No ML data — prefer device est_timeout (it used real airtime), then physics.
|
||||
// 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}) {
|
||||
@@ -4831,17 +4936,14 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
final existing = _conversations[message.senderKeyHex];
|
||||
final incomingTimestamp = message.timestamp.millisecondsSinceEpoch;
|
||||
if (existing != null && existing.isNotEmpty) {
|
||||
final startIndex = existing.length > 10 ? existing.length - 10 : 0;
|
||||
for (int i = existing.length - 1; i >= startIndex; i--) {
|
||||
final recent = existing[i];
|
||||
if (!recent.isOutgoing &&
|
||||
recent.timestamp.millisecondsSinceEpoch == incomingTimestamp &&
|
||||
recent.text == message.text) {
|
||||
final last = existing.last;
|
||||
if (!last.isOutgoing &&
|
||||
last.timestamp.millisecondsSinceEpoch == incomingTimestamp &&
|
||||
last.text == message.text) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_addMessage(message.senderKeyHex, message);
|
||||
_maybeIncrementContactUnread(message);
|
||||
notifyListeners();
|
||||
@@ -5425,12 +5527,37 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
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() {
|
||||
if (_pendingGenericAckQueue.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final pendingAck = _pendingGenericAckQueue.removeAt(0);
|
||||
pendingAck.completer?.complete();
|
||||
if (pendingAck.commandCode != cmdSendChannelTxtMsg ||
|
||||
pendingAck.channelSendQueueId == null) {
|
||||
return;
|
||||
@@ -5839,7 +5966,9 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
) {
|
||||
if (!isRoomServer) return null;
|
||||
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 != null &&
|
||||
_matchesPrefix(c.publicKey, msg.fourByteRoomContactKey),
|
||||
@@ -6262,18 +6391,25 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
_scheduleReconnect();
|
||||
}
|
||||
|
||||
void _trackPendingGenericAck(
|
||||
_PendingCommandAck? _trackPendingGenericAck(
|
||||
Uint8List data, {
|
||||
String? channelSendQueueId,
|
||||
required bool expectsGenericAck,
|
||||
required bool waitForAck,
|
||||
}) {
|
||||
if (!expectsGenericAck || data.isEmpty) return;
|
||||
_pendingGenericAckQueue.add(
|
||||
_PendingCommandAck(
|
||||
if (!expectsGenericAck || data.isEmpty) return null;
|
||||
final pendingAck = _PendingCommandAck(
|
||||
commandCode: data[0],
|
||||
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() {
|
||||
@@ -7033,6 +7169,11 @@ class _RepeaterAckContext {
|
||||
class _PendingCommandAck {
|
||||
final int commandCode;
|
||||
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 activePortDisplayLabel => _activePortLabel ?? _activePortKey;
|
||||
bool get isConnected => _service.isConnected;
|
||||
Object? get lastError => _service.lastError;
|
||||
Stream<Uint8List> get frameStream => _service.frameStream;
|
||||
|
||||
// --- Configuration ---
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
import '../connector/meshcore_protocol.dart';
|
||||
import 'path_helper.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,
|
||||
int pathHashByteWidth = 1,
|
||||
}) {
|
||||
final width = pathHashByteWidth.clamp(1, 4).toInt();
|
||||
final candidatesByPrefix = <String, List<Contact>>{};
|
||||
for (final contact in contacts) {
|
||||
if (contact.publicKey.length < width) continue;
|
||||
if (contact.type != advTypeRepeater && contact.type != advTypeRoom) {
|
||||
continue;
|
||||
}
|
||||
final prefix = PathHelper.formatHopHex(contact.publicKey.sublist(0, width));
|
||||
candidatesByPrefix
|
||||
.putIfAbsent(prefix, () => <Contact>[])
|
||||
.add(contact);
|
||||
}
|
||||
for (final candidates in candidatesByPrefix.values) {
|
||||
candidates.sort((a, b) => b.lastSeen.compareTo(a.lastSeen));
|
||||
}
|
||||
|
||||
final hops = PathHelper.splitPathBytes(pathBytes, width);
|
||||
final resolved = List<Contact?>.filled(hops.length, null);
|
||||
final indexes = resolveFromEnd
|
||||
? List<int>.generate(hops.length, (i) => hops.length - 1 - i)
|
||||
: List<int>.generate(hops.length, (i) => i);
|
||||
final distance = Distance();
|
||||
var previousPosition = endpoint;
|
||||
|
||||
for (final index in indexes) {
|
||||
final candidates = candidatesByPrefix[PathHelper.formatHopHex(hops[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!);
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,19 @@ void showDismissibleSnackBar(
|
||||
DismissDirection? dismissDirection,
|
||||
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(
|
||||
SnackBar(
|
||||
key: key,
|
||||
|
||||
+337
-243
File diff suppressed because it is too large
Load Diff
+538
-338
File diff suppressed because it is too large
Load Diff
+95
-1
@@ -143,6 +143,7 @@
|
||||
"scanner_chromeRequired": "Chrome Browser Required",
|
||||
"scanner_chromeRequiredMessage": "This web application requires Google Chrome or a Chromium-based browser for Bluetooth support.",
|
||||
"scanner_enableBluetooth": "Enable Bluetooth",
|
||||
"scanner_bluetoothWebUnsupported": "Bluetooth isn't available in the browser. Connect over USB instead.",
|
||||
"device_quickSwitch": "Quick switch",
|
||||
"device_meshcore": "MeshCore",
|
||||
"settings_title": "Settings",
|
||||
@@ -904,6 +905,17 @@
|
||||
},
|
||||
"chat_invalidLink": "Invalid link format",
|
||||
"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_losScreenTitle": "Line of Sight",
|
||||
"map_noNodesWithLocation": "No nodes with location data",
|
||||
@@ -2505,5 +2517,87 @@
|
||||
}
|
||||
},
|
||||
"pathTrace_legendGpsConfirmed": "GPS confirmed",
|
||||
"pathTrace_legendInferred": "Inferred position"
|
||||
"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"
|
||||
}
|
||||
|
||||
+472
-272
File diff suppressed because it is too large
Load Diff
+426
-205
File diff suppressed because it is too large
Load Diff
+1123
-1029
File diff suppressed because it is too large
Load Diff
+514
-239
File diff suppressed because it is too large
Load Diff
+562
-366
File diff suppressed because it is too large
Load Diff
+367
-174
@@ -5,8 +5,8 @@
|
||||
"nav_channels": "채널",
|
||||
"nav_map": "지도",
|
||||
"common_cancel": "취소",
|
||||
"common_ok": "알겠습니다",
|
||||
"common_connect": "연결",
|
||||
"common_ok": "확인",
|
||||
"common_connect": "연결하기",
|
||||
"common_unknownDevice": "알 수 없는 장치",
|
||||
"common_save": "저장",
|
||||
"common_delete": "삭제",
|
||||
@@ -16,21 +16,21 @@
|
||||
"common_add": "추가",
|
||||
"common_settings": "설정",
|
||||
"common_disconnect": "연결 해제",
|
||||
"common_connected": "연결된",
|
||||
"common_disconnected": "단절",
|
||||
"common_create": "만들다",
|
||||
"common_connected": "연결됨",
|
||||
"common_disconnected": "연결 해제됨",
|
||||
"common_create": "만들기",
|
||||
"common_continue": "계속",
|
||||
"common_share": "공유",
|
||||
"common_copy": "복사",
|
||||
"common_retry": "다시 시도",
|
||||
"common_hide": "숨기다",
|
||||
"common_hide": "숨기기",
|
||||
"common_remove": "제거",
|
||||
"common_enable": "활성화",
|
||||
"common_disable": "비활성화",
|
||||
"common_enable": "사용",
|
||||
"common_disable": "사용 안 함",
|
||||
"common_autoRefresh": "자동 새로고침",
|
||||
"common_interval": "간격",
|
||||
"common_reboot": "재부팅",
|
||||
"common_loading": "로딩 중...",
|
||||
"common_loading": "불러오는 중...",
|
||||
"common_notAvailable": "—",
|
||||
"common_voltageValue": "{volts} V",
|
||||
"@common_voltageValue": {
|
||||
@@ -48,16 +48,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"scanner_title": "MeshCore 공개",
|
||||
"scanner_title": "MeshCore Open",
|
||||
"connectionChoiceUsbLabel": "USB",
|
||||
"connectionChoiceBluetoothLabel": "블루투스",
|
||||
"connectionChoiceTcpLabel": "TCP",
|
||||
"tcpScreenTitle": "TCP를 통해 연결",
|
||||
"tcpHostLabel": "IP 주소",
|
||||
"tcpHostHint": "192.168.40.10",
|
||||
"tcpPortLabel": "항",
|
||||
"tcpHostHint": "192.168.40.10 / example.com",
|
||||
"tcpPortLabel": "포트",
|
||||
"tcpPortHint": "5000",
|
||||
"tcpStatus_notConnected": "목적지 주소 입력 후 연결",
|
||||
"tcpStatus_notConnected": "엔드포인트를 입력한 뒤 연결하세요.",
|
||||
"tcpStatus_connectingTo": "{endpoint}에 연결 중...",
|
||||
"@tcpStatus_connectingTo": {
|
||||
"placeholders": {
|
||||
@@ -80,21 +80,21 @@
|
||||
},
|
||||
"usbScreenTitle": "USB를 통해 연결",
|
||||
"usbScreenSubtitle": "감지된 시리얼 장치를 선택하고 MeshCore 노드에 직접 연결하십시오.",
|
||||
"usbScreenStatus": "USB 장치를 선택합니다.",
|
||||
"usbScreenNote": "USB 직렬 통신은 지원되는 안드로이드 장치 및 데스크톱 플랫폼에서 활성화됩니다.",
|
||||
"usbScreenEmptyState": "USB 장치가 탐지되지 않았습니다. USB 장치를 연결하고 다시 시도해 보세요.",
|
||||
"usbScreenStatus": "USB 장치를 선택하세요.",
|
||||
"usbScreenNote": "USB 직렬 통신은 지원되는 Android 기기 및 데스크톱 플랫폼에서 사용할 수 있습니다.",
|
||||
"usbScreenEmptyState": "USB 장치가 없습니다. 하나 연결한 뒤 새로고침하세요.",
|
||||
"usbErrorPermissionDenied": "USB 접근 권한이 거부되었습니다.",
|
||||
"usbErrorDeviceMissing": "선택한 USB 장치는 더 이상 사용 불가능합니다.",
|
||||
"usbErrorDeviceMissing": "선택한 USB 장치를 더 이상 사용할 수 없습니다.",
|
||||
"usbErrorInvalidPort": "유효한 USB 장치를 선택하세요.",
|
||||
"usbErrorBusy": "또 다른 USB 연결 요청이 이미 진행 중입니다.",
|
||||
"usbErrorBusy": "다른 USB 연결 요청이 이미 진행 중입니다.",
|
||||
"usbErrorNotConnected": "USB 장치가 연결되지 않았습니다.",
|
||||
"usbErrorOpenFailed": "선택한 USB 장치를 열 수 없습니다.",
|
||||
"usbErrorConnectFailed": "선택한 USB 장치에 연결에 실패했습니다.",
|
||||
"usbErrorConnectFailed": "선택한 USB 장치에 연결하지 못했습니다.",
|
||||
"usbErrorUnsupported": "이 플랫폼에서는 USB 직렬 통신을 지원하지 않습니다.",
|
||||
"usbErrorAlreadyActive": "USB 연결이 이미 활성화되어 있습니다.",
|
||||
"usbErrorAlreadyActive": "USB 연결이 이미 활성 상태입니다.",
|
||||
"usbErrorNoDeviceSelected": "USB 장치가 선택되지 않았습니다.",
|
||||
"usbErrorPortClosed": "USB 연결이 활성화되지 않았습니다.",
|
||||
"usbErrorConnectTimedOut": "연결이 시간 초과되었습니다. 장치가 USB Companion 펌웨어를 가지고 있는지 확인해 주세요.",
|
||||
"usbErrorPortClosed": "USB 연결이 열려 있지 않습니다.",
|
||||
"usbErrorConnectTimedOut": "연결 시간이 초과되었습니다. 장치에 USB Companion 펌웨어가 있는지 확인하세요.",
|
||||
"usbFallbackDeviceName": "웹 시리얼 장치",
|
||||
"usbStatus_notConnected": "USB 장치를 선택합니다.",
|
||||
"usbStatus_connecting": "USB 장치에 연결 중...",
|
||||
@@ -131,13 +131,13 @@
|
||||
},
|
||||
"scanner_stop": "멈춰",
|
||||
"scanner_scan": "스캔",
|
||||
"scanner_bluetoothOff": "블루투스는 꺼져 있습니다.",
|
||||
"scanner_bluetoothOffMessage": "블루투스를 켜서 장치를 검색해주세요.",
|
||||
"scanner_chromeRequired": "크롬 브라우저 필요",
|
||||
"scanner_chromeRequiredMessage": "이 웹 애플리케이션은 블루투드 지원을 위해 Google Chrome 또는 Chromium 기반 브라우저가 필요합니다.",
|
||||
"scanner_enableBluetooth": "블루투스 활성화",
|
||||
"scanner_bluetoothOff": "블루투스가 꺼져 있습니다.",
|
||||
"scanner_bluetoothOffMessage": "기기를 검색하려면 블루투스를 켜세요.",
|
||||
"scanner_chromeRequired": "Chrome 브라우저 필요",
|
||||
"scanner_chromeRequiredMessage": "이 웹 앱은 블루투스 지원을 위해 Google Chrome 또는 Chromium 기반 브라우저가 필요합니다.",
|
||||
"scanner_enableBluetooth": "블루투스 켜기",
|
||||
"device_quickSwitch": "빠른 전환",
|
||||
"device_meshcore": "메쉬코어",
|
||||
"device_meshcore": "MeshCore",
|
||||
"settings_title": "설정",
|
||||
"settings_deviceInfo": "장치 정보",
|
||||
"settings_appSettings": "앱 설정",
|
||||
@@ -148,7 +148,7 @@
|
||||
"settings_nodeNameHint": "노드 이름을 입력하세요",
|
||||
"settings_nodeNameUpdated": "이름 변경",
|
||||
"settings_radioSettings": "라디오 설정",
|
||||
"settings_radioSettingsSubtitle": "주파수, 전력, 스펙트럼",
|
||||
"settings_radioSettingsSubtitle": "주파수, 전력, 확산 계수",
|
||||
"settings_radioSettingsUpdated": "라디오 설정이 업데이트되었습니다.",
|
||||
"settings_location": "위치",
|
||||
"settings_locationSubtitle": "GPS 좌표",
|
||||
@@ -169,26 +169,26 @@
|
||||
"settings_privacyModeEnabled": "개인 정보 보호 모드 활성화",
|
||||
"settings_privacyModeDisabled": "개인 정보 보호 모드 비활성화",
|
||||
"settings_actions": "행동",
|
||||
"settings_deleteAllPaths": "Delete All Paths",
|
||||
"settings_deleteAllPathsSubtitle": "Clear all path data from contacts.",
|
||||
"settings_deleteAllPaths": "모든 경로 삭제",
|
||||
"settings_deleteAllPathsSubtitle": "연락처의 모든 경로 데이터를 지웁니다.",
|
||||
"settings_sendAdvertisement": "광고 전송",
|
||||
"settings_sendAdvertisementSubtitle": "방송 활동",
|
||||
"settings_advertisementSent": "광고 전송",
|
||||
"settings_syncTime": "동기화 시간",
|
||||
"settings_sendAdvertisementSubtitle": "현재 존재를 방송합니다.",
|
||||
"settings_advertisementSent": "광고가 전송되었습니다.",
|
||||
"settings_syncTime": "시간 동기화",
|
||||
"settings_syncTimeSubtitle": "장치 시계를 휴대폰 시간으로 설정",
|
||||
"settings_timeSynchronized": "시간 동기화",
|
||||
"settings_refreshContacts": "연락처 갱신",
|
||||
"settings_timeSynchronized": "시간이 동기화되었습니다.",
|
||||
"settings_refreshContacts": "연락처 새로고침",
|
||||
"settings_refreshContactsSubtitle": "장치에서 연락처 목록을 다시 불러오기",
|
||||
"settings_rebootDevice": "장치 재부팅",
|
||||
"settings_rebootDeviceSubtitle": "MeshCore 장치를 재부팅하세요.",
|
||||
"settings_rebootDeviceConfirm": "정말로 장치를 재부팅하시겠습니까? 이 경우 연결이 끊어집니다.",
|
||||
"settings_debug": "디버깅",
|
||||
"settings_rebootDeviceSubtitle": "MeshCore 장치를 재부팅합니다.",
|
||||
"settings_rebootDeviceConfirm": "정말 장치를 재부팅하시겠습니까? 연결이 끊어집니다.",
|
||||
"settings_debug": "디버그",
|
||||
"settings_bleDebugLog": "BLE 디버그 로그",
|
||||
"settings_bleDebugLogSubtitle": "BLE 명령어, 응답 및 원시 데이터",
|
||||
"settings_appDebugLog": "앱 디버깅 로그",
|
||||
"settings_appDebugLogSubtitle": "애플리케이션 디버깅 메시지",
|
||||
"settings_about": "소개",
|
||||
"settings_aboutVersion": "MeshCore Open {version} 버전",
|
||||
"settings_bleDebugLogSubtitle": "BLE 명령, 응답 및 원시 데이터",
|
||||
"settings_appDebugLog": "앱 디버그 로그",
|
||||
"settings_appDebugLogSubtitle": "애플리케이션 디버그 메시지",
|
||||
"settings_about": "정보",
|
||||
"settings_aboutVersion": "MeshCore Open v{version}",
|
||||
"@settings_aboutVersion": {
|
||||
"placeholders": {
|
||||
"version": {
|
||||
@@ -196,8 +196,8 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings_aboutLegalese": "2026년 MeshCore 오픈 소스 프로젝트",
|
||||
"settings_aboutDescription": "MeshCore LoRa 메시 네트워크 장치를 위한 오픈 소스 Flutter 클라이언트.",
|
||||
"settings_aboutLegalese": "2026 MeshCore 오픈 소스 프로젝트",
|
||||
"settings_aboutDescription": "MeshCore LoRa 메시 네트워크 장치를 위한 오픈소스 Flutter 클라이언트.",
|
||||
"settings_aboutOpenMeteoAttribution": "LOS 고도 데이터: Open-Meteo (CC BY 4.0)",
|
||||
"settings_infoName": "이름",
|
||||
"settings_infoId": "ID",
|
||||
@@ -206,19 +206,19 @@
|
||||
"settings_infoPublicKey": "공개 키",
|
||||
"settings_infoContactsCount": "연락처 수",
|
||||
"settings_infoChannelCount": "채널 수",
|
||||
"settings_presets": "기본 설정",
|
||||
"settings_presets": "프리셋",
|
||||
"settings_frequency": "주파수 (MHz)",
|
||||
"settings_frequencyHelper": "300.0 - 2500.0",
|
||||
"settings_frequencyInvalid": "유효하지 않은 주파수 (300-2500 MHz)",
|
||||
"settings_bandwidth": "대역폭",
|
||||
"settings_spreadingFactor": "분산 계수",
|
||||
"settings_codingRate": "코딩 속도",
|
||||
"settings_txPower": "TX 전력 (dBm)",
|
||||
"settings_txPower": "송신 전력 (dBm)",
|
||||
"settings_txPowerHelper": "0 - 22",
|
||||
"settings_txPowerInvalid": "유효하지 않은 TX 전력 (0-22 dBm)",
|
||||
"settings_txPowerInvalid": "유효하지 않은 송신 전력 (0-22 dBm)",
|
||||
"settings_clientRepeat": "오프그리드 반복",
|
||||
"settings_clientRepeatSubtitle": "이 장치가 다른 사람들을 위해 메시 패킷을 반복하도록 허용합니다.",
|
||||
"settings_clientRepeatFreqWarning": "오프그리드(무전력) 시스템 재연결에는 433MHz, 869MHz, 또는 918MHz 주파수가 필요합니다.",
|
||||
"settings_clientRepeatSubtitle": "이 장치가 다른 장치의 메시 패킷을 반복하도록 허용합니다.",
|
||||
"settings_clientRepeatFreqWarning": "오프그리드 반복에는 433MHz, 869MHz 또는 918MHz 주파수가 필요합니다.",
|
||||
"settings_error": "오류: {message}",
|
||||
"@settings_error": {
|
||||
"placeholders": {
|
||||
@@ -228,36 +228,36 @@
|
||||
}
|
||||
},
|
||||
"appSettings_title": "앱 설정",
|
||||
"appSettings_appearance": "외관",
|
||||
"appSettings_theme": "주제",
|
||||
"appSettings_themeSystem": "기본 설정",
|
||||
"appSettings_themeLight": "빛",
|
||||
"appSettings_themeDark": "어둡다",
|
||||
"appSettings_appearance": "모양",
|
||||
"appSettings_theme": "테마",
|
||||
"appSettings_themeSystem": "시스템 기본값",
|
||||
"appSettings_themeLight": "밝음",
|
||||
"appSettings_themeDark": "어두움",
|
||||
"appSettings_language": "언어",
|
||||
"appSettings_languageSystem": "기본 설정",
|
||||
"appSettings_languageEn": "영어",
|
||||
"appSettings_languageFr": "프랑스어",
|
||||
"appSettings_languageEs": "스페인어",
|
||||
"appSettings_languageDe": "독일어",
|
||||
"appSettings_languagePl": "폴란드",
|
||||
"appSettings_languagePl": "폴란드어",
|
||||
"appSettings_languageSl": "슬로베니아어",
|
||||
"appSettings_languagePt": "포르투갈어",
|
||||
"appSettings_languageIt": "이탈리아어",
|
||||
"appSettings_languageZh": "중국어",
|
||||
"appSettings_languageSv": "스웨덴어",
|
||||
"appSettings_languageNl": "네덜란드어",
|
||||
"appSettings_languageSk": "슬로베니아어",
|
||||
"appSettings_languageBg": "불가리",
|
||||
"appSettings_languageSk": "슬로바키아어",
|
||||
"appSettings_languageBg": "불가리아어",
|
||||
"appSettings_languageRu": "러시아어",
|
||||
"appSettings_languageUk": "우크라이나",
|
||||
"appSettings_languageUk": "우크라이나어",
|
||||
"appSettings_enableMessageTracing": "메시지 추적 기능 활성화",
|
||||
"appSettings_enableMessageTracingSubtitle": "메시지에 대한 상세한 경로 및 시간 정보를 표시",
|
||||
"appSettings_notifications": "알림",
|
||||
"appSettings_enableNotifications": "알림 활성화",
|
||||
"appSettings_enableNotificationsSubtitle": "메시지와 광고에 대한 알림을 받으세요.",
|
||||
"appSettings_notificationPermissionDenied": "알림 권한 거부",
|
||||
"appSettings_notificationsEnabled": "알림 기능 활성화",
|
||||
"appSettings_notificationsDisabled": "알림 기능 끄기",
|
||||
"appSettings_notificationsEnabled": "알림 사용",
|
||||
"appSettings_notificationsDisabled": "알림 사용 안 함",
|
||||
"appSettings_messageNotifications": "메시지 알림",
|
||||
"appSettings_messageNotificationsSubtitle": "새로운 메시지를 받을 때 알림 표시",
|
||||
"appSettings_channelMessageNotifications": "채널 메시지 알림",
|
||||
@@ -265,22 +265,22 @@
|
||||
"appSettings_advertisementNotifications": "광고 알림",
|
||||
"appSettings_advertisementNotificationsSubtitle": "새 노드가 발견되었을 때 알림 표시",
|
||||
"appSettings_messaging": "메시징",
|
||||
"appSettings_clearPathOnMaxRetry": "Max 재시도 시 경로 명확하게 설정",
|
||||
"appSettings_clearPathOnMaxRetrySubtitle": "5번의 전송 시도가 실패하면 연락 경로를 재설정",
|
||||
"appSettings_pathsWillBeCleared": "5번의 시도 실패 후, 해당 경로가 확보될 것입니다.",
|
||||
"appSettings_pathsWillNotBeCleared": "경로는 자동으로 정리되지 않습니다.",
|
||||
"appSettings_clearPathOnMaxRetry": "최대 재시도 시 경로 지우기",
|
||||
"appSettings_clearPathOnMaxRetrySubtitle": "전송 시도가 5번 실패하면 연락 경로를 재설정합니다.",
|
||||
"appSettings_pathsWillBeCleared": "5번 실패하면 해당 경로를 지웁니다.",
|
||||
"appSettings_pathsWillNotBeCleared": "경로를 자동으로 지우지 않습니다.",
|
||||
"appSettings_autoRouteRotation": "자동 경로 순환",
|
||||
"appSettings_autoRouteRotationSubtitle": "최적 경로와 방수 모드 사이를 전환",
|
||||
"appSettings_autoRouteRotationSubtitle": "최적 경로와 플러드 모드 사이를 전환합니다.",
|
||||
"appSettings_autoRouteRotationEnabled": "자동 경로 순환 기능 활성화",
|
||||
"appSettings_autoRouteRotationDisabled": "자동 경로 순환 기능 비활성화",
|
||||
"appSettings_maxRouteWeight": "최대 경로 무게",
|
||||
"appSettings_maxRouteWeightSubtitle": "한 경로가 성공적인 배송을 통해 누적할 수 있는 최대 무게",
|
||||
"appSettings_maxRouteWeight": "최대 경로 가중치",
|
||||
"appSettings_maxRouteWeightSubtitle": "한 경로가 성공적인 전송을 통해 누적할 수 있는 최대 가중치",
|
||||
"appSettings_initialRouteWeight": "초기 경로 가중치",
|
||||
"appSettings_initialRouteWeightSubtitle": "새롭게 발견된 경로의 초기 무게",
|
||||
"appSettings_routeWeightSuccessIncrement": "성공 횟수 증가",
|
||||
"appSettings_routeWeightSuccessIncrementSubtitle": "성공적으로 배송된 경로에 추가된 무게",
|
||||
"appSettings_routeWeightFailureDecrement": "오류 가중치 감소",
|
||||
"appSettings_routeWeightFailureDecrementSubtitle": "배송 실패 후 경로에서 제거된 무게",
|
||||
"appSettings_initialRouteWeightSubtitle": "새로 발견된 경로의 초기 가중치",
|
||||
"appSettings_routeWeightSuccessIncrement": "성공 시 증가",
|
||||
"appSettings_routeWeightSuccessIncrementSubtitle": "성공적으로 전송된 경로에 추가되는 가중치",
|
||||
"appSettings_routeWeightFailureDecrement": "실패 시 감소",
|
||||
"appSettings_routeWeightFailureDecrementSubtitle": "전송 실패 후 경로에서 제거되는 가중치",
|
||||
"appSettings_maxMessageRetries": "최대 메시지 재시도 횟수",
|
||||
"appSettings_maxMessageRetriesSubtitle": "메시지를 실패로 처리하기 전 시도 횟수",
|
||||
"path_routeWeight": "{weight}/{max}",
|
||||
@@ -295,8 +295,8 @@
|
||||
}
|
||||
},
|
||||
"appSettings_battery": "배터리",
|
||||
"appSettings_batteryChemistry": "배터리 화학",
|
||||
"appSettings_batteryChemistryPerDevice": "{deviceName} 당분간",
|
||||
"appSettings_batteryChemistry": "배터리 종류",
|
||||
"appSettings_batteryChemistryPerDevice": "{deviceName}별",
|
||||
"@appSettings_batteryChemistryPerDevice": {
|
||||
"placeholders": {
|
||||
"deviceName": {
|
||||
@@ -304,20 +304,20 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"appSettings_batteryChemistryConnectFirst": "장치를 선택하기 위해 연결",
|
||||
"appSettings_batteryChemistryConnectFirst": "배터리 종류를 선택하려면 먼저 장치를 연결하세요.",
|
||||
"appSettings_batteryNmc": "18650 NMC (3.0-4.2V)",
|
||||
"appSettings_batteryLifepo4": "LiFePO4 (2.6-3.65V)",
|
||||
"appSettings_batteryLipo": "리튬 폴리머 (3.0-4.2V)",
|
||||
"appSettings_mapDisplay": "지도 표시",
|
||||
"appSettings_showRepeaters": "반복 기능 표시",
|
||||
"appSettings_showRepeatersSubtitle": "지도에 반복자 노드를 표시",
|
||||
"appSettings_showRepeaters": "리피터 표시",
|
||||
"appSettings_showRepeatersSubtitle": "지도에 리피터 노드를 표시",
|
||||
"appSettings_showChatNodes": "채팅 노드 표시",
|
||||
"appSettings_showChatNodesSubtitle": "지도에 채팅 노드를 표시",
|
||||
"appSettings_showOtherNodes": "다른 노드 표시",
|
||||
"appSettings_showOtherNodesSubtitle": "지도에서 다른 노드 유형을 표시",
|
||||
"appSettings_showOtherNodesSubtitle": "지도에 다른 노드 유형을 표시",
|
||||
"appSettings_timeFilter": "시간 필터",
|
||||
"appSettings_timeFilterShowAll": "모든 노드 표시",
|
||||
"appSettings_timeFilterShowLast": "지난 {hours} 시간 동안의 노드 표시",
|
||||
"appSettings_timeFilterShowLast": "최근 {hours}시간 동안의 노드 표시",
|
||||
"@appSettings_timeFilterShowLast": {
|
||||
"placeholders": {
|
||||
"hours": {
|
||||
@@ -325,17 +325,17 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"appSettings_mapTimeFilter": "지도 필터",
|
||||
"appSettings_showNodesDiscoveredWithin": "다음 내역에서 발견된 노드 표시:",
|
||||
"appSettings_allTime": "모든 시간",
|
||||
"appSettings_lastHour": "지난 시간",
|
||||
"appSettings_mapTimeFilter": "지도 시간 필터",
|
||||
"appSettings_showNodesDiscoveredWithin": "다음 기간 내에 발견된 노드 표시:",
|
||||
"appSettings_allTime": "전체 기간",
|
||||
"appSettings_lastHour": "지난 1시간",
|
||||
"appSettings_last6Hours": "지난 6시간",
|
||||
"appSettings_last24Hours": "지난 24시간",
|
||||
"appSettings_lastWeek": "지난 주",
|
||||
"appSettings_offlineMapCache": "오프라인 지도 캐시",
|
||||
"appSettings_unitsTitle": "단위",
|
||||
"appSettings_unitsMetric": "단위 (m / km)",
|
||||
"appSettings_unitsImperial": "제국 (피트/마일)",
|
||||
"appSettings_unitsMetric": "미터법 (m / km)",
|
||||
"appSettings_unitsImperial": "영국식 (ft / mi)",
|
||||
"appSettings_noAreaSelected": "선택된 영역 없음",
|
||||
"appSettings_areaSelectedZoom": "선택된 영역 (줌 레벨: {minZoom} - {maxZoom})",
|
||||
"@appSettings_areaSelectedZoom": {
|
||||
@@ -348,9 +348,9 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"appSettings_debugCard": "디버깅",
|
||||
"appSettings_appDebugLogging": "앱 디버깅 로깅",
|
||||
"appSettings_appDebugLoggingSubtitle": "로그 앱 디버깅 메시지 (문제 해결을 위한)",
|
||||
"appSettings_debugCard": "디버그",
|
||||
"appSettings_appDebugLogging": "앱 디버그 로깅",
|
||||
"appSettings_appDebugLoggingSubtitle": "문제 해결을 위한 앱 디버그 메시지를 기록합니다.",
|
||||
"appSettings_appDebugLoggingEnabled": "앱 디버깅 로깅 활성화",
|
||||
"appSettings_appDebugLoggingDisabled": "앱 디버깅 로깅 비활성화",
|
||||
"contacts_title": "연락처",
|
||||
@@ -454,7 +454,7 @@
|
||||
"contacts_noContactsMatchFilter": "입력하신 검색 조건과 일치하는 연락처가 없습니다.",
|
||||
"contacts_noMembers": "회원 없음",
|
||||
"contacts_lastSeenNow": "최근",
|
||||
"contacts_lastSeenMinsAgo": "~ {minutes} min.",
|
||||
"contacts_lastSeenMinsAgo": "~ {minutes}분",
|
||||
"@contacts_lastSeenMinsAgo": {
|
||||
"placeholders": {
|
||||
"minutes": {
|
||||
@@ -463,7 +463,7 @@
|
||||
}
|
||||
},
|
||||
"contacts_lastSeenHourAgo": "약 1시간",
|
||||
"contacts_lastSeenHoursAgo": "~ {hours} hours",
|
||||
"contacts_lastSeenHoursAgo": "~ {hours}시간",
|
||||
"@contacts_lastSeenHoursAgo": {
|
||||
"placeholders": {
|
||||
"hours": {
|
||||
@@ -721,7 +721,7 @@
|
||||
}
|
||||
},
|
||||
"debugFrame_textTypeCli": "명령줄 인터페이스 (CLI)",
|
||||
"debugFrame_textTypePlain": "단순한",
|
||||
"debugFrame_textTypePlain": "일반 텍스트",
|
||||
"debugFrame_text": "- 텍스트: \"{text}\"",
|
||||
"@debugFrame_text": {
|
||||
"placeholders": {
|
||||
@@ -735,7 +735,7 @@
|
||||
"chat_ShowAllPaths": "모든 경로 표시",
|
||||
"chat_routingMode": "라우팅 방식",
|
||||
"chat_autoUseSavedPath": "자동 (저장된 경로 사용)",
|
||||
"chat_forceFloodMode": "강수 모드 활성화",
|
||||
"chat_forceFloodMode": "플러드 모드 활성화",
|
||||
"chat_recentAckPaths": "최근 사용한 ACK 경로 (사용하려면 탭):",
|
||||
"chat_pathHistoryFull": "이력 기록은 이미 가득 차 있습니다. 항목을 삭제하여 새로운 항목을 추가할 수 있습니다.",
|
||||
"chat_hopSingular": "점프",
|
||||
@@ -748,20 +748,20 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"chat_successes": "성공 사례",
|
||||
"chat_successes": "성공",
|
||||
"chat_removePath": "경로 제거",
|
||||
"chat_noPathHistoryYet": "아직 경로 기록이 없습니다.\n경로를 찾기 위해 메시지를 보내세요.",
|
||||
"chat_pathActions": "경로 작업:",
|
||||
"chat_setCustomPath": "사용자 지정 경로 설정",
|
||||
"chat_setCustomPathSubtitle": "수동으로 경로를 지정",
|
||||
"chat_clearPath": "명확한 길",
|
||||
"chat_clearPathSubtitle": "다음 전송 시, 강제 재전송 설정",
|
||||
"chat_clearPath": "경로 지우기",
|
||||
"chat_clearPathSubtitle": "다음 전송 시 강제로 새 경로를 찾습니다.",
|
||||
"chat_pathCleared": "경로가 확보되었습니다. 다음 메시지는 경로를 다시 찾을 것입니다.",
|
||||
"chat_floodModeSubtitle": "앱 바에서 라우팅 스위치를 사용",
|
||||
"chat_floodModeEnabled": "홍수 모드 활성화됨. 앱 바의 경로 아이콘을 사용하여 다시 전환할 수 있습니다.",
|
||||
"chat_floodModeSubtitle": "앱 바의 라우팅 스위치를 사용하세요.",
|
||||
"chat_floodModeEnabled": "플러드 모드가 활성화되었습니다. 앱 바의 경로 아이콘으로 다시 전환할 수 있습니다.",
|
||||
"chat_fullPath": "전체 경로",
|
||||
"chat_pathDetailsNotAvailable": "경로 정보는 아직 제공되지 않습니다. 메시지를 보내어 다시 시도해 보세요.",
|
||||
"chat_pathSetHops": "Path set: {hopCount} {hopCount, plural, =1{hop} other{hops}} - {status}",
|
||||
"chat_pathSetHops": "경로 설정: {hopCount} {hopCount, plural, =1{홉} other{홉}} - {status}",
|
||||
"@chat_pathSetHops": {
|
||||
"placeholders": {
|
||||
"hopCount": {
|
||||
@@ -772,16 +772,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"chat_pathSavedLocally": "로컬에 저장. 동기화 연결",
|
||||
"chat_pathDeviceConfirmed": "장치 확인 완료.",
|
||||
"chat_pathSavedLocally": "로컬에 저장되었습니다. 동기화할 장치에 연결하세요.",
|
||||
"chat_pathDeviceConfirmed": "장치가 확인되었습니다.",
|
||||
"chat_pathDeviceNotConfirmed": "기기가 아직 확인되지 않았습니다.",
|
||||
"chat_type": "종류",
|
||||
"chat_type": "유형",
|
||||
"chat_path": "경로",
|
||||
"chat_publicKey": "공개 키",
|
||||
"chat_compressOutgoingMessages": "전송되는 메시지 압축",
|
||||
"chat_floodForced": "홍수 (강제)",
|
||||
"chat_directForced": "직접적인 (강제적인)",
|
||||
"chat_hopsForced": "{count}번 띄우기 (강제)",
|
||||
"chat_floodForced": "플러드 (강제)",
|
||||
"chat_directForced": "직접 (강제)",
|
||||
"chat_hopsForced": "{count}홉 (강제)",
|
||||
"@chat_hopsForced": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
@@ -789,7 +789,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"chat_floodAuto": "홍수 (자동)",
|
||||
"chat_floodAuto": "플러드 (자동)",
|
||||
"chat_direct": "직접",
|
||||
"chat_poiShared": "공유된 POI",
|
||||
"chat_unread": "읽지 않음: {count}",
|
||||
@@ -905,7 +905,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"mapCache_cachedTilesWithFailed": "Cached {downloaded} tiles ({failed} failed)",
|
||||
"mapCache_cachedTilesWithFailed": "캐시된 타일 {downloaded}개 ({failed}개 실패)",
|
||||
"@mapCache_cachedTilesWithFailed": {
|
||||
"placeholders": {
|
||||
"downloaded": {
|
||||
@@ -931,7 +931,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"mapCache_downloadedTiles": "Downloaded {completed} / {total}",
|
||||
"mapCache_downloadedTiles": "다운로드됨 {completed} / {total}",
|
||||
"@mapCache_downloadedTiles": {
|
||||
"placeholders": {
|
||||
"completed": {
|
||||
@@ -978,7 +978,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"time_hoursAgo": "{hours}h ago",
|
||||
"time_hoursAgo": "{hours}시간 전",
|
||||
"@time_hoursAgo": {
|
||||
"placeholders": {
|
||||
"hours": {
|
||||
@@ -1040,8 +1040,8 @@
|
||||
}
|
||||
},
|
||||
"login_failedMessage": "로그인에 실패했습니다. 비밀번호가 잘못되었거나, 연결이 되지 않는 것 같습니다.",
|
||||
"common_reload": "다시 로드",
|
||||
"common_clear": "명확하게",
|
||||
"common_reload": "다시 불러오기",
|
||||
"common_clear": "지우기",
|
||||
"path_currentPath": "현재 경로: {path}",
|
||||
"@path_currentPath": {
|
||||
"placeholders": {
|
||||
@@ -1050,7 +1050,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"path_usingHopsPath": "Using {count} {count, plural, =1{hop} other{hops}} path",
|
||||
"path_usingHopsPath": "{count} {count, plural, =1{홉} other{홉}} 경로 사용 중",
|
||||
"@path_usingHopsPath": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
@@ -1577,7 +1577,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"neighbors_heardAgo": "Heard: {time} ago",
|
||||
"neighbors_heardAgo": "수신: {time} 전",
|
||||
"@neighbors_heardAgo": {
|
||||
"placeholders": {
|
||||
"time": {
|
||||
@@ -1641,7 +1641,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"channelPath_observedSomeOf": "{observed} of {total} hops",
|
||||
"channelPath_observedSomeOf": "{observed}/{total} 홉 관찰됨",
|
||||
"@channelPath_observedSomeOf": {
|
||||
"placeholders": {
|
||||
"observed": {
|
||||
@@ -1877,7 +1877,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"losAntennaB": "Antenna B: {value} {unit}",
|
||||
"losAntennaB": "안테나 B: {value} {unit}",
|
||||
"@losAntennaB": {
|
||||
"placeholders": {
|
||||
"value": {
|
||||
@@ -1890,7 +1890,7 @@
|
||||
},
|
||||
"losRun": "LOS (Loss of Signal) 상태로 전환",
|
||||
"losNoElevationData": "고도 정보 없음",
|
||||
"losProfileClear": "{distance} {distanceUnit}, clear LOS, min clearance {clearance} {heightUnit}",
|
||||
"losProfileClear": "{distance} {distanceUnit}, LOS 확보, 최소 여유 {clearance} {heightUnit}",
|
||||
"@losProfileClear": {
|
||||
"placeholders": {
|
||||
"distance": {
|
||||
@@ -1907,7 +1907,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"losProfileBlocked": "{distance} {distanceUnit}, blocked by {obstruction} {heightUnit}",
|
||||
"losProfileBlocked": "{distance} {distanceUnit}, {obstruction} {heightUnit}에 의해 차단됨",
|
||||
"@losProfileBlocked": {
|
||||
"placeholders": {
|
||||
"distance": {
|
||||
@@ -2305,10 +2305,10 @@
|
||||
"repeater_cliHelpStatsPackets": "(전송 속도만 표시) 패킷 수준의 통계 정보를 보여줍니다.",
|
||||
"repeater_cliHelpStatsRadio": "(특정 시리즈만 해당) 라디오 통계 정보를 표시합니다.",
|
||||
"repeater_cliHelpStatsCore": "(시리얼 번호만 표시) 핵심 펌웨어 통계 정보를 보여줍니다.",
|
||||
"common_done": "Done",
|
||||
"background_serviceTitle": "MeshCore running",
|
||||
"background_serviceText": "Keeping BLE connected",
|
||||
"appSettings_translationModelDeleted": "Deleted {name}",
|
||||
"common_done": "완료",
|
||||
"background_serviceTitle": "MeshCore 실행 중",
|
||||
"background_serviceText": "BLE 연결 유지 중",
|
||||
"appSettings_translationModelDeleted": "{name} 삭제됨",
|
||||
"@appSettings_translationModelDeleted": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
@@ -2316,7 +2316,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"appSettings_translationModelDeleteFailed": "Failed to delete: {error}",
|
||||
"appSettings_translationModelDeleteFailed": "삭제 실패: {error}",
|
||||
"@appSettings_translationModelDeleteFailed": {
|
||||
"placeholders": {
|
||||
"error": {
|
||||
@@ -2324,7 +2324,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"channels_channelUpdateFailed": "Failed to update channel: {error}",
|
||||
"channels_channelUpdateFailed": "채널 업데이트 실패: {error}",
|
||||
"@channels_channelUpdateFailed": {
|
||||
"placeholders": {
|
||||
"error": {
|
||||
@@ -2332,19 +2332,19 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"map_type": "Type",
|
||||
"map_path": "Path",
|
||||
"map_location": "Location",
|
||||
"map_estLocation": "Est. Location",
|
||||
"map_publicKey": "Public Key",
|
||||
"map_publicKeyPrefixHint": "e.g. ab12",
|
||||
"contact_typeChat": "Chat",
|
||||
"contact_typeRepeater": "Repeater",
|
||||
"contact_typeRoom": "Room",
|
||||
"contact_typeSensor": "Sensor",
|
||||
"contact_typeUnknown": "Unknown",
|
||||
"channels_via": "via {path}",
|
||||
"chat_score": "Score",
|
||||
"map_type": "유형",
|
||||
"map_path": "경로",
|
||||
"map_location": "위치",
|
||||
"map_estLocation": "추정 위치",
|
||||
"map_publicKey": "공개 키",
|
||||
"map_publicKeyPrefixHint": "예: ab12",
|
||||
"contact_typeChat": "채팅",
|
||||
"contact_typeRepeater": "리피터",
|
||||
"contact_typeRoom": "룸",
|
||||
"contact_typeSensor": "센서",
|
||||
"contact_typeUnknown": "알 수 없음",
|
||||
"channels_via": "{path} 경유",
|
||||
"chat_score": "점수",
|
||||
"settings_multiAck": "다중 ACK",
|
||||
"map_sharedAt": "공유됨",
|
||||
"@losBlockedSpotChip": {
|
||||
@@ -2386,7 +2386,7 @@
|
||||
"losBlockedSpotsTitle": "차단된 공간",
|
||||
"losSelectedObstructionTitle": "선택된 장애물",
|
||||
"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": "동반 디버깅 로그",
|
||||
"chat_newMessages": "새로운 메시지",
|
||||
"settings_companionDebugLogSubtitle": "BLE/TCP/USB 명령어, 응답 및 원시 데이터",
|
||||
@@ -2433,56 +2433,249 @@
|
||||
"messageStatus_pending": "발송",
|
||||
"messageStatus_sent": "발송",
|
||||
"messageStatus_delivered": "배송 완료",
|
||||
"common_undo": "취소",
|
||||
"messageStatus_failed": "발송 실패",
|
||||
"messageStatus_repeated": "반복적으로 들었습니다",
|
||||
"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_modeFlood": "플러드",
|
||||
"routing_modeManual": "수동",
|
||||
"routing_modeAutoHint": "가장 잘 알려진 경로를 자동으로 선택하고, 경로가 없으면 플러드로 전환합니다.",
|
||||
"routing_modeFloodHint": "모든 중계기를 통해 방송합니다. 가장 안정적이지만 송신 시간을 더 많이 사용합니다.",
|
||||
"routing_modeManualHint": "항상 지정한 정확한 경로를 따릅니다.",
|
||||
"routing_currentRoute": "현재 경로",
|
||||
"routing_directNoHops": "직접 연결 – 중계 장치 사용 없이",
|
||||
"routing_directNoHops": "직접 연결 - 중계 없음",
|
||||
"routing_noPathYet": "아직 경로가 없습니다. 다음 메시지가 도착할 때까지 계속 탐색합니다.",
|
||||
"routing_floodBroadcast": "모든 증폭기를 통해 방송",
|
||||
"routing_floodBroadcast": "모든 중계기를 통해 방송",
|
||||
"routing_editPath": "경로 편집",
|
||||
"routing_forgetPath": "길을 잊어라",
|
||||
"routing_forgetPath": "경로 지우기",
|
||||
"routing_knownPaths": "알려진 경로",
|
||||
"routing_knownPathsHint": "해당 항목으로 전환하기 위한 경로를 선택합니다.",
|
||||
"routing_knownPathsHint": "전환할 경로를 선택하세요.",
|
||||
"routing_inUse": "사용 중",
|
||||
"routing_qualityStrong": "강력한 첫 번째 단계",
|
||||
"routing_qualityGood": "좋은 첫 시작",
|
||||
"routing_qualityFair": "처음 시도",
|
||||
"routing_qualityWorked": "완료됨",
|
||||
"routing_qualityFlood": "홍수 피해 상황을 통해 들었습니다.",
|
||||
"routing_qualityUntested": "검증되지 않음",
|
||||
"routing_lastWorked": "{when}에 일했습니다",
|
||||
"routing_neverWorked": "확인되지 않음",
|
||||
"routing_floodDelivery": "홍수 피해 지역 배송",
|
||||
"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": "고급: 원시 헥스 경로",
|
||||
"pathEditor_hexLabel": "헥스 접두사",
|
||||
"pathEditor_hexHelper": "각 홉마다 2개의 6자리 숫자, 쉼표로 구분",
|
||||
"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_usePath": "이 경로 사용",
|
||||
"pathEditor_removeHop": "홉 제거",
|
||||
"pathEditor_unknownHop": "알 수 없는 중계기",
|
||||
"map_zoomIn": "줌 인",
|
||||
"routing_deliveryCounts": "{successes} delivered, {failures} failed",
|
||||
"map_zoomOut": "줌 아웃",
|
||||
"map_centerMap": "중심 지도",
|
||||
"chrome_bluetoothRequiresChromium": "웹 블루투스는 크롬 브라우저가 필요합니다.",
|
||||
"map_zoomIn": "확대",
|
||||
"routing_deliveryCounts": "{successes}건 성공, {failures}건 실패",
|
||||
"map_zoomOut": "축소",
|
||||
"map_centerMap": "지도 중앙 맞추기",
|
||||
"chrome_bluetoothRequiresChromium": "웹 블루투스는 Chromium 기반 브라우저가 필요합니다.",
|
||||
"channels_communityShortId": "ID: {id}...",
|
||||
"pathTrace_legendGpsConfirmed": "GPS 확인 완료",
|
||||
"pathTrace_legendInferred": "추론된 위치"
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -694,6 +694,12 @@ abstract class AppLocalizations {
|
||||
/// **'Enable Bluetooth'**
|
||||
String get scanner_enableBluetooth;
|
||||
|
||||
/// No description provided for @scanner_bluetoothWebUnsupported.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Bluetooth isn\'t available in the browser. Connect over USB instead.'**
|
||||
String get scanner_bluetoothWebUnsupported;
|
||||
|
||||
/// No description provided for @device_quickSwitch.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -3178,6 +3184,72 @@ abstract class AppLocalizations {
|
||||
/// **'Node Map'**
|
||||
String get map_title;
|
||||
|
||||
/// No description provided for @map_searchHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Search node name or ID'**
|
||||
String get map_searchHint;
|
||||
|
||||
/// No description provided for @map_activity.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Activity'**
|
||||
String get map_activity;
|
||||
|
||||
/// No description provided for @map_online.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Online'**
|
||||
String get map_online;
|
||||
|
||||
/// No description provided for @map_recent.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Recent'**
|
||||
String get map_recent;
|
||||
|
||||
/// No description provided for @map_stale.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Stale'**
|
||||
String get map_stale;
|
||||
|
||||
/// No description provided for @map_visible.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Visible'**
|
||||
String get map_visible;
|
||||
|
||||
/// No description provided for @map_hidden.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Hidden'**
|
||||
String get map_hidden;
|
||||
|
||||
/// No description provided for @map_centerOnNode.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Center on node'**
|
||||
String get map_centerOnNode;
|
||||
|
||||
/// No description provided for @map_details.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Details'**
|
||||
String get map_details;
|
||||
|
||||
/// No description provided for @map_noGps.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'No GPS'**
|
||||
String get map_noGps;
|
||||
|
||||
/// No description provided for @map_noResults.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'No matching nodes'**
|
||||
String get map_noResults;
|
||||
|
||||
/// No description provided for @map_lineOfSight.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -7725,6 +7797,168 @@ abstract class AppLocalizations {
|
||||
/// In en, this message translates to:
|
||||
/// **'Inferred position'**
|
||||
String get pathTrace_legendInferred;
|
||||
|
||||
/// No description provided for @pathMap_viewSingle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Single'**
|
||||
String get pathMap_viewSingle;
|
||||
|
||||
/// No description provided for @pathMap_viewCombined.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Combined'**
|
||||
String get pathMap_viewCombined;
|
||||
|
||||
/// No description provided for @pathMap_play.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Play'**
|
||||
String get pathMap_play;
|
||||
|
||||
/// No description provided for @pathMap_pause.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Pause'**
|
||||
String get pathMap_pause;
|
||||
|
||||
/// No description provided for @pathMap_replay.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Replay'**
|
||||
String get pathMap_replay;
|
||||
|
||||
/// No description provided for @pathMap_stepBack.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Previous hop'**
|
||||
String get pathMap_stepBack;
|
||||
|
||||
/// No description provided for @pathMap_stepForward.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Next hop'**
|
||||
String get pathMap_stepForward;
|
||||
|
||||
/// No description provided for @pathMap_animationOn.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Show packet animation'**
|
||||
String get pathMap_animationOn;
|
||||
|
||||
/// No description provided for @pathMap_animationOff.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Hide packet animation'**
|
||||
String get pathMap_animationOff;
|
||||
|
||||
/// No description provided for @pathMap_hopOf.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Hop {current} of {total}'**
|
||||
String pathMap_hopOf(int current, int total);
|
||||
|
||||
/// No description provided for @pathMap_observedPaths.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Observed paths: {count}'**
|
||||
String pathMap_observedPaths(int count);
|
||||
|
||||
/// No description provided for @pathMap_primary.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Primary'**
|
||||
String get pathMap_primary;
|
||||
|
||||
/// No description provided for @pathMap_alternate.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Alt {index}'**
|
||||
String pathMap_alternate(int index);
|
||||
|
||||
/// No description provided for @pathMap_hopCount.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count, plural, =1{1 hop} other{{count} hops}}'**
|
||||
String pathMap_hopCount(int count);
|
||||
|
||||
/// No description provided for @pathMap_gpsCount.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{confirmed}/{total} GPS'**
|
||||
String pathMap_gpsCount(int confirmed, int total);
|
||||
|
||||
/// No description provided for @pathMap_legendShared.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Shared segment'**
|
||||
String get pathMap_legendShared;
|
||||
|
||||
/// No description provided for @pathMap_legendEstimated.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Estimated segment'**
|
||||
String get pathMap_legendEstimated;
|
||||
|
||||
/// No description provided for @pathMap_sharedNodeCount.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Used by {count} paths'**
|
||||
String pathMap_sharedNodeCount(int count);
|
||||
|
||||
/// No description provided for @pathMap_partialAnimation.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count, plural, =1{1 hop has no location — the shown path is partial} other{{count} hops have no location — the shown path is partial}}'**
|
||||
String pathMap_partialAnimation(int count);
|
||||
|
||||
/// No description provided for @pathMap_showAllPaths.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Show all'**
|
||||
String get pathMap_showAllPaths;
|
||||
|
||||
/// No description provided for @pathMap_hidePath.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Hide path'**
|
||||
String get pathMap_hidePath;
|
||||
|
||||
/// No description provided for @pathMap_showPath.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Show path'**
|
||||
String get pathMap_showPath;
|
||||
|
||||
/// No description provided for @pathMap_collapsePanel.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Collapse panel'**
|
||||
String get pathMap_collapsePanel;
|
||||
|
||||
/// No description provided for @pathMap_expandPanel.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Expand panel'**
|
||||
String get pathMap_expandPanel;
|
||||
|
||||
/// No description provided for @pathMap_noLocation.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'No location'**
|
||||
String get pathMap_noLocation;
|
||||
|
||||
/// No description provided for @pathMap_followPacket.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Lock view to packet'**
|
||||
String get pathMap_followPacket;
|
||||
|
||||
/// No description provided for @pathMap_unfollowPacket.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Unlock view from packet'**
|
||||
String get pathMap_unfollowPacket;
|
||||
}
|
||||
|
||||
class _AppLocalizationsDelegate
|
||||
|
||||
+379
-236
File diff suppressed because it is too large
Load Diff
+474
-340
File diff suppressed because it is too large
Load Diff
@@ -315,6 +315,10 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get scanner_enableBluetooth => 'Enable Bluetooth';
|
||||
|
||||
@override
|
||||
String get scanner_bluetoothWebUnsupported =>
|
||||
'Bluetooth isn\'t available in the browser. Connect over USB instead.';
|
||||
|
||||
@override
|
||||
String get device_quickSwitch => 'Quick switch';
|
||||
|
||||
@@ -1715,6 +1719,39 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
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
|
||||
String get map_lineOfSight => 'Line of Sight';
|
||||
|
||||
@@ -4417,4 +4454,111 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
|
||||
@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';
|
||||
}
|
||||
|
||||
+398
-256
File diff suppressed because it is too large
Load Diff
+338
-201
File diff suppressed because it is too large
Load Diff
+1193
-1073
File diff suppressed because it is too large
Load Diff
+367
-226
File diff suppressed because it is too large
Load Diff
+492
-356
File diff suppressed because it is too large
Load Diff
+316
-170
File diff suppressed because it is too large
Load Diff
@@ -317,6 +317,10 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
@override
|
||||
String get scanner_enableBluetooth => 'Activeer Bluetooth';
|
||||
|
||||
@override
|
||||
String get scanner_bluetoothWebUnsupported =>
|
||||
'Bluetooth is niet beschikbaar in de browser. Verbind dan via USB.';
|
||||
|
||||
@override
|
||||
String get device_quickSwitch => 'Snelle overschakeling';
|
||||
|
||||
@@ -1733,6 +1737,39 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
@override
|
||||
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
|
||||
String get map_lineOfSight => 'Zichtlijn';
|
||||
|
||||
@@ -4475,4 +4512,112 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
|
||||
@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';
|
||||
}
|
||||
|
||||
@@ -322,6 +322,10 @@ class AppLocalizationsPl extends AppLocalizations {
|
||||
@override
|
||||
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
|
||||
String get device_quickSwitch => 'Szybka zmiana';
|
||||
|
||||
@@ -1762,6 +1766,39 @@ class AppLocalizationsPl extends AppLocalizations {
|
||||
@override
|
||||
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
|
||||
String get map_lineOfSight => 'Linia wzroku';
|
||||
|
||||
@@ -4513,4 +4550,118 @@ class AppLocalizationsPl extends AppLocalizations {
|
||||
|
||||
@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';
|
||||
}
|
||||
|
||||
@@ -320,6 +320,10 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
@override
|
||||
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
|
||||
String get device_quickSwitch => 'Mudar rapidamente';
|
||||
|
||||
@@ -1746,6 +1750,39 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
@override
|
||||
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
|
||||
String get map_lineOfSight => 'Linha de visão';
|
||||
|
||||
@@ -4492,4 +4529,111 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
|
||||
@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';
|
||||
}
|
||||
|
||||
@@ -320,6 +320,10 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get scanner_enableBluetooth => 'Включите Bluetooth';
|
||||
|
||||
@override
|
||||
String get scanner_bluetoothWebUnsupported =>
|
||||
'Bluetooth недоступен в браузере. Подключитесь через USB.';
|
||||
|
||||
@override
|
||||
String get device_quickSwitch => 'Быстрое переключение';
|
||||
|
||||
@@ -1751,6 +1755,39 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
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
|
||||
String get map_lineOfSight => 'Линия видимости';
|
||||
|
||||
@@ -4508,4 +4545,115 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
|
||||
@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 => 'Не следить за пакетом';
|
||||
}
|
||||
|
||||
@@ -319,6 +319,10 @@ class AppLocalizationsSk extends AppLocalizations {
|
||||
@override
|
||||
String get scanner_enableBluetooth => 'Povolte Bluetooth';
|
||||
|
||||
@override
|
||||
String get scanner_bluetoothWebUnsupported =>
|
||||
'Funkcia Bluetooth nie je dostupná v prehliadači. Prepojte sa pomocou USB.';
|
||||
|
||||
@override
|
||||
String get device_quickSwitch => 'Rýchle prepínač';
|
||||
|
||||
@@ -1738,6 +1742,39 @@ class AppLocalizationsSk extends AppLocalizations {
|
||||
@override
|
||||
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
|
||||
String get map_lineOfSight => 'Úroveň výhľadu';
|
||||
|
||||
@@ -4474,4 +4511,113 @@ class AppLocalizationsSk extends AppLocalizations {
|
||||
|
||||
@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';
|
||||
}
|
||||
|
||||
@@ -318,6 +318,10 @@ class AppLocalizationsSl extends AppLocalizations {
|
||||
@override
|
||||
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
|
||||
String get device_quickSwitch => 'Hitro preklop';
|
||||
|
||||
@@ -1731,6 +1735,39 @@ class AppLocalizationsSl extends AppLocalizations {
|
||||
@override
|
||||
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
|
||||
String get map_lineOfSight => 'Linija vida';
|
||||
|
||||
@@ -4473,4 +4510,115 @@ class AppLocalizationsSl extends AppLocalizations {
|
||||
|
||||
@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';
|
||||
}
|
||||
|
||||
@@ -316,6 +316,10 @@ class AppLocalizationsSv extends AppLocalizations {
|
||||
@override
|
||||
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
|
||||
String get device_quickSwitch => 'Snabb växling';
|
||||
|
||||
@@ -1724,6 +1728,39 @@ class AppLocalizationsSv extends AppLocalizations {
|
||||
@override
|
||||
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
|
||||
String get map_lineOfSight => 'Synlinje';
|
||||
|
||||
@@ -4447,4 +4484,111 @@ class AppLocalizationsSv extends AppLocalizations {
|
||||
|
||||
@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';
|
||||
}
|
||||
|
||||
@@ -319,6 +319,10 @@ class AppLocalizationsUk extends AppLocalizations {
|
||||
@override
|
||||
String get scanner_enableBluetooth => 'Увімкніть Bluetooth';
|
||||
|
||||
@override
|
||||
String get scanner_bluetoothWebUnsupported =>
|
||||
'Bluetooth недоступний у браузері. Підключіться через USB.';
|
||||
|
||||
@override
|
||||
String get device_quickSwitch => 'Швидке перемикання';
|
||||
|
||||
@@ -1744,6 +1748,39 @@ class AppLocalizationsUk extends AppLocalizations {
|
||||
@override
|
||||
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
|
||||
String get map_lineOfSight => 'Пряма видимість';
|
||||
|
||||
@@ -4507,4 +4544,116 @@ class AppLocalizationsUk extends AppLocalizations {
|
||||
|
||||
@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 => 'Відв\'язати вигляд від пакету';
|
||||
}
|
||||
|
||||
@@ -305,6 +305,9 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get scanner_enableBluetooth => '启用蓝牙';
|
||||
|
||||
@override
|
||||
String get scanner_bluetoothWebUnsupported => '浏览器不支持蓝牙,请改用 USB 连接。';
|
||||
|
||||
@override
|
||||
String get device_quickSwitch => '快速切换';
|
||||
|
||||
@@ -1646,6 +1649,39 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
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
|
||||
String get map_lineOfSight => '视线';
|
||||
|
||||
@@ -4157,4 +4193,111 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
|
||||
@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 => '解锁视图跟随';
|
||||
}
|
||||
|
||||
+95
-1
@@ -2446,5 +2446,99 @@
|
||||
"chrome_bluetoothRequiresChromium": "Web Bluetooth vereist een Chromium-browser.",
|
||||
"channels_communityShortId": "ID: {id}...",
|
||||
"pathTrace_legendGpsConfirmed": "GPS-locatie bevestigd",
|
||||
"pathTrace_legendInferred": "Afgeleide positie"
|
||||
"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"
|
||||
}
|
||||
|
||||
+95
-1
@@ -2484,5 +2484,99 @@
|
||||
"chrome_bluetoothRequiresChromium": "Web Bluetooth wymaga przeglądarki Chromium.",
|
||||
"channels_communityShortId": "ID: {id}...",
|
||||
"pathTrace_legendGpsConfirmed": "GPS potwierdzone",
|
||||
"pathTrace_legendInferred": "Wywnioskowana pozycja"
|
||||
"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"
|
||||
}
|
||||
|
||||
+95
-1
@@ -2446,5 +2446,99 @@
|
||||
"chrome_bluetoothRequiresChromium": "O Web Bluetooth requer um navegador Chromium.",
|
||||
"channels_communityShortId": "ID: {id}...",
|
||||
"pathTrace_legendGpsConfirmed": "GPS confirmado",
|
||||
"pathTrace_legendInferred": "Posição inferida"
|
||||
"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"
|
||||
}
|
||||
|
||||
+95
-1
@@ -1749,5 +1749,99 @@
|
||||
"chrome_bluetoothRequiresChromium": "Для работы Web Bluetooth требуется браузер на основе Chromium.",
|
||||
"channels_communityShortId": "Идентификатор: {id}...",
|
||||
"pathTrace_legendGpsConfirmed": "GPS подтверждено",
|
||||
"pathTrace_legendInferred": "Выведенная позиция"
|
||||
"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"
|
||||
}
|
||||
|
||||
+95
-1
@@ -2446,5 +2446,99 @@
|
||||
"chrome_bluetoothRequiresChromium": "Web Bluetooth vyžaduje prehliadač Chromium.",
|
||||
"channels_communityShortId": "ID: {id}...",
|
||||
"pathTrace_legendGpsConfirmed": "GPS potvrdilo",
|
||||
"pathTrace_legendInferred": "Odvodená poloha"
|
||||
"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"
|
||||
}
|
||||
|
||||
+95
-1
@@ -2446,5 +2446,99 @@
|
||||
"chrome_bluetoothRequiresChromium": "Web Bluetooth zahteva brskalnik Chromium.",
|
||||
"channels_communityShortId": "ID: {id}...",
|
||||
"pathTrace_legendGpsConfirmed": "GPS potrdilo",
|
||||
"pathTrace_legendInferred": "Izpeljana lokacija"
|
||||
"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"
|
||||
}
|
||||
|
||||
+95
-1
@@ -2446,5 +2446,99 @@
|
||||
"chrome_bluetoothRequiresChromium": "Web Bluetooth kräver en Chromium-baserad webbläsare.",
|
||||
"channels_communityShortId": "ID: {id}...",
|
||||
"pathTrace_legendGpsConfirmed": "GPS-verifierat",
|
||||
"pathTrace_legendInferred": "Antagen position"
|
||||
"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"
|
||||
}
|
||||
|
||||
+95
-1
@@ -2426,5 +2426,99 @@
|
||||
"chrome_bluetoothRequiresChromium": "Web Bluetooth вимагає браузера на основі Chromium",
|
||||
"channels_communityShortId": "ID: {id}...",
|
||||
"pathTrace_legendGpsConfirmed": "GPS підтверджено",
|
||||
"pathTrace_legendInferred": "Висновок щодо положення"
|
||||
"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"
|
||||
}
|
||||
|
||||
+95
-1
@@ -2451,5 +2451,99 @@
|
||||
"chrome_bluetoothRequiresChromium": "Web Bluetooth 需要 Chromium 浏览器",
|
||||
"channels_communityShortId": "ID:{id}...",
|
||||
"pathTrace_legendGpsConfirmed": "通过GPS确认",
|
||||
"pathTrace_legendInferred": "推测的位置"
|
||||
"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} 跳无位置信息 — 显示的路径不完整}}"
|
||||
}
|
||||
|
||||
@@ -141,7 +141,7 @@ class AppSettings {
|
||||
this.mapKeyPrefix = '',
|
||||
this.mapShowMarkers = true,
|
||||
this.mapShowGuessedLocations = true,
|
||||
this.enableMessageTracing = false,
|
||||
this.enableMessageTracing = true,
|
||||
this.mapCacheBounds,
|
||||
this.mapCacheMinZoom = 10,
|
||||
this.mapCacheMaxZoom = 15,
|
||||
@@ -149,7 +149,7 @@ class AppSettings {
|
||||
this.notifyOnNewMessage = true,
|
||||
this.notifyOnNewChannelMessage = true,
|
||||
this.notifyOnNewAdvert = true,
|
||||
this.autoRouteRotationEnabled = false,
|
||||
this.autoRouteRotationEnabled = true,
|
||||
this.maxRouteWeight = 5.0,
|
||||
this.initialRouteWeight = 3.0,
|
||||
this.routeWeightSuccessIncrement = 0.5,
|
||||
@@ -264,7 +264,7 @@ class AppSettings {
|
||||
mapShowMarkers: json['map_show_markers'] as bool? ?? true,
|
||||
mapShowGuessedLocations:
|
||||
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(
|
||||
(key, value) => MapEntry(key.toString(), (value as num).toDouble()),
|
||||
),
|
||||
@@ -276,7 +276,7 @@ class AppSettings {
|
||||
json['notify_on_new_channel_message'] as bool? ?? true,
|
||||
notifyOnNewAdvert: json['notify_on_new_advert'] as bool? ?? true,
|
||||
autoRouteRotationEnabled:
|
||||
json['auto_route_rotation_enabled'] as bool? ?? false,
|
||||
json['auto_route_rotation_enabled'] as bool? ?? true,
|
||||
maxRouteWeight: (json['max_route_weight'] as num?)?.toDouble() ?? 5.0,
|
||||
initialRouteWeight:
|
||||
(json['initial_route_weight'] as num?)?.toDouble() ?? 3.0,
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import 'dart:typed_data';
|
||||
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 prefixes, including hops that could not be placed on the map.
|
||||
final List<Uint8List> 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,
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import 'package:provider/provider.dart';
|
||||
|
||||
import '../l10n/l10n.dart';
|
||||
import '../services/app_debug_log_service.dart';
|
||||
import '../theme/mesh_theme.dart';
|
||||
import '../widgets/adaptive_app_bar_title.dart';
|
||||
import '../helpers/snack_bar_builder.dart';
|
||||
|
||||
@@ -58,28 +59,58 @@ class AppDebugLogScreen extends StatelessWidget {
|
||||
child: hasEntries
|
||||
? ListView.separated(
|
||||
itemCount: entries.length,
|
||||
separatorBuilder: (_, _) => const Divider(height: 1),
|
||||
separatorBuilder: (_, _) =>
|
||||
const Divider(height: 1, color: MeshPalette.line),
|
||||
itemBuilder: (context, index) {
|
||||
final entry = entries[index];
|
||||
return ListTile(
|
||||
dense: true,
|
||||
leading: _buildLevelIcon(context, entry.level),
|
||||
title: Text(
|
||||
'[${entry.tag}] ${entry.message}',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontFamily: 'monospace',
|
||||
return Container(
|
||||
color: MeshPalette.bg,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 8,
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildLevelIcon(context, entry.level),
|
||||
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),
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
TextSpan(
|
||||
text: entry.message,
|
||||
style: MeshTheme.mono(
|
||||
fontSize: 11.5,
|
||||
color: MeshPalette.ink2,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
entry.formattedTime,
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurfaceVariant,
|
||||
style: MeshTheme.mono(
|
||||
fontSize: 9.5,
|
||||
color: MeshPalette.ink4,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
)
|
||||
@@ -87,29 +118,25 @@ class AppDebugLogScreen extends StatelessWidget {
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
const Icon(
|
||||
Icons.bug_report_outlined,
|
||||
size: 64,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
color: MeshPalette.ink3,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
context.l10n.debugLog_noEntries,
|
||||
style: TextStyle(
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurfaceVariant,
|
||||
color: MeshPalette.ink3,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
context.l10n.debugLog_enableInSettings,
|
||||
style: TextStyle(
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurfaceVariant,
|
||||
color: MeshPalette.ink3,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -121,19 +148,37 @@ class AppDebugLogScreen extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLevelIcon(BuildContext context, AppDebugLogLevel level) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
Color _levelColor(AppDebugLogLevel level) {
|
||||
switch (level) {
|
||||
case AppDebugLogLevel.info:
|
||||
return Icon(Icons.info_outline, size: 18, color: colorScheme.primary);
|
||||
return MeshPalette.blue;
|
||||
case AppDebugLogLevel.warning:
|
||||
return Icon(
|
||||
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:
|
||||
return const Icon(
|
||||
Icons.warning_amber_outlined,
|
||||
size: 18,
|
||||
color: colorScheme.tertiary,
|
||||
color: MeshPalette.warn,
|
||||
);
|
||||
case AppDebugLogLevel.error:
|
||||
return Icon(Icons.error_outline, size: 18, color: colorScheme.error);
|
||||
return const Icon(
|
||||
Icons.error_outline,
|
||||
size: 18,
|
||||
color: MeshPalette.alert,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@ import 'package:flutter/services.dart';
|
||||
import '../l10n/l10n.dart';
|
||||
import '../services/ble_debug_log_service.dart';
|
||||
import '../connector/meshcore_protocol.dart';
|
||||
import '../theme/mesh_theme.dart';
|
||||
import '../widgets/adaptive_app_bar_title.dart';
|
||||
import '../helpers/snack_bar_builder.dart';
|
||||
|
||||
@@ -39,6 +40,7 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: AdaptiveAppBarTitle(context.l10n.debugLog_bleTitle),
|
||||
centerTitle: true,
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: context.l10n.debugLog_copyLog,
|
||||
@@ -108,23 +110,14 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
|
||||
itemCount: showingFrames
|
||||
? entries.length
|
||||
: rawEntries.length,
|
||||
separatorBuilder: (_, _) => const Divider(height: 1),
|
||||
separatorBuilder: (_, _) =>
|
||||
const Divider(height: 1, color: MeshPalette.line),
|
||||
itemBuilder: (context, index) {
|
||||
if (showingFrames) {
|
||||
final entry = entries[index];
|
||||
final time =
|
||||
'${entry.timestamp.hour.toString().padLeft(2, '0')}:${entry.timestamp.minute.toString().padLeft(2, '0')}:${entry.timestamp.second.toString().padLeft(2, '0')}';
|
||||
return ListTile(
|
||||
dense: true,
|
||||
title: Text(entry.description),
|
||||
subtitle: Text('${entry.hexPreview}\n$time'),
|
||||
isThreeLine: true,
|
||||
leading: Icon(
|
||||
entry.outgoing
|
||||
? Icons.upload
|
||||
: Icons.download,
|
||||
size: 18,
|
||||
),
|
||||
return GestureDetector(
|
||||
onLongPress: () async {
|
||||
await Clipboard.setData(
|
||||
ClipboardData(
|
||||
@@ -138,6 +131,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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -145,18 +192,65 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
|
||||
final info = _decodeRawPacket(entry.payload);
|
||||
final time =
|
||||
'${entry.timestamp.hour.toString().padLeft(2, '0')}:${entry.timestamp.minute.toString().padLeft(2, '0')}:${entry.timestamp.second.toString().padLeft(2, '0')}';
|
||||
return ListTile(
|
||||
dense: true,
|
||||
title: Text(info.title),
|
||||
subtitle: Text('${info.summary}\n$time'),
|
||||
isThreeLine: true,
|
||||
leading: const Icon(Icons.download, size: 18),
|
||||
return GestureDetector(
|
||||
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(
|
||||
child: Text(context.l10n.debugLog_noBleActivity),
|
||||
child: Text(
|
||||
context.l10n.debugLog_noBleActivity,
|
||||
style: const TextStyle(color: MeshPalette.ink3),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -26,7 +26,6 @@ import '../models/translation_support.dart';
|
||||
import '../services/app_settings_service.dart';
|
||||
import '../services/chat_text_scale_service.dart';
|
||||
import '../services/translation_service.dart';
|
||||
import '../helpers/contact_ui.dart';
|
||||
import '../widgets/byte_count_input.dart';
|
||||
import '../widgets/empty_state.dart';
|
||||
import '../widgets/chat_zoom_wrapper.dart';
|
||||
@@ -40,6 +39,8 @@ import '../widgets/radio_stats_entry.dart';
|
||||
import '../widgets/sync_progress_overlay.dart';
|
||||
import '../widgets/translated_message_content.dart';
|
||||
import '../widgets/unread_divider.dart';
|
||||
import '../theme/mesh_theme.dart';
|
||||
import '../widgets/mesh_ui.dart';
|
||||
import 'channel_message_path_screen.dart';
|
||||
import 'map_screen.dart';
|
||||
|
||||
@@ -109,7 +110,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
totalMessages: messages.length,
|
||||
onJumped: () {
|
||||
if (!mounted) return;
|
||||
_scrollToMessage(anchor!.messageId);
|
||||
_scrollToMessage(anchor!.messageId, quiet: true);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -194,9 +195,12 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _scrollToMessage(String messageId) async {
|
||||
Future<void> _scrollToMessage(String messageId, {bool quiet = false}) async {
|
||||
final key = _messageKeys[messageId];
|
||||
if (key == null) {
|
||||
// The auto unread-jump can resolve a frame after navigating away;
|
||||
// a deactivated context can't host a snackbar.
|
||||
if (quiet || !mounted || !context.mounted) return;
|
||||
showDismissibleSnackBar(
|
||||
context,
|
||||
content: Text(context.l10n.chat_originalMessageNotFound),
|
||||
@@ -492,6 +496,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
final settingsService = context.watch<AppSettingsService>();
|
||||
final enableTracing = settingsService.settings.enableMessageTracing;
|
||||
final isOutgoing = message.isOutgoing;
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final gifId = GifHelper.parseGif(message.text);
|
||||
final poi = parseMarkerText(message.text);
|
||||
final translatedDisplayText =
|
||||
@@ -511,9 +516,34 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
message.pathHashWidth ??
|
||||
context.read<MeshCoreConnector>().pathHashByteWidth;
|
||||
|
||||
// Bubble colors — outgoing uses MeshPalette.me / meBorder / meInk.
|
||||
final bubbleColor = isOutgoing
|
||||
? MeshPalette.me
|
||||
: scheme.surfaceContainerLow;
|
||||
final bubbleBorder = isOutgoing
|
||||
? MeshPalette.meBorder
|
||||
: scheme.outlineVariant;
|
||||
final textColor = isOutgoing ? MeshPalette.meInk : scheme.onSurface;
|
||||
final metaColor = textColor.withValues(alpha: 0.65);
|
||||
const bodyFontSize = 14.0;
|
||||
|
||||
// Asymmetric radius matching chat_screen bubbles.
|
||||
final borderRadius = isOutgoing
|
||||
? const BorderRadius.only(
|
||||
topLeft: Radius.circular(MeshRadii.lg),
|
||||
topRight: Radius.circular(MeshRadii.lg),
|
||||
bottomLeft: Radius.circular(MeshRadii.lg),
|
||||
bottomRight: Radius.circular(MeshRadii.xs),
|
||||
)
|
||||
: const BorderRadius.only(
|
||||
topLeft: Radius.circular(MeshRadii.xs),
|
||||
topRight: Radius.circular(MeshRadii.lg),
|
||||
bottomLeft: Radius.circular(MeshRadii.lg),
|
||||
bottomRight: Radius.circular(MeshRadii.lg),
|
||||
);
|
||||
|
||||
const maxSwipeOffset = 64.0;
|
||||
const replySwipeThreshold = 64.0;
|
||||
const bodyFontSize = 14.0;
|
||||
final messageBody = LayoutBuilder(
|
||||
builder: (context, constraints) => Column(
|
||||
crossAxisAlignment: isOutgoing
|
||||
@@ -524,11 +554,11 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
mainAxisAlignment: isOutgoing
|
||||
? MainAxisAlignment.end
|
||||
: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
if (!isOutgoing) ...[
|
||||
_buildAvatar(message.senderName),
|
||||
const SizedBox(width: 8),
|
||||
const SizedBox(width: 6),
|
||||
],
|
||||
Flexible(
|
||||
child: GestureDetector(
|
||||
@@ -544,15 +574,12 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
vertical: 8,
|
||||
),
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: constraints.maxWidth * 0.65,
|
||||
maxWidth: constraints.maxWidth * 0.72,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: isOutgoing
|
||||
? Theme.of(context).colorScheme.primaryContainer
|
||||
: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
color: bubbleColor,
|
||||
borderRadius: borderRadius,
|
||||
border: Border.all(color: bubbleBorder, width: 1),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -568,14 +595,14 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
: EdgeInsets.zero,
|
||||
child: Text(
|
||||
message.senderName,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
style: MeshTheme.mono(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: _colorForName(message.senderName),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (gifId == null) const SizedBox(height: 4),
|
||||
if (gifId == null) const SizedBox(height: 2),
|
||||
],
|
||||
if (message.replyToMessageId != null) ...[
|
||||
_buildReplyPreview(message, textScale),
|
||||
@@ -598,13 +625,9 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
url:
|
||||
'https://media.giphy.com/media/$gifId/giphy.gif',
|
||||
backgroundColor: Colors.transparent,
|
||||
fallbackTextColor: isOutgoing
|
||||
? Theme.of(context)
|
||||
.colorScheme
|
||||
.onPrimaryContainer
|
||||
.withValues(alpha: 0.7)
|
||||
: Theme.of(context).colorScheme.onSurface
|
||||
.withValues(alpha: 0.6),
|
||||
fallbackTextColor: textColor.withValues(
|
||||
alpha: 0.7,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -619,43 +642,51 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
displayText: translatedDisplayText,
|
||||
originalText: originalDisplayText,
|
||||
style: TextStyle(
|
||||
color: textColor,
|
||||
fontSize: bodyFontSize * textScale,
|
||||
),
|
||||
originalStyle: TextStyle(
|
||||
fontSize: bodyFontSize * textScale,
|
||||
fontStyle: FontStyle.italic,
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurface
|
||||
.withValues(alpha: 0.72),
|
||||
color: textColor.withValues(alpha: 0.72),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (enableTracing && displayPath.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
const SizedBox(height: 3),
|
||||
Padding(
|
||||
padding: gifId != null
|
||||
? const EdgeInsets.symmetric(horizontal: 8)
|
||||
: EdgeInsets.zero,
|
||||
child: Text(
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
RouteChip(
|
||||
isDirect: (message.pathLength ?? -1) >= 0,
|
||||
hops: (message.pathLength ?? -1) >= 0
|
||||
? message.pathLength
|
||||
: null,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
context.l10n.channels_via(
|
||||
_formatPathPrefixes(
|
||||
displayPath,
|
||||
displayPathHashWidth,
|
||||
),
|
||||
),
|
||||
style: TextStyle(
|
||||
fontSize: 11 * textScale,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
style: MeshTheme.mono(
|
||||
fontSize: 9.5 * textScale,
|
||||
color: metaColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 4),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 3),
|
||||
Padding(
|
||||
padding: gifId != null
|
||||
? const EdgeInsets.only(
|
||||
@@ -669,30 +700,24 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
children: [
|
||||
Text(
|
||||
_formatTime(context, message.timestamp),
|
||||
style: TextStyle(
|
||||
fontSize: 11 * textScale,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurfaceVariant,
|
||||
style: MeshTheme.mono(
|
||||
fontSize: 10 * textScale,
|
||||
color: metaColor,
|
||||
),
|
||||
),
|
||||
if (enableTracing && message.repeatCount > 0) ...[
|
||||
const SizedBox(width: 6),
|
||||
Icon(
|
||||
Icons.repeat,
|
||||
size: 12 * textScale,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurfaceVariant,
|
||||
size: 11 * textScale,
|
||||
color: metaColor,
|
||||
),
|
||||
const SizedBox(width: 2),
|
||||
Text(
|
||||
'${message.repeatCount}',
|
||||
style: TextStyle(
|
||||
fontSize: 11 * textScale,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurfaceVariant,
|
||||
style: MeshTheme.mono(
|
||||
fontSize: 10 * textScale,
|
||||
color: metaColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -712,6 +737,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
isFailed:
|
||||
message.status ==
|
||||
ChannelMessageStatus.failed,
|
||||
onColor: metaColor,
|
||||
),
|
||||
],
|
||||
],
|
||||
@@ -727,7 +753,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
if (message.reactions.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Padding(
|
||||
padding: EdgeInsets.only(left: isOutgoing ? 0 : 48),
|
||||
padding: EdgeInsets.only(left: isOutgoing ? 0 : 42),
|
||||
child: _buildReactionsDisplay(message),
|
||||
),
|
||||
],
|
||||
@@ -746,7 +772,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
);
|
||||
} else {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
padding: const EdgeInsets.symmetric(vertical: 3),
|
||||
child: messageBody,
|
||||
);
|
||||
}
|
||||
@@ -843,7 +869,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderRadius: BorderRadius.circular(MeshRadii.sm),
|
||||
border: Border(
|
||||
left: BorderSide(color: colorScheme.primary, width: 3),
|
||||
),
|
||||
@@ -856,9 +882,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
style: TextStyle(
|
||||
fontSize: 11 * textScale,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isOwnNode
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Theme.of(context).colorScheme.onSurface,
|
||||
color: isOwnNode ? colorScheme.primary : colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
@@ -870,6 +894,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
}
|
||||
|
||||
Widget _buildReactionsDisplay(ChannelMessage message) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 6,
|
||||
@@ -880,27 +905,29 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.secondaryContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.outline.withValues(alpha: 0.3),
|
||||
width: 1,
|
||||
),
|
||||
color: scheme.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(MeshRadii.pill),
|
||||
border: Border.all(color: scheme.outlineVariant, width: 1),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(emoji, style: const TextStyle(fontSize: 16)),
|
||||
Text(
|
||||
emoji,
|
||||
style: MeshTheme.emoji(fontSize: 16),
|
||||
textHeightBehavior: const TextHeightBehavior(
|
||||
applyHeightToFirstAscent: false,
|
||||
applyHeightToLastDescent: false,
|
||||
),
|
||||
),
|
||||
if (count > 1) ...[
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'$count',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).colorScheme.onSecondaryContainer,
|
||||
style: MeshTheme.mono(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: scheme.onSurface,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -919,20 +946,15 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
String senderName, {
|
||||
Widget? trailing,
|
||||
}) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final textColor = isOutgoing
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSurface;
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final textColor = isOutgoing ? MeshPalette.meInk : scheme.onSurface;
|
||||
final metaColor = textColor.withValues(alpha: 0.7);
|
||||
final channelColor = widget.channel.isPublicChannel
|
||||
? Colors.orange
|
||||
: Colors.blue;
|
||||
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: Icon(Icons.location_on_outlined, color: channelColor),
|
||||
icon: Icon(Icons.location_on_outlined, color: scheme.primary),
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(minWidth: 40, minHeight: 40),
|
||||
onPressed: () {
|
||||
@@ -998,41 +1020,24 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
}
|
||||
|
||||
Widget _buildAvatar(String senderName) {
|
||||
final initial = firstCharacterOrEmoji(senderName);
|
||||
final color = colorForName(senderName);
|
||||
|
||||
return CircleAvatar(
|
||||
radius: 18,
|
||||
backgroundColor: color.withValues(alpha: 0.2),
|
||||
child: Text(
|
||||
initial,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
);
|
||||
return AvatarCircle(name: senderName, size: 32);
|
||||
}
|
||||
|
||||
Widget _buildReplyBanner(double textScale) {
|
||||
final message = _replyingToMessage!;
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.secondaryContainer,
|
||||
color: scheme.surfaceContainerHigh,
|
||||
border: Border(
|
||||
bottom: BorderSide(color: Theme.of(context).dividerColor, width: 1),
|
||||
bottom: BorderSide(color: scheme.outlineVariant, width: 1),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.reply,
|
||||
size: 18,
|
||||
color: Theme.of(context).colorScheme.onSecondaryContainer,
|
||||
),
|
||||
Icon(Icons.reply, size: 18, color: scheme.primary),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
@@ -1040,10 +1045,10 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
children: [
|
||||
Text(
|
||||
context.l10n.chat_replyingTo(message.senderName),
|
||||
style: TextStyle(
|
||||
fontSize: 12 * textScale,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).colorScheme.onSecondaryContainer,
|
||||
style: MeshTheme.mono(
|
||||
fontSize: 11 * textScale,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: scheme.primary,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
@@ -1052,9 +1057,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 11 * textScale,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSecondaryContainer.withValues(alpha: 0.7),
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -1063,7 +1066,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close, size: 18),
|
||||
onPressed: _cancelReply,
|
||||
color: Theme.of(context).colorScheme.onSecondaryContainer,
|
||||
color: scheme.onSurfaceVariant,
|
||||
constraints: const BoxConstraints(minWidth: 44, minHeight: 44),
|
||||
),
|
||||
],
|
||||
@@ -1075,6 +1078,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
final connector = context.watch<MeshCoreConnector>();
|
||||
final maxBytes = maxChannelMessageBytes(connector.selfName);
|
||||
final settings = context.watch<AppSettingsService>().settings;
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
@@ -1088,18 +1092,17 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
},
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.1),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, -2),
|
||||
color: scheme.surface,
|
||||
border: Border(
|
||||
top: BorderSide(color: scheme.outlineVariant, width: 1),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.gif_box),
|
||||
@@ -1122,7 +1125,8 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
autofocus: true,
|
||||
onKeyEvent: (node, event) {
|
||||
if (event is KeyDownEvent &&
|
||||
(event.logicalKey == LogicalKeyboardKey.enter ||
|
||||
(event.logicalKey ==
|
||||
LogicalKeyboardKey.enter ||
|
||||
event.logicalKey ==
|
||||
LogicalKeyboardKey.numpadEnter)) {
|
||||
_sendMessage();
|
||||
@@ -1138,12 +1142,9 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
child: GifMessage(
|
||||
url:
|
||||
'https://media.giphy.com/media/$gifId/giphy.gif',
|
||||
backgroundColor: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerHighest,
|
||||
fallbackTextColor: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurface
|
||||
backgroundColor:
|
||||
scheme.surfaceContainerHighest,
|
||||
fallbackTextColor: scheme.onSurface
|
||||
.withValues(alpha: 0.6),
|
||||
maxSize: 160,
|
||||
),
|
||||
@@ -1182,31 +1183,77 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
decoration: InputDecoration(
|
||||
hintText: context.l10n.chat_typeMessage,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
borderRadius: BorderRadius.circular(
|
||||
MeshRadii.pill,
|
||||
),
|
||||
borderSide: BorderSide(
|
||||
color: scheme.outlineVariant,
|
||||
),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(
|
||||
MeshRadii.pill,
|
||||
),
|
||||
borderSide: BorderSide(
|
||||
color: scheme.outlineVariant,
|
||||
),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(
|
||||
MeshRadii.pill,
|
||||
),
|
||||
borderSide: BorderSide(
|
||||
color: scheme.primary,
|
||||
width: 1.5,
|
||||
),
|
||||
),
|
||||
filled: true,
|
||||
fillColor: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerLow,
|
||||
fillColor: scheme.surfaceContainerLow,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 20,
|
||||
vertical: 14,
|
||||
horizontal: 18,
|
||||
vertical: 12,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.send),
|
||||
const SizedBox(width: 6),
|
||||
ValueListenableBuilder<TextEditingValue>(
|
||||
valueListenable: _textController,
|
||||
builder: (context, value, _) {
|
||||
final hasText = value.text.trim().isNotEmpty;
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
curve: Curves.easeInOut,
|
||||
child: IconButton.filled(
|
||||
icon: const Icon(Icons.send, size: 20),
|
||||
tooltip: context.l10n.chat_sendMessage,
|
||||
onPressed: _sendMessage,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: hasText
|
||||
? scheme.primary
|
||||
: scheme.surfaceContainerHighest,
|
||||
foregroundColor: hasText
|
||||
? scheme.onPrimary
|
||||
: scheme.onSurfaceVariant,
|
||||
minimumSize: const Size(40, 40),
|
||||
shape: const CircleBorder(),
|
||||
),
|
||||
onPressed: hasText
|
||||
? () {
|
||||
HapticFeedback.lightImpact();
|
||||
_sendMessage();
|
||||
}
|
||||
: null,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -1345,12 +1392,20 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
) &&
|
||||
(message.translatedText?.trim().isEmpty ?? true);
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
showMeshSheet(
|
||||
context,
|
||||
builder: (sheetContext) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
BottomSheetHeader(
|
||||
title: message.text.length > 40
|
||||
? '${message.text.substring(0, 40)}…'
|
||||
: message.text,
|
||||
subtitle: message.senderName.isNotEmpty
|
||||
? message.senderName
|
||||
: null,
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.reply),
|
||||
title: Text(context.l10n.chat_reply),
|
||||
@@ -1409,7 +1464,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
_markAsUnread(message);
|
||||
},
|
||||
),
|
||||
const Divider(),
|
||||
const Divider(height: 1),
|
||||
ListTile(
|
||||
leading: Icon(
|
||||
Icons.delete_outline,
|
||||
@@ -1424,6 +1479,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
await _deleteMessage(message);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -1507,6 +1563,23 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
pathHashByteWidth,
|
||||
).map(PathHelper.formatHopHex).join(',');
|
||||
}
|
||||
|
||||
/// Deterministic name-to-hue mapping consistent with [AvatarCircle].
|
||||
Color _colorForName(String name) {
|
||||
const hues = [
|
||||
MeshPalette.blue,
|
||||
MeshPalette.magenta,
|
||||
MeshPalette.signal,
|
||||
MeshPalette.warn,
|
||||
Color(0xFF8FA8F0),
|
||||
Color(0xFF6FD9CE),
|
||||
];
|
||||
var h = 0;
|
||||
for (final c in name.codeUnits) {
|
||||
h = (h * 31 + c) & 0x7fffffff;
|
||||
}
|
||||
return hues[h % hues.length];
|
||||
}
|
||||
}
|
||||
|
||||
class _SwipeReplyBubble extends StatefulWidget {
|
||||
@@ -1648,7 +1721,7 @@ class _SwipeReplyBubbleState extends State<_SwipeReplyBubble> {
|
||||
onPointerUp: (event) => _handleSwipePointerUp(event.position),
|
||||
onPointerCancel: (_) => _resetSwipe(),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
|
||||
padding: const EdgeInsets.symmetric(vertical: 3, horizontal: 8),
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+419
-309
File diff suppressed because it is too large
Load Diff
+187
-100
@@ -31,7 +31,6 @@ import '../widgets/chat_zoom_wrapper.dart';
|
||||
import '../widgets/byte_count_input.dart';
|
||||
import 'channel_message_path_screen.dart';
|
||||
import 'map_screen.dart';
|
||||
import '../helpers/contact_ui.dart';
|
||||
import '../widgets/emoji_picker.dart';
|
||||
import '../widgets/gif_message.dart';
|
||||
import '../widgets/jump_to_bottom_button.dart';
|
||||
@@ -44,6 +43,8 @@ import '../widgets/translated_message_content.dart';
|
||||
import '../l10n/l10n.dart';
|
||||
import '../helpers/snack_bar_builder.dart';
|
||||
import '../widgets/unread_divider.dart';
|
||||
import '../theme/mesh_theme.dart';
|
||||
import '../widgets/mesh_ui.dart';
|
||||
import 'telemetry_screen.dart';
|
||||
|
||||
class ChatScreen extends StatefulWidget {
|
||||
@@ -381,12 +382,14 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
}
|
||||
final messageIndex = index;
|
||||
Contact contact = _resolveContact(connector);
|
||||
final bool isRoom = contact.type == advTypeRoom;
|
||||
final message = reversedMessages[messageIndex];
|
||||
String fourByteHex = '';
|
||||
if (contact.type == advTypeRoom) {
|
||||
Contact? roomAuthor;
|
||||
if (isRoom) {
|
||||
// Room-server messages carry the original author's 4-byte prefix
|
||||
// separately from message.text; use it only for resolving the name.
|
||||
contact = _resolveContactFrom4Bytes(
|
||||
roomAuthor = _resolveContactFrom4Bytes(
|
||||
connector,
|
||||
message.fourByteRoomContactKey.isEmpty
|
||||
? Uint8List.fromList([0, 0, 0, 0])
|
||||
@@ -396,6 +399,9 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join()
|
||||
.toUpperCase();
|
||||
// Only adopt the author identity when we actually know them; never
|
||||
// fall back to the room server's own name as the sender.
|
||||
if (roomAuthor != null) contact = roomAuthor;
|
||||
}
|
||||
|
||||
return Builder(
|
||||
@@ -403,11 +409,12 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
final textScale = context.select<ChatTextScaleService, double>(
|
||||
(service) => service.scale,
|
||||
);
|
||||
final resolvedContact = _resolveContact(connector);
|
||||
final bubble = _MessageBubble(
|
||||
message: message,
|
||||
senderName: resolvedContact.type == advTypeRoom
|
||||
? "${contact.name} [$fourByteHex]"
|
||||
senderName: isRoom
|
||||
? (roomAuthor != null
|
||||
? "${roomAuthor.name} [$fourByteHex]"
|
||||
: "[$fourByteHex]")
|
||||
: contact.name,
|
||||
sourceId: widget.contact.publicKeyHex,
|
||||
textScale: textScale,
|
||||
@@ -449,16 +456,18 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
|
||||
Widget _buildInputBar(MeshCoreConnector connector) {
|
||||
final maxBytes = maxContactMessageBytes();
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final settings = context.watch<AppSettingsService>().settings;
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surface,
|
||||
border: Border(top: BorderSide(color: Theme.of(context).dividerColor)),
|
||||
color: scheme.surface,
|
||||
border: Border(top: BorderSide(color: scheme.outlineVariant, width: 1)),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.gif_box),
|
||||
@@ -498,8 +507,8 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
url:
|
||||
'https://media.giphy.com/media/$gifId/giphy.gif',
|
||||
backgroundColor:
|
||||
colorScheme.surfaceContainerHighest,
|
||||
fallbackTextColor: colorScheme.onSurface
|
||||
scheme.surfaceContainerHighest,
|
||||
fallbackTextColor: scheme.onSurface
|
||||
.withValues(alpha: 0.6),
|
||||
maxSize: 160,
|
||||
),
|
||||
@@ -538,32 +547,68 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
decoration: InputDecoration(
|
||||
hintText: context.l10n.chat_typeMessage,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
borderRadius: BorderRadius.circular(MeshRadii.pill),
|
||||
borderSide: BorderSide(color: scheme.outlineVariant),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(MeshRadii.pill),
|
||||
borderSide: BorderSide(color: scheme.outlineVariant),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(MeshRadii.pill),
|
||||
borderSide: BorderSide(
|
||||
color: scheme.primary,
|
||||
width: 1.5,
|
||||
),
|
||||
),
|
||||
filled: true,
|
||||
fillColor: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerLow,
|
||||
fillColor: scheme.surfaceContainerLow,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 20,
|
||||
vertical: 14,
|
||||
horizontal: 18,
|
||||
vertical: 12,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
IconButton.filled(
|
||||
icon: const Icon(Icons.send),
|
||||
const SizedBox(width: 6),
|
||||
ValueListenableBuilder<TextEditingValue>(
|
||||
valueListenable: _textController,
|
||||
builder: (context, value, _) {
|
||||
final hasText = value.text.trim().isNotEmpty;
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
curve: Curves.easeInOut,
|
||||
child: IconButton.filled(
|
||||
icon: const Icon(Icons.send, size: 20),
|
||||
tooltip: context.l10n.chat_sendMessageTo(
|
||||
_resolveContact(connector).name,
|
||||
),
|
||||
onPressed: () => _sendMessage(connector),
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: hasText
|
||||
? scheme.primary
|
||||
: scheme.surfaceContainerHighest,
|
||||
foregroundColor: hasText
|
||||
? scheme.onPrimary
|
||||
: scheme.onSurfaceVariant,
|
||||
minimumSize: const Size(40, 40),
|
||||
shape: const CircleBorder(),
|
||||
),
|
||||
onPressed: hasText
|
||||
? () {
|
||||
HapticFeedback.lightImpact();
|
||||
_sendMessage(connector);
|
||||
}
|
||||
: null,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -717,13 +762,17 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
return connector.contacts[_resolveContactIndex];
|
||||
}
|
||||
|
||||
Contact _resolveContactFrom4Bytes(
|
||||
Contact? _resolveContactFrom4Bytes(
|
||||
MeshCoreConnector connector,
|
||||
Uint8List key4Bytes,
|
||||
) {
|
||||
return connector.contacts.firstWhere(
|
||||
(c) => listEquals(c.publicKey.sublist(0, 4), key4Bytes.sublist(0, 4)),
|
||||
orElse: () => widget.contact,
|
||||
// Match against saved contacts first, then nodes only seen via discovery —
|
||||
// a room poster you haven't saved may still be in the discovered list.
|
||||
return connector.allContactsUnfiltered.cast<Contact?>().firstWhere(
|
||||
(c) =>
|
||||
c != null &&
|
||||
listEquals(c.publicKey.sublist(0, 4), key4Bytes.sublist(0, 4)),
|
||||
orElse: () => null,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1032,7 +1081,11 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
if (message.isOutgoing) {
|
||||
senderName = connector.selfName ?? context.l10n.chat_me;
|
||||
} else if (_resolveContact(connector).type == advTypeRoom) {
|
||||
senderName = "${contact.name} [$fourByteHex]";
|
||||
// An unresolved author leaves `contact` as the room server itself; show
|
||||
// only the prefix rather than mislabeling the post with the room's name.
|
||||
senderName = contact.type == advTypeRoom
|
||||
? "[$fourByteHex]"
|
||||
: "${contact.name} [$fourByteHex]";
|
||||
} else {
|
||||
senderName = _resolveContact(connector).name;
|
||||
}
|
||||
@@ -1065,12 +1118,17 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
) &&
|
||||
(message.translatedText?.trim().isEmpty ?? true);
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
showMeshSheet(
|
||||
context,
|
||||
builder: (sheetContext) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
BottomSheetHeader(
|
||||
title: message.text.length > 40
|
||||
? '${message.text.substring(0, 40)}…'
|
||||
: message.text,
|
||||
),
|
||||
// Can't react to your own messages
|
||||
if (!message.isOutgoing)
|
||||
ListTile(
|
||||
@@ -1140,7 +1198,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
_openChat(context, contact);
|
||||
},
|
||||
),
|
||||
const Divider(),
|
||||
const Divider(height: 1),
|
||||
ListTile(
|
||||
leading: Icon(
|
||||
Icons.delete_outline,
|
||||
@@ -1155,6 +1213,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
await _deleteMessage(message);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -1243,20 +1302,45 @@ class _MessageBubble extends StatelessWidget {
|
||||
final settingsService = context.watch<AppSettingsService>();
|
||||
final enableTracing = settingsService.settings.enableMessageTracing;
|
||||
final isOutgoing = message.isOutgoing;
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final gifId = GifHelper.parseGif(message.text);
|
||||
final poi = parseMarkerText(message.text);
|
||||
final isFailed = message.status == MessageStatus.failed;
|
||||
|
||||
// Bubble colors — outgoing uses MeshPalette.me / meBorder / meInk.
|
||||
final bubbleColor = isFailed
|
||||
? colorScheme.errorContainer
|
||||
: (isOutgoing
|
||||
? colorScheme.primary
|
||||
: colorScheme.surfaceContainerHighest);
|
||||
? scheme.errorContainer
|
||||
: isOutgoing
|
||||
? MeshPalette.me
|
||||
: scheme.surfaceContainerLow;
|
||||
final bubbleBorder = isFailed
|
||||
? scheme.error
|
||||
: isOutgoing
|
||||
? MeshPalette.meBorder
|
||||
: scheme.outlineVariant;
|
||||
final textColor = isFailed
|
||||
? colorScheme.onErrorContainer
|
||||
: (isOutgoing ? colorScheme.onPrimary : colorScheme.onSurface);
|
||||
final metaColor = textColor.withValues(alpha: 0.7);
|
||||
? scheme.onErrorContainer
|
||||
: isOutgoing
|
||||
? MeshPalette.meInk
|
||||
: scheme.onSurface;
|
||||
final metaColor = textColor.withValues(alpha: 0.65);
|
||||
const bodyFontSize = 14.0;
|
||||
|
||||
// Asymmetric radius: outgoing — top-left large, others also large; outgoing bottom-right tight.
|
||||
final borderRadius = isOutgoing
|
||||
? const BorderRadius.only(
|
||||
topLeft: Radius.circular(MeshRadii.lg),
|
||||
topRight: Radius.circular(MeshRadii.lg),
|
||||
bottomLeft: Radius.circular(MeshRadii.lg),
|
||||
bottomRight: Radius.circular(MeshRadii.xs),
|
||||
)
|
||||
: const BorderRadius.only(
|
||||
topLeft: Radius.circular(MeshRadii.xs),
|
||||
topRight: Radius.circular(MeshRadii.lg),
|
||||
bottomLeft: Radius.circular(MeshRadii.lg),
|
||||
bottomRight: Radius.circular(MeshRadii.lg),
|
||||
);
|
||||
|
||||
// Do not strip room-server author bytes here: the parser stores them in
|
||||
// fourByteRoomContactKey, so message.text is safe to render as-is.
|
||||
final messageText = message.text;
|
||||
@@ -1268,8 +1352,9 @@ class _MessageBubble extends StatelessWidget {
|
||||
final originalDisplayText = isOutgoing
|
||||
? message.originalText
|
||||
: (translatedDisplayText != messageText ? messageText : null);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
padding: const EdgeInsets.symmetric(vertical: 3),
|
||||
child: Column(
|
||||
crossAxisAlignment: isOutgoing
|
||||
? CrossAxisAlignment.end
|
||||
@@ -1285,11 +1370,11 @@ class _MessageBubble extends StatelessWidget {
|
||||
mainAxisAlignment: isOutgoing
|
||||
? MainAxisAlignment.end
|
||||
: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
if (!isOutgoing) ...[
|
||||
_buildAvatar(senderName, colorScheme),
|
||||
const SizedBox(width: 8),
|
||||
_buildAvatar(senderName),
|
||||
const SizedBox(width: 6),
|
||||
],
|
||||
Flexible(
|
||||
child: Container(
|
||||
@@ -1300,14 +1385,12 @@ class _MessageBubble extends StatelessWidget {
|
||||
vertical: 8,
|
||||
),
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: constraints.maxWidth * 0.65,
|
||||
maxWidth: constraints.maxWidth * 0.72,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: bubbleColor,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: isFailed
|
||||
? Border.all(color: colorScheme.error)
|
||||
: null,
|
||||
borderRadius: borderRadius,
|
||||
border: Border.all(color: bubbleBorder, width: 1),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -1323,14 +1406,14 @@ class _MessageBubble extends StatelessWidget {
|
||||
: EdgeInsets.zero,
|
||||
child: Text(
|
||||
senderName,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.primary,
|
||||
style: MeshTheme.mono(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: _colorForName(senderName),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (gifId == null) const SizedBox(height: 4),
|
||||
if (gifId == null) const SizedBox(height: 2),
|
||||
],
|
||||
if (poi != null)
|
||||
_buildPoiMessage(
|
||||
@@ -1371,7 +1454,7 @@ class _MessageBubble extends StatelessWidget {
|
||||
fontSize: bodyFontSize * textScale,
|
||||
),
|
||||
originalStyle: TextStyle(
|
||||
color: textColor.withValues(alpha: 0.78),
|
||||
color: textColor.withValues(alpha: 0.72),
|
||||
fontSize: bodyFontSize * textScale,
|
||||
),
|
||||
),
|
||||
@@ -1381,7 +1464,7 @@ class _MessageBubble extends StatelessWidget {
|
||||
if (enableTracing &&
|
||||
isOutgoing &&
|
||||
message.retryCount > 0) ...[
|
||||
const SizedBox(height: 4),
|
||||
const SizedBox(height: 3),
|
||||
Padding(
|
||||
padding: gifId != null
|
||||
? const EdgeInsets.symmetric(horizontal: 8)
|
||||
@@ -1394,15 +1477,15 @@ class _MessageBubble extends StatelessWidget {
|
||||
.settings
|
||||
.maxMessageRetries,
|
||||
),
|
||||
style: TextStyle(
|
||||
fontSize: 10 * textScale,
|
||||
style: MeshTheme.mono(
|
||||
fontSize: 9.5 * textScale,
|
||||
color: metaColor,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 4),
|
||||
const SizedBox(height: 3),
|
||||
// Meta row: timestamp + status icon + optional tracing
|
||||
Padding(
|
||||
padding: gifId != null
|
||||
? const EdgeInsets.only(
|
||||
@@ -1417,13 +1500,13 @@ class _MessageBubble extends StatelessWidget {
|
||||
children: [
|
||||
Text(
|
||||
_formatTime(message.timestamp),
|
||||
style: TextStyle(
|
||||
style: MeshTheme.mono(
|
||||
fontSize: 10 * textScale,
|
||||
color: metaColor,
|
||||
),
|
||||
),
|
||||
if (isOutgoing) ...[
|
||||
const SizedBox(width: 4),
|
||||
const SizedBox(width: 2),
|
||||
MessageStatusIcon(
|
||||
size: 12 * textScale,
|
||||
onColor: metaColor,
|
||||
@@ -1440,25 +1523,21 @@ class _MessageBubble extends StatelessWidget {
|
||||
message.tripTimeMs != null &&
|
||||
message.status ==
|
||||
MessageStatus.delivered) ...[
|
||||
const SizedBox(width: 4),
|
||||
const SizedBox(width: 2),
|
||||
Icon(
|
||||
Icons.speed,
|
||||
size: 10 * textScale,
|
||||
color: isOutgoing
|
||||
? metaColor
|
||||
: Theme.of(
|
||||
context,
|
||||
).colorScheme.tertiary,
|
||||
: scheme.tertiary,
|
||||
),
|
||||
Text(
|
||||
'${(message.tripTimeMs! / 1000).toStringAsFixed(1)}s',
|
||||
style: TextStyle(
|
||||
style: MeshTheme.mono(
|
||||
fontSize: 9 * textScale,
|
||||
color: isOutgoing
|
||||
? metaColor
|
||||
: Theme.of(
|
||||
context,
|
||||
).colorScheme.tertiary,
|
||||
: scheme.tertiary,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -1476,8 +1555,8 @@ class _MessageBubble extends StatelessWidget {
|
||||
if (message.reactions.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Padding(
|
||||
padding: EdgeInsets.only(left: isOutgoing ? 0 : 48),
|
||||
child: _buildReactionsDisplay(context, message, colorScheme),
|
||||
padding: EdgeInsets.only(left: isOutgoing ? 0 : 42),
|
||||
child: _buildReactionsDisplay(context, message, scheme),
|
||||
),
|
||||
],
|
||||
],
|
||||
@@ -1554,7 +1633,7 @@ class _MessageBubble extends StatelessWidget {
|
||||
Widget _buildReactionsDisplay(
|
||||
BuildContext context,
|
||||
Message message,
|
||||
ColorScheme colorScheme,
|
||||
ColorScheme scheme,
|
||||
) {
|
||||
return Wrap(
|
||||
spacing: 6,
|
||||
@@ -1577,28 +1656,33 @@ class _MessageBubble extends StatelessWidget {
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: isFailed
|
||||
? colorScheme.errorContainer
|
||||
: colorScheme.secondaryContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
? scheme.errorContainer
|
||||
: scheme.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(MeshRadii.pill),
|
||||
border: Border.all(
|
||||
color: isFailed
|
||||
? colorScheme.error
|
||||
: colorScheme.outline.withValues(alpha: 0.3),
|
||||
color: isFailed ? scheme.error : scheme.outlineVariant,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(emoji, style: const TextStyle(fontSize: 16)),
|
||||
Text(
|
||||
emoji,
|
||||
style: MeshTheme.emoji(fontSize: 16),
|
||||
textHeightBehavior: const TextHeightBehavior(
|
||||
applyHeightToFirstAscent: false,
|
||||
applyHeightToLastDescent: false,
|
||||
),
|
||||
),
|
||||
if (count > 1) ...[
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'$count',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSecondaryContainer,
|
||||
style: MeshTheme.mono(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: scheme.onSurface,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -1609,13 +1693,13 @@ class _MessageBubble extends StatelessWidget {
|
||||
height: 8,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 1.5,
|
||||
color: colorScheme.onSecondaryContainer,
|
||||
color: scheme.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
if (isFailed) ...[
|
||||
const SizedBox(width: 2),
|
||||
Icon(Icons.replay, size: 10, color: colorScheme.error),
|
||||
Icon(Icons.replay, size: 10, color: scheme.error),
|
||||
],
|
||||
],
|
||||
),
|
||||
@@ -1626,22 +1710,8 @@ class _MessageBubble extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAvatar(String senderName, ColorScheme colorScheme) {
|
||||
final initial = firstCharacterOrEmoji(senderName);
|
||||
final color = colorForName(senderName);
|
||||
|
||||
return CircleAvatar(
|
||||
radius: 18,
|
||||
backgroundColor: color.withValues(alpha: 0.2),
|
||||
child: Text(
|
||||
initial,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
);
|
||||
Widget _buildAvatar(String senderName) {
|
||||
return AvatarCircle(name: senderName, size: 32);
|
||||
}
|
||||
|
||||
String _formatTime(DateTime time) {
|
||||
@@ -1650,3 +1720,20 @@ class _MessageBubble extends StatelessWidget {
|
||||
return '$hour:$minute';
|
||||
}
|
||||
}
|
||||
|
||||
/// Deterministic name-to-hue mapping consistent with [AvatarCircle].
|
||||
Color _colorForName(String name) {
|
||||
const hues = [
|
||||
MeshPalette.blue,
|
||||
MeshPalette.magenta,
|
||||
MeshPalette.signal,
|
||||
MeshPalette.warn,
|
||||
Color(0xFF8FA8F0),
|
||||
Color(0xFF6FD9CE),
|
||||
];
|
||||
var h = 0;
|
||||
for (final c in name.codeUnits) {
|
||||
h = (h * 31 + c) & 0x7fffffff;
|
||||
}
|
||||
return hues[h % hues.length];
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../l10n/l10n.dart';
|
||||
import '../theme/mesh_theme.dart';
|
||||
import '../widgets/mesh_ui.dart';
|
||||
|
||||
class ChromeRequiredScreen extends StatelessWidget {
|
||||
const ChromeRequiredScreen({super.key});
|
||||
@@ -7,73 +9,88 @@ class ChromeRequiredScreen extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Scaffold(
|
||||
body: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||
color: colorScheme.surface,
|
||||
body: SafeArea(
|
||||
child: Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 40),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// Icon in tinted circle
|
||||
Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
width: 88,
|
||||
height: 88,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.tertiaryContainer.withValues(alpha: 0.4),
|
||||
shape: BoxShape.circle,
|
||||
color: scheme.tertiary.withValues(alpha: 0.10),
|
||||
border: Border.all(
|
||||
color: scheme.tertiary.withValues(alpha: 0.25),
|
||||
width: 1.5,
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.browser_not_supported_rounded,
|
||||
size: 80,
|
||||
color: colorScheme.tertiary,
|
||||
size: 42,
|
||||
color: scheme.tertiary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
const SizedBox(height: 28),
|
||||
|
||||
// Title
|
||||
Text(
|
||||
l10n.scanner_chromeRequired,
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.headlineMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: scheme.onSurface,
|
||||
letterSpacing: -0.3,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Body text
|
||||
Text(
|
||||
l10n.scanner_chromeRequiredMessage,
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
height: 1.5,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: scheme.onSurfaceVariant,
|
||||
height: 1.55,
|
||||
),
|
||||
),
|
||||
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: colorScheme.secondaryContainer.withValues(alpha: 0.4),
|
||||
borderRadius: BorderRadius.circular(30),
|
||||
border: Border.all(
|
||||
color: colorScheme.outline.withValues(alpha: 0.4),
|
||||
),
|
||||
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: 20,
|
||||
color: colorScheme.secondary,
|
||||
size: 18,
|
||||
color: scheme.secondary,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
const SizedBox(width: 10),
|
||||
Flexible(
|
||||
child: Text(
|
||||
l10n.chrome_bluetoothRequiresChromium,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSecondaryContainer,
|
||||
style: MeshTheme.mono(
|
||||
fontSize: 12,
|
||||
color: scheme.onSecondaryContainer,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -81,6 +98,8 @@ class ChromeRequiredScreen extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../connector/meshcore_connector.dart';
|
||||
import '../helpers/snack_bar_builder.dart';
|
||||
import '../l10n/l10n.dart';
|
||||
import '../models/community.dart';
|
||||
import '../storage/community_store.dart';
|
||||
import '../theme/mesh_theme.dart';
|
||||
import '../widgets/adaptive_app_bar_title.dart';
|
||||
import '../widgets/mesh_ui.dart';
|
||||
import '../widgets/qr_scanner_widget.dart';
|
||||
import '../helpers/snack_bar_builder.dart';
|
||||
|
||||
/// Screen for scanning community QR codes to join communities.
|
||||
///
|
||||
@@ -35,16 +39,87 @@ class _CommunityQrScannerScreenState extends State<CommunityQrScannerScreen> {
|
||||
centerTitle: true,
|
||||
),
|
||||
body: _isProcessing
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
? Container(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
child: const Center(child: CircularProgressIndicator()),
|
||||
)
|
||||
: QrScannerWidget(
|
||||
onScanned: (data) => _handleScannedData(context, data),
|
||||
validator: Community.isValidQrData,
|
||||
onValidationFailed: (_) => _showInvalidQrError(context),
|
||||
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 {
|
||||
if (_isProcessing) return;
|
||||
|
||||
@@ -80,7 +155,7 @@ class _CommunityQrScannerScreenState extends State<CommunityQrScannerScreen> {
|
||||
showDismissibleSnackBar(
|
||||
context,
|
||||
content: Text(context.l10n.community_invalidQrCode),
|
||||
backgroundColor: Colors.red,
|
||||
backgroundColor: MeshPalette.alert,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
@@ -96,29 +171,74 @@ class _CommunityQrScannerScreenState extends State<CommunityQrScannerScreen> {
|
||||
showDismissibleSnackBar(
|
||||
context,
|
||||
content: Text(context.l10n.community_invalidQrCode),
|
||||
backgroundColor: Colors.orange,
|
||||
backgroundColor: MeshPalette.warn,
|
||||
duration: const Duration(seconds: 2),
|
||||
);
|
||||
}
|
||||
|
||||
void _showAlreadyMemberDialog(BuildContext context, Community community) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: Text(context.l10n.community_alreadyMember),
|
||||
content: Text(
|
||||
showMeshSheet(
|
||||
context,
|
||||
builder: (sheetContext) {
|
||||
final sheetScheme = Theme.of(sheetContext).colorScheme;
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
BottomSheetHeader(title: context.l10n.community_alreadyMember),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 0, 20, 4),
|
||||
child: Text(
|
||||
context.l10n.community_alreadyMemberMessage(community.name),
|
||||
style: TextStyle(color: sheetScheme.onSurfaceVariant),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
),
|
||||
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(dialogContext);
|
||||
Navigator.pop(sheetContext);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: Text(context.l10n.common_ok),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -127,38 +247,51 @@ class _CommunityQrScannerScreenState extends State<CommunityQrScannerScreen> {
|
||||
Community community,
|
||||
) async {
|
||||
bool addPublicChannel = true;
|
||||
final completer = Completer<bool>();
|
||||
|
||||
final result = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (dialogContext) => StatefulBuilder(
|
||||
builder: (dialogContext, setDialogState) => AlertDialog(
|
||||
title: Text(context.l10n.community_joinTitle),
|
||||
content: Column(
|
||||
await showMeshSheet<void>(
|
||||
context,
|
||||
builder: (sheetContext) => StatefulBuilder(
|
||||
builder: (sheetContext, setSheetState) {
|
||||
final joinScheme = Theme.of(sheetContext).colorScheme;
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(context.l10n.community_joinConfirmation(community.name)),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.groups,
|
||||
color: Theme.of(dialogContext).colorScheme.primary,
|
||||
BottomSheetHeader(title: context.l10n.community_joinTitle),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 0, 20, 4),
|
||||
child: Text(
|
||||
context.l10n.community_joinConfirmation(community.name),
|
||||
style: TextStyle(color: joinScheme.onSurfaceVariant),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
),
|
||||
MeshCard(
|
||||
child: Row(
|
||||
children: [
|
||||
AvatarCircle(
|
||||
name: community.name,
|
||||
icon: Icons.groups,
|
||||
color: MeshPalette.magenta,
|
||||
size: 44,
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
community.name,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 15,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'ID: ${community.shortCommunityId}...',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[600],
|
||||
style: MeshTheme.mono(
|
||||
fontSize: 11.5,
|
||||
color: joinScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -166,38 +299,59 @@ class _CommunityQrScannerScreenState extends State<CommunityQrScannerScreen> {
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Divider(),
|
||||
const SizedBox(height: 8),
|
||||
),
|
||||
CheckboxListTile(
|
||||
value: addPublicChannel,
|
||||
onChanged: (value) {
|
||||
setDialogState(() {
|
||||
setSheetState(() {
|
||||
addPublicChannel = value ?? true;
|
||||
});
|
||||
},
|
||||
title: Text(context.l10n.community_addPublicChannel),
|
||||
subtitle: Text(context.l10n.community_addPublicChannelHint),
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogContext, false),
|
||||
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),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(dialogContext, true),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: FilledButton(
|
||||
onPressed: () {
|
||||
completer.complete(true);
|
||||
Navigator.pop(sheetContext);
|
||||
},
|
||||
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);
|
||||
} else if (context.mounted) {
|
||||
// User cancelled - go back
|
||||
@@ -231,7 +385,7 @@ class _CommunityQrScannerScreenState extends State<CommunityQrScannerScreen> {
|
||||
showDismissibleSnackBar(
|
||||
context,
|
||||
content: Text(context.l10n.community_joined(community.name)),
|
||||
backgroundColor: Colors.green,
|
||||
backgroundColor: MeshPalette.signal,
|
||||
);
|
||||
|
||||
// Return to previous screen
|
||||
|
||||
@@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
|
||||
import 'package:meshcore_open/connector/meshcore_connector.dart';
|
||||
import 'package:meshcore_open/models/companion_radio_stats.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';
|
||||
|
||||
class CompanionRadioStatsScreen extends StatefulWidget {
|
||||
@@ -49,6 +51,25 @@ class _CompanionRadioStatsScreenState extends State<CompanionRadioStatsScreen> {
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
@@ -85,27 +106,93 @@ class _CompanionRadioStatsScreenState extends State<CompanionRadioStatsScreen> {
|
||||
valueListenable: connector.radioStatsNotifier,
|
||||
builder: (context, stats, _) {
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
children: [
|
||||
if (stats != null) ...[
|
||||
Text(
|
||||
l10n.radioStats_noiseFloor(stats.noiseFloorDbm),
|
||||
style: tt.titleMedium,
|
||||
const SectionHeader(
|
||||
'Signal',
|
||||
padding: EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(l10n.radioStats_lastRssi(stats.lastRssiDbm)),
|
||||
Text(
|
||||
MeshCard(
|
||||
margin: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 4,
|
||||
),
|
||||
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)),
|
||||
Text(l10n.radioStats_rxAir(stats.rxAirSecs)),
|
||||
const SizedBox(height: 16),
|
||||
] else
|
||||
Text(l10n.radioStats_waiting),
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(
|
||||
],
|
||||
),
|
||||
),
|
||||
const SectionHeader(
|
||||
'Airtime',
|
||||
padding: EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
),
|
||||
MeshCard(
|
||||
margin: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 4,
|
||||
),
|
||||
padding: const EdgeInsets.all(4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
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(
|
||||
@@ -116,13 +203,8 @@ class _CompanionRadioStatsScreenState extends State<CompanionRadioStatsScreen> {
|
||||
child: const SizedBox.expand(),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
l10n.radioStats_chartCaption,
|
||||
style: tt.bodySmall?.copyWith(
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
|
||||
@@ -16,6 +16,7 @@ import '../models/contact.dart';
|
||||
import '../l10n/contact_localization.dart';
|
||||
import '../models/contact_group.dart';
|
||||
import '../services/ui_view_state_service.dart';
|
||||
import '../theme/mesh_theme.dart';
|
||||
import '../utils/contact_search.dart';
|
||||
import '../storage/contact_group_store.dart';
|
||||
import '../utils/dialog_utils.dart';
|
||||
@@ -24,12 +25,12 @@ import '../utils/emoji_utils.dart';
|
||||
import '../utils/route_transitions.dart';
|
||||
import '../widgets/list_filter_widget.dart';
|
||||
import '../widgets/empty_state.dart';
|
||||
import '../widgets/mesh_ui.dart';
|
||||
import '../widgets/quick_switch_bar.dart';
|
||||
import '../widgets/repeater_login_dialog.dart';
|
||||
import '../widgets/room_login_dialog.dart';
|
||||
import '../widgets/sync_progress_overlay.dart';
|
||||
import '../widgets/unread_badge.dart';
|
||||
import '../helpers/contact_ui.dart';
|
||||
import '../helpers/snack_bar_builder.dart';
|
||||
import 'channels_screen.dart';
|
||||
import 'chat_screen.dart';
|
||||
@@ -472,12 +473,13 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
}
|
||||
|
||||
void _showAddContactSheet(BuildContext context) {
|
||||
showModalBottomSheet(
|
||||
context: 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),
|
||||
@@ -499,6 +501,7 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -909,7 +912,8 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
final unreadCount = connector.getUnreadCountForContact(
|
||||
contact,
|
||||
);
|
||||
return _ContactTile(
|
||||
return _ContactTileEntrance(
|
||||
index: index,
|
||||
contact: contact,
|
||||
pathHashByteWidth: connector.pathHashByteWidth,
|
||||
lastSeen: _resolveLastSeen(contact),
|
||||
@@ -1344,17 +1348,22 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
final isRoom = contact.type == advTypeRoom;
|
||||
final isFavorite = contact.isFavorite;
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
showMeshSheet(
|
||||
context,
|
||||
builder: (sheetContext) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
BottomSheetHeader(
|
||||
title: contact.name,
|
||||
subtitle: contact.typeLabel(context.l10n),
|
||||
),
|
||||
if (isRepeater) ...[
|
||||
ListTile(
|
||||
leading: const Icon(Icons.radar, color: Colors.green),
|
||||
leading: Icon(Icons.radar, color: MeshPalette.signal),
|
||||
title: Text(context.l10n.contacts_ping),
|
||||
onTap: () {
|
||||
Navigator.pop(sheetContext);
|
||||
final hw = context
|
||||
.read<MeshCoreConnector>()
|
||||
.pathHashByteWidth;
|
||||
@@ -1375,7 +1384,7 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.cell_tower, color: Colors.orange),
|
||||
leading: Icon(Icons.cell_tower, color: MeshPalette.warn),
|
||||
title: Text(context.l10n.contacts_manageRepeater),
|
||||
onTap: () {
|
||||
Navigator.pop(sheetContext);
|
||||
@@ -1384,9 +1393,10 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
),
|
||||
] else if (isRoom) ...[
|
||||
ListTile(
|
||||
leading: const Icon(Icons.radar, color: Colors.green),
|
||||
leading: Icon(Icons.radar, color: MeshPalette.signal),
|
||||
title: Text(context.l10n.contacts_pathTrace),
|
||||
onTap: () {
|
||||
Navigator.pop(sheetContext);
|
||||
final hw = context
|
||||
.read<MeshCoreConnector>()
|
||||
.pathHashByteWidth;
|
||||
@@ -1409,7 +1419,7 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.room, color: Colors.blue),
|
||||
leading: Icon(Icons.meeting_room, color: MeshPalette.blue),
|
||||
title: Text(context.l10n.contacts_roomLogin),
|
||||
onTap: () {
|
||||
Navigator.pop(sheetContext);
|
||||
@@ -1417,10 +1427,7 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(
|
||||
Icons.room_preferences,
|
||||
color: Colors.orange,
|
||||
),
|
||||
leading: Icon(Icons.room_preferences, color: MeshPalette.warn),
|
||||
title: Text(context.l10n.room_management),
|
||||
onTap: () {
|
||||
Navigator.pop(sheetContext);
|
||||
@@ -1434,9 +1441,10 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
] else ...[
|
||||
if (contact.pathLength > 0)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.radar, color: Colors.green),
|
||||
leading: Icon(Icons.radar, color: MeshPalette.signal),
|
||||
title: Text(context.l10n.contacts_chatTraceRoute),
|
||||
onTap: () {
|
||||
Navigator.pop(sheetContext);
|
||||
final hw = context
|
||||
.read<MeshCoreConnector>()
|
||||
.pathHashByteWidth;
|
||||
@@ -1460,7 +1468,7 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
ListTile(
|
||||
leading: Icon(
|
||||
isFavorite ? Icons.star : Icons.star_border,
|
||||
color: Colors.amber[700],
|
||||
color: MeshPalette.warn,
|
||||
),
|
||||
title: Text(
|
||||
isFavorite
|
||||
@@ -1505,6 +1513,7 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
_confirmDelete(context, connector, contact);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -1571,34 +1580,155 @@ class _ContactTile extends StatelessWidget {
|
||||
required this.onLongPress,
|
||||
});
|
||||
|
||||
/// Node-type avatar color per design language.
|
||||
Color _avatarColor() {
|
||||
switch (contact.type) {
|
||||
case advTypeRepeater:
|
||||
return MeshPalette.warn;
|
||||
case advTypeRoom:
|
||||
return MeshPalette.magenta;
|
||||
case advTypeSensor:
|
||||
return const Color(0xFF4ACCC4); // teal
|
||||
default:
|
||||
return MeshPalette
|
||||
.blue; // chat — AvatarCircle handles deterministic hue
|
||||
}
|
||||
}
|
||||
|
||||
/// Node-type avatar icon. Returns null for chat nodes so AvatarCircle shows initials.
|
||||
IconData? _avatarIcon() {
|
||||
switch (contact.type) {
|
||||
case advTypeRepeater:
|
||||
return Icons.cell_tower;
|
||||
case advTypeRoom:
|
||||
return Icons.meeting_room;
|
||||
case advTypeSensor:
|
||||
return Icons.sensors;
|
||||
default:
|
||||
return null; // chat uses initials
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final emoji = firstEmoji(contact.name);
|
||||
final isChat = contact.type == advTypeChat;
|
||||
final pathLen = contact.pathBytesForDisplay.length;
|
||||
final isDirect = contact.pathLength >= 0;
|
||||
final hasPath = pathLen > 0 || contact.pathLength == 0;
|
||||
|
||||
return GestureDetector(
|
||||
onSecondaryTapUp: PlatformInfo.isDesktop ? (_) => onLongPress() : null,
|
||||
child: ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: contactTypeColor(contact.type),
|
||||
child: _buildContactAvatar(contact),
|
||||
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),
|
||||
),
|
||||
title: Text(contact.name, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
subtitle: Text(
|
||||
contact.pathLabel(context.l10n, pathHashByteWidth: pathHashByteWidth),
|
||||
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,
|
||||
),
|
||||
// Clamp text scaling in trailing section to prevent overflow while
|
||||
// maintaining accessibility. Primary content (title/subtitle) scales normally.
|
||||
trailing: MediaQuery(
|
||||
),
|
||||
),
|
||||
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,
|
||||
pathHashByteWidth: pathHashByteWidth,
|
||||
),
|
||||
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: SizedBox(
|
||||
width: 96,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (unreadCount > 0) ...[
|
||||
UnreadBadge(count: unreadCount),
|
||||
@@ -1609,46 +1739,22 @@ class _ContactTile extends StatelessWidget {
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.right,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
style: MeshTheme.mono(
|
||||
fontSize: 11,
|
||||
color: unreadCount > 0
|
||||
? MeshPalette.blue
|
||||
: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
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: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurfaceVariant.withValues(alpha: 0.6),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
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(contactTypeIcon(contact.type), color: Colors.white, size: 20);
|
||||
}
|
||||
|
||||
String _formatLastSeen(BuildContext context, DateTime lastSeen) {
|
||||
final now = DateTime.now();
|
||||
final diff = now.difference(lastSeen);
|
||||
@@ -1671,3 +1777,42 @@ class _ContactTile extends StatelessWidget {
|
||||
: context.l10n.contacts_lastSeenDaysAgo(days);
|
||||
}
|
||||
}
|
||||
|
||||
// Wrap each contact tile with staggered entrance.
|
||||
class _ContactTileEntrance extends StatelessWidget {
|
||||
final int index;
|
||||
final Contact contact;
|
||||
final int pathHashByteWidth;
|
||||
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.pathHashByteWidth,
|
||||
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,
|
||||
pathHashByteWidth: pathHashByteWidth,
|
||||
lastSeen: lastSeen,
|
||||
unreadCount: unreadCount,
|
||||
isFavorite: isFavorite,
|
||||
onTap: onTap,
|
||||
onLongPress: onLongPress,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,12 +7,14 @@ import 'package:provider/provider.dart';
|
||||
import '../connector/meshcore_connector.dart';
|
||||
import '../connector/meshcore_protocol.dart';
|
||||
import '../l10n/l10n.dart';
|
||||
import '../l10n/contact_localization.dart';
|
||||
import '../models/contact.dart';
|
||||
import '../theme/mesh_theme.dart';
|
||||
import '../utils/contact_search.dart';
|
||||
import '../utils/platform_info.dart';
|
||||
import '../widgets/app_bar.dart';
|
||||
import '../widgets/list_filter_widget.dart';
|
||||
import '../helpers/contact_ui.dart';
|
||||
import '../widgets/mesh_ui.dart';
|
||||
import '../helpers/snack_bar_builder.dart';
|
||||
|
||||
enum DiscoverySortOption { lastSeen, name, type }
|
||||
@@ -47,6 +49,34 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
|
||||
: 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
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
@@ -93,108 +123,29 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
|
||||
children: [
|
||||
_buildFilters(filteredAndSorted, connector),
|
||||
Expanded(
|
||||
child: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 220),
|
||||
child: discoveredContacts.isEmpty
|
||||
? Center(child: Text(l10n.contacts_noContacts))
|
||||
? Center(
|
||||
key: const ValueKey('empty_all'),
|
||||
child: Text(l10n.contacts_noContacts),
|
||||
)
|
||||
: filteredAndSorted.isEmpty
|
||||
? Center(child: Text(l10n.discoveredContacts_noMatching))
|
||||
? Center(
|
||||
key: const ValueKey('empty_filtered'),
|
||||
child: Text(l10n.discoveredContacts_noMatching),
|
||||
)
|
||||
: ListView.builder(
|
||||
key: const ValueKey('list'),
|
||||
padding: const EdgeInsets.only(bottom: 24),
|
||||
itemCount: filteredAndSorted.length,
|
||||
itemBuilder: (context, index) {
|
||||
final contact = filteredAndSorted[index];
|
||||
final tile = ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: contactTypeColor(contact.type),
|
||||
child: Icon(
|
||||
contactTypeIcon(contact.type),
|
||||
color: Colors.white,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
contact.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: Text(
|
||||
contact.shortPubKeyHex,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
// 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(
|
||||
final tile = _buildDiscoveryTile(
|
||||
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: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (contact.hasLocation)
|
||||
Icon(
|
||||
Icons.location_on,
|
||||
size: 14,
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurfaceVariant
|
||||
.withValues(alpha: 0.6),
|
||||
),
|
||||
if (contact.rawPacket != null)
|
||||
const SizedBox(width: 2),
|
||||
if (contact.rawPacket != null)
|
||||
Icon(
|
||||
Icons.cell_tower,
|
||||
size: 14,
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurfaceVariant
|
||||
.withValues(alpha: 0.6),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
onTap: () {
|
||||
connector.importDiscoveredContact(contact);
|
||||
showDismissibleSnackBar(
|
||||
context,
|
||||
content: Text(
|
||||
context.l10n.discoveredContacts_contactAdded,
|
||||
),
|
||||
action: SnackBarAction(
|
||||
label: context.l10n.common_undo,
|
||||
onPressed: () => connector.removeContact(contact),
|
||||
),
|
||||
);
|
||||
},
|
||||
onLongPress: () =>
|
||||
_showContactContextMenu(contact, connector),
|
||||
contact,
|
||||
connector,
|
||||
index,
|
||||
);
|
||||
if (PlatformInfo.isDesktop) {
|
||||
return GestureDetector(
|
||||
@@ -207,24 +158,170 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 15,
|
||||
),
|
||||
),
|
||||
),
|
||||
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,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: MeshTheme.mono(
|
||||
fontSize: 11,
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (contact.hasLocation) ...[
|
||||
const SizedBox(width: 6),
|
||||
Icon(
|
||||
Icons.location_on,
|
||||
size: 13,
|
||||
color: scheme.onSurfaceVariant.withValues(
|
||||
alpha: 0.55,
|
||||
),
|
||||
),
|
||||
],
|
||||
if (contact.rawPacket != null) ...[
|
||||
const SizedBox(width: 4),
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showContactContextMenu(
|
||||
Contact contact,
|
||||
MeshCoreConnector connector,
|
||||
) async {
|
||||
final action = await showModalBottomSheet<String>(
|
||||
context: context,
|
||||
showDragHandle: true,
|
||||
final action = await showMeshSheet<String>(
|
||||
context,
|
||||
builder: (sheetContext) {
|
||||
final l10n = context.l10n;
|
||||
return SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
BottomSheetHeader(
|
||||
title: contact.name,
|
||||
subtitle: contact.typeLabel(l10n),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.copy),
|
||||
title: Text(l10n.discoveredContacts_copyContact),
|
||||
@@ -235,6 +332,7 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
|
||||
title: Text(l10n.discoveredContacts_deleteContact),
|
||||
onTap: () => Navigator.of(sheetContext).pop('delete_contact'),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,9 @@ import '../services/app_settings_service.dart';
|
||||
import '../services/map_tile_cache_service.dart';
|
||||
import '../widgets/adaptive_app_bar_title.dart';
|
||||
import '../helpers/snack_bar_builder.dart';
|
||||
import '../theme/mesh_theme.dart';
|
||||
import '../widgets/mesh_ui.dart';
|
||||
import '../widgets/themed_map_tile_layer.dart';
|
||||
|
||||
class MapCacheScreen extends StatefulWidget {
|
||||
const MapCacheScreen({super.key});
|
||||
@@ -76,8 +79,14 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
|
||||
return Positioned(
|
||||
top: 12,
|
||||
left: 12,
|
||||
child: Card(
|
||||
elevation: 4,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: MeshPalette.bg1.withValues(alpha: 0.90),
|
||||
borderRadius: BorderRadius.circular(MeshRadii.md),
|
||||
border: Border.all(color: MeshPalette.line2),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(MeshRadii.md),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
@@ -99,6 +108,7 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -281,6 +291,7 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
|
||||
final tileCache = context.read<MapTileCacheService>();
|
||||
final selectedBounds = _selectedBounds;
|
||||
final l10n = context.l10n;
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final isDesktop = _isDesktopPlatform(defaultTargetPlatform);
|
||||
final progressValue = _estimatedTiles == 0
|
||||
? 0.0
|
||||
@@ -318,13 +329,7 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
|
||||
),
|
||||
),
|
||||
children: [
|
||||
TileLayer(
|
||||
urlTemplate: kMapTileUrlTemplate,
|
||||
tileProvider: tileCache.tileProvider,
|
||||
userAgentPackageName:
|
||||
MapTileCacheService.userAgentPackageName,
|
||||
maxZoom: 19,
|
||||
),
|
||||
ThemedMapTileLayer(tileCache: tileCache),
|
||||
if (selectedBounds != null)
|
||||
PolygonLayer(
|
||||
polygons: [
|
||||
@@ -342,14 +347,25 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
|
||||
Positioned(
|
||||
top: 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(
|
||||
padding: const EdgeInsets.all(8),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 6,
|
||||
),
|
||||
child: Text(
|
||||
selectedBounds == null
|
||||
? l10n.mapCache_noAreaSelected
|
||||
: _formatBounds(selectedBounds, l10n),
|
||||
style: const TextStyle(fontSize: 12),
|
||||
style: MeshTheme.mono(
|
||||
fontSize: 11,
|
||||
color: MeshPalette.ink2,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -359,27 +375,30 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
|
||||
),
|
||||
SafeArea(
|
||||
top: false,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.surfaceContainerLow,
|
||||
border: Border(top: BorderSide(color: scheme.outlineVariant)),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
|
||||
padding: const EdgeInsets.fromLTRB(16, 4, 16, 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
SectionHeader(
|
||||
l10n.mapCache_cacheArea,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
padding: const EdgeInsets.fromLTRB(0, 12, 0, 8),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ElevatedButton.icon(
|
||||
icon: const Icon(Icons.crop_free),
|
||||
label: Text(l10n.mapCache_useCurrentView),
|
||||
onPressed: _isDownloading ? null : _setBoundsFromView,
|
||||
onPressed: _isDownloading
|
||||
? null
|
||||
: _setBoundsFromView,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
@@ -392,12 +411,9 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
SectionHeader(
|
||||
l10n.mapCache_zoomRange,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
),
|
||||
padding: const EdgeInsets.fromLTRB(0, 8, 0, 0),
|
||||
),
|
||||
RangeSlider(
|
||||
values: RangeValues(
|
||||
@@ -422,16 +438,30 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
|
||||
_saveZoomRange();
|
||||
},
|
||||
),
|
||||
Text(l10n.mapCache_estimatedTiles(_estimatedTiles)),
|
||||
Text(
|
||||
l10n.mapCache_estimatedTiles(_estimatedTiles),
|
||||
style: MeshTheme.mono(
|
||||
fontSize: 12,
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
if (_isDownloading) ...[
|
||||
const SizedBox(height: 8),
|
||||
LinearProgressIndicator(value: progressValue),
|
||||
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),
|
||||
@@ -448,6 +478,12 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
|
||||
),
|
||||
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),
|
||||
),
|
||||
@@ -458,8 +494,9 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Text(
|
||||
l10n.mapCache_failedDownloads(_failedTiles),
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
style: MeshTheme.mono(
|
||||
fontSize: 12,
|
||||
color: MeshPalette.alert,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -467,6 +504,7 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
+1927
-526
File diff suppressed because it is too large
Load Diff
@@ -9,9 +9,10 @@ import '../models/path_selection.dart';
|
||||
import '../connector/meshcore_connector.dart';
|
||||
import '../connector/meshcore_protocol.dart';
|
||||
import '../services/repeater_command_service.dart';
|
||||
import '../theme/mesh_theme.dart';
|
||||
import '../widgets/empty_state.dart';
|
||||
import '../widgets/mesh_ui.dart';
|
||||
import '../widgets/routing_sheet.dart';
|
||||
import '../widgets/snr_indicator.dart';
|
||||
import '../helpers/snack_bar_builder.dart';
|
||||
|
||||
class NeighborsScreen extends StatefulWidget {
|
||||
@@ -321,7 +322,7 @@ class _NeighborsScreenState extends State<NeighborsScreen> {
|
||||
child: RefreshIndicator(
|
||||
onRefresh: _loadNeighbors,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
|
||||
children: [
|
||||
if (!_isLoaded &&
|
||||
!_hasData &&
|
||||
@@ -330,9 +331,7 @@ class _NeighborsScreenState extends State<NeighborsScreen> {
|
||||
if (_isLoaded ||
|
||||
_hasData &&
|
||||
!(_parsedNeighbors == null || _parsedNeighbors!.isEmpty))
|
||||
_buildNeighborsInfoCard(
|
||||
"${l10n.repeater_neighbors} - $_neighborCount",
|
||||
),
|
||||
_buildNeighborsList(connector),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -340,81 +339,97 @@ class _NeighborsScreenState extends State<NeighborsScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNeighborsInfoCard(String title) {
|
||||
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
Widget _buildNeighborsList(MeshCoreConnector connector) {
|
||||
final l10n = context.l10n;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SectionHeader(
|
||||
'${l10n.repeater_neighbors} — $_neighborCount',
|
||||
padding: const EdgeInsets.fromLTRB(4, 8, 4, 10),
|
||||
),
|
||||
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: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.info_outline,
|
||||
color: Theme.of(context).textTheme.headlineSmall?.color,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
title,
|
||||
name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 15,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Divider(),
|
||||
for (final entry in _parsedNeighbors!.asMap().entries)
|
||||
_buildInfoRow(
|
||||
entry.value['contact'] != null
|
||||
? entry.value['contact'].name
|
||||
: context.l10n.neighbors_unknownContact(
|
||||
"<${pubKeyToHex(entry.value['publicKey'])}>",
|
||||
),
|
||||
context.l10n.neighbors_heardAgo(
|
||||
fmtDuration(entry.value['lastHeard'] + 0.0),
|
||||
),
|
||||
entry.value['snr'],
|
||||
connector.currentSf,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoRow(
|
||||
String label,
|
||||
String value,
|
||||
double snr,
|
||||
int? spreadingFactor,
|
||||
) {
|
||||
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),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
snrUi.text,
|
||||
style: TextStyle(fontSize: 10, color: snrUi.color),
|
||||
heardLabel,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SignalBars(snr: snr, height: 16),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${snr.toStringAsFixed(1)} dB',
|
||||
style: MeshTheme.mono(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: snrColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
+843
-149
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,12 @@
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../l10n/l10n.dart';
|
||||
import '../models/contact.dart';
|
||||
import '../connector/meshcore_connector.dart';
|
||||
import '../connector/meshcore_protocol.dart';
|
||||
import '../theme/mesh_theme.dart';
|
||||
import '../widgets/debug_frame_viewer.dart';
|
||||
import '../services/repeater_command_service.dart';
|
||||
import '../widgets/routing_sheet.dart';
|
||||
@@ -34,7 +35,6 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
|
||||
StreamSubscription<Uint8List>? _frameSubscription;
|
||||
RepeaterCommandService? _commandService;
|
||||
|
||||
// Common commands for quick access
|
||||
late final List<Map<String, String>> _quickCommands = [
|
||||
{'labelKey': 'advertise', 'command': 'advert'},
|
||||
{'labelKey': 'getName', 'command': 'get name'},
|
||||
@@ -67,12 +67,8 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
|
||||
|
||||
void _setupMessageListener() {
|
||||
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||
|
||||
// Listen for incoming text messages from the repeater
|
||||
_frameSubscription = connector.receivedFrames.listen((frame) {
|
||||
if (frame.isEmpty) return;
|
||||
|
||||
// Check if it's a text message response
|
||||
if (frame[0] == respCodeContactMsgRecv ||
|
||||
frame[0] == respCodeContactMsgRecvV3) {
|
||||
_handleTextMessageResponse(frame);
|
||||
@@ -102,12 +98,7 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
|
||||
final parsed = parseContactMessageText(frame);
|
||||
if (parsed == null) return;
|
||||
if (!_matchesRepeaterPrefix(parsed.senderPrefix)) return;
|
||||
|
||||
// Notify command service of response (for retry handling)
|
||||
_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) {
|
||||
@@ -131,7 +122,6 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
|
||||
});
|
||||
});
|
||||
|
||||
// Show debug info if requested
|
||||
if (showDebug && mounted) {
|
||||
final frame = buildSendCliCommandFrame(
|
||||
widget.repeater.publicKey,
|
||||
@@ -144,7 +134,6 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
// Send CLI command to repeater with retry
|
||||
try {
|
||||
if (_commandService != null) {
|
||||
final connector = Provider.of<MeshCoreConnector>(
|
||||
@@ -157,7 +146,6 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
|
||||
command,
|
||||
retries: 1,
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_commandHistory.add({
|
||||
@@ -184,7 +172,6 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
|
||||
_historyIndex = -1;
|
||||
_commandFocusNode.requestFocus();
|
||||
|
||||
// Auto-scroll to bottom
|
||||
Future.delayed(const Duration(milliseconds: 100), () {
|
||||
if (_scrollController.hasClients) {
|
||||
_scrollController.animateTo(
|
||||
@@ -239,36 +226,46 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
|
||||
});
|
||||
}
|
||||
|
||||
String _quickCommandLabel(String key) {
|
||||
final l10n = context.l10n;
|
||||
switch (key) {
|
||||
case 'getName':
|
||||
return l10n.repeater_cliQuickGetName;
|
||||
case 'getRadio':
|
||||
return l10n.repeater_cliQuickGetRadio;
|
||||
case 'getTx':
|
||||
return l10n.repeater_cliQuickGetTx;
|
||||
case 'neighbors':
|
||||
return l10n.repeater_cliQuickNeighbors;
|
||||
case 'version':
|
||||
return l10n.repeater_cliQuickVersion;
|
||||
case 'advertise':
|
||||
return l10n.repeater_cliQuickAdvertise;
|
||||
case 'clock':
|
||||
return l10n.repeater_cliQuickClock;
|
||||
case 'clock sync':
|
||||
return l10n.repeater_cliQuickClockSync;
|
||||
case 'discovery':
|
||||
return l10n.repeater_cliQuickDiscovery;
|
||||
default:
|
||||
return key;
|
||||
}
|
||||
}
|
||||
|
||||
@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(
|
||||
title: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
l10n.repeater_cliTitle,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
Text(
|
||||
repeater.name,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.normal,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
centerTitle: false,
|
||||
backgroundColor: MeshPalette.bg1,
|
||||
title: Text(l10n.repeater_cliTitle),
|
||||
centerTitle: true,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: Icon(isFloodMode ? Icons.waves : Icons.route),
|
||||
@@ -317,66 +314,154 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
|
||||
),
|
||||
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),
|
||||
// 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: 8),
|
||||
padding: const EdgeInsets.only(right: 6),
|
||||
child: ActionChip(
|
||||
label: Text(label),
|
||||
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']!),
|
||||
avatar: const Icon(Icons.play_arrow, size: 16),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
),
|
||||
Divider(height: 1, color: MeshPalette.line),
|
||||
|
||||
String _quickCommandLabel(String key) {
|
||||
final l10n = context.l10n;
|
||||
switch (key) {
|
||||
case 'getName':
|
||||
return l10n.repeater_cliQuickGetName;
|
||||
case 'getRadio':
|
||||
return l10n.repeater_cliQuickGetRadio;
|
||||
case 'getTx':
|
||||
return l10n.repeater_cliQuickGetTx;
|
||||
case 'neighbors':
|
||||
return l10n.repeater_cliQuickNeighbors;
|
||||
case 'version':
|
||||
return l10n.repeater_cliQuickVersion;
|
||||
case 'advertise':
|
||||
return l10n.repeater_cliQuickAdvertise;
|
||||
case 'clock':
|
||||
return l10n.repeater_cliQuickClock;
|
||||
case 'clock sync':
|
||||
return l10n.repeater_cliQuickClockSync;
|
||||
case 'discovery':
|
||||
return l10n.repeater_cliQuickDiscovery;
|
||||
default:
|
||||
return key;
|
||||
}
|
||||
// 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() {
|
||||
@@ -385,26 +470,16 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.terminal,
|
||||
size: 64,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Icon(Icons.terminal, size: 48, color: MeshPalette.ink4),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
l10n.repeater_noCommandsSent,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
style: MeshTheme.mono(fontSize: 13, color: MeshPalette.ink3),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
l10n.repeater_typeCommandOrUseQuick,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
style: const TextStyle(fontSize: 12, color: MeshPalette.ink4),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -414,50 +489,38 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
|
||||
Widget _buildCommandHistory() {
|
||||
return ListView.builder(
|
||||
controller: _scrollController,
|
||||
padding: const EdgeInsets.all(16),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
itemCount: _commandHistory.length,
|
||||
itemBuilder: (context, index) {
|
||||
final entry = _commandHistory[index];
|
||||
final isCommand = entry['type'] == 'command';
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
padding: const EdgeInsets.only(bottom: 2),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(6),
|
||||
decoration: BoxDecoration(
|
||||
color: isCommand
|
||||
? Theme.of(context).colorScheme.primaryContainer
|
||||
: Theme.of(context).colorScheme.secondaryContainer,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Icon(
|
||||
isCommand ? Icons.chevron_right : Icons.arrow_back,
|
||||
size: 16,
|
||||
color: isCommand
|
||||
? Theme.of(context).colorScheme.onPrimaryContainer
|
||||
: Theme.of(context).colorScheme.onSecondaryContainer,
|
||||
// Gutter prefix
|
||||
SizedBox(
|
||||
width: 20,
|
||||
child: Text(
|
||||
isCommand ? '>' : ' ',
|
||||
style: MeshTheme.mono(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: isCommand ? MeshPalette.blue : MeshPalette.ink3,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SelectableText(
|
||||
child: SelectableText(
|
||||
entry['text']!,
|
||||
style: TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 13,
|
||||
color: isCommand
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Theme.of(context).colorScheme.onSurface,
|
||||
style: MeshTheme.mono(
|
||||
fontSize: 12.5,
|
||||
color: isCommand ? MeshPalette.blue : MeshPalette.ink,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -466,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) {
|
||||
_commandController.text = command;
|
||||
_commandController.selection = TextSelection.fromPosition(
|
||||
@@ -1134,16 +1149,20 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
|
||||
List<_CommandHelpEntry> commands, {
|
||||
String? note,
|
||||
}) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 15),
|
||||
),
|
||||
if (note != null) ...[
|
||||
const SizedBox(height: 6),
|
||||
Text(note, style: const TextStyle(fontSize: 12)),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
note,
|
||||
style: TextStyle(fontSize: 11, color: scheme.onSurfaceVariant),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 8),
|
||||
...commands.map((entry) => _buildHelpCommandCard(context, entry)),
|
||||
@@ -1152,39 +1171,35 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
|
||||
}
|
||||
|
||||
Widget _buildHelpCommandCard(BuildContext context, _CommandHelpEntry entry) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return Card(
|
||||
elevation: 0,
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
margin: const EdgeInsets.only(bottom: 6),
|
||||
color: scheme.surfaceContainerHighest,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
side: BorderSide(color: colorScheme.outlineVariant),
|
||||
borderRadius: BorderRadius.circular(MeshRadii.sm),
|
||||
side: BorderSide(color: scheme.outlineVariant),
|
||||
),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderRadius: BorderRadius.circular(MeshRadii.sm),
|
||||
onTap: () => _applyHelpCommand(entry.command),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
padding: const EdgeInsets.all(10),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
entry.command,
|
||||
style: TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
style: MeshTheme.mono(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: MeshPalette.blue,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
entry.description,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
style: TextStyle(fontSize: 12, color: scheme.onSurfaceVariant),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -1197,6 +1212,5 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
|
||||
class _CommandHelpEntry {
|
||||
final String command;
|
||||
final String description;
|
||||
|
||||
const _CommandHelpEntry({required this.command, required this.description});
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:meshcore_open/connector/meshcore_protocol.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../l10n/l10n.dart';
|
||||
import '../models/contact.dart';
|
||||
import '../l10n/contact_localization.dart';
|
||||
import '../services/app_settings_service.dart';
|
||||
import '../theme/mesh_theme.dart';
|
||||
import '../widgets/mesh_ui.dart';
|
||||
import 'repeater_status_screen.dart';
|
||||
import 'repeater_cli_screen.dart';
|
||||
import 'repeater_settings_screen.dart';
|
||||
@@ -26,146 +29,116 @@ class RepeaterHubScreen extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final settingsService = context.watch<AppSettingsService>();
|
||||
final chemistry = settingsService.batteryChemistryForRepeater(
|
||||
repeater.publicKeyHex,
|
||||
);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (isAdmin)
|
||||
Text(
|
||||
title: Text(
|
||||
repeater.type == advTypeRepeater
|
||||
? l10n.repeater_management
|
||||
: l10n.room_management,
|
||||
? (isAdmin ? l10n.repeater_management : l10n.repeater_guest)
|
||||
: (isAdmin ? l10n.room_management : l10n.room_guest),
|
||||
),
|
||||
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(
|
||||
top: false,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
padding: const EdgeInsets.only(bottom: 24),
|
||||
children: [
|
||||
// Repeater info card
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
// ── Identity card ─────────────────────────────────────────────
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 20, 16, 4),
|
||||
child: MeshCard(
|
||||
margin: EdgeInsets.zero,
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 40,
|
||||
backgroundColor: Theme.of(
|
||||
context,
|
||||
).colorScheme.tertiaryContainer,
|
||||
child: Icon(
|
||||
Icons.cell_tower,
|
||||
size: 40,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onTertiaryContainer,
|
||||
AvatarCircle(
|
||||
name: repeater.name,
|
||||
size: 52,
|
||||
color: MeshPalette.warn,
|
||||
icon: Icons.cell_tower,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
repeater.name,
|
||||
style: const TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
repeater.shortPubKeyHex,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
repeater.pathLabel(context.l10n),
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
if (repeater.hasLocation) ...[
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.location_on,
|
||||
size: 14,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${repeater.latitude?.toStringAsFixed(4)}, ${repeater.longitude?.toStringAsFixed(4)}',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
if (isAdmin)
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 12),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
repeater.name,
|
||||
style: Theme.of(context).textTheme.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.w700),
|
||||
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: [
|
||||
const Icon(Icons.battery_full),
|
||||
const SizedBox(width: 10),
|
||||
Icon(
|
||||
Icons.location_on,
|
||||
size: 12,
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 3),
|
||||
Expanded(
|
||||
child: Text(
|
||||
l10n.appSettings_batteryChemistry,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
'${repeater.latitude?.toStringAsFixed(4)}, '
|
||||
'${repeater.longitude?.toStringAsFixed(4)}',
|
||||
style: MeshTheme.mono(
|
||||
fontSize: 10,
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
DropdownButtonFormField<String>(
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
StatusChip(
|
||||
label: isAdmin ? 'ADMIN' : 'GUEST',
|
||||
color: isAdmin
|
||||
? MeshPalette.blue
|
||||
: scheme.onSurfaceVariant,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// ── Battery chemistry (admin only) ─────────────────────────────
|
||||
if (isAdmin) ...[
|
||||
SectionHeader(l10n.appSettings_batteryChemistry),
|
||||
MeshCard(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
padding: const EdgeInsets.fromLTRB(14, 10, 14, 14),
|
||||
child: DropdownButtonFormField<String>(
|
||||
initialValue: chemistry,
|
||||
isExpanded: true,
|
||||
decoration: const InputDecoration(
|
||||
border: UnderlineInputBorder(),
|
||||
isDense: true,
|
||||
decoration: InputDecoration(
|
||||
prefixIcon: const Icon(Icons.battery_full, size: 18),
|
||||
labelText: l10n.appSettings_batteryChemistry,
|
||||
),
|
||||
onChanged: (value) {
|
||||
if (value == null) return;
|
||||
@@ -189,26 +162,24 @@ class RepeaterHubScreen extends StatelessWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
|
||||
// ── Tools ──────────────────────────────────────────────────────
|
||||
SectionHeader(
|
||||
isAdmin
|
||||
? l10n.repeater_managementTools
|
||||
: l10n.repeater_guestTools,
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// Status button
|
||||
_buildManagementCard(
|
||||
context,
|
||||
|
||||
_HubActionTile(
|
||||
index: 0,
|
||||
icon: Icons.analytics,
|
||||
title: l10n.repeater_status,
|
||||
subtitle: l10n.repeater_statusSubtitle,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
accentColor: MeshPalette.blue,
|
||||
onTap: () {
|
||||
HapticFeedback.selectionClick();
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
@@ -220,15 +191,15 @@ class RepeaterHubScreen extends StatelessWidget {
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// Telemetry button
|
||||
_buildManagementCard(
|
||||
context,
|
||||
|
||||
_HubActionTile(
|
||||
index: 1,
|
||||
icon: Icons.bar_chart_sharp,
|
||||
title: l10n.repeater_telemetry,
|
||||
subtitle: l10n.repeater_telemetrySubtitle,
|
||||
color: Theme.of(context).colorScheme.secondary,
|
||||
accentColor: MeshPalette.magenta,
|
||||
onTap: () {
|
||||
HapticFeedback.selectionClick();
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
@@ -237,16 +208,34 @@ class RepeaterHubScreen extends StatelessWidget {
|
||||
);
|
||||
},
|
||||
),
|
||||
if (isAdmin) const SizedBox(height: 12),
|
||||
// CLI button
|
||||
if (isAdmin)
|
||||
_buildManagementCard(
|
||||
|
||||
_HubActionTile(
|
||||
index: 2,
|
||||
icon: Icons.group,
|
||||
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,
|
||||
title: l10n.repeater_cli,
|
||||
subtitle: l10n.repeater_cliSubtitle,
|
||||
color: Theme.of(context).colorScheme.tertiary,
|
||||
accentColor: MeshPalette.warn,
|
||||
onTap: () {
|
||||
HapticFeedback.selectionClick();
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
@@ -258,34 +247,14 @@ class RepeaterHubScreen extends StatelessWidget {
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// Neighbors button
|
||||
_buildManagementCard(
|
||||
context,
|
||||
icon: Icons.group,
|
||||
title: l10n.repeater_neighbors,
|
||||
subtitle: l10n.repeater_neighborsSubtitle,
|
||||
color: Theme.of(context).colorScheme.tertiary,
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) =>
|
||||
NeighborsScreen(repeater: repeater, password: password),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
if (isAdmin) const SizedBox(height: 12),
|
||||
// Settings button
|
||||
if (isAdmin)
|
||||
_buildManagementCard(
|
||||
context,
|
||||
_HubActionTile(
|
||||
index: 4,
|
||||
icon: Icons.settings,
|
||||
title: l10n.repeater_settings,
|
||||
subtitle: l10n.repeater_settingsSubtitle,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
accentColor: MeshPalette.alert,
|
||||
onTap: () {
|
||||
HapticFeedback.selectionClick();
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
@@ -298,37 +267,51 @@ class RepeaterHubScreen extends StatelessWidget {
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildManagementCard(
|
||||
BuildContext context, {
|
||||
required IconData icon,
|
||||
required String title,
|
||||
required String subtitle,
|
||||
required Color color,
|
||||
required VoidCallback onTap,
|
||||
}) {
|
||||
return Card(
|
||||
elevation: 2,
|
||||
child: InkWell(
|
||||
class _HubActionTile extends StatelessWidget {
|
||||
final int index;
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final Color accentColor;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _HubActionTile({
|
||||
required this.index,
|
||||
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,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
color: accentColor.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(MeshRadii.md),
|
||||
border: Border.all(color: accentColor.withValues(alpha: 0.3)),
|
||||
),
|
||||
child: Icon(icon, color: color, size: 32),
|
||||
alignment: Alignment.center,
|
||||
child: Icon(icon, size: 22, color: accentColor),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -336,29 +319,25 @@ class RepeaterHubScreen extends StatelessWidget {
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 15,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
subtitle,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
fontSize: 12.5,
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
Icons.chevron_right,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
Icon(Icons.chevron_right, color: scheme.onSurfaceVariant, size: 20),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import '../connector/meshcore_connector.dart';
|
||||
import '../connector/meshcore_protocol.dart';
|
||||
import '../services/repeater_command_service.dart';
|
||||
import '../services/storage_service.dart';
|
||||
import '../theme/mesh_theme.dart';
|
||||
import '../widgets/mesh_ui.dart';
|
||||
import '../widgets/routing_sheet.dart';
|
||||
import '../helpers/snack_bar_builder.dart';
|
||||
|
||||
@@ -1003,39 +1005,6 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildSectionHeader({
|
||||
required IconData icon,
|
||||
required String title,
|
||||
String? tooltip,
|
||||
bool isRefreshing = false,
|
||||
VoidCallback? onRefresh,
|
||||
}) {
|
||||
return Row(
|
||||
children: [
|
||||
Icon(icon, color: Theme.of(context).textTheme.headlineSmall?.color),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
),
|
||||
if (onRefresh != null) ...[
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
icon: isRefreshing
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.refresh),
|
||||
onPressed: isRefreshing ? null : onRefresh,
|
||||
tooltip: tooltip,
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInlineRefreshButton({
|
||||
required bool isRefreshing,
|
||||
required VoidCallback onRefresh,
|
||||
@@ -1067,21 +1036,8 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(l10n.repeater_settingsTitle),
|
||||
Text(
|
||||
repeater.name,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
centerTitle: false,
|
||||
title: Text(l10n.repeater_settingsTitle),
|
||||
centerTitle: true,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: Icon(isFloodMode ? Icons.waves : Icons.route),
|
||||
@@ -1102,27 +1058,17 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
|
||||
child: _isLoading && _nameController.text.isEmpty
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
padding: const EdgeInsets.only(bottom: 32),
|
||||
children: [
|
||||
_buildBasicSettingsCard(),
|
||||
const SizedBox(height: 16),
|
||||
_buildRadioSettingsCard(),
|
||||
const SizedBox(height: 16),
|
||||
_buildLocationSettingsCard(),
|
||||
const SizedBox(height: 16),
|
||||
_buildFeatureTogglesCard(),
|
||||
const SizedBox(height: 16),
|
||||
_buildNetworkHealthCard(),
|
||||
const SizedBox(height: 16),
|
||||
_buildAdvertisementSettingsCard(),
|
||||
const SizedBox(height: 16),
|
||||
_buildOwnerInfoCard(),
|
||||
const SizedBox(height: 16),
|
||||
_buildActionsCard(),
|
||||
const SizedBox(height: 16),
|
||||
_buildAdvancedCard(),
|
||||
const SizedBox(height: 32),
|
||||
const Divider(),
|
||||
const SizedBox(height: 16),
|
||||
_buildDangerZoneCard(),
|
||||
],
|
||||
@@ -1133,47 +1079,51 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
|
||||
|
||||
Widget _buildBasicSettingsCard() {
|
||||
final l10n = context.l10n;
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
final refreshButton = _refreshingBasic
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: IconButton(
|
||||
icon: const Icon(Icons.refresh, size: 18),
|
||||
onPressed: _refreshBasicSettings,
|
||||
tooltip: l10n.repeater_refreshBasicSettings,
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: EdgeInsets.zero,
|
||||
);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SectionHeader(l10n.repeater_basicSettings, trailing: refreshButton),
|
||||
MeshCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildSectionHeader(
|
||||
icon: Icons.settings,
|
||||
title: l10n.repeater_basicSettings,
|
||||
tooltip: l10n.repeater_refreshBasicSettings,
|
||||
isRefreshing: _refreshingBasic,
|
||||
onRefresh: _refreshBasicSettings,
|
||||
),
|
||||
const Divider(),
|
||||
TextField(
|
||||
controller: _nameController,
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.repeater_repeaterName,
|
||||
helperText: l10n.repeater_repeaterNameHelper,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
onChanged: (_) => _markChanged(_SettingField.name),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _passwordController,
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.repeater_adminPassword,
|
||||
helperText: l10n.repeater_adminPasswordHelper,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
obscureText: true,
|
||||
onChanged: (_) => _flagHasChanges(),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _guestPasswordController,
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.repeater_guestPassword,
|
||||
helperText: l10n.repeater_guestPasswordHelper,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
obscureText: true,
|
||||
onChanged: (_) => _flagHasChanges(),
|
||||
@@ -1181,31 +1131,38 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRadioSettingsCard() {
|
||||
final l10n = context.l10n;
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
final refreshButton = _refreshingRadio
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: IconButton(
|
||||
icon: const Icon(Icons.refresh, size: 18),
|
||||
onPressed: _refreshRadioSettings,
|
||||
tooltip: l10n.repeater_refreshRadioSettings,
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: EdgeInsets.zero,
|
||||
);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SectionHeader(l10n.repeater_radioSettings, trailing: refreshButton),
|
||||
MeshCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildSectionHeader(
|
||||
icon: Icons.radio,
|
||||
title: l10n.repeater_radioSettings,
|
||||
tooltip: l10n.repeater_refreshRadioSettings,
|
||||
isRefreshing: _refreshingRadio,
|
||||
onRefresh: _refreshRadioSettings,
|
||||
),
|
||||
const Divider(),
|
||||
TextField(
|
||||
controller: _freqController,
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.repeater_frequencyMhz,
|
||||
helperText: l10n.repeater_frequencyHelper,
|
||||
border: const OutlineInputBorder(),
|
||||
suffixText: 'MHz',
|
||||
),
|
||||
keyboardType: const TextInputType.numberWithOptions(
|
||||
@@ -1213,7 +1170,7 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
|
||||
),
|
||||
onChanged: (_) => _markChanged(_SettingField.radio),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -1223,14 +1180,12 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.repeater_txPower,
|
||||
helperText: l10n.repeater_txPowerHelper,
|
||||
border: const OutlineInputBorder(),
|
||||
suffixText: 'dBm',
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
onChanged: (_) => _markChanged(_SettingField.txPower),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_buildInlineRefreshButton(
|
||||
isRefreshing: _refreshingTxPower,
|
||||
onRefresh: _refreshTxPower,
|
||||
@@ -1238,13 +1193,10 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const SizedBox(height: 12),
|
||||
DropdownButtonFormField<int>(
|
||||
initialValue: _bandwidth,
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.repeater_bandwidth,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
decoration: InputDecoration(labelText: l10n.repeater_bandwidth),
|
||||
items: _bandwidthOptions.map((bw) {
|
||||
return DropdownMenuItem(
|
||||
value: bw,
|
||||
@@ -1260,12 +1212,11 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const SizedBox(height: 12),
|
||||
DropdownButtonFormField<int>(
|
||||
initialValue: _spreadingFactor,
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.repeater_spreadingFactor,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
items: _spreadingFactorOptions.map((sf) {
|
||||
return DropdownMenuItem(value: sf, child: Text('SF$sf'));
|
||||
@@ -1279,12 +1230,11 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const SizedBox(height: 12),
|
||||
DropdownButtonFormField<int>(
|
||||
initialValue: _codingRate,
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.repeater_codingRate,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
items: _codingRateOptions.map((cr) {
|
||||
return DropdownMenuItem(value: cr, child: Text('4/$cr'));
|
||||
@@ -1298,7 +1248,7 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const SizedBox(height: 4),
|
||||
_buildFeatureToggleRow(
|
||||
title: l10n.repeater_rxGain,
|
||||
subtitle: l10n.repeater_rxGainHelper,
|
||||
@@ -1314,22 +1264,20 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLocationSettingsCard() {
|
||||
final l10n = context.l10n;
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SectionHeader(l10n.repeater_locationSettings),
|
||||
MeshCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildSectionHeader(
|
||||
icon: Icons.location_on,
|
||||
title: l10n.repeater_locationSettings,
|
||||
),
|
||||
const Divider(),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -1342,7 +1290,6 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
|
||||
errorText: _latInvalid
|
||||
? l10n.settings_locationInvalid
|
||||
: null,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
keyboardType: const TextInputType.numberWithOptions(
|
||||
decimal: true,
|
||||
@@ -1357,7 +1304,6 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_buildInlineRefreshButton(
|
||||
isRefreshing: _refreshingLat,
|
||||
onRefresh: _refreshLat,
|
||||
@@ -1365,7 +1311,7 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -1378,7 +1324,6 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
|
||||
errorText: _lonInvalid
|
||||
? l10n.settings_locationInvalid
|
||||
: null,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
keyboardType: const TextInputType.numberWithOptions(
|
||||
decimal: true,
|
||||
@@ -1393,7 +1338,6 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_buildInlineRefreshButton(
|
||||
isRefreshing: _refreshingLon,
|
||||
onRefresh: _refreshLon,
|
||||
@@ -1404,34 +1348,20 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFeatureTogglesCard() {
|
||||
final l10n = context.l10n;
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SectionHeader(l10n.repeater_features),
|
||||
MeshCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.toggle_on,
|
||||
color: Theme.of(context).textTheme.headlineSmall?.color,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
l10n.repeater_features,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Divider(),
|
||||
_buildFeatureToggleRow(
|
||||
title: l10n.repeater_packetForwarding,
|
||||
subtitle: l10n.repeater_packetForwardingSubtitle,
|
||||
@@ -1490,6 +1420,7 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1531,24 +1462,23 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
|
||||
|
||||
Widget _buildAdvertisementSettingsCard() {
|
||||
final l10n = context.l10n;
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SectionHeader(l10n.repeater_advertisementSettings),
|
||||
MeshCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildSectionHeader(
|
||||
icon: Icons.broadcast_on_personal,
|
||||
title: l10n.repeater_advertisementSettings,
|
||||
),
|
||||
const Divider(),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ListTile(
|
||||
title: Text(l10n.repeater_localAdvertInterval),
|
||||
subtitle: Text(
|
||||
l10n.repeater_localAdvertIntervalMinutes(_advertInterval),
|
||||
l10n.repeater_localAdvertIntervalMinutes(
|
||||
_advertInterval,
|
||||
),
|
||||
),
|
||||
trailing: Switch(
|
||||
value: _advertEnable,
|
||||
@@ -1586,7 +1516,9 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
|
||||
min: 60,
|
||||
max: 240,
|
||||
divisions: 18,
|
||||
label: l10n.repeater_localAdvertIntervalMinutes(_advertInterval),
|
||||
label: l10n.repeater_localAdvertIntervalMinutes(
|
||||
_advertInterval,
|
||||
),
|
||||
onChanged: _advertEnable
|
||||
? (value) {
|
||||
setState(() {
|
||||
@@ -1596,7 +1528,7 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
|
||||
}
|
||||
: null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
@@ -1655,7 +1587,7 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
|
||||
}
|
||||
: null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
@@ -1697,22 +1629,20 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNetworkHealthCard() {
|
||||
final l10n = context.l10n;
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SectionHeader(l10n.repeater_networkHealth),
|
||||
MeshCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildSectionHeader(
|
||||
icon: Icons.health_and_safety,
|
||||
title: l10n.repeater_networkHealth,
|
||||
),
|
||||
const Divider(),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -1723,7 +1653,6 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
|
||||
labelText: l10n.repeater_loopDetect,
|
||||
helperText: l10n.repeater_loopDetectHelper,
|
||||
helperMaxLines: 3,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
items: [
|
||||
DropdownMenuItem(
|
||||
@@ -1751,7 +1680,6 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_buildInlineRefreshButton(
|
||||
isRefreshing: _refreshingLoopDetect,
|
||||
onRefresh: _refreshLoopDetect,
|
||||
@@ -1759,7 +1687,7 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
@@ -1801,68 +1729,56 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildOwnerInfoCard() {
|
||||
final l10n = context.l10n;
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
final refreshButton = _refreshingOwnerInfo
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: IconButton(
|
||||
icon: const Icon(Icons.refresh, size: 18),
|
||||
onPressed: _refreshOwnerInfo,
|
||||
tooltip: l10n.repeater_refreshOwnerInfo,
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: EdgeInsets.zero,
|
||||
);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildSectionHeader(
|
||||
icon: Icons.person_outline,
|
||||
title: l10n.repeater_ownerInfo,
|
||||
tooltip: l10n.repeater_refreshOwnerInfo,
|
||||
isRefreshing: _refreshingOwnerInfo,
|
||||
onRefresh: _refreshOwnerInfo,
|
||||
),
|
||||
const Divider(),
|
||||
TextField(
|
||||
SectionHeader(l10n.repeater_ownerInfo, trailing: refreshButton),
|
||||
MeshCard(
|
||||
child: TextField(
|
||||
controller: _ownerInfoController,
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.repeater_ownerInfo,
|
||||
helperText: l10n.repeater_ownerInfoHelper,
|
||||
helperMaxLines: 3,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
maxLines: 4,
|
||||
minLines: 2,
|
||||
onChanged: (_) => _markChanged(_SettingField.ownerInfo),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildActionsCard() {
|
||||
final l10n = context.l10n;
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SectionHeader(l10n.repeater_actionsTitle),
|
||||
MeshCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.flash_on,
|
||||
color: Theme.of(context).textTheme.headlineSmall?.color,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
l10n.repeater_actionsTitle,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Divider(),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.podcasts),
|
||||
title: Text(l10n.repeater_sendAdvert),
|
||||
@@ -1899,20 +1815,21 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAdvancedCard() {
|
||||
final l10n = context.l10n;
|
||||
return Card(
|
||||
return MeshCard(
|
||||
child: ExpansionTile(
|
||||
leading: const Icon(Icons.tune),
|
||||
title: Text(
|
||||
l10n.repeater_advancedSettings,
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
|
||||
),
|
||||
subtitle: Text(l10n.repeater_advancedSettingsSubtitle),
|
||||
childrenPadding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
|
||||
childrenPadding: const EdgeInsets.fromLTRB(0, 8, 0, 4),
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -1924,7 +1841,6 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
|
||||
labelText: l10n.repeater_pathHashMode,
|
||||
helperText: l10n.repeater_pathHashModeHelper,
|
||||
helperMaxLines: 5,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
items: const [
|
||||
DropdownMenuItem(value: 0, child: Text('0')),
|
||||
@@ -1939,7 +1855,6 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_buildInlineRefreshButton(
|
||||
isRefreshing: _refreshingPathHashMode,
|
||||
onRefresh: _refreshPathHashMode,
|
||||
@@ -1947,7 +1862,7 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -1958,7 +1873,6 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
|
||||
labelText: l10n.repeater_txDelay,
|
||||
helperText: l10n.repeater_txDelayHelper,
|
||||
helperMaxLines: 3,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
keyboardType: const TextInputType.numberWithOptions(
|
||||
decimal: true,
|
||||
@@ -1966,7 +1880,6 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
|
||||
onChanged: (_) => _markChanged(_SettingField.txDelay),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_buildInlineRefreshButton(
|
||||
isRefreshing: _refreshingTxDelay,
|
||||
onRefresh: _refreshTxDelay,
|
||||
@@ -1974,7 +1887,7 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -1985,7 +1898,6 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
|
||||
labelText: l10n.repeater_directTxDelay,
|
||||
helperText: l10n.repeater_directTxDelayHelper,
|
||||
helperMaxLines: 3,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
keyboardType: const TextInputType.numberWithOptions(
|
||||
decimal: true,
|
||||
@@ -1993,7 +1905,6 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
|
||||
onChanged: (_) => _markChanged(_SettingField.directTxDelay),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_buildInlineRefreshButton(
|
||||
isRefreshing: _refreshingDirectTxDelay,
|
||||
onRefresh: _refreshDirectTxDelay,
|
||||
@@ -2001,7 +1912,7 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -2012,13 +1923,11 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
|
||||
labelText: l10n.repeater_intThresh,
|
||||
helperText: l10n.repeater_intThreshHelper,
|
||||
helperMaxLines: 3,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
onChanged: (_) => _markChanged(_SettingField.intThresh),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_buildInlineRefreshButton(
|
||||
isRefreshing: _refreshingIntThresh,
|
||||
onRefresh: _refreshIntThresh,
|
||||
@@ -2026,7 +1935,7 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
@@ -2077,75 +1986,55 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
|
||||
|
||||
Widget _buildDangerZoneCard() {
|
||||
final l10n = context.l10n;
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Card(
|
||||
color: colorScheme.errorContainer,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
return MeshCard(
|
||||
color: MeshPalette.alertBg,
|
||||
borderColor: MeshPalette.alertLine,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.warning, color: colorScheme.onErrorContainer),
|
||||
const Icon(Icons.warning, color: MeshPalette.alert),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
l10n.repeater_dangerZone,
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onErrorContainer,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: MeshPalette.alert,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Divider(),
|
||||
const Divider(height: 20, color: MeshPalette.alertLine),
|
||||
ListTile(
|
||||
leading: Icon(Icons.refresh, color: colorScheme.onErrorContainer),
|
||||
leading: const Icon(Icons.refresh, color: MeshPalette.alert),
|
||||
title: Text(
|
||||
l10n.repeater_rebootRepeater,
|
||||
style: TextStyle(color: colorScheme.onErrorContainer),
|
||||
style: const TextStyle(color: MeshPalette.alert),
|
||||
),
|
||||
subtitle: Text(
|
||||
l10n.repeater_rebootRepeaterSubtitle,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onErrorContainer.withValues(alpha: 0.8),
|
||||
),
|
||||
style: const TextStyle(color: MeshPalette.warnDim),
|
||||
),
|
||||
onTap: () => _confirmAction(
|
||||
l10n.repeater_rebootRepeater,
|
||||
l10n.repeater_rebootRepeaterConfirm,
|
||||
() => _sendDangerCommand('reboot'),
|
||||
),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
// Regenerate identity key - hidden until fully implemented
|
||||
// ListTile(
|
||||
// leading: Icon(Icons.vpn_key, color: colorScheme.onErrorContainer),
|
||||
// title: Text('Regenerate Identity Key', style: TextStyle(color: colorScheme.onErrorContainer)),
|
||||
// subtitle: Text(
|
||||
// 'Generate new public/private key pair',
|
||||
// style: TextStyle(color: colorScheme.onErrorContainer.withValues(alpha: 0.8)),
|
||||
// ),
|
||||
// onTap: () => _confirmAction(
|
||||
// 'Regenerate Identity',
|
||||
// 'This will generate a new identity for the repeater. Continue?',
|
||||
// () => _sendDangerCommand('regen key'),
|
||||
// ),
|
||||
// ),
|
||||
ListTile(
|
||||
leading: Icon(
|
||||
Icons.delete_forever,
|
||||
color: colorScheme.onErrorContainer,
|
||||
),
|
||||
leading: const Icon(Icons.delete_forever, color: MeshPalette.alert),
|
||||
title: Text(
|
||||
l10n.repeater_eraseFileSystem,
|
||||
style: TextStyle(color: colorScheme.onErrorContainer),
|
||||
style: const TextStyle(color: MeshPalette.alert),
|
||||
),
|
||||
subtitle: Text(
|
||||
l10n.repeater_eraseFileSystemSubtitle,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onErrorContainer.withValues(alpha: 0.8),
|
||||
),
|
||||
style: const TextStyle(color: MeshPalette.warnDim),
|
||||
),
|
||||
onTap: () => _confirmAction(
|
||||
l10n.repeater_eraseFileSystem,
|
||||
@@ -2153,10 +2042,10 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
|
||||
() => _sendDangerCommand('erase'),
|
||||
isDestructive: true,
|
||||
),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../l10n/l10n.dart';
|
||||
import '../models/contact.dart';
|
||||
@@ -10,7 +11,9 @@ import '../connector/meshcore_connector.dart';
|
||||
import '../connector/meshcore_protocol.dart';
|
||||
import '../services/app_settings_service.dart';
|
||||
import '../services/repeater_command_service.dart';
|
||||
import '../theme/mesh_theme.dart';
|
||||
import '../utils/battery_utils.dart';
|
||||
import '../widgets/mesh_ui.dart';
|
||||
import '../widgets/routing_sheet.dart';
|
||||
import '../helpers/snack_bar_builder.dart';
|
||||
|
||||
@@ -64,8 +67,6 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
|
||||
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||
_commandService = RepeaterCommandService(connector);
|
||||
_setupMessageListener();
|
||||
// Defer until after the first frame so any notifyListeners() triggered
|
||||
// during preparePathForContactSend doesn't fire mid-build.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) _loadStatus();
|
||||
});
|
||||
@@ -81,12 +82,8 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
|
||||
|
||||
void _setupMessageListener() {
|
||||
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||
|
||||
// Listen for incoming text messages from the repeater
|
||||
_frameSubscription = connector.receivedFrames.listen((frame) {
|
||||
if (frame.isEmpty) return;
|
||||
|
||||
// Check if it's a text message response
|
||||
if (frame[0] == pushCodeStatusResponse) {
|
||||
_handleStatusResponse(frame);
|
||||
} else if (frame[0] == respCodeContactMsgRecv ||
|
||||
@@ -118,11 +115,7 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
|
||||
final parsed = parseContactMessageText(frame);
|
||||
if (parsed == null) return;
|
||||
if (!_matchesRepeaterPrefix(parsed.senderPrefix)) return;
|
||||
|
||||
// Notify command service of response (for retry handling)
|
||||
_commandService?.handleResponse(widget.repeater, parsed.text);
|
||||
|
||||
// Parse status responses
|
||||
_parseStatusResponse(parsed.text);
|
||||
_recordStatusResult(true);
|
||||
}
|
||||
@@ -131,7 +124,6 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
|
||||
if (frame.length < 8) return;
|
||||
final prefix = frame.sublist(2, 8);
|
||||
if (!_matchesRepeaterPrefix(prefix)) return;
|
||||
|
||||
if (frame.length < _statusResponseBytes) return;
|
||||
|
||||
final data = ByteData.sublistView(
|
||||
@@ -254,14 +246,9 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
|
||||
_dupFlood = _asInt(data['dup_flood']);
|
||||
_dupDirect = _asInt(data['dup_direct']);
|
||||
}
|
||||
} catch (_) {
|
||||
// Ignore parse failures for non-JSON responses.
|
||||
}
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
} catch (_) {}
|
||||
}
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
Future<void> _loadStatus() async {
|
||||
@@ -302,9 +289,7 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
|
||||
var messageBytes = frame.length >= _statusResponseBytes
|
||||
? frame.length
|
||||
: _statusResponseBytes;
|
||||
if (messageBytes < maxFrameSize) {
|
||||
messageBytes = maxFrameSize;
|
||||
}
|
||||
if (messageBytes < maxFrameSize) messageBytes = maxFrameSize;
|
||||
final timeoutMs = connector.calculateTimeout(
|
||||
pathLength: pathLengthValue,
|
||||
messageBytes: messageBytes,
|
||||
@@ -312,9 +297,7 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
|
||||
_statusTimeout?.cancel();
|
||||
_statusTimeout = Timer(Duration(milliseconds: timeoutMs), () {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
setState(() => _isLoading = false);
|
||||
showDismissibleSnackBar(
|
||||
context,
|
||||
content: Text(context.l10n.repeater_statusRequestTimeout),
|
||||
@@ -324,10 +307,7 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
|
||||
});
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
|
||||
setState(() => _isLoading = false);
|
||||
showDismissibleSnackBar(
|
||||
context,
|
||||
content: Text(context.l10n.repeater_errorLoadingStatus(e.toString())),
|
||||
@@ -347,214 +327,6 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
|
||||
_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,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
Text(
|
||||
repeater.name,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.normal,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
centerTitle: false,
|
||||
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: 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: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
value,
|
||||
style: const TextStyle(fontWeight: FontWeight.w400),
|
||||
textAlign: TextAlign.end,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
int? _asInt(dynamic value) {
|
||||
if (value == null) return null;
|
||||
if (value is int) return value;
|
||||
@@ -661,4 +433,222 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
|
||||
if (snr == null) return '—';
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
+138
-74
@@ -1,5 +1,6 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import '../utils/platform_info.dart';
|
||||
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
@@ -7,10 +8,12 @@ import 'package:provider/provider.dart';
|
||||
import '../connector/meshcore_connector.dart';
|
||||
import '../l10n/l10n.dart';
|
||||
import '../services/linux_ble_error_classifier.dart';
|
||||
import '../theme/mesh_theme.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../widgets/adaptive_app_bar_title.dart';
|
||||
import '../widgets/device_tile.dart';
|
||||
import '../widgets/empty_state.dart';
|
||||
import '../widgets/mesh_ui.dart';
|
||||
import '../helpers/snack_bar_builder.dart';
|
||||
import 'channels_screen.dart';
|
||||
import 'tcp_screen.dart';
|
||||
@@ -136,12 +139,21 @@ class _ScannerScreenState extends State<ScannerScreen> {
|
||||
builder: (context, connector, child) {
|
||||
return Column(
|
||||
children: [
|
||||
// Bluetooth off warning
|
||||
if (_bluetoothState == BluetoothAdapterState.off)
|
||||
_bluetoothOffWarning(context),
|
||||
// Bluetooth off warning — slides in/out with AnimatedSize
|
||||
AnimatedSize(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
curve: Curves.easeInOut,
|
||||
child: _bluetoothState == BluetoothAdapterState.off
|
||||
? _BluetoothOffBanner(
|
||||
onEnable: PlatformInfo.isAndroid
|
||||
? () => FlutterBluePlus.turnOn()
|
||||
: null,
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
|
||||
// Status bar
|
||||
_buildStatusBar(context, connector),
|
||||
// Connection status header
|
||||
_ConnectionStatusHeader(connector: connector),
|
||||
|
||||
// Device list
|
||||
Expanded(child: _buildDeviceList(context, connector)),
|
||||
@@ -158,14 +170,31 @@ class _ScannerScreenState extends State<ScannerScreen> {
|
||||
|
||||
return FloatingActionButton.extended(
|
||||
heroTag: 'scanner_ble_action',
|
||||
onPressed: isBluetoothOff ? null : () => _toggleScan(connector),
|
||||
icon: isScanning
|
||||
? const SizedBox(
|
||||
onPressed: isBluetoothOff
|
||||
? null
|
||||
: () {
|
||||
HapticFeedback.lightImpact();
|
||||
_toggleScan(connector);
|
||||
},
|
||||
icon: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 220),
|
||||
transitionBuilder: (child, anim) =>
|
||||
ScaleTransition(scale: anim, child: child),
|
||||
child: isScanning
|
||||
? SizedBox(
|
||||
key: const ValueKey('scanning'),
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Theme.of(context).colorScheme.onPrimary,
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.bluetooth_searching),
|
||||
: const Icon(
|
||||
Icons.bluetooth_searching,
|
||||
key: ValueKey('idle'),
|
||||
),
|
||||
),
|
||||
label: Text(
|
||||
isScanning
|
||||
? context.l10n.scanner_stop
|
||||
@@ -178,6 +207,15 @@ class _ScannerScreenState extends State<ScannerScreen> {
|
||||
}
|
||||
|
||||
void _toggleScan(MeshCoreConnector connector) {
|
||||
if (PlatformInfo.isWeb) {
|
||||
// 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.
|
||||
showDismissibleSnackBar(
|
||||
context,
|
||||
content: Text(context.l10n.scanner_bluetoothWebUnsupported),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (connector.state == MeshCoreConnectionState.scanning) {
|
||||
connector.stopScan();
|
||||
} else {
|
||||
@@ -189,51 +227,6 @@ class _ScannerScreenState extends State<ScannerScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildStatusBar(BuildContext context, MeshCoreConnector connector) {
|
||||
String statusText;
|
||||
Color statusColor;
|
||||
|
||||
final l10n = context.l10n;
|
||||
switch (connector.state) {
|
||||
case MeshCoreConnectionState.scanning:
|
||||
statusText = l10n.scanner_scanning;
|
||||
statusColor = Colors.blue;
|
||||
break;
|
||||
case MeshCoreConnectionState.connecting:
|
||||
statusText = l10n.scanner_connecting;
|
||||
statusColor = Colors.orange;
|
||||
break;
|
||||
case MeshCoreConnectionState.connected:
|
||||
statusText = l10n.scanner_connectedTo(connector.deviceDisplayName);
|
||||
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) {
|
||||
if (connector.scanResults.isEmpty) {
|
||||
final isBluetoothOff = _bluetoothState == BluetoothAdapterState.off;
|
||||
@@ -251,7 +244,10 @@ class _ScannerScreenState extends State<ScannerScreen> {
|
||||
action: (isBluetoothOff || isScanning)
|
||||
? null
|
||||
: FilledButton.icon(
|
||||
onPressed: () => _toggleScan(connector),
|
||||
onPressed: () {
|
||||
HapticFeedback.lightImpact();
|
||||
_toggleScan(connector);
|
||||
},
|
||||
icon: const Icon(Icons.bluetooth_searching),
|
||||
label: Text(context.l10n.scanner_scan),
|
||||
),
|
||||
@@ -259,19 +255,21 @@ class _ScannerScreenState extends State<ScannerScreen> {
|
||||
}
|
||||
|
||||
final isConnecting = connector.state == MeshCoreConnectionState.connecting;
|
||||
return ListView.separated(
|
||||
padding: const EdgeInsets.all(8),
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.fromLTRB(0, 8, 0, 96),
|
||||
itemCount: connector.scanResults.length,
|
||||
separatorBuilder: (context, index) => const Divider(),
|
||||
itemBuilder: (context, index) {
|
||||
final result = connector.scanResults[index];
|
||||
final deviceId = result.device.remoteId.toString();
|
||||
return DeviceTile(
|
||||
return ListEntrance(
|
||||
index: index,
|
||||
child: DeviceTile(
|
||||
scanResult: result,
|
||||
isConnecting: isConnecting && _connectingDeviceId == deviceId,
|
||||
onTap: isConnecting
|
||||
? null
|
||||
: () => _connectToDevice(context, connector, result),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -413,46 +411,112 @@ class _ScannerScreenState extends State<ScannerScreen> {
|
||||
);
|
||||
return pin;
|
||||
}
|
||||
}
|
||||
|
||||
Widget _bluetoothOffWarning(BuildContext context) {
|
||||
final errorColor = Theme.of(context).colorScheme.error;
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
|
||||
color: errorColor.withValues(alpha: 0.15),
|
||||
// ── Private sub-widgets ────────────────────────────────────────────────────
|
||||
|
||||
/// Bluetooth-off warning banner — styled as an alert MeshCard.
|
||||
class _BluetoothOffBanner extends StatelessWidget {
|
||||
final VoidCallback? onEnable;
|
||||
|
||||
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(
|
||||
children: [
|
||||
Icon(Icons.bluetooth_disabled, size: 24, color: errorColor),
|
||||
const SizedBox(width: 12),
|
||||
Icon(Icons.bluetooth_disabled, size: 20, color: scheme.error),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
context.l10n.scanner_bluetoothOff,
|
||||
style: TextStyle(
|
||||
color: errorColor,
|
||||
color: scheme.error,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 14,
|
||||
fontSize: 13.5,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
context.l10n.scanner_bluetoothOffMessage,
|
||||
style: TextStyle(
|
||||
color: errorColor.withValues(alpha: 0.85),
|
||||
color: scheme.error.withValues(alpha: 0.8),
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (PlatformInfo.isAndroid)
|
||||
if (onEnable != null) ...[
|
||||
const SizedBox(width: 8),
|
||||
TextButton(
|
||||
onPressed: () => FlutterBluePlus.turnOn(),
|
||||
onPressed: onEnable,
|
||||
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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
+475
-327
File diff suppressed because it is too large
Load Diff
+98
-56
@@ -1,13 +1,16 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../connector/meshcore_connector.dart';
|
||||
import '../l10n/l10n.dart';
|
||||
import '../services/app_settings_service.dart';
|
||||
import '../theme/mesh_theme.dart';
|
||||
import '../utils/platform_info.dart';
|
||||
import '../widgets/adaptive_app_bar_title.dart';
|
||||
import '../widgets/mesh_ui.dart';
|
||||
import '../helpers/snack_bar_builder.dart';
|
||||
import 'channels_screen.dart';
|
||||
import 'usb_screen.dart';
|
||||
@@ -95,15 +98,32 @@ class _TcpScreenState extends State<TcpScreen> {
|
||||
final isConnecting =
|
||||
connector.state == MeshCoreConnectionState.connecting &&
|
||||
connector.activeTransport == MeshCoreTransportType.tcp;
|
||||
// A running BLE scan must not block TCP connect: connectTcp() stops
|
||||
// any active scan before connecting, so the only reason to disable
|
||||
// the button is a TCP connect already in flight.
|
||||
final isButtonDisabled = isConnecting;
|
||||
return Column(
|
||||
// Connect is only available from a fully disconnected state —
|
||||
// scanning, connecting, or an active session must settle first.
|
||||
final isButtonDisabled =
|
||||
connector.state != MeshCoreConnectionState.disconnected;
|
||||
return ListView(
|
||||
padding: const EdgeInsets.only(bottom: 32),
|
||||
children: [
|
||||
_buildStatusBar(context, connector),
|
||||
_buildTransportLinks(context),
|
||||
// Status header
|
||||
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),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
@@ -113,7 +133,6 @@ class _TcpScreenState extends State<TcpScreen> {
|
||||
decoration: InputDecoration(
|
||||
labelText: context.l10n.tcpHostLabel,
|
||||
hintText: context.l10n.tcpHostHint,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
enabled: !isConnecting,
|
||||
keyboardType: TextInputType.url,
|
||||
@@ -124,7 +143,6 @@ class _TcpScreenState extends State<TcpScreen> {
|
||||
decoration: InputDecoration(
|
||||
labelText: context.l10n.tcpPortLabel,
|
||||
hintText: context.l10n.tcpPortHint,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
enabled: !isConnecting,
|
||||
keyboardType: TextInputType.number,
|
||||
@@ -132,7 +150,12 @@ class _TcpScreenState extends State<TcpScreen> {
|
||||
const SizedBox(height: 16),
|
||||
FilledButton.icon(
|
||||
key: const Key('tcp_connect_button'),
|
||||
onPressed: isButtonDisabled ? null : _connectTcp,
|
||||
onPressed: isButtonDisabled
|
||||
? null
|
||||
: () {
|
||||
HapticFeedback.lightImpact();
|
||||
_connectTcp();
|
||||
},
|
||||
icon: isConnecting
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
@@ -151,6 +174,39 @@ 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
@@ -159,6 +215,38 @@ class _TcpScreenState extends State<TcpScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatusChip(BuildContext context, MeshCoreConnector connector) {
|
||||
final l10n = context.l10n;
|
||||
|
||||
if (connector.isTcpTransportConnected) {
|
||||
return StatusChip(
|
||||
label: l10n.scanner_connectedTo(connector.activeTcpEndpoint ?? 'TCP'),
|
||||
color: MeshPalette.signal,
|
||||
);
|
||||
} else if (connector.state == MeshCoreConnectionState.connecting &&
|
||||
connector.activeTransport == MeshCoreTransportType.tcp) {
|
||||
return StatusChip(
|
||||
label: l10n.tcpStatus_connectingTo(
|
||||
'${_hostController.text}:${_portController.text}',
|
||||
),
|
||||
color: MeshPalette.warn,
|
||||
pulse: true,
|
||||
);
|
||||
} else if (connector.state == MeshCoreConnectionState.disconnecting &&
|
||||
connector.activeTransport == MeshCoreTransportType.tcp) {
|
||||
return StatusChip(
|
||||
label: l10n.scanner_disconnecting,
|
||||
color: MeshPalette.warn,
|
||||
pulse: true,
|
||||
);
|
||||
} else {
|
||||
return StatusChip(
|
||||
label: l10n.tcpStatus_notConnected,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildTransportLinks(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
@@ -186,52 +274,6 @@ class _TcpScreenState extends State<TcpScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatusBar(BuildContext context, MeshCoreConnector connector) {
|
||||
final l10n = context.l10n;
|
||||
String statusText;
|
||||
Color statusColor;
|
||||
|
||||
if (connector.isTcpTransportConnected) {
|
||||
statusText = l10n.scanner_connectedTo(
|
||||
connector.activeTcpEndpoint ?? 'TCP',
|
||||
);
|
||||
statusColor = Colors.green;
|
||||
} else if (connector.state == MeshCoreConnectionState.connecting &&
|
||||
connector.activeTransport == MeshCoreTransportType.tcp) {
|
||||
statusText = l10n.tcpStatus_connectingTo(
|
||||
'${_hostController.text}:${_portController.text}',
|
||||
);
|
||||
statusColor = Colors.orange;
|
||||
} else if (connector.state == MeshCoreConnectionState.disconnecting &&
|
||||
connector.activeTransport == MeshCoreTransportType.tcp) {
|
||||
statusText = l10n.scanner_disconnecting;
|
||||
statusColor = Colors.orange;
|
||||
} else {
|
||||
statusText = l10n.tcpStatus_notConnected;
|
||||
statusColor = Theme.of(context).colorScheme.onSurfaceVariant;
|
||||
}
|
||||
|
||||
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),
|
||||
Expanded(
|
||||
child: Text(
|
||||
statusText,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: statusColor, fontWeight: FontWeight.w500),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _connectTcp() async {
|
||||
if (_connector.state == MeshCoreConnectionState.connecting ||
|
||||
_connector.state == MeshCoreConnectionState.connected ||
|
||||
|
||||
@@ -19,6 +19,8 @@ import '../utils/battery_utils.dart';
|
||||
import '../helpers/snack_bar_builder.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 {
|
||||
final Contact contact;
|
||||
@@ -319,6 +321,7 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final connector = context.watch<MeshCoreConnector>();
|
||||
final settings = context.watch<AppSettingsService>().settings;
|
||||
final isImperialUnits = settings.unitSystem == UnitSystem.imperial;
|
||||
@@ -387,7 +390,7 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
|
||||
l10n.telemetry_noData,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -415,34 +418,21 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
|
||||
int channel,
|
||||
bool isImperialUnits,
|
||||
) {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SectionHeader(title, padding: const EdgeInsets.fromLTRB(16, 16, 16, 8)),
|
||||
MeshCard(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.info_outline,
|
||||
color: Theme.of(context).textTheme.headlineSmall?.color,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Divider(),
|
||||
for (final entry in channelData.entries)
|
||||
_buildTelemetryField(entry, channel, isImperialUnits),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -601,29 +591,18 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
|
||||
final l10n = context.l10n;
|
||||
final counterText = _autoRefreshCounterText();
|
||||
|
||||
return Card(
|
||||
child: Padding(
|
||||
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: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.autorenew,
|
||||
color: Theme.of(context).textTheme.headlineSmall?.color,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
l10n.common_autoRefresh,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Divider(),
|
||||
_buildAutoRefreshNumberField(
|
||||
controller: _autoRefreshIntervalController,
|
||||
label: l10n.common_interval,
|
||||
@@ -672,7 +651,9 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Theme.of(context).colorScheme.onPrimary,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -684,6 +665,7 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -913,6 +895,7 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
|
||||
}
|
||||
|
||||
Widget _buildInfoRow(String label, String value) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Row(
|
||||
@@ -922,7 +905,8 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
color: scheme.onSurfaceVariant,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
@@ -930,7 +914,7 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
value,
|
||||
style: const TextStyle(fontWeight: FontWeight.w400),
|
||||
style: MeshTheme.mono(fontSize: 13, color: scheme.onSurface),
|
||||
textAlign: TextAlign.end,
|
||||
),
|
||||
],
|
||||
|
||||
+125
-103
@@ -6,10 +6,13 @@ import 'package:provider/provider.dart';
|
||||
|
||||
import '../connector/meshcore_connector.dart';
|
||||
import '../l10n/l10n.dart';
|
||||
import '../theme/mesh_theme.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../utils/platform_info.dart';
|
||||
import '../utils/usb_port_labels.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 'channels_screen.dart';
|
||||
import 'tcp_screen.dart';
|
||||
@@ -97,9 +100,25 @@ class _UsbScreenState extends State<UsbScreen> {
|
||||
child: Consumer<MeshCoreConnector>(
|
||||
builder: (context, connector, child) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
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)),
|
||||
],
|
||||
);
|
||||
@@ -132,6 +151,52 @@ class _UsbScreenState extends State<UsbScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatusChip(BuildContext context, MeshCoreConnector connector) {
|
||||
final l10n = context.l10n;
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
|
||||
if (_isLoadingPorts) {
|
||||
return StatusChip(
|
||||
label: l10n.usbStatus_searching,
|
||||
color: scheme.primary,
|
||||
pulse: true,
|
||||
);
|
||||
} else if (connector.isUsbTransportConnected) {
|
||||
switch (connector.state) {
|
||||
case MeshCoreConnectionState.connected:
|
||||
return StatusChip(
|
||||
label: l10n.scanner_connectedTo(
|
||||
connector.activeUsbPortDisplayLabel ?? 'USB',
|
||||
),
|
||||
color: MeshPalette.signal,
|
||||
);
|
||||
case MeshCoreConnectionState.disconnecting:
|
||||
return StatusChip(
|
||||
label: l10n.scanner_disconnecting,
|
||||
color: MeshPalette.warn,
|
||||
pulse: true,
|
||||
);
|
||||
default:
|
||||
return StatusChip(
|
||||
label: l10n.usbStatus_notConnected,
|
||||
color: scheme.onSurfaceVariant,
|
||||
);
|
||||
}
|
||||
} else if (connector.state == MeshCoreConnectionState.connecting &&
|
||||
connector.activeTransport == MeshCoreTransportType.usb) {
|
||||
return StatusChip(
|
||||
label: l10n.usbStatus_connecting,
|
||||
color: MeshPalette.warn,
|
||||
pulse: true,
|
||||
);
|
||||
} else {
|
||||
return StatusChip(
|
||||
label: l10n.usbStatus_notConnected,
|
||||
color: scheme.onSurfaceVariant,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildTransportLinks(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
@@ -159,116 +224,24 @@ class _UsbScreenState extends State<UsbScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatusBar(BuildContext context, MeshCoreConnector connector) {
|
||||
final l10n = context.l10n;
|
||||
String statusText;
|
||||
Color statusColor;
|
||||
|
||||
if (_isLoadingPorts) {
|
||||
statusText = l10n.usbStatus_searching;
|
||||
statusColor = Theme.of(context).colorScheme.primary;
|
||||
} else if (connector.isUsbTransportConnected) {
|
||||
switch (connector.state) {
|
||||
case MeshCoreConnectionState.connected:
|
||||
statusText = l10n.scanner_connectedTo(
|
||||
connector.activeUsbPortDisplayLabel ?? 'USB',
|
||||
);
|
||||
statusColor = Colors.green;
|
||||
case MeshCoreConnectionState.disconnecting:
|
||||
statusText = l10n.scanner_disconnecting;
|
||||
statusColor = Colors.orange;
|
||||
default:
|
||||
statusText = l10n.usbStatus_notConnected;
|
||||
statusColor = Theme.of(context).colorScheme.onSurfaceVariant;
|
||||
}
|
||||
} else if (connector.state == MeshCoreConnectionState.connecting &&
|
||||
connector.activeTransport == MeshCoreTransportType.usb) {
|
||||
statusText = l10n.usbStatus_connecting;
|
||||
statusColor = Colors.orange;
|
||||
} else {
|
||||
statusText = l10n.usbStatus_notConnected;
|
||||
statusColor = Theme.of(context).colorScheme.onSurfaceVariant;
|
||||
}
|
||||
|
||||
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),
|
||||
Expanded(
|
||||
child: Text(
|
||||
statusText,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: statusColor, fontWeight: FontWeight.w500),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPortList(BuildContext context, MeshCoreConnector connector) {
|
||||
final l10n = context.l10n;
|
||||
|
||||
if (_isLoadingPorts) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.usb,
|
||||
size: 64,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
l10n.usbStatus_searching,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
return EmptyState(icon: Icons.usb, title: l10n.usbStatus_searching);
|
||||
}
|
||||
|
||||
if (_ports.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.usb,
|
||||
size: 64,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
l10n.usbScreenEmptyState,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
return EmptyState(icon: Icons.usb, title: l10n.usbScreenEmptyState);
|
||||
}
|
||||
|
||||
final isConnecting =
|
||||
connector.state == MeshCoreConnectionState.connecting &&
|
||||
connector.activeTransport == MeshCoreTransportType.usb;
|
||||
|
||||
return ListView.separated(
|
||||
padding: const EdgeInsets.all(8),
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.only(bottom: 32),
|
||||
itemCount: _ports.length,
|
||||
separatorBuilder: (context, index) => const Divider(),
|
||||
itemBuilder: (context, index) {
|
||||
final port = _ports[index];
|
||||
final displayName = friendlyUsbPortName(port);
|
||||
@@ -276,15 +249,50 @@ class _UsbScreenState extends State<UsbScreen> {
|
||||
final showRawName =
|
||||
rawName != displayName && !rawName.startsWith('web:');
|
||||
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.usb),
|
||||
return ListEntrance(
|
||||
index: index,
|
||||
child: MeshCard(
|
||||
padding: EdgeInsets.zero,
|
||||
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: const TextStyle(fontWeight: FontWeight.w500),
|
||||
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: const Icon(Icons.chevron_right),
|
||||
onTap: isConnecting ? null : () => _connectPort(port),
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -370,6 +378,10 @@ class _UsbScreenState extends State<UsbScreen> {
|
||||
|
||||
void _showError(Object error) {
|
||||
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(
|
||||
context,
|
||||
content: Text(_friendlyErrorMessage(error)),
|
||||
@@ -377,6 +389,16 @@ class _UsbScreenState extends State<UsbScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
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) {
|
||||
final l10n = context.l10n;
|
||||
|
||||
|
||||
@@ -39,7 +39,12 @@ class RetryServiceConfig {
|
||||
final void Function(Message) updateMessage;
|
||||
final Function(Contact)? clearContactPath;
|
||||
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;
|
||||
final Uint8List? Function()? getSelfPublicKey;
|
||||
final String Function(Contact, String)? prepareContactOutboundText;
|
||||
@@ -74,6 +79,12 @@ class RetryServiceConfig {
|
||||
|
||||
class MessageRetryService extends ChangeNotifier {
|
||||
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 get maxRetries => _maxRetries;
|
||||
|
||||
@@ -170,8 +181,9 @@ class MessageRetryService extends ChangeNotifier {
|
||||
|
||||
_config?.addMessage(contact.publicKeyHex, message);
|
||||
|
||||
// Queue per contact — only one message in-flight at a time to avoid
|
||||
// overflowing the firmware's 8-entry expected_ack_table.
|
||||
// Queue per contact — one message in-flight per contact at a time, and
|
||||
// bounded globally by _maxGlobalInFlight across all contacts so we never
|
||||
// overflow the firmware's 8-entry global expected_ack_table.
|
||||
final contactKey = contact.publicKeyHex;
|
||||
_sendQueue[contactKey] ??= [];
|
||||
_sendQueue[contactKey]!.add(messageId);
|
||||
@@ -184,6 +196,11 @@ class MessageRetryService extends ChangeNotifier {
|
||||
}
|
||||
|
||||
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];
|
||||
if (queue == null) return;
|
||||
|
||||
@@ -210,8 +227,23 @@ class MessageRetryService extends ChangeNotifier {
|
||||
void _onMessageResolved(String messageId, String contactKey) {
|
||||
if (_resolvedMessages.contains(messageId)) return;
|
||||
_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);
|
||||
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) {
|
||||
@@ -352,6 +384,10 @@ class MessageRetryService extends ChangeNotifier {
|
||||
}
|
||||
|
||||
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;
|
||||
if (config == null) return false;
|
||||
|
||||
@@ -404,13 +440,18 @@ class MessageRetryService extends ChangeNotifier {
|
||||
|
||||
// Calculate timeout: prefer ML prediction, then device-provided, then physics fallback
|
||||
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;
|
||||
if (config.calculateTimeout != null) {
|
||||
actualTimeout = config.calculateTimeout!(
|
||||
pathLengthValue,
|
||||
message.text.length,
|
||||
messageBytesForTimeout,
|
||||
contactKey: contact.publicKeyHex,
|
||||
deviceTimeoutMs: timeoutMs > 0 ? timeoutMs : null,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -460,11 +501,17 @@ class MessageRetryService extends ChangeNotifier {
|
||||
(_, mapping) => mapping.messageId == messageId,
|
||||
);
|
||||
_expectedHashToMessageId.removeWhere((_, msgId) => msgId == messageId);
|
||||
final contactKey = _pendingContacts[messageId]?.publicKeyHex;
|
||||
_pendingMessages.remove(messageId);
|
||||
_pendingContacts.remove(messageId);
|
||||
_attemptPathHistory.remove(messageId);
|
||||
_timeoutTimers.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) {
|
||||
@@ -617,7 +664,6 @@ class MessageRetryService extends ChangeNotifier {
|
||||
for (final expectedHash in expectedHashes) {
|
||||
if (expectedHash == ackHash) {
|
||||
matchedMessageId = messageId;
|
||||
matchedAttemptIndex = expectedHashes.indexOf(expectedHash);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -669,10 +715,16 @@ class MessageRetryService extends ChangeNotifier {
|
||||
if (config?.onDeliveryObserved != null &&
|
||||
tripTimeMs > 0 &&
|
||||
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,
|
||||
message.pathLength!,
|
||||
message.text.length,
|
||||
messageBytesForObserved,
|
||||
tripTimeMs,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -119,6 +119,36 @@ class NotificationService {
|
||||
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 {
|
||||
if (!_isInitialized) {
|
||||
await initialize();
|
||||
@@ -131,7 +161,8 @@ class NotificationService {
|
||||
>();
|
||||
if (androidPlugin != null) {
|
||||
final granted = await androidPlugin.requestNotificationsPermission();
|
||||
return granted ?? false;
|
||||
_canNotify = granted ?? false;
|
||||
return _canNotify!;
|
||||
}
|
||||
|
||||
// iOS permissions are requested during initialization
|
||||
@@ -145,7 +176,8 @@ class NotificationService {
|
||||
badge: true,
|
||||
sound: true,
|
||||
);
|
||||
return granted ?? false;
|
||||
_canNotify = granted ?? false;
|
||||
return _canNotify!;
|
||||
}
|
||||
|
||||
return true;
|
||||
@@ -170,7 +202,7 @@ class NotificationService {
|
||||
String? contactId,
|
||||
int? badgeCount,
|
||||
}) async {
|
||||
if (!await _ensureInitialized()) return;
|
||||
if (!await _ensureCanNotify()) return;
|
||||
|
||||
final androidDetails = AndroidNotificationDetails(
|
||||
'messages',
|
||||
@@ -220,7 +252,7 @@ class NotificationService {
|
||||
required String contactType,
|
||||
String? contactId,
|
||||
}) async {
|
||||
if (!await _ensureInitialized()) return;
|
||||
if (!await _ensureCanNotify()) return;
|
||||
|
||||
const androidDetails = AndroidNotificationDetails(
|
||||
'adverts',
|
||||
@@ -270,7 +302,7 @@ class NotificationService {
|
||||
int? channelIndex,
|
||||
int? badgeCount,
|
||||
}) async {
|
||||
if (!await _ensureInitialized()) return;
|
||||
if (!await _ensureCanNotify()) return;
|
||||
|
||||
final androidDetails = AndroidNotificationDetails(
|
||||
'channel_messages',
|
||||
@@ -550,7 +582,7 @@ class NotificationService {
|
||||
}
|
||||
|
||||
Future<void> _showBatchSummary(List<_PendingNotification> batch) async {
|
||||
if (!await _ensureInitialized()) return;
|
||||
if (!await _ensureCanNotify()) return;
|
||||
|
||||
// Group by type
|
||||
final messages = batch
|
||||
|
||||
@@ -134,10 +134,12 @@ class PathHistoryService extends ChangeNotifier {
|
||||
newWeight = (currentWeight + successIncrement).clamp(0.0, maxWeight);
|
||||
} else {
|
||||
newWeight = currentWeight - failureDecrement;
|
||||
if (newWeight <= 0) {
|
||||
if (newWeight <= 0 && failureCount >= 3) {
|
||||
removePathRecord(contactPubKeyHex, selection.pathBytes);
|
||||
return;
|
||||
}
|
||||
// Keep the record with a small floor weight until we have enough evidence
|
||||
newWeight = newWeight.clamp(0.1, maxWeight);
|
||||
}
|
||||
|
||||
_addPathRecord(
|
||||
|
||||
@@ -63,12 +63,15 @@ class TimeoutPredictionService extends ChangeNotifier {
|
||||
required int tripTimeMs,
|
||||
int secondsSinceLastRx = 0,
|
||||
}) {
|
||||
final isFlood = pathLength < 0;
|
||||
final observation = DeliveryObservation(
|
||||
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,
|
||||
secondsSinceLastRx: secondsSinceLastRx,
|
||||
isFlood: pathLength < 0,
|
||||
isFlood: isFlood,
|
||||
deliveryMs: tripTimeMs,
|
||||
timestamp: DateTime.now(),
|
||||
);
|
||||
@@ -76,10 +79,11 @@ class TimeoutPredictionService extends ChangeNotifier {
|
||||
_observations.add(observation);
|
||||
if (_observations.length > maxObservations) {
|
||||
_observations.removeAt(0);
|
||||
}
|
||||
|
||||
_rebuildContactStats();
|
||||
} else {
|
||||
_contactStats.putIfAbsent(contactKey, () => _ContactStats());
|
||||
_contactStats[contactKey]!.add(tripTimeMs.toDouble());
|
||||
}
|
||||
|
||||
_observationsSinceLastTrain++;
|
||||
if (_observationsSinceLastTrain >= _retrainInterval &&
|
||||
@@ -108,11 +112,14 @@ class TimeoutPredictionService extends ChangeNotifier {
|
||||
try {
|
||||
if (_activeFeatures.isEmpty) return null;
|
||||
|
||||
final flood = pathLength < 0;
|
||||
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(),
|
||||
'secSinceRx': secondsSinceLastRx.toDouble(),
|
||||
'isFlood': pathLength < 0 ? 1.0 : 0.0,
|
||||
'isFlood': flood ? 1.0 : 0.0,
|
||||
};
|
||||
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)
|
||||
final allNames = ['pathLength', 'messageBytes', 'secSinceRx', 'isFlood'];
|
||||
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.secondsSinceLastRx.toDouble(),
|
||||
(o) => o.isFlood ? 1.0 : 0.0,
|
||||
@@ -215,6 +224,9 @@ class TimeoutPredictionService extends ChangeNotifier {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
if (_persistTimer?.isActive == true) {
|
||||
_storage?.saveDeliveryObservations(_observations);
|
||||
}
|
||||
_persistTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -33,12 +33,14 @@ class UsbSerialService {
|
||||
String? _connectedPortLabel;
|
||||
FlSerial? _serial;
|
||||
AppDebugLogService? _debugLogService;
|
||||
Object? _lastError;
|
||||
|
||||
UsbSerialStatus get status => _status;
|
||||
String? get activePortKey => _connectedPortKey;
|
||||
String? get activePortDisplayLabel =>
|
||||
_connectedPortLabel ?? _connectedPortKey;
|
||||
Stream<Uint8List> get frameStream => _frameController.stream;
|
||||
Object? get lastError => _lastError;
|
||||
bool get _useAndroidUsbHost =>
|
||||
!kIsWeb && defaultTargetPlatform == TargetPlatform.android;
|
||||
bool get _useDesktopFlSerial =>
|
||||
@@ -434,6 +436,7 @@ class UsbSerialService {
|
||||
}
|
||||
|
||||
void _addFrameError(Object error, [StackTrace? stackTrace]) {
|
||||
_lastError = error;
|
||||
if (_frameController.isClosed) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,18 @@ class UsbSerialService {
|
||||
static const Map<String, String> _knownUsbNames = <String, String>{
|
||||
'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> _baseLabelsByPortKey = <String, String>{};
|
||||
static final Map<String, JSObject> _authorizedPortsByKey =
|
||||
@@ -34,12 +46,14 @@ class UsbSerialService {
|
||||
String _requestPortLabel = 'Choose USB Device';
|
||||
String _fallbackDeviceName = 'Web Serial Device';
|
||||
AppDebugLogService? _debugLogService;
|
||||
Object? _lastError;
|
||||
|
||||
UsbSerialStatus get status => _status;
|
||||
String? get activePortKey => _connectedPortKey;
|
||||
String? get activePortDisplayLabel => _connectedPortName ?? _connectedPortKey;
|
||||
Stream<Uint8List> get frameStream => _frameController.stream;
|
||||
bool get isConnected => _status == UsbSerialStatus.connected;
|
||||
Object? get lastError => _lastError;
|
||||
|
||||
JSObject get _navigator => JSObject.fromInteropObject(web.window.navigator);
|
||||
bool get _isSupported => _navigator.has('serial');
|
||||
@@ -74,6 +88,7 @@ class UsbSerialService {
|
||||
}
|
||||
|
||||
_status = UsbSerialStatus.connecting;
|
||||
_lastError = null;
|
||||
_frameDecoder.reset();
|
||||
|
||||
try {
|
||||
@@ -282,7 +297,20 @@ class UsbSerialService {
|
||||
..['flowControl'] = 'none'.toJS;
|
||||
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
|
||||
// avoid the auto-reset circuit firing on open. Native-USB-CDC boards
|
||||
// (e.g. nRF52840/Adafruit) tie DTR to the reset line — toggling it there
|
||||
// re-enumerates the device and Web Serial reports "The device has been
|
||||
// lost". Leave their signals untouched.
|
||||
final vendorId = _portInfo(port)?.usbVendorId;
|
||||
final isUartBridge =
|
||||
vendorId != null && _uartBridgeVendorIds.contains(vendorId);
|
||||
_debugLogService?.info(
|
||||
'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
|
||||
@@ -294,6 +322,7 @@ class UsbSerialService {
|
||||
// setSignals may not be supported on all browsers/devices.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _cleanupFailedConnect() async {
|
||||
final reader = _reader;
|
||||
@@ -384,13 +413,21 @@ class UsbSerialService {
|
||||
} catch (error, stackTrace) {
|
||||
_debugLogService?.error('_pumpReads error: $error', tag: 'USB Serial');
|
||||
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);
|
||||
}
|
||||
} finally {
|
||||
_debugLogService?.info('_pumpReads: ended', tag: 'USB Serial');
|
||||
_releaseLock(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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+210
-48
@@ -1,47 +1,48 @@
|
||||
import 'package:flutter/cupertino.dart' show CupertinoPageTransitionsBuilder;
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// MeshCore palette — cool slate dark theme with sky-blue accents.
|
||||
/// MeshCore palette — high-contrast slate surfaces with sky-blue accents.
|
||||
class MeshPalette {
|
||||
MeshPalette._();
|
||||
|
||||
// Surfaces (cool near-black, slate undertone)
|
||||
static const bg = Color(0xFF101417);
|
||||
static const bg1 = Color(0xFF161B1F);
|
||||
static const bg2 = Color(0xFF1D242A);
|
||||
static const bg3 = Color(0xFF28313A);
|
||||
static const bg4 = Color(0xFF344049);
|
||||
// Surfaces shared with the map overlays and navigation.
|
||||
static const bg = Color(0xFF0B1220);
|
||||
static const bg1 = Color(0xFF0F172A);
|
||||
static const bg2 = Color(0xFF162033);
|
||||
static const bg3 = Color(0xFF1E293B);
|
||||
static const bg4 = Color(0xFF334155);
|
||||
|
||||
// Lines
|
||||
static const line = Color(0xFF222B31);
|
||||
static const line2 = Color(0xFF344049);
|
||||
static const line3 = Color(0xFF485762);
|
||||
static const line = Color(0xFF1E293B);
|
||||
static const line2 = Color(0xFF334155);
|
||||
static const line3 = Color(0xFF475569);
|
||||
|
||||
// Ink
|
||||
static const ink = Color(0xFFE9EEF3);
|
||||
static const ink2 = Color(0xFFB5C0C9);
|
||||
static const ink3 = Color(0xFF7C8A95);
|
||||
static const ink4 = Color(0xFF556470);
|
||||
static const ink = Color(0xFFF8FAFC);
|
||||
static const ink2 = Color(0xFFCBD5E1);
|
||||
static const ink3 = Color(0xFF94A3B8);
|
||||
static const ink4 = Color(0xFF64748B);
|
||||
|
||||
// Signal-quality green (used only for SNR coloring, not UI chrome)
|
||||
static const signal = Color(0xFF7BEFA8);
|
||||
static const signalDim = Color(0xFF4DC580);
|
||||
static const signal = Color(0xFF22C55E);
|
||||
static const signalDim = Color(0xFF16A34A);
|
||||
|
||||
// Warn (ember)
|
||||
static const warn = Color(0xFFFFA552);
|
||||
static const warnDim = Color(0xFFC27E3C);
|
||||
static const warnBg = Color(0x1CFFA552);
|
||||
static const warnLine = Color(0x4DFFA552);
|
||||
// Warn
|
||||
static const warn = Color(0xFFF59E0B);
|
||||
static const warnDim = Color(0xFFD97706);
|
||||
static const warnBg = Color(0x1FF59E0B);
|
||||
static const warnLine = Color(0x66F59E0B);
|
||||
|
||||
// Alert (coral)
|
||||
static const alert = Color(0xFFFF6A5C);
|
||||
static const alertBg = Color(0x1CFF6A5C);
|
||||
static const alertLine = Color(0x52FF6A5C);
|
||||
// Alert
|
||||
static const alert = Color(0xFFEF4444);
|
||||
static const alertBg = Color(0x1FEF4444);
|
||||
static const alertLine = Color(0x66EF4444);
|
||||
|
||||
// Blue (sky) — primary accent
|
||||
static const blue = Color(0xFF7FCBF5);
|
||||
static const blueDim = Color(0xFF4A9CC9);
|
||||
static const blueBg = Color(0x1C7FCBF5);
|
||||
static const blueLine = Color(0x477FCBF5);
|
||||
// Blue — primary map/app accent
|
||||
static const blue = Color(0xFF0EA5E9);
|
||||
static const blueDim = Color(0xFF0284C7);
|
||||
static const blueBg = Color(0x290EA5E9);
|
||||
static const blueLine = Color(0x800EA5E9);
|
||||
|
||||
// Magenta
|
||||
static const magenta = Color(0xFFDE7FDB);
|
||||
@@ -49,9 +50,9 @@ class MeshPalette {
|
||||
static const magentaLine = Color(0x47DE7FDB);
|
||||
|
||||
// Me bubble (dusk blue)
|
||||
static const me = Color(0xFF1B2C3D);
|
||||
static const meBorder = Color(0xFF2C4A66);
|
||||
static const meInk = Color(0xFFDCE9F5);
|
||||
static const me = Color(0xFF0C4A6E);
|
||||
static const meBorder = Color(0xFF0369A1);
|
||||
static const meInk = Color(0xFFF0F9FF);
|
||||
|
||||
// ── Light variant (used when user explicitly picks light theme)
|
||||
static const lightBg = Color(0xFFF4F6F8);
|
||||
@@ -64,6 +65,51 @@ class MeshPalette {
|
||||
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
|
||||
/// family isn't installed, keeping things working without bundled assets.
|
||||
class MeshFonts {
|
||||
@@ -72,6 +118,7 @@ class MeshFonts {
|
||||
static const sans = 'Inter';
|
||||
static const mono = 'JetBrains Mono';
|
||||
static const display = 'Instrument Serif';
|
||||
static const emoji = 'Noto Color Emoji';
|
||||
|
||||
static const List<String> sansFallback = [
|
||||
'system-ui',
|
||||
@@ -93,6 +140,11 @@ class MeshFonts {
|
||||
'Times New Roman',
|
||||
'serif',
|
||||
];
|
||||
static const List<String> emojiFallback = [
|
||||
'Apple Color Emoji',
|
||||
'Segoe UI Emoji',
|
||||
'Noto Emoji',
|
||||
];
|
||||
}
|
||||
|
||||
/// Radii used consistently across the app.
|
||||
@@ -113,21 +165,21 @@ class MeshTheme {
|
||||
static ThemeData dark() {
|
||||
const scheme = ColorScheme.dark(
|
||||
primary: MeshPalette.blue,
|
||||
onPrimary: Color(0xFF0A1A26),
|
||||
primaryContainer: MeshPalette.blueBg,
|
||||
onPrimaryContainer: MeshPalette.blue,
|
||||
onPrimary: Colors.white,
|
||||
primaryContainer: Color(0xFF075985),
|
||||
onPrimaryContainer: Colors.white,
|
||||
secondary: MeshPalette.magenta,
|
||||
onSecondary: Color(0xFF201020),
|
||||
onSecondary: Colors.white,
|
||||
secondaryContainer: Color(0xFF331A33),
|
||||
onSecondaryContainer: MeshPalette.magenta,
|
||||
onSecondaryContainer: Colors.white,
|
||||
tertiary: MeshPalette.warn,
|
||||
onTertiary: Color(0xFF1F1206),
|
||||
tertiaryContainer: Color(0xFF3A2710),
|
||||
onTertiaryContainer: Color(0xFFFFC58A),
|
||||
onTertiary: Color(0xFF0B1220),
|
||||
tertiaryContainer: Color(0xFF78350F),
|
||||
onTertiaryContainer: Colors.white,
|
||||
error: MeshPalette.alert,
|
||||
onError: Color(0xFF1A0A08),
|
||||
errorContainer: MeshPalette.alertBg,
|
||||
onErrorContainer: MeshPalette.alert,
|
||||
onError: Colors.white,
|
||||
errorContainer: Color(0xFF7F1D1D),
|
||||
onErrorContainer: Colors.white,
|
||||
surface: MeshPalette.bg,
|
||||
onSurface: MeshPalette.ink,
|
||||
surfaceContainerLowest: MeshPalette.bg,
|
||||
@@ -334,9 +386,9 @@ class MeshTheme {
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
),
|
||||
navigationBarTheme: NavigationBarThemeData(
|
||||
backgroundColor: scheme.surfaceContainerLow,
|
||||
backgroundColor: scheme.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
indicatorColor: scheme.primary.withValues(alpha: 0.14),
|
||||
indicatorColor: scheme.primary,
|
||||
indicatorShape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(MeshRadii.md),
|
||||
),
|
||||
@@ -348,13 +400,13 @@ class MeshTheme {
|
||||
fontSize: 10,
|
||||
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
|
||||
letterSpacing: 0.1,
|
||||
color: selected ? scheme.primary : scheme.onSurfaceVariant,
|
||||
color: selected ? scheme.onPrimary : scheme.onSurfaceVariant,
|
||||
);
|
||||
}),
|
||||
iconTheme: WidgetStateProperty.resolveWith((states) {
|
||||
final selected = states.contains(WidgetState.selected);
|
||||
return IconThemeData(
|
||||
color: selected ? scheme.primary : scheme.onSurfaceVariant,
|
||||
color: selected ? scheme.onPrimary : scheme.onSurfaceVariant,
|
||||
size: 22,
|
||||
);
|
||||
}),
|
||||
@@ -393,6 +445,106 @@ class MeshTheme {
|
||||
),
|
||||
iconTheme: IconThemeData(color: scheme.onSurfaceVariant, size: 22),
|
||||
splashFactory: InkSparkle.splashFactory,
|
||||
pageTransitionsTheme: const PageTransitionsTheme(
|
||||
builders: {
|
||||
TargetPlatform.android: FadeForwardsPageTransitionsBuilder(),
|
||||
TargetPlatform.iOS: CupertinoPageTransitionsBuilder(),
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -443,6 +595,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.
|
||||
static Color snrColor(num? snr, {required bool blocked}) {
|
||||
if (blocked) return MeshPalette.alert;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../connector/meshcore_connector.dart';
|
||||
import '../theme/mesh_theme.dart';
|
||||
|
||||
class BatteryUi {
|
||||
final IconData icon;
|
||||
@@ -10,19 +11,19 @@ class BatteryUi {
|
||||
|
||||
BatteryUi batteryUiForPercent(int? percent) {
|
||||
if (percent == null) {
|
||||
return const BatteryUi(Icons.battery_unknown, Colors.grey);
|
||||
return const BatteryUi(Icons.battery_unknown, null);
|
||||
}
|
||||
|
||||
final p = percent.clamp(0, 100);
|
||||
|
||||
return switch (p) {
|
||||
<= 5 => const BatteryUi(Icons.battery_alert, Colors.redAccent),
|
||||
<= 15 => const BatteryUi(Icons.battery_0_bar, Colors.redAccent),
|
||||
<= 30 => const BatteryUi(Icons.battery_1_bar, Colors.orange),
|
||||
<= 45 => const BatteryUi(Icons.battery_2_bar, Colors.amber),
|
||||
<= 60 => const BatteryUi(Icons.battery_3_bar, Colors.lightGreen),
|
||||
<= 80 => const BatteryUi(Icons.battery_5_bar, Colors.green),
|
||||
_ => const BatteryUi(Icons.battery_full, Colors.green),
|
||||
<= 5 => const BatteryUi(Icons.battery_alert, MeshPalette.alert),
|
||||
<= 15 => const BatteryUi(Icons.battery_0_bar, MeshPalette.alert),
|
||||
<= 30 => const BatteryUi(Icons.battery_1_bar, MeshPalette.warn),
|
||||
<= 45 => const BatteryUi(Icons.battery_2_bar, MeshPalette.warn),
|
||||
<= 60 => const BatteryUi(Icons.battery_3_bar, null),
|
||||
<= 80 => const BatteryUi(Icons.battery_5_bar, null),
|
||||
_ => const BatteryUi(Icons.battery_full, MeshPalette.signal),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -76,9 +77,9 @@ class _BatteryIndicatorState extends State<BatteryIndicator> {
|
||||
Flexible(
|
||||
child: Text(
|
||||
displayText,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
style: MeshTheme.mono(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: batteryUi.color,
|
||||
),
|
||||
maxLines: 1,
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
|
||||
|
||||
import '../l10n/l10n.dart';
|
||||
import '../theme/mesh_theme.dart';
|
||||
import 'mesh_ui.dart';
|
||||
import 'signal_ui.dart';
|
||||
|
||||
/// A reusable tile widget for displaying a MeshCore device in a list
|
||||
/// A MeshCard-based row for displaying a scanned BLE device.
|
||||
/// Shows an AvatarCircle (router icon, deterministic hue from device name),
|
||||
/// device name, mono MAC address, mono RSSI dBm, and SignalBars on the right.
|
||||
/// While connecting, shows a small progress ring instead of signal bars.
|
||||
class DeviceTile extends StatelessWidget {
|
||||
final ScanResult scanResult;
|
||||
final VoidCallback? onTap;
|
||||
@@ -23,27 +30,12 @@ class DeviceTile extends StatelessWidget {
|
||||
final name = device.platformName.isNotEmpty
|
||||
? device.platformName
|
||||
: scanResult.advertisementData.advName;
|
||||
final displayName = name.isNotEmpty
|
||||
? name
|
||||
: context.l10n.common_unknownDevice;
|
||||
final mac = device.remoteId.toString();
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
|
||||
return ListTile(
|
||||
enabled: onTap != null || isConnecting,
|
||||
leading: _buildSignalIcon(rssi),
|
||||
title: Text(
|
||||
name.isNotEmpty ? name : context.l10n.common_unknownDevice,
|
||||
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||
),
|
||||
subtitle: Text(device.remoteId.toString()),
|
||||
trailing: isConnecting
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: null,
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSignalIcon(int rssi) {
|
||||
final tier = rssi >= -60
|
||||
? 0
|
||||
: rssi >= -70
|
||||
@@ -55,15 +47,70 @@ class DeviceTile extends StatelessWidget {
|
||||
: 4;
|
||||
final signalUi = signalUiForStrengthTier(tier);
|
||||
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
return MeshCard(
|
||||
onTap: onTap == null
|
||||
? null
|
||||
: () {
|
||||
HapticFeedback.selectionClick();
|
||||
onTap!();
|
||||
},
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
AvatarCircle(name: displayName, size: 42, icon: Icons.router),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(signalUi.icon, color: signalUi.color),
|
||||
Text(
|
||||
'$rssi dBm',
|
||||
style: TextStyle(fontSize: 10, color: signalUi.color),
|
||||
displayName,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: scheme.onSurface,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
mac,
|
||||
style: MeshTheme.mono(
|
||||
fontSize: 11,
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
if (isConnecting)
|
||||
SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: scheme.primary,
|
||||
),
|
||||
)
|
||||
else
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Icon(signalUi.icon, size: 16, color: signalUi.color),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
'$rssi dBm',
|
||||
style: MeshTheme.mono(fontSize: 10, color: signalUi.color),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,31 +29,65 @@ class FeatureToggleRow extends StatefulWidget {
|
||||
class _FeatureToggleRow extends State<FeatureToggleRow> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SwitchListTile(
|
||||
title: Text(widget.title),
|
||||
subtitle: Text(widget.subtitle),
|
||||
value: widget.value,
|
||||
onChanged: widget.onChanged,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.title,
|
||||
style: textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
if (widget.hasRefreshing)
|
||||
IconButton(
|
||||
icon: widget.isRefreshing
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.refresh, size: 20),
|
||||
onPressed: widget.isRefreshing ? null : widget.onRefresh,
|
||||
tooltip: widget.refreshTooltip,
|
||||
visualDensity: VisualDensity.compact,
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
widget.subtitle,
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Switch(value: widget.value, onChanged: widget.onChanged),
|
||||
if (widget.hasRefreshing) ...[
|
||||
const SizedBox(width: 4),
|
||||
widget.isRefreshing
|
||||
? SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 1.8,
|
||||
color: scheme.primary,
|
||||
),
|
||||
)
|
||||
: IconButton(
|
||||
icon: const Icon(Icons.refresh, size: 18),
|
||||
onPressed: widget.onRefresh,
|
||||
tooltip: widget.refreshTooltip,
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(
|
||||
minWidth: 32,
|
||||
minHeight: 32,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../l10n/l10n.dart';
|
||||
import '../theme/mesh_theme.dart';
|
||||
|
||||
class EmojiPicker extends StatelessWidget {
|
||||
final Function(String) onEmojiSelected;
|
||||
@@ -257,7 +258,11 @@ class EmojiPicker extends StatelessWidget {
|
||||
),
|
||||
child: Text(
|
||||
emoji,
|
||||
style: const TextStyle(fontSize: 28),
|
||||
style: MeshTheme.emoji(),
|
||||
textHeightBehavior: const TextHeightBehavior(
|
||||
applyHeightToFirstAscent: false,
|
||||
applyHeightToLastDescent: false,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -298,7 +303,12 @@ class EmojiPicker extends StatelessWidget {
|
||||
child: Center(
|
||||
child: Text(
|
||||
emojis[index],
|
||||
style: const TextStyle(fontSize: 28),
|
||||
style: MeshTheme.emoji(),
|
||||
textHeightBehavior:
|
||||
const TextHeightBehavior(
|
||||
applyHeightToFirstAscent: false,
|
||||
applyHeightToLastDescent: false,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// A centered empty state display with icon, title, and optional subtitle/action.
|
||||
class EmptyState extends StatelessWidget {
|
||||
/// Features a tinted icon circle, fade+slide entrance animation, and clear
|
||||
/// typography hierarchy using the MeshCore design system.
|
||||
class EmptyState extends StatefulWidget {
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String? subtitle;
|
||||
@@ -15,29 +17,97 @@ class EmptyState extends StatelessWidget {
|
||||
this.action,
|
||||
});
|
||||
|
||||
@override
|
||||
State<EmptyState> createState() => _EmptyStateState();
|
||||
}
|
||||
|
||||
class _EmptyStateState extends State<EmptyState>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 420),
|
||||
);
|
||||
late final CurvedAnimation _curve = CurvedAnimation(
|
||||
parent: _controller,
|
||||
curve: Curves.easeOutCubic,
|
||||
);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller.forward();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_curve.dispose();
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final onSurfaceVariant = Theme.of(context).colorScheme.onSurfaceVariant;
|
||||
return Center(
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return FadeTransition(
|
||||
opacity: _curve,
|
||||
child: SlideTransition(
|
||||
position: Tween(
|
||||
begin: const Offset(0, 0.06),
|
||||
end: Offset.zero,
|
||||
).animate(_curve),
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 40),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(icon, size: 64, color: onSurfaceVariant.withValues(alpha: 0.6)),
|
||||
const SizedBox(height: 16),
|
||||
Text(title, style: TextStyle(fontSize: 16, color: onSurfaceVariant)),
|
||||
if (subtitle != null) ...[
|
||||
Container(
|
||||
width: 80,
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: scheme.primary.withValues(alpha: 0.08),
|
||||
border: Border.all(
|
||||
color: scheme.primary.withValues(alpha: 0.18),
|
||||
width: 1.5,
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
widget.icon,
|
||||
size: 36,
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
widget.title,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: scheme.onSurface,
|
||||
letterSpacing: -0.1,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
if (widget.subtitle != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
subtitle!,
|
||||
widget.subtitle!,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: onSurfaceVariant.withValues(alpha: 0.8),
|
||||
fontSize: 13.5,
|
||||
color: scheme.onSurfaceVariant,
|
||||
height: 1.45,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
if (action != null) ...[const SizedBox(height: 24), action!],
|
||||
if (widget.action != null) ...[
|
||||
const SizedBox(height: 28),
|
||||
widget.action!,
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../helpers/chat_scroll_controller.dart';
|
||||
import '../theme/mesh_theme.dart';
|
||||
|
||||
class JumpToBottomButton extends StatelessWidget {
|
||||
final ChatScrollController scrollController;
|
||||
@@ -8,6 +10,7 @@ class JumpToBottomButton extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return ValueListenableBuilder<bool>(
|
||||
valueListenable: scrollController.showJumpToBottom,
|
||||
builder: (context, show, _) {
|
||||
@@ -15,9 +18,33 @@ class JumpToBottomButton extends StatelessWidget {
|
||||
return Positioned(
|
||||
right: 16,
|
||||
bottom: 16,
|
||||
child: FloatingActionButton.small(
|
||||
onPressed: scrollController.jumpToBottom,
|
||||
child: const Icon(Icons.keyboard_arrow_down),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: scrollController.jumpToBottom,
|
||||
borderRadius: BorderRadius.circular(MeshRadii.pill),
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: scheme.surfaceContainerHigh.withValues(alpha: 0.92),
|
||||
border: Border.all(color: scheme.outlineVariant, width: 1),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.18),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Icon(
|
||||
Icons.keyboard_arrow_down,
|
||||
size: 22,
|
||||
color: scheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,637 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../theme/mesh_theme.dart';
|
||||
|
||||
/// MeshCore shared design kit.
|
||||
///
|
||||
/// Building blocks used across all screens so the app reads as one product:
|
||||
/// [SectionHeader], [MeshCard], [StatusChip], [StatTile], [AvatarCircle],
|
||||
/// [SignalBars], [RouteChip], [PulseDot], [BottomSheetHeader] +
|
||||
/// [showMeshSheet], [ErrorRetryCard], and [ListEntrance].
|
||||
|
||||
/// Small-caps mono section label, optionally with a trailing widget.
|
||||
class SectionHeader extends StatelessWidget {
|
||||
final String label;
|
||||
final Widget? trailing;
|
||||
final EdgeInsetsGeometry padding;
|
||||
|
||||
const SectionHeader(
|
||||
this.label, {
|
||||
super.key,
|
||||
this.trailing,
|
||||
this.padding = const EdgeInsets.fromLTRB(16, 20, 16, 8),
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return Padding(
|
||||
padding: padding,
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
label.toUpperCase(),
|
||||
style: MeshTheme.accentLabel(color: scheme.onSurfaceVariant),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
?trailing,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Bordered surface card with press feedback. The standard container for
|
||||
/// grouped content and tappable list entries.
|
||||
class MeshCard extends StatelessWidget {
|
||||
final Widget child;
|
||||
final VoidCallback? onTap;
|
||||
final VoidCallback? onLongPress;
|
||||
final EdgeInsetsGeometry padding;
|
||||
final EdgeInsetsGeometry margin;
|
||||
final Color? color;
|
||||
final Color? borderColor;
|
||||
final double radius;
|
||||
|
||||
const MeshCard({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.onTap,
|
||||
this.onLongPress,
|
||||
this.padding = const EdgeInsets.all(14),
|
||||
this.margin = const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
this.color,
|
||||
this.borderColor,
|
||||
this.radius = MeshRadii.md,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final shape = RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(radius),
|
||||
side: BorderSide(color: borderColor ?? scheme.outlineVariant),
|
||||
);
|
||||
return Padding(
|
||||
padding: margin,
|
||||
child: Material(
|
||||
color: color ?? scheme.surfaceContainerLow,
|
||||
shape: shape,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
onLongPress: onLongPress == null
|
||||
? null
|
||||
: () {
|
||||
HapticFeedback.selectionClick();
|
||||
onLongPress!();
|
||||
},
|
||||
child: Padding(padding: padding, child: child),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Tinted pill chip for statuses: a dot or icon plus a short label.
|
||||
class StatusChip extends StatelessWidget {
|
||||
final String label;
|
||||
final Color color;
|
||||
final IconData? icon;
|
||||
final bool pulse;
|
||||
final double fontSize;
|
||||
|
||||
const StatusChip({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.color,
|
||||
this.icon,
|
||||
this.pulse = false,
|
||||
this.fontSize = 11.5,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(MeshRadii.pill),
|
||||
border: Border.all(color: color.withValues(alpha: 0.3)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (icon != null)
|
||||
Icon(icon, size: fontSize + 2, color: color)
|
||||
else
|
||||
PulseDot(color: color, size: 7, animate: pulse),
|
||||
const SizedBox(width: 5),
|
||||
Flexible(
|
||||
child: Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: MeshTheme.mono(
|
||||
fontSize: fontSize,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Compact metric tile: icon, mono value (+ optional unit), small label.
|
||||
class StatTile extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final String value;
|
||||
final String? unit;
|
||||
final Color? color;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const StatTile({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.value,
|
||||
this.unit,
|
||||
this.color,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final accent = color ?? scheme.primary;
|
||||
return MeshCard(
|
||||
onTap: onTap,
|
||||
margin: EdgeInsets.zero,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(icon, size: 14, color: accent),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
label.toUpperCase(),
|
||||
style: MeshTheme.accentLabel(
|
||||
color: scheme.onSurfaceVariant,
|
||||
fontSize: 9,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text.rich(
|
||||
TextSpan(
|
||||
text: value,
|
||||
style: MeshTheme.mono(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: scheme.onSurface,
|
||||
),
|
||||
children: [
|
||||
if (unit != null)
|
||||
TextSpan(
|
||||
text: ' $unit',
|
||||
style: MeshTheme.mono(
|
||||
fontSize: 11,
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Initials avatar with a deterministic per-name hue, or a fixed [color]
|
||||
/// for node-type coloring. Optional [icon] replaces initials.
|
||||
class AvatarCircle extends StatelessWidget {
|
||||
final String name;
|
||||
final double size;
|
||||
final Color? color;
|
||||
final IconData? icon;
|
||||
|
||||
const AvatarCircle({
|
||||
super.key,
|
||||
required this.name,
|
||||
this.size = 40,
|
||||
this.color,
|
||||
this.icon,
|
||||
});
|
||||
|
||||
static const _hues = [
|
||||
MeshPalette.blue,
|
||||
MeshPalette.magenta,
|
||||
MeshPalette.signal,
|
||||
MeshPalette.warn,
|
||||
Color(0xFF8FA8F0),
|
||||
Color(0xFF6FD9CE),
|
||||
];
|
||||
|
||||
Color _colorFor(String s) {
|
||||
var h = 0;
|
||||
for (final c in s.codeUnits) {
|
||||
h = (h * 31 + c) & 0x7fffffff;
|
||||
}
|
||||
return _hues[h % _hues.length];
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final accent = color ?? _colorFor(name);
|
||||
final initials = _initials(name);
|
||||
return Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: accent.withValues(alpha: 0.14),
|
||||
border: Border.all(color: accent.withValues(alpha: 0.4)),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: icon != null
|
||||
? Icon(icon, size: size * 0.5, color: accent)
|
||||
: Text(
|
||||
initials,
|
||||
style: MeshTheme.mono(
|
||||
fontSize: size * 0.36,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: accent,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static String _initials(String name) {
|
||||
final words = name
|
||||
.trim()
|
||||
.split(RegExp(r'\s+'))
|
||||
.where((w) => w.isNotEmpty)
|
||||
.toList();
|
||||
if (words.isEmpty) return '?';
|
||||
if (words.length == 1) {
|
||||
return words.first.characters.take(2).toString().toUpperCase();
|
||||
}
|
||||
return (words.first.characters.take(1).toString() +
|
||||
words[1].characters.take(1).toString())
|
||||
.toUpperCase();
|
||||
}
|
||||
}
|
||||
|
||||
/// Four-bar signal strength indicator driven by an SNR value (dB), colored
|
||||
/// with the shared [MeshTheme.snrColor] ramp.
|
||||
class SignalBars extends StatelessWidget {
|
||||
final double? snr;
|
||||
final double height;
|
||||
|
||||
const SignalBars({super.key, required this.snr, this.height = 14});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final color = MeshTheme.snrColor(snr, blocked: false);
|
||||
final active = snr == null
|
||||
? 0
|
||||
: snr! > 0
|
||||
? 4
|
||||
: snr! > -5
|
||||
? 3
|
||||
: snr! > -12
|
||||
? 2
|
||||
: 1;
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: List.generate(4, (i) {
|
||||
final on = i < active;
|
||||
return Container(
|
||||
width: 3,
|
||||
height: height * (0.4 + i * 0.2),
|
||||
margin: const EdgeInsets.only(right: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: on ? color : scheme.outlineVariant,
|
||||
borderRadius: BorderRadius.circular(1),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Chip describing how a message was routed: direct (with hop count) vs flood.
|
||||
class RouteChip extends StatelessWidget {
|
||||
final bool isDirect;
|
||||
final int? hops;
|
||||
|
||||
const RouteChip({super.key, required this.isDirect, this.hops});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final label = isDirect
|
||||
? (hops == null || hops == 0
|
||||
? 'DIRECT'
|
||||
: '$hops HOP${hops == 1 ? '' : 'S'}')
|
||||
: 'FLOOD';
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(MeshRadii.xs),
|
||||
border: Border.all(color: scheme.outlineVariant),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
isDirect ? Icons.trending_flat : Icons.podcasts,
|
||||
size: 11,
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 3),
|
||||
Text(
|
||||
label,
|
||||
style: MeshTheme.accentLabel(
|
||||
color: scheme.onSurfaceVariant,
|
||||
fontSize: 8.5,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Small status dot, optionally with a soft breathing animation.
|
||||
class PulseDot extends StatefulWidget {
|
||||
final Color color;
|
||||
final double size;
|
||||
final bool animate;
|
||||
|
||||
const PulseDot({
|
||||
super.key,
|
||||
required this.color,
|
||||
this.size = 8,
|
||||
this.animate = false,
|
||||
});
|
||||
|
||||
@override
|
||||
State<PulseDot> createState() => _PulseDotState();
|
||||
}
|
||||
|
||||
class _PulseDotState extends State<PulseDot>
|
||||
with SingleTickerProviderStateMixin {
|
||||
// Created eagerly: a lazy `late final` initializer would run on first
|
||||
// access — which can be dispose(), where ticker creation throws.
|
||||
late final AnimationController _controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 1400),
|
||||
);
|
||||
if (widget.animate) _controller.repeat(reverse: true);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(PulseDot old) {
|
||||
super.didUpdateWidget(old);
|
||||
if (widget.animate && !_controller.isAnimating) {
|
||||
_controller.repeat(reverse: true);
|
||||
} else if (!widget.animate && _controller.isAnimating) {
|
||||
_controller.stop();
|
||||
_controller.value = 0;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FadeTransition(
|
||||
opacity: widget.animate
|
||||
? Tween(begin: 0.35, end: 1.0).animate(
|
||||
CurvedAnimation(parent: _controller, curve: Curves.easeInOut),
|
||||
)
|
||||
: const AlwaysStoppedAnimation(1.0),
|
||||
child: Container(
|
||||
width: widget.size,
|
||||
height: widget.size,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: widget.color,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: widget.color.withValues(alpha: 0.45),
|
||||
blurRadius: widget.size * 0.7,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Standard modal sheet header: drag handle, title, optional subtitle and
|
||||
/// trailing action, and a close button.
|
||||
class BottomSheetHeader extends StatelessWidget {
|
||||
final String title;
|
||||
final String? subtitle;
|
||||
final Widget? trailing;
|
||||
|
||||
const BottomSheetHeader({
|
||||
super.key,
|
||||
required this.title,
|
||||
this.subtitle,
|
||||
this.trailing,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 8, 4),
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
width: 36,
|
||||
height: 4,
|
||||
margin: const EdgeInsets.only(bottom: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.outline,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.2,
|
||||
),
|
||||
),
|
||||
if (subtitle != null)
|
||||
Text(
|
||||
subtitle!,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
?trailing,
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close, size: 20),
|
||||
onPressed: () => Navigator.of(context).maybePop(),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Shows a modal bottom sheet with the app-standard shape, scroll behavior
|
||||
/// and safe-area handling. Pair the content with [BottomSheetHeader].
|
||||
Future<T?> showMeshSheet<T>(
|
||||
BuildContext context, {
|
||||
required WidgetBuilder builder,
|
||||
bool isScrollControlled = true,
|
||||
}) {
|
||||
return showModalBottomSheet<T>(
|
||||
context: context,
|
||||
isScrollControlled: isScrollControlled,
|
||||
useSafeArea: true,
|
||||
showDragHandle: false,
|
||||
builder: (context) => Padding(
|
||||
padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(context).bottom),
|
||||
child: builder(context),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Inline error surface with an optional retry action.
|
||||
class ErrorRetryCard extends StatelessWidget {
|
||||
final String message;
|
||||
final VoidCallback? onRetry;
|
||||
final String? retryLabel;
|
||||
|
||||
const ErrorRetryCard({
|
||||
super.key,
|
||||
required this.message,
|
||||
this.onRetry,
|
||||
this.retryLabel,
|
||||
});
|
||||
|
||||
@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),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.error_outline, color: scheme.error, size: 20),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
message,
|
||||
style: TextStyle(color: scheme.error, fontSize: 13),
|
||||
),
|
||||
),
|
||||
if (onRetry != null)
|
||||
TextButton(onPressed: onRetry, child: Text(retryLabel ?? 'Retry')),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Staggered fade + slide entrance for list items. Wrap each item and pass
|
||||
/// its [index]; animation only plays once per widget lifecycle.
|
||||
class ListEntrance extends StatefulWidget {
|
||||
final int index;
|
||||
final Widget child;
|
||||
|
||||
const ListEntrance({super.key, required this.index, required this.child});
|
||||
|
||||
@override
|
||||
State<ListEntrance> createState() => _ListEntranceState();
|
||||
}
|
||||
|
||||
class _ListEntranceState extends State<ListEntrance>
|
||||
with SingleTickerProviderStateMixin {
|
||||
// Created eagerly: a lazy `late final` initializer would run on first
|
||||
// access — which can be dispose(), where ticker creation throws.
|
||||
late final AnimationController _controller;
|
||||
late final CurvedAnimation _curve;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 280),
|
||||
);
|
||||
_curve = CurvedAnimation(parent: _controller, curve: Curves.easeOutCubic);
|
||||
final delay = Duration(milliseconds: 24 * widget.index.clamp(0, 12));
|
||||
Future.delayed(delay, () {
|
||||
if (mounted) _controller.forward();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_curve.dispose();
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FadeTransition(
|
||||
opacity: _curve,
|
||||
child: SlideTransition(
|
||||
position: Tween(
|
||||
begin: const Offset(0, 0.04),
|
||||
end: Offset.zero,
|
||||
).animate(_curve),
|
||||
child: widget.child,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
|
||||
import '../l10n/l10n.dart';
|
||||
import '../theme/mesh_theme.dart';
|
||||
|
||||
class MessageStatusIcon extends StatefulWidget {
|
||||
final bool isAcked;
|
||||
@@ -92,7 +93,10 @@ class _MessageStatusIconState extends State<MessageStatusIcon>
|
||||
: widget.isAcked
|
||||
? l10n.messageStatus_delivered
|
||||
: l10n.messageStatus_sent;
|
||||
final Color color = delivered ? colorScheme.tertiary : baseColor;
|
||||
// Use palette colors: tertiary (warn/amber) for acked/repeated, base for sent.
|
||||
final Color color = delivered
|
||||
? MeshPalette.signal.withValues(alpha: 0.9)
|
||||
: baseColor;
|
||||
|
||||
return Semantics(
|
||||
label: label,
|
||||
|
||||
@@ -0,0 +1,661 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
import '../l10n/l10n.dart';
|
||||
import '../models/display_path.dart';
|
||||
import '../models/path_playback.dart';
|
||||
import '../theme/mesh_theme.dart';
|
||||
|
||||
/// Shared UI for the path map screens (live path trace and received-message
|
||||
/// path map): packet-flow animation overlays, single/combined view toggle,
|
||||
/// playback controls, and the multi-path summary/legend.
|
||||
|
||||
enum PathViewMode { single, combined }
|
||||
|
||||
const Color kPrimaryPathColor = Colors.blueAccent;
|
||||
const List<Color> kAlternatePathColors = [
|
||||
Color(0xFF8B5CF6), // purple
|
||||
MeshPalette.signal, // green
|
||||
MeshPalette.warn, // amber
|
||||
MeshPalette.magenta,
|
||||
];
|
||||
|
||||
double getPathDistanceMeters(List<LatLng> points) {
|
||||
if (points.length <= 1) return 0.0;
|
||||
|
||||
double distanceMeters = 0.0;
|
||||
final distanceCalculator = Distance();
|
||||
|
||||
for (int i = 0; i < points.length - 1; i++) {
|
||||
distanceMeters += distanceCalculator(points[i], points[i + 1]);
|
||||
}
|
||||
|
||||
return distanceMeters;
|
||||
}
|
||||
|
||||
String formatDistance(double distanceMeters, {required bool isImperial}) {
|
||||
if (isImperial) {
|
||||
return '(${(distanceMeters / 1609.34).toStringAsFixed(2)} mi)';
|
||||
}
|
||||
return '(${(distanceMeters / 1000).toStringAsFixed(2)} km)';
|
||||
}
|
||||
|
||||
String formatLastObserved(BuildContext context, DateTime timestamp) {
|
||||
final l10n = context.l10n;
|
||||
final diff = DateTime.now().difference(timestamp);
|
||||
if (diff.isNegative || diff.inMinutes < 5) return l10n.contacts_lastSeenNow;
|
||||
if (diff.inMinutes < 60) return l10n.contacts_lastSeenMinsAgo(diff.inMinutes);
|
||||
if (diff.inHours < 24) {
|
||||
return diff.inHours == 1
|
||||
? l10n.contacts_lastSeenHourAgo
|
||||
: l10n.contacts_lastSeenHoursAgo(diff.inHours);
|
||||
}
|
||||
return diff.inDays == 1
|
||||
? l10n.contacts_lastSeenDayAgo
|
||||
: l10n.contacts_lastSeenDaysAgo(diff.inDays);
|
||||
}
|
||||
|
||||
/// Polylines for the visible paths: shared-segment halos (combined view),
|
||||
/// dashed runs for estimated segments, dimming for unfocused paths and for
|
||||
/// the selected path while its packet animation is running.
|
||||
List<Polyline> buildMultiPathPolylines({
|
||||
required List<DisplayPath> visible,
|
||||
required DisplayPath? selected,
|
||||
required bool combined,
|
||||
required bool animating,
|
||||
}) {
|
||||
final lines = <Polyline>[];
|
||||
|
||||
if (combined && visible.length > 1) {
|
||||
final counts = <String, int>{};
|
||||
for (final path in visible) {
|
||||
for (var i = 0; i < path.points.length - 1; i++) {
|
||||
counts.update(
|
||||
_segmentKey(path.points[i], path.points[i + 1]),
|
||||
(v) => v + 1,
|
||||
ifAbsent: () => 1,
|
||||
);
|
||||
}
|
||||
}
|
||||
final drawn = <String>{};
|
||||
for (final path in visible) {
|
||||
for (var i = 0; i < path.points.length - 1; i++) {
|
||||
final key = _segmentKey(path.points[i], path.points[i + 1]);
|
||||
if ((counts[key] ?? 0) < 2 || !drawn.add(key)) continue;
|
||||
lines.add(
|
||||
Polyline(
|
||||
points: [path.points[i], path.points[i + 1]],
|
||||
strokeWidth: 11,
|
||||
color: Colors.white.withValues(alpha: 0.22),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void addPath(DisplayPath path, {required bool isSelected}) {
|
||||
final dimmedByFocus = combined && !isSelected;
|
||||
final alpha = dimmedByFocus ? 0.38 : (isSelected && animating ? 0.30 : 1.0);
|
||||
final width = isSelected ? 5.0 : 3.0;
|
||||
var i = 0;
|
||||
while (i < path.segmentEstimated.length) {
|
||||
final dashed = path.segmentEstimated[i];
|
||||
var j = i;
|
||||
while (j < path.segmentEstimated.length &&
|
||||
path.segmentEstimated[j] == dashed) {
|
||||
j++;
|
||||
}
|
||||
lines.add(
|
||||
Polyline(
|
||||
points: path.points.sublist(i, j + 1),
|
||||
strokeWidth: width,
|
||||
color: path.color.withValues(alpha: alpha),
|
||||
pattern: dashed
|
||||
? StrokePattern.dashed(segments: const [10, 7])
|
||||
: const StrokePattern.solid(),
|
||||
),
|
||||
);
|
||||
i = j;
|
||||
}
|
||||
}
|
||||
|
||||
for (final path in visible) {
|
||||
if (path.id != selected?.id) addPath(path, isSelected: false);
|
||||
}
|
||||
if (selected != null && visible.any((p) => p.id == selected.id)) {
|
||||
addPath(selected, isSelected: true);
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
String _segmentKey(LatLng a, LatLng b) {
|
||||
final ka =
|
||||
'${a.latitude.toStringAsFixed(6)},${a.longitude.toStringAsFixed(6)}';
|
||||
final kb =
|
||||
'${b.latitude.toStringAsFixed(6)},${b.longitude.toStringAsFixed(6)}';
|
||||
return ka.compareTo(kb) <= 0 ? '$ka|$kb' : '$kb|$ka';
|
||||
}
|
||||
|
||||
/// Bright traversed portion plus the glow on the active segment.
|
||||
List<Polyline> buildPacketTrailPolylines(
|
||||
PathPlaybackController playback,
|
||||
Color color,
|
||||
) {
|
||||
if (!playback.started || !playback.hasPath) return const [];
|
||||
final seg = playback.currentSegment;
|
||||
final traversed = <LatLng>[
|
||||
...playback.points.take(seg + 1),
|
||||
playback.position,
|
||||
];
|
||||
return [
|
||||
Polyline(
|
||||
points: [playback.points[seg], playback.position],
|
||||
strokeWidth: 8,
|
||||
color: Colors.white.withValues(alpha: 0.45),
|
||||
),
|
||||
Polyline(points: traversed, strokeWidth: 5, color: color),
|
||||
];
|
||||
}
|
||||
|
||||
/// The moving packet dot and the pulse ring at the hop it just reached.
|
||||
List<Marker> buildPacketMarkers(PathPlaybackController playback, Color color) {
|
||||
if (!playback.started || !playback.hasPath) return const [];
|
||||
final markers = <Marker>[];
|
||||
|
||||
final dwell = playback.dwellProgress;
|
||||
if (dwell != null) {
|
||||
final reached = playback.points[playback.reachedPointIndex];
|
||||
markers.add(
|
||||
Marker(
|
||||
point: reached,
|
||||
width: 56,
|
||||
height: 56,
|
||||
child: IgnorePointer(
|
||||
child: Center(
|
||||
child: Container(
|
||||
width: 24 + 28 * dwell,
|
||||
height: 24 + 28 * dwell,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: color.withValues(alpha: 1.0 - dwell),
|
||||
width: 3,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
markers.add(
|
||||
Marker(
|
||||
point: playback.position,
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: IgnorePointer(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: color,
|
||||
border: Border.all(color: Colors.white, width: 2),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: color.withValues(alpha: 0.7),
|
||||
blurRadius: 12,
|
||||
spreadRadius: 2,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return markers;
|
||||
}
|
||||
|
||||
/// Bottom sheet listing the paths that pass through a shared node.
|
||||
void showSharedNodeSheet(
|
||||
BuildContext context, {
|
||||
required String title,
|
||||
required List<DisplayPath> paths,
|
||||
required ValueChanged<DisplayPath> onSelect,
|
||||
}) {
|
||||
final l10n = context.l10n;
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (sheetContext) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
|
||||
child: Text(
|
||||
title,
|
||||
style: MeshTheme.mono(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: MeshPalette.ink,
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Text(
|
||||
l10n.pathMap_sharedNodeCount(paths.length),
|
||||
style: TextStyle(fontSize: 12, color: MeshPalette.ink3),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
for (final path in paths)
|
||||
ListTile(
|
||||
dense: true,
|
||||
leading: _colorDot(path.color),
|
||||
title: Text(
|
||||
path.label,
|
||||
style: MeshTheme.mono(fontSize: 13, color: MeshPalette.ink),
|
||||
),
|
||||
trailing: Text(
|
||||
l10n.pathMap_hopCount(path.totalTransmissions),
|
||||
style: MeshTheme.mono(fontSize: 11, color: MeshPalette.ink3),
|
||||
),
|
||||
onTap: () {
|
||||
Navigator.pop(sheetContext);
|
||||
onSelect(path);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _colorDot(Color color) => Container(
|
||||
width: 10,
|
||||
height: 10,
|
||||
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
|
||||
);
|
||||
|
||||
/// Floating Single/Combined toggle for the top of a path map Stack.
|
||||
class PathViewModeToggle extends StatelessWidget {
|
||||
final PathViewMode mode;
|
||||
final ValueChanged<PathViewMode> onChanged;
|
||||
|
||||
const PathViewModeToggle({
|
||||
super.key,
|
||||
required this.mode,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
return Positioned(
|
||||
top: 12,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Center(
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: MeshPalette.bg1.withValues(alpha: 0.92),
|
||||
borderRadius: BorderRadius.circular(MeshRadii.pill),
|
||||
),
|
||||
child: SegmentedButton<PathViewMode>(
|
||||
style: const ButtonStyle(
|
||||
visualDensity: VisualDensity(horizontal: -3, vertical: -3),
|
||||
),
|
||||
showSelectedIcon: false,
|
||||
segments: [
|
||||
ButtonSegment(
|
||||
value: PathViewMode.single,
|
||||
label: Text(l10n.pathMap_viewSingle),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: PathViewMode.combined,
|
||||
label: Text(l10n.pathMap_viewCombined),
|
||||
),
|
||||
],
|
||||
selected: {mode},
|
||||
onSelectionChanged: (selection) => onChanged(selection.first),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Compact playback control row: animation toggle, step/play/replay buttons,
|
||||
/// follow-packet lock, speed chip, and the live "Hop x of y · from → to"
|
||||
/// label.
|
||||
class PathAnimationControls extends StatelessWidget {
|
||||
final PathPlaybackController playback;
|
||||
final DisplayPath? selected;
|
||||
final bool animationEnabled;
|
||||
final VoidCallback onToggleAnimation;
|
||||
final bool followEnabled;
|
||||
final VoidCallback onToggleFollow;
|
||||
|
||||
const PathAnimationControls({
|
||||
super.key,
|
||||
required this.playback,
|
||||
required this.selected,
|
||||
required this.animationEnabled,
|
||||
required this.onToggleAnimation,
|
||||
required this.followEnabled,
|
||||
required this.onToggleFollow,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: playback,
|
||||
builder: (context, _) {
|
||||
final l10n = context.l10n;
|
||||
final enabled = animationEnabled && playback.hasPath;
|
||||
final path = selected;
|
||||
String? hopLabel;
|
||||
if (animationEnabled &&
|
||||
playback.started &&
|
||||
playback.hasPath &&
|
||||
path != null) {
|
||||
final seg = playback.currentSegment;
|
||||
final row = seg < path.rowForSegment.length
|
||||
? path.rowForSegment[seg]
|
||||
: 0;
|
||||
final from = path.pointLabels[seg];
|
||||
final to = path.pointLabels[seg + 1];
|
||||
hopLabel =
|
||||
'${l10n.pathMap_hopOf(row + 1, path.totalTransmissions)} · $from → $to';
|
||||
}
|
||||
|
||||
Widget controlButton({
|
||||
required IconData icon,
|
||||
required String tooltip,
|
||||
VoidCallback? onPressed,
|
||||
Color? color,
|
||||
}) => IconButton(
|
||||
icon: Icon(icon, size: 20, color: color),
|
||||
tooltip: tooltip,
|
||||
onPressed: onPressed,
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(minWidth: 34, minHeight: 34),
|
||||
);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(4, 0, 12, 2),
|
||||
child: Row(
|
||||
children: [
|
||||
controlButton(
|
||||
icon: Icons.animation,
|
||||
tooltip: animationEnabled
|
||||
? l10n.pathMap_animationOff
|
||||
: l10n.pathMap_animationOn,
|
||||
color: animationEnabled ? MeshPalette.blue : MeshPalette.ink4,
|
||||
onPressed: onToggleAnimation,
|
||||
),
|
||||
controlButton(
|
||||
icon: Icons.skip_previous,
|
||||
tooltip: l10n.pathMap_stepBack,
|
||||
onPressed: enabled && playback.started
|
||||
? playback.stepBack
|
||||
: null,
|
||||
),
|
||||
controlButton(
|
||||
icon: playback.playing ? Icons.pause : Icons.play_arrow,
|
||||
tooltip: playback.playing
|
||||
? l10n.pathMap_pause
|
||||
: l10n.pathMap_play,
|
||||
onPressed: enabled ? playback.togglePlay : null,
|
||||
),
|
||||
controlButton(
|
||||
icon: Icons.skip_next,
|
||||
tooltip: l10n.pathMap_stepForward,
|
||||
onPressed: enabled ? playback.stepForward : null,
|
||||
),
|
||||
controlButton(
|
||||
icon: Icons.replay,
|
||||
tooltip: l10n.pathMap_replay,
|
||||
onPressed: enabled ? playback.replay : null,
|
||||
),
|
||||
controlButton(
|
||||
icon: followEnabled ? Icons.lock : Icons.lock_open,
|
||||
tooltip: followEnabled
|
||||
? l10n.pathMap_unfollowPacket
|
||||
: l10n.pathMap_followPacket,
|
||||
color: followEnabled ? MeshPalette.blue : null,
|
||||
onPressed: enabled ? onToggleFollow : null,
|
||||
),
|
||||
TextButton(
|
||||
onPressed: enabled ? playback.cycleSpeed : null,
|
||||
style: TextButton.styleFrom(
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6),
|
||||
minimumSize: const Size(36, 30),
|
||||
),
|
||||
child: Text(
|
||||
playback.speed == 0.5 ? '0.5×' : '${playback.speed.toInt()}×',
|
||||
style: MeshTheme.mono(fontSize: 12),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
hopLabel ?? '',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.right,
|
||||
style: MeshTheme.mono(
|
||||
fontSize: 10.5,
|
||||
color: MeshPalette.ink2,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Marker/line style legend swatches.
|
||||
class PathMiniLegend extends StatelessWidget {
|
||||
final bool combined;
|
||||
final bool showInferred;
|
||||
|
||||
const PathMiniLegend({
|
||||
super.key,
|
||||
required this.combined,
|
||||
this.showInferred = true,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
Widget item(Widget swatch, String text) => Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
swatch,
|
||||
const SizedBox(width: 4),
|
||||
Text(text, style: TextStyle(fontSize: 11, color: MeshPalette.ink3)),
|
||||
],
|
||||
);
|
||||
Widget dashSample() => Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (var i = 0; i < 3; i++)
|
||||
Container(
|
||||
width: 5,
|
||||
height: 3,
|
||||
margin: const EdgeInsets.only(right: 2),
|
||||
color: MeshPalette.ink3,
|
||||
),
|
||||
],
|
||||
);
|
||||
return Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 2,
|
||||
children: [
|
||||
item(_colorDot(MeshPalette.signal), l10n.pathTrace_legendGpsConfirmed),
|
||||
if (showInferred)
|
||||
item(_colorDot(MeshPalette.warn), l10n.pathTrace_legendInferred),
|
||||
if (combined) ...[
|
||||
item(
|
||||
Container(
|
||||
width: 14,
|
||||
height: 6,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.35),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
),
|
||||
l10n.pathMap_legendShared,
|
||||
),
|
||||
item(dashSample(), l10n.pathMap_legendEstimated),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// "Observed paths: N" header plus one selectable row per path with hop
|
||||
/// count, distance, GPS-confirmed count, last-observed time, and an eye
|
||||
/// toggle for visibility.
|
||||
class PathSummaryList extends StatelessWidget {
|
||||
final List<DisplayPath> paths;
|
||||
final String selectedId;
|
||||
final Set<String> hiddenIds;
|
||||
final bool isImperial;
|
||||
final ValueChanged<DisplayPath> onSelect;
|
||||
final ValueChanged<DisplayPath> onToggleVisibility;
|
||||
final VoidCallback onShowAll;
|
||||
|
||||
const PathSummaryList({
|
||||
super.key,
|
||||
required this.paths,
|
||||
required this.selectedId,
|
||||
required this.hiddenIds,
|
||||
required this.isImperial,
|
||||
required this.onSelect,
|
||||
required this.onToggleVisibility,
|
||||
required this.onShowAll,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 2, 12, 0),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
l10n.pathMap_observedPaths(paths.length),
|
||||
style: MeshTheme.accentLabel(color: MeshPalette.ink3),
|
||||
),
|
||||
const Spacer(),
|
||||
if (hiddenIds.isNotEmpty)
|
||||
TextButton(
|
||||
onPressed: onShowAll,
|
||||
style: TextButton.styleFrom(
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
minimumSize: const Size(0, 26),
|
||||
),
|
||||
child: Text(
|
||||
l10n.pathMap_showAllPaths,
|
||||
style: const TextStyle(fontSize: 11),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
for (final path in paths) _buildRow(context, path),
|
||||
const SizedBox(height: 4),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRow(BuildContext context, DisplayPath path) {
|
||||
final l10n = context.l10n;
|
||||
final isSelected = path.id == selectedId;
|
||||
final hidden = hiddenIds.contains(path.id);
|
||||
final timestamp = path.record?.timestamp;
|
||||
final parts = <String>[
|
||||
'${l10n.pathMap_hopCount(path.totalTransmissions)} ${formatDistance(path.distanceMeters, isImperial: isImperial)}',
|
||||
l10n.pathMap_gpsCount(path.gpsConfirmedHops, path.hopBytes.length),
|
||||
if (timestamp != null) formatLastObserved(context, timestamp),
|
||||
];
|
||||
|
||||
return InkWell(
|
||||
onTap: () => onSelect(path),
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 1),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? MeshPalette.bg3 : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(MeshRadii.sm),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Opacity(
|
||||
opacity: hidden ? 0.45 : 1,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_colorDot(path.color),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
path.label,
|
||||
style: MeshTheme.mono(
|
||||
fontSize: 12,
|
||||
fontWeight: isSelected
|
||||
? FontWeight.w700
|
||||
: FontWeight.w500,
|
||||
color: MeshPalette.ink,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Opacity(
|
||||
opacity: hidden ? 0.45 : 1,
|
||||
child: Text(
|
||||
parts.join(' · '),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: MeshTheme.mono(
|
||||
fontSize: 10.5,
|
||||
color: MeshPalette.ink3,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
hidden ? Icons.visibility_off : Icons.visibility,
|
||||
size: 16,
|
||||
color: hidden ? MeshPalette.ink4 : MeshPalette.ink3,
|
||||
),
|
||||
tooltip: hidden ? l10n.pathMap_showPath : l10n.pathMap_hidePath,
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(minWidth: 30, minHeight: 30),
|
||||
onPressed: () => onToggleVisibility(path),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user