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:
Janez T
2025-10-14 20:43:58 +02:00
parent 5ff4138de0
commit 219eeecacd
8 changed files with 1121 additions and 1026 deletions

517
CLAUDE.md
View File

@@ -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:

View File

@@ -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,
); );
} }

View File

@@ -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),
),
),
),
],
),
),
],
),
);
}
}

View File

@@ -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',

View File

@@ -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: [

View File

@@ -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,
),
),
],
);
}
}

View File

@@ -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);
},
);
},
);
},
),
],
),
),
],
),
);
}
}

View File

@@ -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());
} }