Merge branch 'dev' into update-pacox-multibyte

This commit is contained in:
HDDen
2026-06-14 01:12:49 +03:00
115 changed files with 25324 additions and 12783 deletions
+10 -9
View File
@@ -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`) |
+2 -2
View File
@@ -94,12 +94,12 @@ MeshCore Open is a cross-platform mobile application for communicating with Mesh
|---------|---------|
| flutter_blue_plus | Bluetooth Low Energy communication |
| 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
+4
View File
@@ -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
+1
View File
@@ -198,6 +198,7 @@ Tap the translate button on any received message. On first use, the GGUF model f
### How It Works
- 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
+5 -1
View File
@@ -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 |
+8 -9
View File
@@ -4,7 +4,7 @@
Channels are broadcast group-chat spaces secured by a 16-byte pre-shared key (PSK). Any device with the same channel index and PSK will receive and decrypt channel messages. Unlike direct messages, channel messages are broadcast to the entire mesh.
Up to 8 channels (indices 07) can be active simultaneously on one device.
The number of active channels is determined by the firmware (default 40); the device reports its actual limit at login.
## How to Access
@@ -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
+6 -8
View File
@@ -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.8x1.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 210)
6. After max retries, the message is marked "failed" — but a **30-second grace window** remains during which a late ACK can still resolve the message to "delivered"
@@ -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
View File
@@ -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
+25 -18
View File
@@ -35,9 +35,9 @@ Location pins shared in chat messages are displayed as flags:
Tap a pin to see its info. Options to "Hide" (session only) or "Remove" (persistent).
### 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 (80120m) applied for visual separation.
- **Two or more anchors**: The position is a weighted average of all anchor coordinates (each subsequent anchor weighted at half the previous one, biasing toward the first), with a smaller offset radius (120m for 2 anchors, 80m for 3+) applied for visual separation.
6. **Assign confidence level**:
- **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 (0400 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 (0400 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 (2181 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 (318) with estimated tile count
- **Zoom Range** range slider (318, dual-handle for min and max) with estimated tile count
- **Download progress** bar (when downloading)
- **Download Tiles** and **Clear Cache** buttons
+10 -10
View File
@@ -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
+2 -2
View File
@@ -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
+6 -4
View File
@@ -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
+15 -13
View File
@@ -28,9 +28,11 @@ The BLE Scanner is the app's home screen, displayed immediately on launch.
**Device List**: When no devices are found, shows a large Bluetooth icon with a prompt. The prompt text is dynamic: "Searching for devices..." while actively scanning, or "Tap Scan to search" when idle. When devices are found, shows a scrollable list of `DeviceTile` widgets.
**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
View File
@@ -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 (110, default 5, integer steps)
- Initial Route Weight (0.55.0, default 3.0)
- Success Increment (0.12.0, default 0.5, 0.1 steps)
- Failure Decrement (0.12.0, default 0.2, 0.1 steps)
- Max Message Retries (210, default 5)
### Battery
- **Battery Chemistry**: NMC / LiFePO4 / LiPo (per device, used to calibrate percentage from voltage)
### Translation
Not shown on web. Controls on-device message translation powered by a locally-downloaded ML model:
- **Enable Translation**: Translates incoming messages into the selected target language
- **Translate Composer**: Translates outgoing messages from the target language back before sending
- **Target Language**: Language to translate into (searchable list; defaults to the app language)
- **Downloaded Model**: Dropdown to select among already-downloaded translation models
- **Preset Model**: Download a curated preset model with one tap
- **Custom Model URL**: Enter a URL to download a custom GGUF-format model; shows download progress and a cancel button
### Map Display
- **Show Repeaters**: Toggle repeater markers on map
- **Show Chat Nodes**: Toggle chat node markers
- **Show Other Nodes**: Toggle room/sensor markers
- **Time Filter**: All time / Last 1h / Last 6h / Last 24h / Last week
- **Units**: Metric / Imperial
- **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 3002500 MHz
- **Bandwidth**: Dropdown (7.8 / 10.4 / 15.6 / 20.8 / 31.25 / 41.7 / 62.5 / 125 / 250 / 500 kHz)
- **Spreading Factor**: SF5SF12
@@ -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 (110, default 5, integer steps)
- Initial Route Weight (0.55.0, default 3.0)
- Success Increment (0.12.0, default 0.5, 0.1 steps)
- Failure Decrement (0.12.0, default 0.2, 0.1 steps)
- Max Message Retries (210, default 5)
- **Enable Message Tracing**: Shows path trace overlays and extra metadata on messages
### Battery
- **Battery Chemistry**: NMC / LiFePO4 / LiPo (per device, used to calibrate percentage from voltage)
### Map Display
- **Show Repeaters**: Toggle repeater markers on map
- **Show Chat Nodes**: Toggle chat node markers
- **Show Other Nodes**: Toggle room/sensor markers
- **Time Filter**: All time / Last 1h / Last 6h / Last 24h / Last week
- **Units**: Metric / Imperial
- **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.
+173 -32
View File
@@ -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 ---
+75
View File
@@ -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!);
}
}
+13 -1
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+538 -338
View File
File diff suppressed because it is too large Load Diff
+95 -1
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+426 -205
View File
File diff suppressed because it is too large Load Diff
+1123 -1029
View File
File diff suppressed because it is too large Load Diff
+514 -239
View File
File diff suppressed because it is too large Load Diff
+562 -366
View File
File diff suppressed because it is too large Load Diff
+367 -174
View File
@@ -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"
}
}
}
}
+234
View File
@@ -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
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+144
View File
@@ -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';
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+145
View File
@@ -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';
}
+151
View File
@@ -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';
}
+144
View File
@@ -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';
}
+148
View File
@@ -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 => 'Не следить за пакетом';
}
+146
View File
@@ -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';
}
+148
View File
@@ -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';
}
+144
View File
@@ -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';
}
+149
View File
@@ -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 => 'Відв\'язати вигляд від пакету';
}
+143
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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} 跳无位置信息 — 显示的路径不完整}}"
}
+4 -4
View File
@@ -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,
+70
View File
@@ -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,
});
}
+177
View File
@@ -0,0 +1,177 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/scheduler.dart';
import 'package:latlong2/latlong.dart';
/// Timeline state for the packet-flow animation on the path map.
///
/// The packet travels each segment over [segmentMs] (scaled by [speed]),
/// then dwells at the reached hop for [dwellMs] so the hop visibly lights up.
/// Overlay layers listen to this controller directly; [activeSegment] only
/// fires when the segment index changes so list highlights rebuild cheaply.
class PathPlaybackController extends ChangeNotifier {
static const double segmentMs = 1100;
static const double dwellMs = 380;
static const List<double> speedSteps = [0.5, 1.0, 2.0];
late final Ticker _ticker;
List<LatLng> _points = const [];
double _timelineMs = 0;
Duration _lastTick = Duration.zero;
bool _playing = false;
bool _started = false;
double _speed = 1.0;
/// Segment currently being traveled (clamped to the last segment), or -1
/// while the animation has not been started listeners use this for
/// hop-list highlighting without rebuilding every tick.
final ValueNotifier<int> activeSegment = ValueNotifier(-1);
PathPlaybackController(TickerProvider vsync) {
_ticker = vsync.createTicker(_onTick);
}
List<LatLng> get points => _points;
bool get hasPath => _points.length >= 2;
int get segmentCount => hasPath ? _points.length - 1 : 0;
bool get playing => _playing;
double get speed => _speed;
/// True once the user has started or stepped the animation; the packet
/// overlay renders only in this state.
bool get started => _started;
double get _slotMs => segmentMs + dwellMs;
double get _totalMs => segmentCount * _slotMs;
bool get isComplete => hasPath && _timelineMs >= _totalMs;
int get currentSegment {
if (!hasPath) return 0;
return (_timelineMs / _slotMs).floor().clamp(0, segmentCount - 1);
}
/// Travel progress through [currentSegment]; 1.0 while dwelling at its end.
double get segmentProgress {
if (!hasPath) return 0;
final within = _timelineMs - currentSegment * _slotMs;
return (within / segmentMs).clamp(0.0, 1.0);
}
/// Dwell progress (0..1) at the reached hop, or null while traveling.
double? get dwellProgress {
if (!hasPath || isComplete) return null;
final within = _timelineMs - currentSegment * _slotMs;
if (within < segmentMs) return null;
return ((within - segmentMs) / dwellMs).clamp(0.0, 1.0);
}
/// Index of the point the packet has most recently reached.
int get reachedPointIndex {
if (!hasPath) return 0;
if (isComplete) return _points.length - 1;
return segmentProgress >= 1.0 ? currentSegment + 1 : currentSegment;
}
LatLng get position {
if (!hasPath) return const LatLng(0, 0);
final seg = currentSegment;
final a = _points[seg];
final b = _points[seg + 1];
final t = segmentProgress;
return LatLng(
a.latitude + (b.latitude - a.latitude) * t,
a.longitude + (b.longitude - a.longitude) * t,
);
}
/// Replaces the path and resets the animation to the start.
void setPath(List<LatLng> points) {
_ticker.stop();
_points = List.unmodifiable(points);
_timelineMs = 0;
_playing = false;
_started = false;
activeSegment.value = -1;
notifyListeners();
}
void play() {
if (!hasPath) return;
if (isComplete) _timelineMs = 0;
_started = true;
_playing = true;
activeSegment.value = currentSegment;
if (!_ticker.isActive) {
_lastTick = Duration.zero;
_ticker.start();
}
notifyListeners();
}
void pause() {
_ticker.stop();
_playing = false;
notifyListeners();
}
void togglePlay() => _playing ? pause() : play();
void replay() {
if (!hasPath) return;
_timelineMs = 0;
activeSegment.value = 0;
play();
}
/// Stops playback and hides the packet overlay.
void stop() {
_ticker.stop();
_playing = false;
_started = false;
_timelineMs = 0;
activeSegment.value = -1;
notifyListeners();
}
void stepForward() => _jumpToPoint(reachedPointIndex + 1);
void stepBack() => _jumpToPoint(reachedPointIndex - 1);
void cycleSpeed() {
final index = speedSteps.indexOf(_speed);
_speed = speedSteps[(index + 1) % speedSteps.length];
notifyListeners();
}
void _jumpToPoint(int index) {
if (!hasPath) return;
_ticker.stop();
_playing = false;
_started = true;
final clamped = index.clamp(0, _points.length - 1);
// Land at the start of the dwell window so the hop pulse plays.
_timelineMs = clamped == 0 ? 0 : (clamped - 1) * _slotMs + segmentMs;
activeSegment.value = currentSegment;
notifyListeners();
}
void _onTick(Duration elapsed) {
final dtMs = (elapsed - _lastTick).inMicroseconds / 1000.0;
_lastTick = elapsed;
_timelineMs = (_timelineMs + dtMs * _speed).clamp(0.0, _totalMs);
if (_timelineMs >= _totalMs) {
_ticker.stop();
_playing = false;
}
if (activeSegment.value != currentSegment) {
activeSegment.value = currentSegment;
}
notifyListeners();
}
@override
void dispose() {
_ticker.dispose();
activeSegment.dispose();
super.dispose();
}
}
+76 -31
View File
@@ -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
+113 -19
View File
@@ -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),
),
),
),
],
+212 -139
View File
@@ -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
File diff suppressed because it is too large Load Diff
+187 -100
View File
@@ -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];
}
+54 -35
View File
@@ -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 {
],
),
),
),
),
);
}
}
+200 -46
View File
@@ -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
+102 -20
View File
@@ -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,
),
),
],
);
},
+203 -58
View File
@@ -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,
),
);
}
}
+197 -99
View File
@@ -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
+67 -29
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+83 -68
View File
@@ -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,
),
),
],
),
],
),
File diff suppressed because it is too large Load Diff
+209 -195
View File
@@ -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});
}
+164 -185
View File
@@ -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),
],
),
),
),
);
}
}
+134 -245
View File
@@ -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,
),
],
),
),
);
}
+226 -236
View File
@@ -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
View File
@@ -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),
),
),
);
}
File diff suppressed because it is too large Load Diff
+98 -56
View File
@@ -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 ||
+27 -43
View File
@@ -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
View File
@@ -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;
+60 -8
View File
@@ -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,
);
}
+38 -6
View File
@@ -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
+3 -1
View File
@@ -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(
+19 -7
View File
@@ -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;
}
+39 -2
View File
@@ -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
View File
@@ -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;
+12 -11
View File
@@ -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,
+73 -26
View File
@@ -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),
),
],
),
],
),
);
}
}
+53 -19
View File
@@ -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,
),
),
],
],
),
],
),
);
}
}
+12 -2
View File
@@ -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,
),
),
),
),
+81 -11
View File
@@ -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!,
],
],
),
),
),
),
);
}
+30 -3
View File
@@ -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,
),
),
),
),
);
},
+637
View File
@@ -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,
),
);
}
}
+5 -1
View File
@@ -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,
+661
View File
@@ -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