mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-12 00:40:28 +00:00
feat: Enhance Device Configuration Screen with Device Info and Telemetry Modes
- Added a new section to display device information including Device Type, Max Contacts, Max Channels, Telemetry Modes, and Manual Add Contacts. - Implemented helper methods to convert device types and telemetry modes to user-friendly strings. - Removed Message History Screen and its references from the Home Screen. - Simplified message sending logic in Messages Tab to always send to the public channel. - Introduced a new channel selection feature in the SAR Update Sheet for sending messages. - Updated MeshCore BLE service to handle send confirmation responses and improved message sending protocols.
This commit is contained in:
517
CLAUDE.md
517
CLAUDE.md
@@ -68,72 +68,513 @@ lib/
|
|||||||
|
|
||||||
### MeshCore Protocol
|
### MeshCore Protocol
|
||||||
|
|
||||||
The app implements the MeshCore BLE protocol based on https://github.com/meshcore-dev/meshcore.js
|
The app implements the MeshCore BLE Companion Radio protocol based on https://github.com/meshcore-dev/meshcore.js
|
||||||
|
|
||||||
**BLE Service**: `6E400001-B5A3-F393-E0A9-E50E24DCCA9E`
|
**BLE Service**: `6E400001-B5A3-F393-E0A9-E50E24DCCA9E`
|
||||||
**RX Characteristic** (write): `6E400002-B5A3-F393-E0A9-E50E24DCCA9E`
|
**RX Characteristic** (write): `6E400002-B5A3-F393-E0A9-E50E24DCCA9E`
|
||||||
**TX Characteristic** (notify): `6E400003-B5A3-F393-E0A9-E50E24DCCA9E`
|
**TX Characteristic** (notify): `6E400003-B5A3-F393-E0A9-E50E24DCCA9E`
|
||||||
|
|
||||||
#### Command Codes (Client → Device)
|
#### Protocol Overview
|
||||||
- `4`: Get contacts list
|
|
||||||
- `2`: Send text message
|
|
||||||
- `39`: Request telemetry
|
|
||||||
|
|
||||||
#### Response Codes (Device → Client)
|
The companion radio acts as a 'server', responding to requests from the connected app (the 'client').
|
||||||
- `3`: Contact information
|
|
||||||
- `7`: Message received
|
|
||||||
- `0x8B` (139): Telemetry response (Cayenne LPP format)
|
|
||||||
|
|
||||||
#### Binary Protocol Format
|
**Frame Delimiters**:
|
||||||
|
- **BLE**: A frame is a single characteristic value (BLE link layer handles integrity)
|
||||||
|
- **USB**:
|
||||||
|
- Outbound (radio → app): Starts with `>` (0x3E), 2-byte length (LE), then frame data
|
||||||
|
- Inbound (app → radio): Starts with `<` (0x3C), 2-byte length (LE), then frame data
|
||||||
|
|
||||||
All protocol messages use little-endian byte order.
|
**NOTE**: All uint32 values use Little Endian byte order!
|
||||||
|
|
||||||
**Get Contacts Request**:
|
#### Command Codes (App → Radio)
|
||||||
|
|
||||||
|
| Code | Name | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| 1 | CMD_APP_START | First command after connection, returns RESP_CODE_SELF_INFO(5) |
|
||||||
|
| 2 | CMD_SEND_TXT_MSG | Send text message to contact (DM) |
|
||||||
|
| 3 | CMD_SEND_CHANNEL_TXT_MSG | Send flood-mode text message to channel |
|
||||||
|
| 4 | CMD_GET_CONTACTS | Sync contacts list (optional 'since' param) |
|
||||||
|
| 5 | CMD_GET_DEVICE_TIME | Get device clock (epoch secs, UTC) |
|
||||||
|
| 6 | CMD_SET_DEVICE_TIME | Set device clock |
|
||||||
|
| 7 | CMD_SEND_SELF_ADVERT | Send Advertisement packet (optional flood-mode) |
|
||||||
|
| 8 | CMD_SET_ADVERT_NAME | Update node name in advertisements |
|
||||||
|
| 9 | CMD_ADD_UPDATE_CONTACT | Add or modify a contact |
|
||||||
|
| 10 | CMD_SYNC_NEXT_MESSAGE | Get next text message from queue |
|
||||||
|
| 11 | CMD_SET_RADIO_PARAMS | Save new radio parameters |
|
||||||
|
| 12 | CMD_SET_RADIO_TX_POWER | Set radio TX power level |
|
||||||
|
| 13 | CMD_RESET_PATH | Reset out_path for a contact |
|
||||||
|
| 14 | CMD_SET_ADVERT_LATLON | Update lat/lon in advertisements |
|
||||||
|
| 15 | CMD_REMOVE_CONTACT | Remove a contact |
|
||||||
|
| 16 | CMD_SHARE_CONTACT | Share contact via zero-hop advert |
|
||||||
|
| 17 | CMD_EXPORT_CONTACT | Export contact as 'business card' |
|
||||||
|
| 18 | CMD_IMPORT_CONTACT | Import contact from 'business card' |
|
||||||
|
| 19 | CMD_REBOOT | Reboot companion device |
|
||||||
|
| 20 | CMD_GET_BATT_AND_STORAGE | Get battery mV and storage stats |
|
||||||
|
| 21 | CMD_SET_TUNING_PARAMS | Set tuning parameters |
|
||||||
|
| 22 | CMD_DEVICE_QUERY | First command to send, returns RESP_CODE_DEVICE_INFO(13) |
|
||||||
|
| 25 | CMD_SEND_RAW_DATA | Transmit PAYLOAD_TYPE_RAW_CUSTOM packet |
|
||||||
|
| 26 | CMD_SEND_LOGIN | Send login request to repeater/room |
|
||||||
|
| 27 | CMD_SEND_STATUS_REQ | Send status request to repeater/sensor |
|
||||||
|
| 36 | CMD_SEND_TRACE_PATH | Initiate TRACE packet with SNR collection |
|
||||||
|
| 37 | CMD_SET_DEVICE_PIN | Set BLE PIN code |
|
||||||
|
| 38 | CMD_SET_OTHER_PARAMS | Set various other parameters |
|
||||||
|
| 39 | CMD_SEND_TELEMETRY_REQ | Send telemetry request to node |
|
||||||
|
| 40 | CMD_GET_CUSTOM_VARS | Retrieve all custom variables |
|
||||||
|
| 41 | CMD_SET_CUSTOM_VAR | Set single custom variable |
|
||||||
|
| 42 | CMD_GET_ADVERT_PATH | Query last advert path for contact |
|
||||||
|
| 43 | CMD_GET_TUNING_PARAMS | Get airtime-factor and rx-delay settings |
|
||||||
|
| 50 | CMD_SEND_BINARY_REQ | Send binary request to node (preferred over CMD_SEND_TELEMETRY_REQ) |
|
||||||
|
| 51 | CMD_FACTORY_RESET | Erase flash file system |
|
||||||
|
|
||||||
|
#### Response Codes (Radio → App)
|
||||||
|
|
||||||
|
| Code | Name | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| 0 | RESP_CODE_OK | Success |
|
||||||
|
| 1 | RESP_CODE_ERR | Error (includes err_code) |
|
||||||
|
| 2 | RESP_CODE_CONTACTS_START | Start of contacts sync sequence |
|
||||||
|
| 3 | RESP_CODE_CONTACT | Single contact information |
|
||||||
|
| 4 | RESP_CODE_END_OF_CONTACTS | End of contacts sync sequence |
|
||||||
|
| 5 | RESP_CODE_SELF_INFO | Node's own information |
|
||||||
|
| 6 | RESP_CODE_SENT | Message sent with expected ACK/TAG |
|
||||||
|
| 7 | RESP_CODE_CONTACT_MSG_RECV | Contact message received |
|
||||||
|
| 8 | RESP_CODE_CHANNEL_MSG_RECV | Channel message received |
|
||||||
|
| 9 | RESP_CODE_CURR_TIME | Current device time |
|
||||||
|
| 10 | RESP_CODE_NO_MORE_MESSAGES | Message queue empty |
|
||||||
|
| 11 | RESP_CODE_EXPORT_CONTACT | Contact export data |
|
||||||
|
| 12 | RESP_CODE_BATT_AND_STORAGE | Battery and storage info |
|
||||||
|
| 13 | RESP_CODE_DEVICE_INFO | Device firmware and hardware info |
|
||||||
|
| 21 | RESP_CODE_CUSTOM_VARS | Custom variables state |
|
||||||
|
| 22 | RESP_CODE_ADVERT_PATH | Last advert path for contact |
|
||||||
|
|
||||||
|
#### Push Notifications (Radio → App, Async)
|
||||||
|
|
||||||
|
| Code | Name | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| 0x80 | PUSH_CODE_ADVERT | New advertisement packet received |
|
||||||
|
| 0x81 | PUSH_CODE_PATH_UPDATED | Contact received new path |
|
||||||
|
| 0x82 | PUSH_CODE_SEND_CONFIRMED | Message ACK received |
|
||||||
|
| 0x83 | PUSH_CODE_MSG_WAITING | New text message received |
|
||||||
|
| 0x84 | PUSH_CODE_RAW_DATA | PAYLOAD_TYPE_RAW_CUSTOM received |
|
||||||
|
| 0x85 | PUSH_CODE_LOGIN_SUCCESS | Login response successful |
|
||||||
|
| 0x86 | PUSH_CODE_LOGIN_FAIL | Login response failed |
|
||||||
|
| 0x87 | PUSH_CODE_STATUS_RESPONSE | Status response received |
|
||||||
|
| 0x89 | PUSH_CODE_TRACE_DATA | TRACE packet reached end of path |
|
||||||
|
| 0x8A | PUSH_CODE_NEW_ADVERT | New contact advert (manual_add_contacts=1) |
|
||||||
|
| 0x8B | PUSH_CODE_TELEMETRY_RESPONSE | Telemetry response received |
|
||||||
|
| 0x8C | PUSH_CODE_BINARY_RESPONSE | Binary response received |
|
||||||
|
|
||||||
|
#### Frame Formats
|
||||||
|
|
||||||
|
**CMD_DEVICE_QUERY (22)**:
|
||||||
```
|
```
|
||||||
[0x04] - Command code
|
[0x16] - Command code (22)
|
||||||
|
[1 byte] - App target version (protocol version app understands)
|
||||||
```
|
```
|
||||||
|
|
||||||
**Contact Response**:
|
**RESP_CODE_DEVICE_INFO (13)**:
|
||||||
```
|
```
|
||||||
[0x03] - Response code
|
[0x0D] - Response code (13)
|
||||||
|
[1 byte] - Firmware version
|
||||||
|
[1 byte] - Max contacts ÷ 2 (ver 3+)
|
||||||
|
[1 byte] - Max channels (ver 3+)
|
||||||
|
[4 bytes] - BLE PIN (uint32, ver 3+)
|
||||||
|
[12 bytes] - Firmware build date (ASCII null-terminated, e.g., "19 Feb 2025")
|
||||||
|
[40 bytes] - Manufacturer model (ASCII null-terminated)
|
||||||
|
[20 bytes] - Semantic version (ASCII null-terminated)
|
||||||
|
```
|
||||||
|
|
||||||
|
**CMD_APP_START (1)**:
|
||||||
|
```
|
||||||
|
[0x01] - Command code (1)
|
||||||
|
[1 byte] - App version
|
||||||
|
[6 bytes] - Reserved (zeros)
|
||||||
|
[N bytes] - App name (remainder of frame, varchar)
|
||||||
|
```
|
||||||
|
|
||||||
|
**RESP_CODE_SELF_INFO (5)**:
|
||||||
|
```
|
||||||
|
[0x05] - Response code (5)
|
||||||
|
[1 byte] - Type (ADV_TYPE_*)
|
||||||
|
[1 byte] - TX power in dBm (current)
|
||||||
|
[1 byte] - Max TX power radio supports
|
||||||
[32 bytes] - Public key
|
[32 bytes] - Public key
|
||||||
[1 byte] - Contact type (0=none, 1=chat, 2=repeater, 3=room)
|
[4 bytes] - Advert latitude * 1E6 (int32)
|
||||||
[64 bytes] - Advertised name (null-terminated string)
|
[4 bytes] - Advert longitude * 1E6 (int32)
|
||||||
[4 bytes] - Latitude (int32, divide by 10000 for degrees)
|
[1 byte] - Multi ACKs (0=no extra, 1=send extra ACK)
|
||||||
[4 bytes] - Longitude (int32, divide by 10000 for degrees)
|
[1 byte] - Advert location policy (0=don't share, 1=share)
|
||||||
|
[1 byte] - Telemetry modes (bits 0-1: Base mode, bits 2-3: Location mode)
|
||||||
|
Modes: 0=DENY, 1=apply contact.flags, 2=ALLOW ALL
|
||||||
|
[1 byte] - Manual add contacts (0 or 1)
|
||||||
|
[4 bytes] - Radio freq * 1000 (uint32)
|
||||||
|
[4 bytes] - Radio bandwidth (kHz) * 1000 (uint32)
|
||||||
|
[1 byte] - Spreading factor
|
||||||
|
[1 byte] - Coding rate
|
||||||
|
[N bytes] - Name (remainder of frame, varchar)
|
||||||
```
|
```
|
||||||
|
|
||||||
**Message Received**:
|
**CMD_GET_CONTACTS (4)**:
|
||||||
```
|
```
|
||||||
[0x07] - Response code
|
[0x04] - Command code (4)
|
||||||
[1 byte] - Message type (0=contact, 1=channel)
|
[4 bytes] - (Optional) Since timestamp (uint32, last contact.lastmod received)
|
||||||
[4 bytes] - Sender public key prefix
|
|
||||||
[4 bytes] - Recipient public key prefix
|
|
||||||
[2 bytes] - Text length (uint16)
|
|
||||||
[N bytes] - UTF-8 text
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Send Text Message**:
|
**RESP_CODE_CONTACTS_START (2)**:
|
||||||
```
|
```
|
||||||
[0x02] - Command code
|
[0x02] - Response code (2)
|
||||||
[32 bytes] - Recipient public key
|
[4 bytes] - Total contact count (uint32)
|
||||||
[2 bytes] - Text length (uint16)
|
|
||||||
[N bytes] - UTF-8 text
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Request Telemetry**:
|
**RESP_CODE_CONTACT (3)**:
|
||||||
```
|
```
|
||||||
[0x27] (39) - Command code
|
[0x03] - Response code (3)
|
||||||
[32 bytes] - Contact public key
|
[32 bytes] - Public key
|
||||||
|
[1 byte] - Type (ADV_TYPE_*)
|
||||||
|
[1 byte] - Flags
|
||||||
|
[1 byte] - Out path length (signed)
|
||||||
|
[64 bytes] - Out path
|
||||||
|
[32 bytes] - Advertised name (null-terminated)
|
||||||
|
[4 bytes] - Last advert timestamp (uint32)
|
||||||
|
[4 bytes] - Advert latitude * 1E6 (int32)
|
||||||
|
[4 bytes] - Advert longitude * 1E6 (int32)
|
||||||
|
[4 bytes] - Last modified timestamp (uint32)
|
||||||
```
|
```
|
||||||
|
|
||||||
**Telemetry Response**:
|
**RESP_CODE_END_OF_CONTACTS (4)**:
|
||||||
```
|
```
|
||||||
[0x8B] (139) - Response code
|
[0x04] - Response code (4)
|
||||||
[4 bytes] - Contact public key prefix
|
[4 bytes] - Most recent lastmod (uint32, use for next 'since' param)
|
||||||
[N bytes] - Cayenne LPP payload
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**CMD_SET_DEVICE_TIME (6)**:
|
||||||
|
```
|
||||||
|
[0x06] - Command code (6)
|
||||||
|
[4 bytes] - Epoch seconds (uint32)
|
||||||
|
```
|
||||||
|
|
||||||
|
**RESP_CODE_CURR_TIME (9)**:
|
||||||
|
```
|
||||||
|
[0x09] - Response code (9)
|
||||||
|
[4 bytes] - Epoch seconds (uint32)
|
||||||
|
```
|
||||||
|
|
||||||
|
**CMD_SEND_SELF_ADVERT (7)**:
|
||||||
|
```
|
||||||
|
[0x07] - Command code (7)
|
||||||
|
[1 byte] - (Optional) Type: 1=flood, 0=zero-hop (default)
|
||||||
|
```
|
||||||
|
|
||||||
|
**CMD_SET_ADVERT_NAME (8)**:
|
||||||
|
```
|
||||||
|
[0x08] - Command code (8)
|
||||||
|
[N bytes] - Name (remainder of frame, varchar)
|
||||||
|
```
|
||||||
|
|
||||||
|
**CMD_SET_ADVERT_LATLON (14)**:
|
||||||
|
```
|
||||||
|
[0x0E] - Command code (14)
|
||||||
|
[4 bytes] - Latitude * 1E6 (int32)
|
||||||
|
[4 bytes] - Longitude * 1E6 (int32)
|
||||||
|
[4 bytes] - (Optional) Altitude (int32, future support)
|
||||||
|
```
|
||||||
|
|
||||||
|
**CMD_ADD_UPDATE_CONTACT (9)**:
|
||||||
|
```
|
||||||
|
[0x09] - Command code (9)
|
||||||
|
[32 bytes] - Public key
|
||||||
|
[1 byte] - Type (ADV_TYPE_*)
|
||||||
|
[1 byte] - Flags
|
||||||
|
[1 byte] - Out path length (signed)
|
||||||
|
[64 bytes] - Out path
|
||||||
|
[32 bytes] - Advertised name (null-terminated)
|
||||||
|
[4 bytes] - Last advert timestamp (uint32)
|
||||||
|
[4 bytes] - (Optional) Advert latitude * 1E6 (int32)
|
||||||
|
[4 bytes] - (Optional) Advert longitude * 1E6 (int32)
|
||||||
|
```
|
||||||
|
|
||||||
|
**CMD_REMOVE_CONTACT (15)**:
|
||||||
|
```
|
||||||
|
[0x0F] - Command code (15)
|
||||||
|
[32 bytes] - Public key
|
||||||
|
```
|
||||||
|
|
||||||
|
**CMD_SHARE_CONTACT (16)**:
|
||||||
|
```
|
||||||
|
[0x10] - Command code (16)
|
||||||
|
[32 bytes] - Public key
|
||||||
|
```
|
||||||
|
|
||||||
|
**CMD_EXPORT_CONTACT (17)**:
|
||||||
|
```
|
||||||
|
[0x11] - Command code (17)
|
||||||
|
[32 bytes] - (Optional) Public key (if omitted, export SELF)
|
||||||
|
```
|
||||||
|
|
||||||
|
**RESP_CODE_EXPORT_CONTACT (11)**:
|
||||||
|
```
|
||||||
|
[0x0B] - Response code (11)
|
||||||
|
[N bytes] - Card data (remainder of frame)
|
||||||
|
Format: "meshcore://{hex(card_data)}"
|
||||||
|
```
|
||||||
|
|
||||||
|
**CMD_IMPORT_CONTACT (18)**:
|
||||||
|
```
|
||||||
|
[0x12] - Command code (18)
|
||||||
|
[N bytes] - Card data (remainder of frame)
|
||||||
|
```
|
||||||
|
|
||||||
|
**CMD_RESET_PATH (13)**:
|
||||||
|
```
|
||||||
|
[0x0D] - Command code (13)
|
||||||
|
[32 bytes] - Public key
|
||||||
|
```
|
||||||
|
|
||||||
|
**CMD_SEND_TXT_MSG (2)**:
|
||||||
|
```
|
||||||
|
[0x02] - Command code (2)
|
||||||
|
[1 byte] - Text type (TXT_TYPE_*, 0=plain)
|
||||||
|
[1 byte] - Attempt (0-3, attempt number)
|
||||||
|
[4 bytes] - Sender timestamp (uint32)
|
||||||
|
[6 bytes] - Recipient public key prefix (first 6 bytes)
|
||||||
|
[N bytes] - Text (remainder of frame, varchar, max 160 bytes)
|
||||||
|
```
|
||||||
|
|
||||||
|
**CMD_SEND_CHANNEL_TXT_MSG (3)**:
|
||||||
|
```
|
||||||
|
[0x03] - Command code (3)
|
||||||
|
[1 byte] - Text type (TXT_TYPE_*, 0=plain)
|
||||||
|
[1 byte] - Channel index (reserved, 0 for 'public')
|
||||||
|
[4 bytes] - Sender timestamp (uint32)
|
||||||
|
[N bytes] - Text (remainder of frame, max 160 - len(advert_name) - 2)
|
||||||
|
```
|
||||||
|
|
||||||
|
**RESP_CODE_SENT (6)**:
|
||||||
|
```
|
||||||
|
[0x06] - Response code (6)
|
||||||
|
[1 byte] - Send type: 1=flood, 0=direct
|
||||||
|
[4 bytes] - Expected ACK code or TAG
|
||||||
|
[4 bytes] - Suggested timeout (uint32, milliseconds)
|
||||||
|
```
|
||||||
|
|
||||||
|
**PUSH_CODE_SEND_CONFIRMED (0x82)**:
|
||||||
|
```
|
||||||
|
[0x82] - Push code
|
||||||
|
[4 bytes] - ACK code
|
||||||
|
[4 bytes] - Round trip time (uint32, milliseconds)
|
||||||
|
```
|
||||||
|
|
||||||
|
**RESP_CODE_CONTACT_MSG_RECV (7)**:
|
||||||
|
```
|
||||||
|
[0x07] - Response code (7)
|
||||||
|
[6 bytes] - Sender public key prefix (first 6 bytes)
|
||||||
|
[1 byte] - Path length (0xFF if direct, else hop count for flood-mode)
|
||||||
|
[1 byte] - Text type (TXT_TYPE_*, 0=plain)
|
||||||
|
[4 bytes] - Sender timestamp (uint32)
|
||||||
|
[N bytes] - Text (remainder of frame, varchar)
|
||||||
|
```
|
||||||
|
|
||||||
|
**RESP_CODE_CHANNEL_MSG_RECV (8)**:
|
||||||
|
```
|
||||||
|
[0x08] - Response code (8)
|
||||||
|
[1 byte] - Channel index (reserved, 0 for 'public')
|
||||||
|
[1 byte] - Path length (0xFF if direct, else hop count for flood-mode)
|
||||||
|
[1 byte] - Text type (TXT_TYPE_*, 0=plain)
|
||||||
|
[4 bytes] - Sender timestamp (uint32)
|
||||||
|
[N bytes] - Text (remainder of frame, varchar)
|
||||||
|
```
|
||||||
|
|
||||||
|
**CMD_SET_RADIO_PARAMS (11)**:
|
||||||
|
```
|
||||||
|
[0x0B] - Command code (11)
|
||||||
|
[4 bytes] - Radio freq * 1000 (uint32)
|
||||||
|
[4 bytes] - Radio bandwidth (kHz) * 1000 (uint32)
|
||||||
|
[1 byte] - Spreading factor
|
||||||
|
[1 byte] - Coding rate
|
||||||
|
```
|
||||||
|
|
||||||
|
**CMD_SET_RADIO_TX_POWER (12)**:
|
||||||
|
```
|
||||||
|
[0x0C] - Command code (12)
|
||||||
|
[1 byte] - TX power in dBm
|
||||||
|
```
|
||||||
|
|
||||||
|
**CMD_SET_TUNING_PARAMS (21) / RESP_CODE_TUNING_PARAMS**:
|
||||||
|
```
|
||||||
|
[0x15] - Command/Response code (21)
|
||||||
|
[4 bytes] - RX delay base * 1000 (uint32)
|
||||||
|
[4 bytes] - Airtime factor * 1000 (uint32)
|
||||||
|
[8 bytes] - Reserved (set to zero)
|
||||||
|
```
|
||||||
|
|
||||||
|
**CMD_SET_OTHER_PARAMS (38)**:
|
||||||
|
```
|
||||||
|
[0x26] - Command code (38)
|
||||||
|
[1 byte] - Manual add contacts (0 or 1)
|
||||||
|
[1 byte] - (Optional v5+) Telemetry modes
|
||||||
|
[1 byte] - (Optional v5+) Advert location policy
|
||||||
|
[1 byte] - (Optional v7+) Multi ACKs (0=no extra, 1=send extra)
|
||||||
|
```
|
||||||
|
|
||||||
|
**RESP_CODE_BATT_AND_STORAGE (12)**:
|
||||||
|
```
|
||||||
|
[0x0C] - Response code (12)
|
||||||
|
[2 bytes] - Millivolts (uint16)
|
||||||
|
[4 bytes] - (Optional) Used KB (uint32)
|
||||||
|
[4 bytes] - (Optional) Total KB (uint32, zero if unknown)
|
||||||
|
```
|
||||||
|
|
||||||
|
**CMD_SEND_RAW_DATA (25)**:
|
||||||
|
```
|
||||||
|
[0x19] - Command code (25)
|
||||||
|
[1 byte] - Path length
|
||||||
|
[N bytes] - Path (variable length)
|
||||||
|
[M bytes] - Payload (remainder of frame)
|
||||||
|
```
|
||||||
|
|
||||||
|
**PUSH_CODE_RAW_DATA (0x84)**:
|
||||||
|
```
|
||||||
|
[0x84] - Push code
|
||||||
|
[1 byte] - SNR * 4 (signed)
|
||||||
|
[1 byte] - RSSI (signed)
|
||||||
|
[1 byte] - Reserved (0xFF)
|
||||||
|
[N bytes] - Payload (remainder of frame)
|
||||||
|
```
|
||||||
|
|
||||||
|
**CMD_SEND_LOGIN (26)**:
|
||||||
|
```
|
||||||
|
[0x1A] - Command code (26)
|
||||||
|
[32 bytes] - Public key (repeater or room server)
|
||||||
|
[N bytes] - Password (remainder of frame, varchar, max 15 bytes)
|
||||||
|
```
|
||||||
|
|
||||||
|
**PUSH_CODE_LOGIN_SUCCESS (0x85)**:
|
||||||
|
```
|
||||||
|
[0x85] - Push code
|
||||||
|
[1 byte] - Permissions (lowest bit=is_admin)
|
||||||
|
[6 bytes] - Public key prefix (first 6 bytes)
|
||||||
|
[4 bytes] - Tag (int32)
|
||||||
|
[1 byte] - (V7+) New permissions
|
||||||
|
```
|
||||||
|
|
||||||
|
**CMD_SEND_STATUS_REQ (27)**:
|
||||||
|
```
|
||||||
|
[0x1B] - Command code (27)
|
||||||
|
[32 bytes] - Public key (repeater or sensor)
|
||||||
|
```
|
||||||
|
|
||||||
|
**PUSH_CODE_STATUS_RESPONSE (0x87)**:
|
||||||
|
```
|
||||||
|
[0x87] - Push code
|
||||||
|
[1 byte] - Reserved (zero)
|
||||||
|
[6 bytes] - Public key prefix (first 6 bytes)
|
||||||
|
[N bytes] - Status data (remainder of frame)
|
||||||
|
```
|
||||||
|
|
||||||
|
**CMD_SEND_TELEMETRY_REQ (39)**:
|
||||||
|
```
|
||||||
|
[0x27] - Command code (39)
|
||||||
|
[3 bytes] - Reserved (zeros)
|
||||||
|
[32 bytes] - Public key (destination node)
|
||||||
|
```
|
||||||
|
|
||||||
|
**PUSH_CODE_TELEMETRY_RESPONSE (0x8B)**:
|
||||||
|
```
|
||||||
|
[0x8B] - Push code
|
||||||
|
[1 byte] - Reserved (zero)
|
||||||
|
[6 bytes] - Public key prefix (first 6 bytes)
|
||||||
|
[N bytes] - LPP sensor data (Cayenne LPP format, remainder of frame)
|
||||||
|
```
|
||||||
|
|
||||||
|
**CMD_SEND_BINARY_REQ (50)** *(Preferred over CMD_SEND_TELEMETRY_REQ)*:
|
||||||
|
```
|
||||||
|
[0x32] - Command code (50)
|
||||||
|
[32 bytes] - Public key (contact to send request to)
|
||||||
|
[N bytes] - Request code and params (remainder of frame)
|
||||||
|
```
|
||||||
|
|
||||||
|
**PUSH_CODE_BINARY_RESPONSE (0x8C)**:
|
||||||
|
```
|
||||||
|
[0x8C] - Push code
|
||||||
|
[1 byte] - Reserved (zero)
|
||||||
|
[4 bytes] - Tag (uint32, matches RESP_CODE_SENT expected_ack_or_tag)
|
||||||
|
[N bytes] - Response data (remainder of frame)
|
||||||
|
```
|
||||||
|
|
||||||
|
**CMD_SEND_TRACE_PATH (36)**:
|
||||||
|
```
|
||||||
|
[0x24] - Command code (36)
|
||||||
|
[4 bytes] - Tag (int32, random initiator tag)
|
||||||
|
[4 bytes] - Auth code (int32, optional authentication)
|
||||||
|
[1 byte] - Flags (zero for now)
|
||||||
|
[N bytes] - Path (remainder of frame, hashes for TRACE to follow)
|
||||||
|
```
|
||||||
|
|
||||||
|
**PUSH_CODE_TRACE_DATA (0x89)**:
|
||||||
|
```
|
||||||
|
[0x89] - Push code
|
||||||
|
[1 byte] - Reserved (zero)
|
||||||
|
[1 byte] - Path length
|
||||||
|
[1 byte] - Flags (zero for now)
|
||||||
|
[4 bytes] - Tag (int32)
|
||||||
|
[4 bytes] - Auth code (int32)
|
||||||
|
[N bytes] - Path hashes (variable length)
|
||||||
|
[N+1 bytes] - Path SNRs (last byte = SNR for last hop, each byte = SNR * 4)
|
||||||
|
```
|
||||||
|
|
||||||
|
**CMD_SET_DEVICE_PIN (37)**:
|
||||||
|
```
|
||||||
|
[0x25] - Command code (37)
|
||||||
|
[4 bytes] - BLE PIN (uint32)
|
||||||
|
```
|
||||||
|
|
||||||
|
**CMD_GET_ADVERT_PATH (42)**:
|
||||||
|
```
|
||||||
|
[0x2A] - Command code (42)
|
||||||
|
[1 byte] - Reserved (zero)
|
||||||
|
[32 bytes] - Public key (contact being queried)
|
||||||
|
```
|
||||||
|
|
||||||
|
**RESP_CODE_ADVERT_PATH (22)**:
|
||||||
|
```
|
||||||
|
[0x16] - Response code (22)
|
||||||
|
[4 bytes] - Receive timestamp (uint32, by local clock)
|
||||||
|
[1 byte] - Path length
|
||||||
|
[N bytes] - Path (variable length)
|
||||||
|
```
|
||||||
|
|
||||||
|
**CMD_FACTORY_RESET (51)**:
|
||||||
|
```
|
||||||
|
[0x33] - Command code (51)
|
||||||
|
[5 bytes] - ASCII "reset" (confirmation)
|
||||||
|
```
|
||||||
|
|
||||||
|
**RESP_CODE_ERR (1)**:
|
||||||
|
```
|
||||||
|
[0x01] - Response code (1)
|
||||||
|
[1 byte] - Error code (ERR_CODE_*)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Constants
|
||||||
|
|
||||||
|
**ADV_TYPE (Advertisement/Contact Type)**:
|
||||||
|
- `0` - ADV_TYPE_NONE (unknown/invalid)
|
||||||
|
- `1` - ADV_TYPE_CHAT (team member, shown on map)
|
||||||
|
- `2` - ADV_TYPE_REPEATER (network repeater node)
|
||||||
|
- `3` - ADV_TYPE_ROOM (communication channel/room)
|
||||||
|
|
||||||
|
**TXT_TYPE (Text Message Type)**:
|
||||||
|
- `0` - TXT_TYPE_PLAIN (plain text message)
|
||||||
|
- `1` - TXT_TYPE_CLI_DATA (CLI command)
|
||||||
|
- `2` - TXT_TYPE_SIGNED_PLAIN (plain text, signed by sender)
|
||||||
|
|
||||||
|
**ERR_CODE (Error Codes)**:
|
||||||
|
- `1` - ERR_CODE_UNSUPPORTED_CMD
|
||||||
|
- `2` - ERR_CODE_NOT_FOUND
|
||||||
|
- `3` - ERR_CODE_TABLE_FULL
|
||||||
|
- `4` - ERR_CODE_BAD_STATE
|
||||||
|
- `5` - ERR_CODE_FILE_IO_ERROR
|
||||||
|
- `6` - ERR_CODE_ILLEGAL_ARG
|
||||||
|
|
||||||
### Cayenne LPP Format
|
### Cayenne LPP Format
|
||||||
|
|
||||||
Telemetry data uses Cayenne Low Power Payload format:
|
Telemetry data uses Cayenne Low Power Payload format:
|
||||||
|
|||||||
@@ -34,10 +34,19 @@ class DeviceInfo {
|
|||||||
final int? radioCr;
|
final int? radioCr;
|
||||||
final String? selfName;
|
final String? selfName;
|
||||||
|
|
||||||
|
// Additional device capabilities (from RESP_CODE_DEVICE_INFO)
|
||||||
|
final int? maxContacts; // Max contacts device supports
|
||||||
|
final int? maxChannels; // Max channels device supports
|
||||||
|
final int? telemetryModes; // Telemetry permission modes (bits 0-1: Base, bits 2-3: Location)
|
||||||
|
final int? blePin; // BLE PIN code
|
||||||
|
final int? multiAcks; // Extra ACK mode (0=no, 1=yes)
|
||||||
|
final int? advertLocPolicy; // Location sharing policy (0=don't share, 1=share)
|
||||||
|
|
||||||
// Firmware info
|
// Firmware info
|
||||||
final int? firmwareVersion;
|
final int? firmwareVersion;
|
||||||
final String? firmwareBuildDate;
|
final String? firmwareBuildDate;
|
||||||
final String? manufacturerModel;
|
final String? manufacturerModel;
|
||||||
|
final String? semanticVersion;
|
||||||
|
|
||||||
DeviceInfo({
|
DeviceInfo({
|
||||||
this.deviceId,
|
this.deviceId,
|
||||||
@@ -60,9 +69,16 @@ class DeviceInfo {
|
|||||||
this.radioSf,
|
this.radioSf,
|
||||||
this.radioCr,
|
this.radioCr,
|
||||||
this.selfName,
|
this.selfName,
|
||||||
|
this.maxContacts,
|
||||||
|
this.maxChannels,
|
||||||
|
this.telemetryModes,
|
||||||
|
this.blePin,
|
||||||
|
this.multiAcks,
|
||||||
|
this.advertLocPolicy,
|
||||||
this.firmwareVersion,
|
this.firmwareVersion,
|
||||||
this.firmwareBuildDate,
|
this.firmwareBuildDate,
|
||||||
this.manufacturerModel,
|
this.manufacturerModel,
|
||||||
|
this.semanticVersion,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Check if device is connected
|
/// Check if device is connected
|
||||||
@@ -144,9 +160,16 @@ class DeviceInfo {
|
|||||||
int? radioSf,
|
int? radioSf,
|
||||||
int? radioCr,
|
int? radioCr,
|
||||||
String? selfName,
|
String? selfName,
|
||||||
|
int? maxContacts,
|
||||||
|
int? maxChannels,
|
||||||
|
int? telemetryModes,
|
||||||
|
int? blePin,
|
||||||
|
int? multiAcks,
|
||||||
|
int? advertLocPolicy,
|
||||||
int? firmwareVersion,
|
int? firmwareVersion,
|
||||||
String? firmwareBuildDate,
|
String? firmwareBuildDate,
|
||||||
String? manufacturerModel,
|
String? manufacturerModel,
|
||||||
|
String? semanticVersion,
|
||||||
}) {
|
}) {
|
||||||
return DeviceInfo(
|
return DeviceInfo(
|
||||||
deviceId: deviceId ?? this.deviceId,
|
deviceId: deviceId ?? this.deviceId,
|
||||||
@@ -169,9 +192,16 @@ class DeviceInfo {
|
|||||||
radioSf: radioSf ?? this.radioSf,
|
radioSf: radioSf ?? this.radioSf,
|
||||||
radioCr: radioCr ?? this.radioCr,
|
radioCr: radioCr ?? this.radioCr,
|
||||||
selfName: selfName ?? this.selfName,
|
selfName: selfName ?? this.selfName,
|
||||||
|
maxContacts: maxContacts ?? this.maxContacts,
|
||||||
|
maxChannels: maxChannels ?? this.maxChannels,
|
||||||
|
telemetryModes: telemetryModes ?? this.telemetryModes,
|
||||||
|
blePin: blePin ?? this.blePin,
|
||||||
|
multiAcks: multiAcks ?? this.multiAcks,
|
||||||
|
advertLocPolicy: advertLocPolicy ?? this.advertLocPolicy,
|
||||||
firmwareVersion: firmwareVersion ?? this.firmwareVersion,
|
firmwareVersion: firmwareVersion ?? this.firmwareVersion,
|
||||||
firmwareBuildDate: firmwareBuildDate ?? this.firmwareBuildDate,
|
firmwareBuildDate: firmwareBuildDate ?? this.firmwareBuildDate,
|
||||||
manufacturerModel: manufacturerModel ?? this.manufacturerModel,
|
manufacturerModel: manufacturerModel ?? this.manufacturerModel,
|
||||||
|
semanticVersion: semanticVersion ?? this.semanticVersion,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,24 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import '../providers/contacts_provider.dart';
|
import '../providers/contacts_provider.dart';
|
||||||
import '../providers/connection_provider.dart';
|
import '../providers/connection_provider.dart';
|
||||||
|
import '../providers/app_provider.dart';
|
||||||
import '../models/contact.dart';
|
import '../models/contact.dart';
|
||||||
|
|
||||||
class ContactsTab extends StatelessWidget {
|
class ContactsTab extends StatefulWidget {
|
||||||
const ContactsTab({super.key});
|
const ContactsTab({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ContactsTab> createState() => _ContactsTabState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ContactsTabState extends State<ContactsTab> {
|
||||||
|
Future<void> _handleRefresh() async {
|
||||||
|
final appProvider = context.read<AppProvider>();
|
||||||
|
await appProvider.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Consumer<ContactsProvider>(
|
return Consumer<ContactsProvider>(
|
||||||
@@ -32,7 +44,7 @@ class ContactsTab extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
'Connect to a device and refresh to load contacts',
|
'Connect to a device to load contacts',
|
||||||
style: Theme.of(context).textTheme.bodyMedium,
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
@@ -41,9 +53,11 @@ class ContactsTab extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return ListView(
|
return RefreshIndicator(
|
||||||
padding: const EdgeInsets.all(8),
|
onRefresh: _handleRefresh,
|
||||||
children: [
|
child: ListView(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
children: [
|
||||||
// Team Members (Chat contacts)
|
// Team Members (Chat contacts)
|
||||||
if (chatContacts.isNotEmpty) ...[
|
if (chatContacts.isNotEmpty) ...[
|
||||||
_SectionHeader(
|
_SectionHeader(
|
||||||
@@ -75,7 +89,8 @@ class ContactsTab extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
...rooms.map((contact) => _ContactTile(contact: contact)),
|
...rooms.map((contact) => _ContactTile(contact: contact)),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -234,19 +249,32 @@ class _ContactTile extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
trailing: IconButton(
|
trailing: Row(
|
||||||
icon: const Icon(Icons.refresh, size: 20),
|
mainAxisSize: MainAxisSize.min,
|
||||||
onPressed: () {
|
children: [
|
||||||
final connectionProvider = context.read<ConnectionProvider>();
|
// Message icon - only for chat contacts
|
||||||
connectionProvider.requestTelemetry(contact.publicKey);
|
if (contact.type == ContactType.chat)
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
IconButton(
|
||||||
SnackBar(
|
icon: const Icon(Icons.message, size: 20),
|
||||||
content: Text('Requesting telemetry from ${contact.displayName}'),
|
onPressed: () => _showDirectMessageDialog(context, contact),
|
||||||
duration: const Duration(seconds: 2),
|
tooltip: 'Send direct message',
|
||||||
),
|
),
|
||||||
);
|
// Telemetry refresh button
|
||||||
},
|
IconButton(
|
||||||
tooltip: 'Request telemetry',
|
icon: const Icon(Icons.refresh, size: 20),
|
||||||
|
onPressed: () {
|
||||||
|
final connectionProvider = context.read<ConnectionProvider>();
|
||||||
|
connectionProvider.requestTelemetry(contact.publicKey);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Requesting telemetry from ${contact.displayName}'),
|
||||||
|
duration: const Duration(seconds: 2),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
tooltip: 'Request telemetry',
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
onTap: () => _showContactDetails(context, contact),
|
onTap: () => _showContactDetails(context, contact),
|
||||||
onLongPress: () {
|
onLongPress: () {
|
||||||
@@ -263,6 +291,15 @@ class _ContactTile extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _showDirectMessageDialog(BuildContext context, Contact contact) {
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
builder: (context) => _DirectMessageSheet(contact: contact),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
void _showContactDetails(BuildContext context, Contact contact) {
|
void _showContactDetails(BuildContext context, Contact contact) {
|
||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
@@ -478,3 +515,236 @@ class _ContactTile extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Direct Message Sheet Widget
|
||||||
|
class _DirectMessageSheet extends StatefulWidget {
|
||||||
|
final Contact contact;
|
||||||
|
|
||||||
|
const _DirectMessageSheet({required this.contact});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_DirectMessageSheet> createState() => _DirectMessageSheetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DirectMessageSheetState extends State<_DirectMessageSheet> {
|
||||||
|
final TextEditingController _textController = TextEditingController();
|
||||||
|
final FocusNode _focusNode = FocusNode();
|
||||||
|
int _characterCount = 0;
|
||||||
|
static const int _maxCharacters = 160;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_textController.addListener(_updateCharacterCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_textController.dispose();
|
||||||
|
_focusNode.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _updateCharacterCount() {
|
||||||
|
setState(() {
|
||||||
|
_characterCount = _textController.text.length;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _sendDirectMessage() async {
|
||||||
|
final text = _textController.text.trim();
|
||||||
|
if (text.isEmpty) return;
|
||||||
|
|
||||||
|
final connectionProvider = context.read<ConnectionProvider>();
|
||||||
|
|
||||||
|
if (!connectionProvider.deviceInfo.isConnected) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Not connected to device'),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Send direct message to contact
|
||||||
|
await connectionProvider.sendTextMessage(
|
||||||
|
contactPublicKey: widget.contact.publicKey,
|
||||||
|
text: text,
|
||||||
|
);
|
||||||
|
|
||||||
|
_textController.clear();
|
||||||
|
_focusNode.unfocus();
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
Navigator.pop(context); // Close the dialog
|
||||||
|
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Direct message sent to ${widget.contact.displayName}'),
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
duration: const Duration(seconds: 2),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Failed to send: $e'),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
height: MediaQuery.of(context).size.height * 0.9,
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: Color(0xFF1E1E1E),
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
// Header
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.arrow_back, color: Colors.white),
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Direct Message',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
widget.contact.displayName,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.grey,
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.more_vert, color: Colors.white),
|
||||||
|
onPressed: () {},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Info banner
|
||||||
|
Container(
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context).colorScheme.primaryContainer,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.info_outline, color: Theme.of(context).colorScheme.onPrimaryContainer),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'This message will be sent directly to ${widget.contact.displayName}. It will also appear in the main messages feed.',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||||
|
fontSize: 13,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
const Spacer(),
|
||||||
|
|
||||||
|
// Message input
|
||||||
|
Container(
|
||||||
|
padding: EdgeInsets.only(
|
||||||
|
left: 16,
|
||||||
|
right: 16,
|
||||||
|
top: 16,
|
||||||
|
bottom: 16 + MediaQuery.of(context).viewInsets.bottom,
|
||||||
|
),
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: Color(0xFF2D2D2D),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
TextField(
|
||||||
|
controller: _textController,
|
||||||
|
focusNode: _focusNode,
|
||||||
|
maxLength: _maxCharacters,
|
||||||
|
maxLines: 3,
|
||||||
|
autofocus: true,
|
||||||
|
maxLengthEnforcement: MaxLengthEnforcement.enforced,
|
||||||
|
style: const TextStyle(color: Colors.white),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'Type your message...',
|
||||||
|
hintStyle: const TextStyle(color: Colors.grey),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: const BorderSide(color: Colors.grey),
|
||||||
|
),
|
||||||
|
enabledBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: const BorderSide(color: Colors.grey),
|
||||||
|
),
|
||||||
|
focusedBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: const BorderSide(color: Colors.white),
|
||||||
|
),
|
||||||
|
contentPadding: const EdgeInsets.all(16),
|
||||||
|
counterText: _characterCount >= 150
|
||||||
|
? '$_characterCount/$_maxCharacters'
|
||||||
|
: '',
|
||||||
|
counterStyle: TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
color: _characterCount > _maxCharacters * 0.9
|
||||||
|
? Colors.orange
|
||||||
|
: Colors.grey,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
textInputAction: TextInputAction.send,
|
||||||
|
onSubmitted: (_) => _sendDirectMessage(),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: ElevatedButton.icon(
|
||||||
|
onPressed: _textController.text.trim().isEmpty
|
||||||
|
? null
|
||||||
|
: _sendDirectMessage,
|
||||||
|
icon: const Icon(Icons.send),
|
||||||
|
label: const Text('Send Direct Message'),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -265,6 +265,48 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _getDeviceTypeString(int? deviceType) {
|
||||||
|
if (deviceType == null) return 'Unknown';
|
||||||
|
switch (deviceType) {
|
||||||
|
case 0:
|
||||||
|
return 'None/Unknown';
|
||||||
|
case 1:
|
||||||
|
return 'Chat Node';
|
||||||
|
case 2:
|
||||||
|
return 'Repeater';
|
||||||
|
case 3:
|
||||||
|
return 'Room/Channel Server';
|
||||||
|
default:
|
||||||
|
return 'Type $deviceType';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _getTelemetryModesString(deviceInfo) {
|
||||||
|
if (deviceInfo.telemetryModes == null) return 'Unknown';
|
||||||
|
|
||||||
|
final telemetryModes = deviceInfo.telemetryModes!;
|
||||||
|
final baseMode = telemetryModes & 0x03; // bits 0-1
|
||||||
|
final locationMode = (telemetryModes >> 2) & 0x03; // bits 2-3
|
||||||
|
|
||||||
|
String baseModeStr = _getTelemetryModeString(baseMode);
|
||||||
|
String locationModeStr = _getTelemetryModeString(locationMode);
|
||||||
|
|
||||||
|
return 'Base: $baseModeStr, Loc: $locationModeStr';
|
||||||
|
}
|
||||||
|
|
||||||
|
String _getTelemetryModeString(int mode) {
|
||||||
|
switch (mode) {
|
||||||
|
case 0:
|
||||||
|
return 'Deny';
|
||||||
|
case 1:
|
||||||
|
return 'By Contact';
|
||||||
|
case 2:
|
||||||
|
return 'Allow All';
|
||||||
|
default:
|
||||||
|
return 'Unknown';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _useCurrentLocation() async {
|
Future<void> _useCurrentLocation() async {
|
||||||
try {
|
try {
|
||||||
// Check if location services are enabled
|
// Check if location services are enabled
|
||||||
@@ -474,6 +516,101 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
|
|||||||
|
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// Device Information Section (Read-only)
|
||||||
|
_SectionHeader(
|
||||||
|
title: 'Device Information',
|
||||||
|
trailing: IconButton(
|
||||||
|
icon: const Icon(Icons.info_outline),
|
||||||
|
onPressed: () {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Device Information'),
|
||||||
|
content: const Text(
|
||||||
|
'This information is provided by the MeshCore device '
|
||||||
|
'and cannot be edited. Tap refresh to update.',
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('GOT IT'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
iconSize: 20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
_SettingTile(
|
||||||
|
icon: Icons.numbers,
|
||||||
|
label: 'Device Type',
|
||||||
|
isFirst: true,
|
||||||
|
trailing: Text(
|
||||||
|
_getDeviceTypeString(deviceInfo.deviceType),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
_SettingTile(
|
||||||
|
icon: Icons.groups,
|
||||||
|
label: 'Max Contacts',
|
||||||
|
trailing: Text(
|
||||||
|
deviceInfo.maxContacts != null
|
||||||
|
? deviceInfo.maxContacts.toString()
|
||||||
|
: 'Unknown',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
_SettingTile(
|
||||||
|
icon: Icons.tag,
|
||||||
|
label: 'Max Channels',
|
||||||
|
trailing: Text(
|
||||||
|
deviceInfo.maxChannels != null
|
||||||
|
? deviceInfo.maxChannels.toString()
|
||||||
|
: 'Unknown',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
_SettingTile(
|
||||||
|
icon: Icons.settings_suggest,
|
||||||
|
label: 'Telemetry Modes',
|
||||||
|
trailing: Text(
|
||||||
|
_getTelemetryModesString(deviceInfo),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
fontSize: 13,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
_SettingTile(
|
||||||
|
icon: Icons.group_add,
|
||||||
|
label: 'Manual Add Contacts',
|
||||||
|
isLast: true,
|
||||||
|
trailing: Text(
|
||||||
|
deviceInfo.manualAddContacts == true ? 'Enabled' : 'Disabled',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
// Public Info Section
|
// Public Info Section
|
||||||
_SectionHeader(
|
_SectionHeader(
|
||||||
title: 'Public Info',
|
title: 'Public Info',
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import 'map_management_screen.dart';
|
|||||||
import 'settings_screen.dart';
|
import 'settings_screen.dart';
|
||||||
import 'device_config_screen.dart';
|
import 'device_config_screen.dart';
|
||||||
import 'packet_log_screen.dart';
|
import 'packet_log_screen.dart';
|
||||||
import 'message_history_screen.dart';
|
|
||||||
|
|
||||||
class HomeScreen extends StatefulWidget {
|
class HomeScreen extends StatefulWidget {
|
||||||
final Function(AppThemeMode) onThemeChanged;
|
final Function(AppThemeMode) onThemeChanged;
|
||||||
@@ -233,43 +232,6 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
|
|||||||
PopupMenuButton(
|
PopupMenuButton(
|
||||||
icon: const Icon(Icons.more_vert),
|
icon: const Icon(Icons.more_vert),
|
||||||
itemBuilder: (context) => [
|
itemBuilder: (context) => [
|
||||||
PopupMenuItem(
|
|
||||||
child: const Row(
|
|
||||||
children: [
|
|
||||||
Icon(Icons.history),
|
|
||||||
SizedBox(width: 8),
|
|
||||||
Text('Message History'),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
onTap: () {
|
|
||||||
Future.delayed(Duration.zero, () {
|
|
||||||
Navigator.push(
|
|
||||||
context,
|
|
||||||
MaterialPageRoute(
|
|
||||||
builder: (context) => const MessageHistoryScreen(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
),
|
|
||||||
PopupMenuItem(
|
|
||||||
child: const Row(
|
|
||||||
children: [
|
|
||||||
Icon(Icons.refresh),
|
|
||||||
SizedBox(width: 8),
|
|
||||||
Text('Refresh Contacts'),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
onTap: () async {
|
|
||||||
final appProvider = context.read<AppProvider>();
|
|
||||||
await appProvider.refresh();
|
|
||||||
if (context.mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(content: Text('Refreshed contacts')),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
PopupMenuItem(
|
PopupMenuItem(
|
||||||
child: const Row(
|
child: const Row(
|
||||||
children: [
|
children: [
|
||||||
|
|||||||
@@ -1,490 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:provider/provider.dart';
|
|
||||||
import '../providers/messages_provider.dart';
|
|
||||||
import '../models/message.dart';
|
|
||||||
|
|
||||||
/// Screen to view all stored message history
|
|
||||||
class MessageHistoryScreen extends StatefulWidget {
|
|
||||||
const MessageHistoryScreen({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<MessageHistoryScreen> createState() => _MessageHistoryScreenState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _MessageHistoryScreenState extends State<MessageHistoryScreen> {
|
|
||||||
String _searchQuery = '';
|
|
||||||
MessageFilter _filter = MessageFilter.all;
|
|
||||||
final TextEditingController _searchController = TextEditingController();
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_searchController.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
void _showStorageInfo(BuildContext context) async {
|
|
||||||
final messagesProvider = context.read<MessagesProvider>();
|
|
||||||
final stats = await messagesProvider.getStorageStats();
|
|
||||||
|
|
||||||
if (!mounted) return;
|
|
||||||
|
|
||||||
showDialog(
|
|
||||||
context: context,
|
|
||||||
builder: (context) => AlertDialog(
|
|
||||||
title: const Text('Storage Information'),
|
|
||||||
content: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
_InfoRow(
|
|
||||||
label: 'Total Messages',
|
|
||||||
value: '${stats['messageCount']}',
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
_InfoRow(
|
|
||||||
label: 'Storage Size',
|
|
||||||
value: '${stats['storageSizeKB']} KB',
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
_InfoRow(
|
|
||||||
label: 'Storage Size (bytes)',
|
|
||||||
value: '${stats['storageSizeBytes']} bytes',
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.pop(context),
|
|
||||||
child: const Text('Close'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _showClearConfirmation(BuildContext context) {
|
|
||||||
showDialog(
|
|
||||||
context: context,
|
|
||||||
builder: (context) => AlertDialog(
|
|
||||||
title: const Text('Clear All Messages?'),
|
|
||||||
content: const Text(
|
|
||||||
'This will permanently delete all stored messages. This action cannot be undone.',
|
|
||||||
),
|
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.pop(context),
|
|
||||||
child: const Text('Cancel'),
|
|
||||||
),
|
|
||||||
TextButton(
|
|
||||||
onPressed: () {
|
|
||||||
context.read<MessagesProvider>().clearAll();
|
|
||||||
Navigator.pop(context);
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(
|
|
||||||
content: Text('All messages cleared'),
|
|
||||||
backgroundColor: Colors.green,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
style: TextButton.styleFrom(
|
|
||||||
foregroundColor: Colors.red,
|
|
||||||
),
|
|
||||||
child: const Text('Clear All'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
List<Message> _filterMessages(List<Message> messages) {
|
|
||||||
// Apply search filter
|
|
||||||
var filtered = messages.where((msg) {
|
|
||||||
if (_searchQuery.isEmpty) return true;
|
|
||||||
final query = _searchQuery.toLowerCase();
|
|
||||||
return msg.text.toLowerCase().contains(query) ||
|
|
||||||
msg.displaySender.toLowerCase().contains(query);
|
|
||||||
}).toList();
|
|
||||||
|
|
||||||
// Apply type filter
|
|
||||||
switch (_filter) {
|
|
||||||
case MessageFilter.all:
|
|
||||||
break;
|
|
||||||
case MessageFilter.contact:
|
|
||||||
filtered = filtered.where((m) => m.isContactMessage).toList();
|
|
||||||
break;
|
|
||||||
case MessageFilter.channel:
|
|
||||||
filtered = filtered.where((m) => m.isChannelMessage).toList();
|
|
||||||
break;
|
|
||||||
case MessageFilter.sarMarker:
|
|
||||||
filtered = filtered.where((m) => m.isSarMarker).toList();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort by most recent first
|
|
||||||
filtered.sort((a, b) => b.sentAt.compareTo(a.sentAt));
|
|
||||||
|
|
||||||
return filtered;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Scaffold(
|
|
||||||
appBar: AppBar(
|
|
||||||
title: const Text('Message History'),
|
|
||||||
actions: [
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.info_outline),
|
|
||||||
tooltip: 'Storage Info',
|
|
||||||
onPressed: () => _showStorageInfo(context),
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.delete_forever),
|
|
||||||
tooltip: 'Clear All',
|
|
||||||
onPressed: () => _showClearConfirmation(context),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
body: Consumer<MessagesProvider>(
|
|
||||||
builder: (context, messagesProvider, child) {
|
|
||||||
final messages = _filterMessages(messagesProvider.messages);
|
|
||||||
|
|
||||||
return Column(
|
|
||||||
children: [
|
|
||||||
// Search and filter bar
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.all(8),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Theme.of(context).colorScheme.surface,
|
|
||||||
border: Border(
|
|
||||||
bottom: BorderSide(
|
|
||||||
color: Theme.of(context).dividerColor,
|
|
||||||
width: 1,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
// Search field
|
|
||||||
TextField(
|
|
||||||
controller: _searchController,
|
|
||||||
decoration: InputDecoration(
|
|
||||||
hintText: 'Search messages...',
|
|
||||||
prefixIcon: const Icon(Icons.search),
|
|
||||||
suffixIcon: _searchQuery.isNotEmpty
|
|
||||||
? IconButton(
|
|
||||||
icon: const Icon(Icons.clear),
|
|
||||||
onPressed: () {
|
|
||||||
setState(() {
|
|
||||||
_searchController.clear();
|
|
||||||
_searchQuery = '';
|
|
||||||
});
|
|
||||||
},
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
border: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
isDense: true,
|
|
||||||
),
|
|
||||||
onChanged: (value) {
|
|
||||||
setState(() {
|
|
||||||
_searchQuery = value;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
// Filter chips
|
|
||||||
SingleChildScrollView(
|
|
||||||
scrollDirection: Axis.horizontal,
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
_FilterChip(
|
|
||||||
label: 'All (${messagesProvider.messages.length})',
|
|
||||||
isSelected: _filter == MessageFilter.all,
|
|
||||||
onTap: () => setState(() => _filter = MessageFilter.all),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
_FilterChip(
|
|
||||||
label: 'Contacts (${messagesProvider.contactMessages.length})',
|
|
||||||
isSelected: _filter == MessageFilter.contact,
|
|
||||||
onTap: () => setState(() => _filter = MessageFilter.contact),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
_FilterChip(
|
|
||||||
label: 'Channels (${messagesProvider.channelMessages.length})',
|
|
||||||
isSelected: _filter == MessageFilter.channel,
|
|
||||||
onTap: () => setState(() => _filter = MessageFilter.channel),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
_FilterChip(
|
|
||||||
label: 'SAR (${messagesProvider.sarMarkerMessages.length})',
|
|
||||||
isSelected: _filter == MessageFilter.sarMarker,
|
|
||||||
onTap: () => setState(() => _filter = MessageFilter.sarMarker),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Message list
|
|
||||||
Expanded(
|
|
||||||
child: messages.isEmpty
|
|
||||||
? Center(
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
_searchQuery.isNotEmpty
|
|
||||||
? Icons.search_off
|
|
||||||
: Icons.inbox_outlined,
|
|
||||||
size: 64,
|
|
||||||
color: Theme.of(context).disabledColor,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Text(
|
|
||||||
_searchQuery.isNotEmpty
|
|
||||||
? 'No messages found'
|
|
||||||
: 'No messages stored',
|
|
||||||
style: Theme.of(context).textTheme.titleLarge,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
_searchQuery.isNotEmpty
|
|
||||||
? 'Try a different search term'
|
|
||||||
: 'Messages will appear here once received',
|
|
||||||
style: Theme.of(context).textTheme.bodyMedium,
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: ListView.builder(
|
|
||||||
padding: const EdgeInsets.all(8),
|
|
||||||
itemCount: messages.length,
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final message = messages[index];
|
|
||||||
return _MessageHistoryCard(message: message);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
enum MessageFilter {
|
|
||||||
all,
|
|
||||||
contact,
|
|
||||||
channel,
|
|
||||||
sarMarker,
|
|
||||||
}
|
|
||||||
|
|
||||||
class _FilterChip extends StatelessWidget {
|
|
||||||
final String label;
|
|
||||||
final bool isSelected;
|
|
||||||
final VoidCallback onTap;
|
|
||||||
|
|
||||||
const _FilterChip({
|
|
||||||
required this.label,
|
|
||||||
required this.isSelected,
|
|
||||||
required this.onTap,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return FilterChip(
|
|
||||||
label: Text(label),
|
|
||||||
selected: isSelected,
|
|
||||||
onSelected: (_) => onTap(),
|
|
||||||
selectedColor: Theme.of(context).colorScheme.primaryContainer,
|
|
||||||
checkmarkColor: Theme.of(context).colorScheme.onPrimaryContainer,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _MessageHistoryCard extends StatelessWidget {
|
|
||||||
final Message message;
|
|
||||||
|
|
||||||
const _MessageHistoryCard({required this.message});
|
|
||||||
|
|
||||||
String _formatDateTime(DateTime dateTime) {
|
|
||||||
final now = DateTime.now();
|
|
||||||
final today = DateTime(now.year, now.month, now.day);
|
|
||||||
final messageDate = DateTime(dateTime.year, dateTime.month, dateTime.day);
|
|
||||||
|
|
||||||
final hour = dateTime.hour.toString().padLeft(2, '0');
|
|
||||||
final minute = dateTime.minute.toString().padLeft(2, '0');
|
|
||||||
final timeStr = '$hour:$minute';
|
|
||||||
|
|
||||||
if (messageDate == today) {
|
|
||||||
return 'Today $timeStr';
|
|
||||||
} else if (messageDate == today.subtract(const Duration(days: 1))) {
|
|
||||||
return 'Yesterday $timeStr';
|
|
||||||
} else if (now.difference(dateTime).inDays < 7) {
|
|
||||||
final weekdays = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
|
||||||
final weekday = weekdays[dateTime.weekday - 1];
|
|
||||||
return '$weekday $timeStr';
|
|
||||||
} else {
|
|
||||||
final months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
||||||
final month = months[dateTime.month - 1];
|
|
||||||
return '$month ${dateTime.day}, ${dateTime.year} $timeStr';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Card(
|
|
||||||
margin: const EdgeInsets.only(bottom: 8),
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(12),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
// Header row
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
// Type icon
|
|
||||||
Icon(
|
|
||||||
message.isChannelMessage ? Icons.tag : Icons.person,
|
|
||||||
size: 16,
|
|
||||||
color: Theme.of(context).colorScheme.primary,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
// Sender
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
message.displaySender,
|
|
||||||
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
// SAR badge
|
|
||||||
if (message.isSarMarker) ...[
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 6,
|
|
||||||
vertical: 2,
|
|
||||||
),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Theme.of(context).colorScheme.primaryContainer,
|
|
||||||
borderRadius: BorderRadius.circular(4),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
'SAR',
|
|
||||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
|
||||||
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
|
|
||||||
// Message content
|
|
||||||
if (message.isSarMarker && message.sarMarkerType != null) ...[
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
message.sarMarkerType!.emoji,
|
|
||||||
style: const TextStyle(fontSize: 24),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
message.sarMarkerType!.displayName,
|
|
||||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (message.sarGpsCoordinates != null)
|
|
||||||
Text(
|
|
||||||
'${message.sarGpsCoordinates!.latitude.toStringAsFixed(5)}, ${message.sarGpsCoordinates!.longitude.toStringAsFixed(5)}',
|
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
] else
|
|
||||||
Text(
|
|
||||||
message.text,
|
|
||||||
style: Theme.of(context).textTheme.bodyMedium,
|
|
||||||
maxLines: 3,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
|
|
||||||
// Footer row
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Icons.access_time,
|
|
||||||
size: 12,
|
|
||||||
color: Theme.of(context).textTheme.bodySmall?.color,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
Text(
|
|
||||||
_formatDateTime(message.sentAt),
|
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
|
||||||
),
|
|
||||||
const Spacer(),
|
|
||||||
Text(
|
|
||||||
'Received: ${_formatDateTime(message.receivedAt)}',
|
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
||||||
fontStyle: FontStyle.italic,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _InfoRow extends StatelessWidget {
|
|
||||||
final String label;
|
|
||||||
final String value;
|
|
||||||
|
|
||||||
const _InfoRow({
|
|
||||||
required this.label,
|
|
||||||
required this.value,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
label,
|
|
||||||
style: Theme.of(context).textTheme.bodyMedium,
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
value,
|
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -25,10 +25,6 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
int _characterCount = 0;
|
int _characterCount = 0;
|
||||||
static const int _maxCharacters = 160;
|
static const int _maxCharacters = 160;
|
||||||
|
|
||||||
// Message recipient selection
|
|
||||||
String? _selectedRecipientId; // null = broadcast to public channel (channel 0)
|
|
||||||
MessageRecipientType _recipientType = MessageRecipientType.room;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@@ -53,7 +49,6 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
if (text.isEmpty) return;
|
if (text.isEmpty) return;
|
||||||
|
|
||||||
final connectionProvider = context.read<ConnectionProvider>();
|
final connectionProvider = context.read<ConnectionProvider>();
|
||||||
final contactsProvider = context.read<ContactsProvider>();
|
|
||||||
|
|
||||||
if (!connectionProvider.deviceInfo.isConnected) {
|
if (!connectionProvider.deviceInfo.isConnected) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
@@ -67,40 +62,11 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (_recipientType == MessageRecipientType.contact && _selectedRecipientId != null) {
|
// Always send to public channel (channel 0)
|
||||||
// Send to specific contact
|
await connectionProvider.sendChannelMessage(
|
||||||
final contact = contactsProvider.contacts.firstWhere(
|
channelIdx: 0,
|
||||||
(c) => c.publicKeyHex == _selectedRecipientId,
|
text: text,
|
||||||
);
|
);
|
||||||
await connectionProvider.sendTextMessage(
|
|
||||||
contactPublicKey: contact.publicKey,
|
|
||||||
text: text,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
// Send to room/channel
|
|
||||||
// Default to channel 0 (public channel) if no specific room selected
|
|
||||||
int channelIdx = 0;
|
|
||||||
|
|
||||||
if (_selectedRecipientId != null) {
|
|
||||||
// Try to find selected room
|
|
||||||
final rooms = contactsProvider.rooms;
|
|
||||||
try {
|
|
||||||
final targetRoom = rooms.firstWhere(
|
|
||||||
(r) => r.publicKeyHex == _selectedRecipientId,
|
|
||||||
);
|
|
||||||
if (targetRoom.outPath.isNotEmpty) {
|
|
||||||
channelIdx = targetRoom.outPath[0];
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// Room not found, use default channel 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await connectionProvider.sendChannelMessage(
|
|
||||||
channelIdx: channelIdx,
|
|
||||||
text: text,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
_textController.clear();
|
_textController.clear();
|
||||||
_focusNode.unfocus();
|
_focusNode.unfocus();
|
||||||
@@ -108,7 +74,7 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
const SnackBar(
|
||||||
content: Text('Message sent'),
|
content: Text('Message sent to public channel'),
|
||||||
backgroundColor: Colors.green,
|
backgroundColor: Colors.green,
|
||||||
duration: Duration(seconds: 1),
|
duration: Duration(seconds: 1),
|
||||||
),
|
),
|
||||||
@@ -124,81 +90,6 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showRecipientSelector() {
|
|
||||||
showModalBottomSheet(
|
|
||||||
context: context,
|
|
||||||
backgroundColor: Colors.transparent,
|
|
||||||
builder: (context) => _RecipientSelectorSheet(
|
|
||||||
selectedRecipientId: _selectedRecipientId,
|
|
||||||
selectedRecipientType: _recipientType,
|
|
||||||
onSelect: (recipientId, recipientType) {
|
|
||||||
setState(() {
|
|
||||||
_selectedRecipientId = recipientId;
|
|
||||||
_recipientType = recipientType;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Fetch messages from the newly selected channel/room
|
|
||||||
_syncMessagesForRecipient();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sync all messages when recipient changes (for sending context)
|
|
||||||
Future<void> _syncMessagesForRecipient() async {
|
|
||||||
final appProvider = context.read<AppProvider>();
|
|
||||||
|
|
||||||
if (!appProvider.connectionProvider.deviceInfo.isConnected) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
debugPrint('🔄 [MessagesTab] Syncing messages after recipient change...');
|
|
||||||
final messageCount = await appProvider.syncMessages();
|
|
||||||
|
|
||||||
if (!mounted) return;
|
|
||||||
if (messageCount > 0) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text('Synced $messageCount message${messageCount == 1 ? '' : 's'}'),
|
|
||||||
backgroundColor: Colors.green,
|
|
||||||
duration: const Duration(seconds: 2),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('❌ [MessagesTab] Error syncing messages: $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
String _getRecipientDisplayName() {
|
|
||||||
final contactsProvider = context.read<ContactsProvider>();
|
|
||||||
|
|
||||||
if (_selectedRecipientId == null) {
|
|
||||||
// Default to public channel
|
|
||||||
return 'Public Channel';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_recipientType == MessageRecipientType.contact) {
|
|
||||||
try {
|
|
||||||
final contact = contactsProvider.contacts.firstWhere(
|
|
||||||
(c) => c.publicKeyHex == _selectedRecipientId,
|
|
||||||
);
|
|
||||||
return contact.displayName;
|
|
||||||
} catch (e) {
|
|
||||||
return 'Public Channel';
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
final room = contactsProvider.rooms.firstWhere(
|
|
||||||
(r) => r.publicKeyHex == _selectedRecipientId,
|
|
||||||
);
|
|
||||||
return room.displayName;
|
|
||||||
} catch (e) {
|
|
||||||
return 'Public Channel';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _showSarDialog() {
|
void _showSarDialog() {
|
||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
@@ -206,8 +97,8 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
isScrollControlled: true,
|
isScrollControlled: true,
|
||||||
backgroundColor: Colors.transparent,
|
backgroundColor: Colors.transparent,
|
||||||
builder: (context) => _SarUpdateSheet(
|
builder: (context) => _SarUpdateSheet(
|
||||||
onSend: (sarType, position, notes) async {
|
onSend: (sarType, position, notes, channelIdx) async {
|
||||||
await _sendSarMessage(sarType, position, notes);
|
await _sendSarMessage(sarType, position, notes, channelIdx);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -217,9 +108,9 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
SarMarkerType sarType,
|
SarMarkerType sarType,
|
||||||
Position position,
|
Position position,
|
||||||
String? notes,
|
String? notes,
|
||||||
|
int channelIdx,
|
||||||
) async {
|
) async {
|
||||||
final connectionProvider = context.read<ConnectionProvider>();
|
final connectionProvider = context.read<ConnectionProvider>();
|
||||||
final contactsProvider = context.read<ContactsProvider>();
|
|
||||||
|
|
||||||
if (!connectionProvider.deviceInfo.isConnected) {
|
if (!connectionProvider.deviceInfo.isConnected) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
@@ -241,46 +132,17 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
? '$sarMessage $notes'
|
? '$sarMessage $notes'
|
||||||
: sarMessage;
|
: sarMessage;
|
||||||
|
|
||||||
// Send to selected recipient (contact or room)
|
// Send SAR message to selected room/channel
|
||||||
if (_recipientType == MessageRecipientType.contact && _selectedRecipientId != null) {
|
await connectionProvider.sendChannelMessage(
|
||||||
// Send to specific contact
|
channelIdx: channelIdx,
|
||||||
final contact = contactsProvider.contacts.firstWhere(
|
text: fullMessage,
|
||||||
(c) => c.publicKeyHex == _selectedRecipientId,
|
);
|
||||||
);
|
|
||||||
await connectionProvider.sendTextMessage(
|
|
||||||
contactPublicKey: contact.publicKey,
|
|
||||||
text: fullMessage,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
// Send to room/channel
|
|
||||||
// Default to channel 0 (public channel) if no specific room selected
|
|
||||||
int channelIdx = 0;
|
|
||||||
|
|
||||||
if (_selectedRecipientId != null) {
|
|
||||||
// Try to find selected room
|
|
||||||
final rooms = contactsProvider.rooms;
|
|
||||||
try {
|
|
||||||
final targetRoom = rooms.firstWhere(
|
|
||||||
(r) => r.publicKeyHex == _selectedRecipientId,
|
|
||||||
);
|
|
||||||
if (targetRoom.outPath.isNotEmpty) {
|
|
||||||
channelIdx = targetRoom.outPath[0];
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// Room not found, use default channel 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await connectionProvider.sendChannelMessage(
|
|
||||||
channelIdx: channelIdx,
|
|
||||||
text: fullMessage,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
final channelName = channelIdx == 0 ? 'Public Channel' : 'Channel $channelIdx';
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text('${sarType.displayName} marker sent'),
|
content: Text('${sarType.displayName} marker sent to $channelName'),
|
||||||
backgroundColor: Colors.green,
|
backgroundColor: Colors.green,
|
||||||
duration: const Duration(seconds: 2),
|
duration: const Duration(seconds: 2),
|
||||||
),
|
),
|
||||||
@@ -393,54 +255,7 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
||||||
child: Column(
|
child: Row(
|
||||||
children: [
|
|
||||||
// Recipient selector bar
|
|
||||||
Consumer<ContactsProvider>(
|
|
||||||
builder: (context, contactsProvider, child) {
|
|
||||||
return Container(
|
|
||||||
margin: const EdgeInsets.only(bottom: 8),
|
|
||||||
child: InkWell(
|
|
||||||
onTap: _showRecipientSelector,
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Theme.of(context).colorScheme.surfaceVariant,
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
_recipientType == MessageRecipientType.contact
|
|
||||||
? Icons.person
|
|
||||||
: Icons.tag,
|
|
||||||
size: 18,
|
|
||||||
color: Theme.of(context).colorScheme.primary,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
'To: ${_getRecipientDisplayName()}',
|
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Icon(
|
|
||||||
Icons.arrow_drop_down,
|
|
||||||
color: Theme.of(context).colorScheme.primary,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
// Message input row
|
|
||||||
Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
children: [
|
children: [
|
||||||
// SAR quick action button
|
// SAR quick action button
|
||||||
@@ -503,8 +318,6 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -764,7 +577,7 @@ class _MessageBubble extends StatelessWidget {
|
|||||||
|
|
||||||
// SAR Update Sheet
|
// SAR Update Sheet
|
||||||
class _SarUpdateSheet extends StatefulWidget {
|
class _SarUpdateSheet extends StatefulWidget {
|
||||||
final Future<void> Function(SarMarkerType, Position, String?) onSend;
|
final Future<void> Function(SarMarkerType, Position, String?, int) onSend;
|
||||||
|
|
||||||
const _SarUpdateSheet({required this.onSend});
|
const _SarUpdateSheet({required this.onSend});
|
||||||
|
|
||||||
@@ -777,6 +590,7 @@ class _SarUpdateSheetState extends State<_SarUpdateSheet> {
|
|||||||
Position? _currentPosition;
|
Position? _currentPosition;
|
||||||
bool _loadingLocation = false;
|
bool _loadingLocation = false;
|
||||||
String? _locationError;
|
String? _locationError;
|
||||||
|
int _selectedChannelIdx = 0; // Default to Public Channel (channel 0)
|
||||||
final TextEditingController _notesController = TextEditingController();
|
final TextEditingController _notesController = TextEditingController();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -942,6 +756,82 @@ class _SarUpdateSheetState extends State<_SarUpdateSheet> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// Room/Channel selection
|
||||||
|
const Text(
|
||||||
|
'Send To',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Consumer<ContactsProvider>(
|
||||||
|
builder: (context, contactsProvider, child) {
|
||||||
|
// Build list of available rooms/channels
|
||||||
|
final rooms = contactsProvider.rooms;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFF2D2D2D),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: DropdownButtonHideUnderline(
|
||||||
|
child: DropdownButton<int>(
|
||||||
|
value: _selectedChannelIdx,
|
||||||
|
dropdownColor: const Color(0xFF2D2D2D),
|
||||||
|
isExpanded: true,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
icon: const Icon(Icons.arrow_drop_down, color: Colors.white),
|
||||||
|
items: [
|
||||||
|
// Public Channel (always available)
|
||||||
|
const DropdownMenuItem<int>(
|
||||||
|
value: 0,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.public, size: 18, color: Colors.white),
|
||||||
|
SizedBox(width: 12),
|
||||||
|
Text('Public Channel'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Room channels
|
||||||
|
...rooms.asMap().entries.map((entry) {
|
||||||
|
final idx = entry.key + 1; // Rooms start at channel 1
|
||||||
|
final room = entry.value;
|
||||||
|
return DropdownMenuItem<int>(
|
||||||
|
value: idx,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.tag, size: 18, color: Colors.white),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
room.displayName,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
],
|
||||||
|
onChanged: (value) {
|
||||||
|
if (value != null) {
|
||||||
|
setState(() => _selectedChannelIdx = value);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
// Location display
|
// Location display
|
||||||
const Text(
|
const Text(
|
||||||
'Current Location',
|
'Current Location',
|
||||||
@@ -1135,6 +1025,7 @@ class _SarUpdateSheetState extends State<_SarUpdateSheet> {
|
|||||||
_notesController.text.trim().isEmpty
|
_notesController.text.trim().isEmpty
|
||||||
? null
|
? null
|
||||||
: _notesController.text.trim(),
|
: _notesController.text.trim(),
|
||||||
|
_selectedChannelIdx,
|
||||||
);
|
);
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
@@ -1237,210 +1128,3 @@ class _MarkerTypeChip extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Message recipient type enum
|
|
||||||
enum MessageRecipientType {
|
|
||||||
contact,
|
|
||||||
room,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Recipient Selector Sheet
|
|
||||||
class _RecipientSelectorSheet extends StatefulWidget {
|
|
||||||
final String? selectedRecipientId;
|
|
||||||
final MessageRecipientType selectedRecipientType;
|
|
||||||
final void Function(String?, MessageRecipientType) onSelect;
|
|
||||||
|
|
||||||
const _RecipientSelectorSheet({
|
|
||||||
required this.selectedRecipientId,
|
|
||||||
required this.selectedRecipientType,
|
|
||||||
required this.onSelect,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<_RecipientSelectorSheet> createState() => _RecipientSelectorSheetState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _RecipientSelectorSheetState extends State<_RecipientSelectorSheet> with SingleTickerProviderStateMixin {
|
|
||||||
late TabController _tabController;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_tabController = TabController(
|
|
||||||
length: 2,
|
|
||||||
vsync: this,
|
|
||||||
initialIndex: widget.selectedRecipientType == MessageRecipientType.contact ? 0 : 1,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_tabController.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Container(
|
|
||||||
height: MediaQuery.of(context).size.height * 0.7,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Theme.of(context).colorScheme.surface,
|
|
||||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
// Header
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
border: Border(
|
|
||||||
bottom: BorderSide(
|
|
||||||
color: Theme.of(context).dividerColor,
|
|
||||||
width: 1,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
'Select Recipient',
|
|
||||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Spacer(),
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.close),
|
|
||||||
onPressed: () => Navigator.pop(context),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
// Tab bar
|
|
||||||
TabBar(
|
|
||||||
controller: _tabController,
|
|
||||||
tabs: const [
|
|
||||||
Tab(
|
|
||||||
icon: Icon(Icons.person),
|
|
||||||
text: 'Contacts',
|
|
||||||
),
|
|
||||||
Tab(
|
|
||||||
icon: Icon(Icons.tag),
|
|
||||||
text: 'Channels',
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
// Tab view
|
|
||||||
Expanded(
|
|
||||||
child: TabBarView(
|
|
||||||
controller: _tabController,
|
|
||||||
children: [
|
|
||||||
// Contacts tab
|
|
||||||
Consumer<ContactsProvider>(
|
|
||||||
builder: (context, contactsProvider, child) {
|
|
||||||
final contacts = contactsProvider.chatContacts;
|
|
||||||
|
|
||||||
if (contacts.isEmpty) {
|
|
||||||
return const Center(
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Icon(Icons.person_off, size: 64, color: Colors.grey),
|
|
||||||
SizedBox(height: 16),
|
|
||||||
Text('No contacts available'),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return ListView.builder(
|
|
||||||
itemCount: contacts.length,
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final contact = contacts[index];
|
|
||||||
final isSelected = widget.selectedRecipientType == MessageRecipientType.contact &&
|
|
||||||
widget.selectedRecipientId == contact.publicKeyHex;
|
|
||||||
|
|
||||||
return ListTile(
|
|
||||||
leading: CircleAvatar(
|
|
||||||
child: contact.roleEmoji != null
|
|
||||||
? Text(contact.roleEmoji!)
|
|
||||||
: const Icon(Icons.person),
|
|
||||||
),
|
|
||||||
title: Text(contact.displayName),
|
|
||||||
subtitle: Text(
|
|
||||||
contact.publicKeyShort,
|
|
||||||
style: const TextStyle(fontFamily: 'monospace', fontSize: 12),
|
|
||||||
),
|
|
||||||
trailing: isSelected
|
|
||||||
? Icon(
|
|
||||||
Icons.check_circle,
|
|
||||||
color: Theme.of(context).colorScheme.primary,
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
selected: isSelected,
|
|
||||||
onTap: () {
|
|
||||||
widget.onSelect(contact.publicKeyHex, MessageRecipientType.contact);
|
|
||||||
Navigator.pop(context);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
// Channels/Rooms tab
|
|
||||||
Consumer<ContactsProvider>(
|
|
||||||
builder: (context, contactsProvider, child) {
|
|
||||||
final rooms = contactsProvider.rooms;
|
|
||||||
|
|
||||||
if (rooms.isEmpty) {
|
|
||||||
return Center(
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Icon(Icons.tag, size: 64, color: Colors.grey),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
const Text('No channels available'),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return ListView.builder(
|
|
||||||
itemCount: rooms.length,
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final room = rooms[index];
|
|
||||||
final isSelected = widget.selectedRecipientType == MessageRecipientType.room &&
|
|
||||||
widget.selectedRecipientId == room.publicKeyHex;
|
|
||||||
|
|
||||||
return ListTile(
|
|
||||||
leading: const CircleAvatar(
|
|
||||||
child: Icon(Icons.tag),
|
|
||||||
),
|
|
||||||
title: Text(room.displayName),
|
|
||||||
subtitle: Text(
|
|
||||||
room.publicKeyShort,
|
|
||||||
style: const TextStyle(fontFamily: 'monospace', fontSize: 12),
|
|
||||||
),
|
|
||||||
trailing: isSelected
|
|
||||||
? Icon(
|
|
||||||
Icons.check_circle,
|
|
||||||
color: Theme.of(context).colorScheme.primary,
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
selected: isSelected,
|
|
||||||
onTap: () {
|
|
||||||
widget.onSelect(room.publicKeyHex, MessageRecipientType.room);
|
|
||||||
Navigator.pop(context);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -346,6 +346,10 @@ class MeshCoreBleService {
|
|||||||
print(' → Handling NewAdvert push');
|
print(' → Handling NewAdvert push');
|
||||||
_handleNewAdvert(reader);
|
_handleNewAdvert(reader);
|
||||||
break;
|
break;
|
||||||
|
case MeshCoreConstants.pushSendConfirmed:
|
||||||
|
print(' → Handling SendConfirmed push');
|
||||||
|
_handleSendConfirmed(reader);
|
||||||
|
break;
|
||||||
case MeshCoreConstants.respNoMoreMessages:
|
case MeshCoreConstants.respNoMoreMessages:
|
||||||
print(' → Response: No More Messages');
|
print(' → Response: No More Messages');
|
||||||
onNoMoreMessages?.call();
|
onNoMoreMessages?.call();
|
||||||
@@ -442,35 +446,34 @@ class MeshCoreBleService {
|
|||||||
_pendingContacts.clear();
|
_pendingContacts.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handle Sent confirmation response
|
/// Handle Sent confirmation response (RESP_CODE_SENT)
|
||||||
|
///
|
||||||
|
/// Protocol format:
|
||||||
|
/// - 1 byte: send type (1=flood, 0=direct)
|
||||||
|
/// - 4 bytes: expected ACK code or TAG
|
||||||
|
/// - 4 bytes: suggested timeout (uint32, milliseconds)
|
||||||
void _handleSentConfirmation(BufferReader reader) {
|
void _handleSentConfirmation(BufferReader reader) {
|
||||||
try {
|
try {
|
||||||
print(' [Sent] Parsing sent confirmation...');
|
print(' [Sent] Parsing sent confirmation...');
|
||||||
print(' Remaining bytes: ${reader.remainingBytesCount}');
|
print(' Remaining bytes: ${reader.remainingBytesCount}');
|
||||||
|
|
||||||
// Sent confirmation format (from protocol):
|
|
||||||
// - 1 byte: reserved
|
|
||||||
// - 4 bytes: public key prefix (recipient)
|
|
||||||
// - 2 bytes: message ID
|
|
||||||
// - 2 bytes: reserved
|
|
||||||
|
|
||||||
if (reader.remainingBytesCount >= 9) {
|
if (reader.remainingBytesCount >= 9) {
|
||||||
final reserved1 = reader.readByte();
|
final sendType = reader.readByte();
|
||||||
print(' Reserved: $reserved1');
|
final sendTypeStr = sendType == 1 ? 'flood' : 'direct';
|
||||||
|
print(' Send type: $sendType ($sendTypeStr)');
|
||||||
|
|
||||||
final pubKeyPrefix = reader.readBytes(4);
|
final expectedAckOrTag = reader.readBytes(4);
|
||||||
print(' Recipient public key prefix: ${pubKeyPrefix.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}');
|
print(' Expected ACK/TAG: ${expectedAckOrTag.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')}');
|
||||||
|
|
||||||
final messageId = reader.readUInt16LE();
|
final suggestedTimeout = reader.readUInt32LE();
|
||||||
print(' Message ID: $messageId');
|
print(' Suggested timeout: ${suggestedTimeout}ms');
|
||||||
|
|
||||||
if (reader.remainingBytesCount >= 2) {
|
print(' ✅ [Sent] Message sent successfully ($sendTypeStr mode, timeout: ${suggestedTimeout}ms)');
|
||||||
final reserved2 = reader.readUInt16LE();
|
|
||||||
print(' Reserved2: $reserved2');
|
// TODO: Store ACK/TAG to match with PUSH_CODE_SEND_CONFIRMED later
|
||||||
}
|
} else {
|
||||||
|
print(' ⚠️ [Sent] Insufficient data for full parsing');
|
||||||
}
|
}
|
||||||
|
|
||||||
print(' ✅ [Sent] Message sent confirmation');
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print(' ❌ [Sent] Parsing error: $e');
|
print(' ❌ [Sent] Parsing error: $e');
|
||||||
// Don't call onError - sent confirmations are informational
|
// Don't call onError - sent confirmations are informational
|
||||||
@@ -814,6 +817,35 @@ class MeshCoreBleService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Handle SendConfirmed push (PUSH_CODE_SEND_CONFIRMED)
|
||||||
|
///
|
||||||
|
/// Protocol format:
|
||||||
|
/// - 4 bytes: ACK code
|
||||||
|
/// - 4 bytes: round trip time (uint32, milliseconds)
|
||||||
|
void _handleSendConfirmed(BufferReader reader) {
|
||||||
|
try {
|
||||||
|
print(' [SendConfirmed] Parsing send confirmed...');
|
||||||
|
print(' Remaining bytes: ${reader.remainingBytesCount}');
|
||||||
|
|
||||||
|
if (reader.remainingBytesCount >= 8) {
|
||||||
|
final ackCode = reader.readBytes(4);
|
||||||
|
print(' ACK code: ${ackCode.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')}');
|
||||||
|
|
||||||
|
final roundTripTime = reader.readUInt32LE();
|
||||||
|
print(' Round trip time: ${roundTripTime}ms');
|
||||||
|
|
||||||
|
print(' ✅ [SendConfirmed] Message delivery confirmed (RTT: ${roundTripTime}ms)');
|
||||||
|
|
||||||
|
// TODO: Match ACK code with pending sends and notify UI
|
||||||
|
} else {
|
||||||
|
print(' ⚠️ [SendConfirmed] Insufficient data for full parsing');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
print(' ❌ [SendConfirmed] Parsing error: $e');
|
||||||
|
// Don't call onError - confirmations are informational
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Send AppStart command
|
/// Send AppStart command
|
||||||
Future<void> _sendAppStart() async {
|
Future<void> _sendAppStart() async {
|
||||||
final writer = BufferWriter();
|
final writer = BufferWriter();
|
||||||
@@ -845,31 +877,60 @@ class MeshCoreBleService {
|
|||||||
await _writeData(writer.toBytes());
|
await _writeData(writer.toBytes());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Send text message to contact
|
/// Send text message to contact (DM)
|
||||||
|
///
|
||||||
|
/// Protocol format (CMD_SEND_TXT_MSG):
|
||||||
|
/// - 1 byte: command code (2)
|
||||||
|
/// - 1 byte: text type (TXT_TYPE_*, 0=plain)
|
||||||
|
/// - 1 byte: attempt (0-3, attempt number)
|
||||||
|
/// - 4 bytes: sender timestamp (uint32, epoch seconds)
|
||||||
|
/// - 6 bytes: recipient public key prefix (first 6 bytes)
|
||||||
|
/// - N bytes: text (remainder of frame, varchar, max 160 bytes)
|
||||||
Future<void> sendTextMessage({
|
Future<void> sendTextMessage({
|
||||||
required Uint8List contactPublicKey,
|
required Uint8List contactPublicKey,
|
||||||
required String text,
|
required String text,
|
||||||
|
int textType = 0, // TXT_TYPE_PLAIN
|
||||||
|
int attempt = 0,
|
||||||
}) async {
|
}) async {
|
||||||
|
if (text.length > 160) {
|
||||||
|
throw ArgumentError('Text message exceeds 160 character limit');
|
||||||
|
}
|
||||||
|
|
||||||
final writer = BufferWriter();
|
final writer = BufferWriter();
|
||||||
writer.writeByte(MeshCoreConstants.cmdSendTxtMsg);
|
writer.writeByte(MeshCoreConstants.cmdSendTxtMsg); // 0x02
|
||||||
writer.writeByte(MeshCoreConstants.txtTypePlain);
|
writer.writeByte(textType); // TXT_TYPE_*
|
||||||
writer.writeByte(0); // attempt
|
writer.writeByte(attempt); // 0-3
|
||||||
writer.writeUInt32LE(DateTime.now().millisecondsSinceEpoch ~/ 1000);
|
writer.writeUInt32LE(DateTime.now().millisecondsSinceEpoch ~/ 1000); // epoch seconds
|
||||||
writer.writeBytes(contactPublicKey.sublist(0, 6));
|
writer.writeBytes(contactPublicKey.sublist(0, 6)); // first 6 bytes of public key
|
||||||
writer.writeString(text);
|
writer.writeString(text);
|
||||||
await _writeData(writer.toBytes());
|
await _writeData(writer.toBytes());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Send channel text message
|
/// Send flood-mode text message to channel
|
||||||
|
///
|
||||||
|
/// Protocol format (CMD_SEND_CHANNEL_TXT_MSG):
|
||||||
|
/// - 1 byte: command code (3)
|
||||||
|
/// - 1 byte: text type (TXT_TYPE_*, 0=plain)
|
||||||
|
/// - 1 byte: channel index (reserved, 0 for 'public')
|
||||||
|
/// - 4 bytes: sender timestamp (uint32, epoch seconds)
|
||||||
|
/// - N bytes: text (remainder of frame, max 160 - len(advert_name) - 2)
|
||||||
|
///
|
||||||
|
/// Note: For SAR messages, ensure text starts with "S:<emoji>:<lat>,<lon>"
|
||||||
Future<void> sendChannelMessage({
|
Future<void> sendChannelMessage({
|
||||||
required int channelIdx,
|
required int channelIdx,
|
||||||
required String text,
|
required String text,
|
||||||
|
int textType = 0, // TXT_TYPE_PLAIN
|
||||||
}) async {
|
}) async {
|
||||||
|
// Note: Max length depends on advert name length, but typically ~140 chars
|
||||||
|
if (text.length > 160) {
|
||||||
|
throw ArgumentError('Channel message too long (max ~160 characters)');
|
||||||
|
}
|
||||||
|
|
||||||
final writer = BufferWriter();
|
final writer = BufferWriter();
|
||||||
writer.writeByte(MeshCoreConstants.cmdSendChannelTxtMsg);
|
writer.writeByte(MeshCoreConstants.cmdSendChannelTxtMsg); // 0x03
|
||||||
writer.writeByte(MeshCoreConstants.txtTypePlain);
|
writer.writeByte(textType); // TXT_TYPE_*
|
||||||
writer.writeByte(channelIdx);
|
writer.writeByte(channelIdx); // 0 for 'public' channel
|
||||||
writer.writeUInt32LE(DateTime.now().millisecondsSinceEpoch ~/ 1000);
|
writer.writeUInt32LE(DateTime.now().millisecondsSinceEpoch ~/ 1000); // epoch seconds
|
||||||
writer.writeString(text);
|
writer.writeString(text);
|
||||||
await _writeData(writer.toBytes());
|
await _writeData(writer.toBytes());
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user