diff --git a/CLAUDE.md b/CLAUDE.md index 9508f96..f8f1570 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -68,72 +68,513 @@ lib/ ### 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` **RX Characteristic** (write): `6E400002-B5A3-F393-E0A9-E50E24DCCA9E` **TX Characteristic** (notify): `6E400003-B5A3-F393-E0A9-E50E24DCCA9E` -#### Command Codes (Client → Device) -- `4`: Get contacts list -- `2`: Send text message -- `39`: Request telemetry +#### Protocol Overview -#### Response Codes (Device → Client) -- `3`: Contact information -- `7`: Message received -- `0x8B` (139): Telemetry response (Cayenne LPP format) +The companion radio acts as a 'server', responding to requests from the connected app (the 'client'). -#### 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 -[1 byte] - Contact type (0=none, 1=chat, 2=repeater, 3=room) -[64 bytes] - Advertised name (null-terminated string) -[4 bytes] - Latitude (int32, divide by 10000 for degrees) -[4 bytes] - Longitude (int32, divide by 10000 for degrees) +[4 bytes] - Advert latitude * 1E6 (int32) +[4 bytes] - Advert longitude * 1E6 (int32) +[1 byte] - Multi ACKs (0=no extra, 1=send extra ACK) +[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 -[1 byte] - Message type (0=contact, 1=channel) -[4 bytes] - Sender public key prefix -[4 bytes] - Recipient public key prefix -[2 bytes] - Text length (uint16) -[N bytes] - UTF-8 text +[0x04] - Command code (4) +[4 bytes] - (Optional) Since timestamp (uint32, last contact.lastmod received) ``` -**Send Text Message**: +**RESP_CODE_CONTACTS_START (2)**: ``` -[0x02] - Command code -[32 bytes] - Recipient public key -[2 bytes] - Text length (uint16) -[N bytes] - UTF-8 text +[0x02] - Response code (2) +[4 bytes] - Total contact count (uint32) ``` -**Request Telemetry**: +**RESP_CODE_CONTACT (3)**: ``` -[0x27] (39) - Command code -[32 bytes] - Contact public key +[0x03] - Response code (3) +[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 -[4 bytes] - Contact public key prefix -[N bytes] - Cayenne LPP payload +[0x04] - Response code (4) +[4 bytes] - Most recent lastmod (uint32, use for next 'since' param) ``` +**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 Telemetry data uses Cayenne Low Power Payload format: diff --git a/lib/models/device_info.dart b/lib/models/device_info.dart index 386a7b7..2738362 100644 --- a/lib/models/device_info.dart +++ b/lib/models/device_info.dart @@ -34,10 +34,19 @@ class DeviceInfo { final int? radioCr; 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 final int? firmwareVersion; final String? firmwareBuildDate; final String? manufacturerModel; + final String? semanticVersion; DeviceInfo({ this.deviceId, @@ -60,9 +69,16 @@ class DeviceInfo { this.radioSf, this.radioCr, this.selfName, + this.maxContacts, + this.maxChannels, + this.telemetryModes, + this.blePin, + this.multiAcks, + this.advertLocPolicy, this.firmwareVersion, this.firmwareBuildDate, this.manufacturerModel, + this.semanticVersion, }); /// Check if device is connected @@ -144,9 +160,16 @@ class DeviceInfo { int? radioSf, int? radioCr, String? selfName, + int? maxContacts, + int? maxChannels, + int? telemetryModes, + int? blePin, + int? multiAcks, + int? advertLocPolicy, int? firmwareVersion, String? firmwareBuildDate, String? manufacturerModel, + String? semanticVersion, }) { return DeviceInfo( deviceId: deviceId ?? this.deviceId, @@ -169,9 +192,16 @@ class DeviceInfo { radioSf: radioSf ?? this.radioSf, radioCr: radioCr ?? this.radioCr, 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, firmwareBuildDate: firmwareBuildDate ?? this.firmwareBuildDate, manufacturerModel: manufacturerModel ?? this.manufacturerModel, + semanticVersion: semanticVersion ?? this.semanticVersion, ); } diff --git a/lib/screens/contacts_tab.dart b/lib/screens/contacts_tab.dart index 996ba36..7b82167 100644 --- a/lib/screens/contacts_tab.dart +++ b/lib/screens/contacts_tab.dart @@ -1,12 +1,24 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import '../providers/contacts_provider.dart'; import '../providers/connection_provider.dart'; +import '../providers/app_provider.dart'; import '../models/contact.dart'; -class ContactsTab extends StatelessWidget { +class ContactsTab extends StatefulWidget { const ContactsTab({super.key}); + @override + State createState() => _ContactsTabState(); +} + +class _ContactsTabState extends State { + Future _handleRefresh() async { + final appProvider = context.read(); + await appProvider.refresh(); + } + @override Widget build(BuildContext context) { return Consumer( @@ -32,7 +44,7 @@ class ContactsTab extends StatelessWidget { ), const SizedBox(height: 8), Text( - 'Connect to a device and refresh to load contacts', + 'Connect to a device to load contacts', style: Theme.of(context).textTheme.bodyMedium, textAlign: TextAlign.center, ), @@ -41,9 +53,11 @@ class ContactsTab extends StatelessWidget { ); } - return ListView( - padding: const EdgeInsets.all(8), - children: [ + return RefreshIndicator( + onRefresh: _handleRefresh, + child: ListView( + padding: const EdgeInsets.all(8), + children: [ // Team Members (Chat contacts) if (chatContacts.isNotEmpty) ...[ _SectionHeader( @@ -75,7 +89,8 @@ class ContactsTab extends StatelessWidget { ), ...rooms.map((contact) => _ContactTile(contact: contact)), ], - ], + ], + ), ); }, ); @@ -234,19 +249,32 @@ class _ContactTile extends StatelessWidget { ), ], ), - trailing: IconButton( - icon: const Icon(Icons.refresh, size: 20), - onPressed: () { - final connectionProvider = context.read(); - connectionProvider.requestTelemetry(contact.publicKey); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Requesting telemetry from ${contact.displayName}'), - duration: const Duration(seconds: 2), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + // Message icon - only for chat contacts + if (contact.type == ContactType.chat) + IconButton( + icon: const Icon(Icons.message, size: 20), + onPressed: () => _showDirectMessageDialog(context, contact), + tooltip: 'Send direct message', ), - ); - }, - tooltip: 'Request telemetry', + // Telemetry refresh button + IconButton( + icon: const Icon(Icons.refresh, size: 20), + onPressed: () { + final connectionProvider = context.read(); + 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), 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) { showModalBottomSheet( 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 _sendDirectMessage() async { + final text = _textController.text.trim(); + if (text.isEmpty) return; + + final connectionProvider = context.read(); + + 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), + ), + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/screens/device_config_screen.dart b/lib/screens/device_config_screen.dart index 1d96477..facaf6d 100644 --- a/lib/screens/device_config_screen.dart +++ b/lib/screens/device_config_screen.dart @@ -265,6 +265,48 @@ class _DeviceConfigScreenState extends State { } } + 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 _useCurrentLocation() async { try { // Check if location services are enabled @@ -474,6 +516,101 @@ class _DeviceConfigScreenState extends State { 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 _SectionHeader( title: 'Public Info', diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 7b30faa..0aca629 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -10,7 +10,6 @@ import 'map_management_screen.dart'; import 'settings_screen.dart'; import 'device_config_screen.dart'; import 'packet_log_screen.dart'; -import 'message_history_screen.dart'; class HomeScreen extends StatefulWidget { final Function(AppThemeMode) onThemeChanged; @@ -233,43 +232,6 @@ class _HomeScreenState extends State with SingleTickerProviderStateM PopupMenuButton( icon: const Icon(Icons.more_vert), 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(); - await appProvider.refresh(); - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Refreshed contacts')), - ); - } - }, - ), PopupMenuItem( child: const Row( children: [ diff --git a/lib/screens/message_history_screen.dart b/lib/screens/message_history_screen.dart deleted file mode 100644 index 307363f..0000000 --- a/lib/screens/message_history_screen.dart +++ /dev/null @@ -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 createState() => _MessageHistoryScreenState(); -} - -class _MessageHistoryScreenState extends State { - 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(); - 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().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 _filterMessages(List 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( - 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, - ), - ), - ], - ); - } -} diff --git a/lib/screens/messages_tab.dart b/lib/screens/messages_tab.dart index c7949ca..14dc976 100644 --- a/lib/screens/messages_tab.dart +++ b/lib/screens/messages_tab.dart @@ -25,10 +25,6 @@ class _MessagesTabState extends State { int _characterCount = 0; static const int _maxCharacters = 160; - // Message recipient selection - String? _selectedRecipientId; // null = broadcast to public channel (channel 0) - MessageRecipientType _recipientType = MessageRecipientType.room; - @override void initState() { super.initState(); @@ -53,7 +49,6 @@ class _MessagesTabState extends State { if (text.isEmpty) return; final connectionProvider = context.read(); - final contactsProvider = context.read(); if (!connectionProvider.deviceInfo.isConnected) { if (!mounted) return; @@ -67,40 +62,11 @@ class _MessagesTabState extends State { } try { - if (_recipientType == MessageRecipientType.contact && _selectedRecipientId != null) { - // Send to specific contact - final contact = contactsProvider.contacts.firstWhere( - (c) => c.publicKeyHex == _selectedRecipientId, - ); - 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, - ); - } + // Always send to public channel (channel 0) + await connectionProvider.sendChannelMessage( + channelIdx: 0, + text: text, + ); _textController.clear(); _focusNode.unfocus(); @@ -108,7 +74,7 @@ class _MessagesTabState extends State { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( const SnackBar( - content: Text('Message sent'), + content: Text('Message sent to public channel'), backgroundColor: Colors.green, duration: Duration(seconds: 1), ), @@ -124,81 +90,6 @@ class _MessagesTabState extends State { } } - 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 _syncMessagesForRecipient() async { - final appProvider = context.read(); - - 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(); - - 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() { showModalBottomSheet( @@ -206,8 +97,8 @@ class _MessagesTabState extends State { isScrollControlled: true, backgroundColor: Colors.transparent, builder: (context) => _SarUpdateSheet( - onSend: (sarType, position, notes) async { - await _sendSarMessage(sarType, position, notes); + onSend: (sarType, position, notes, channelIdx) async { + await _sendSarMessage(sarType, position, notes, channelIdx); }, ), ); @@ -217,9 +108,9 @@ class _MessagesTabState extends State { SarMarkerType sarType, Position position, String? notes, + int channelIdx, ) async { final connectionProvider = context.read(); - final contactsProvider = context.read(); if (!connectionProvider.deviceInfo.isConnected) { if (!mounted) return; @@ -241,46 +132,17 @@ class _MessagesTabState extends State { ? '$sarMessage $notes' : sarMessage; - // Send to selected recipient (contact or room) - if (_recipientType == MessageRecipientType.contact && _selectedRecipientId != null) { - // Send to specific contact - final contact = contactsProvider.contacts.firstWhere( - (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, - ); - } + // Send SAR message to selected room/channel + await connectionProvider.sendChannelMessage( + channelIdx: channelIdx, + text: fullMessage, + ); if (!mounted) return; + final channelName = channelIdx == 0 ? 'Public Channel' : 'Channel $channelIdx'; ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('${sarType.displayName} marker sent'), + content: Text('${sarType.displayName} marker sent to $channelName'), backgroundColor: Colors.green, duration: const Duration(seconds: 2), ), @@ -393,54 +255,7 @@ class _MessagesTabState extends State { ), ), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), - child: Column( - children: [ - // Recipient selector bar - Consumer( - 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( + child: Row( crossAxisAlignment: CrossAxisAlignment.end, children: [ // SAR quick action button @@ -503,8 +318,6 @@ class _MessagesTabState extends State { ), ], ), - ], - ), ), ], ); @@ -764,7 +577,7 @@ class _MessageBubble extends StatelessWidget { // SAR Update Sheet class _SarUpdateSheet extends StatefulWidget { - final Future Function(SarMarkerType, Position, String?) onSend; + final Future Function(SarMarkerType, Position, String?, int) onSend; const _SarUpdateSheet({required this.onSend}); @@ -777,6 +590,7 @@ class _SarUpdateSheetState extends State<_SarUpdateSheet> { Position? _currentPosition; bool _loadingLocation = false; String? _locationError; + int _selectedChannelIdx = 0; // Default to Public Channel (channel 0) final TextEditingController _notesController = TextEditingController(); @override @@ -942,6 +756,82 @@ class _SarUpdateSheetState extends State<_SarUpdateSheet> { ), 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( + 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( + 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( + 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( + 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 const Text( 'Current Location', @@ -1135,6 +1025,7 @@ class _SarUpdateSheetState extends State<_SarUpdateSheet> { _notesController.text.trim().isEmpty ? null : _notesController.text.trim(), + _selectedChannelIdx, ); if (context.mounted) { 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( - 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( - 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); - }, - ); - }, - ); - }, - ), - ], - ), - ), - ], - ), - ); - } -} diff --git a/lib/services/meshcore_ble_service.dart b/lib/services/meshcore_ble_service.dart index 5210652..6d9141a 100644 --- a/lib/services/meshcore_ble_service.dart +++ b/lib/services/meshcore_ble_service.dart @@ -346,6 +346,10 @@ class MeshCoreBleService { print(' → Handling NewAdvert push'); _handleNewAdvert(reader); break; + case MeshCoreConstants.pushSendConfirmed: + print(' → Handling SendConfirmed push'); + _handleSendConfirmed(reader); + break; case MeshCoreConstants.respNoMoreMessages: print(' → Response: No More Messages'); onNoMoreMessages?.call(); @@ -442,35 +446,34 @@ class MeshCoreBleService { _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) { try { print(' [Sent] Parsing sent confirmation...'); 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) { - final reserved1 = reader.readByte(); - print(' Reserved: $reserved1'); + final sendType = reader.readByte(); + final sendTypeStr = sendType == 1 ? 'flood' : 'direct'; + print(' Send type: $sendType ($sendTypeStr)'); - final pubKeyPrefix = reader.readBytes(4); - print(' Recipient public key prefix: ${pubKeyPrefix.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}'); + final expectedAckOrTag = reader.readBytes(4); + print(' Expected ACK/TAG: ${expectedAckOrTag.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')}'); - final messageId = reader.readUInt16LE(); - print(' Message ID: $messageId'); + final suggestedTimeout = reader.readUInt32LE(); + print(' Suggested timeout: ${suggestedTimeout}ms'); - if (reader.remainingBytesCount >= 2) { - final reserved2 = reader.readUInt16LE(); - print(' Reserved2: $reserved2'); - } + print(' ✅ [Sent] Message sent successfully ($sendTypeStr mode, timeout: ${suggestedTimeout}ms)'); + + // 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) { print(' ❌ [Sent] Parsing error: $e'); // 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 Future _sendAppStart() async { final writer = BufferWriter(); @@ -845,31 +877,60 @@ class MeshCoreBleService { 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 sendTextMessage({ required Uint8List contactPublicKey, required String text, + int textType = 0, // TXT_TYPE_PLAIN + int attempt = 0, }) async { + if (text.length > 160) { + throw ArgumentError('Text message exceeds 160 character limit'); + } + final writer = BufferWriter(); - writer.writeByte(MeshCoreConstants.cmdSendTxtMsg); - writer.writeByte(MeshCoreConstants.txtTypePlain); - writer.writeByte(0); // attempt - writer.writeUInt32LE(DateTime.now().millisecondsSinceEpoch ~/ 1000); - writer.writeBytes(contactPublicKey.sublist(0, 6)); + writer.writeByte(MeshCoreConstants.cmdSendTxtMsg); // 0x02 + writer.writeByte(textType); // TXT_TYPE_* + writer.writeByte(attempt); // 0-3 + writer.writeUInt32LE(DateTime.now().millisecondsSinceEpoch ~/ 1000); // epoch seconds + writer.writeBytes(contactPublicKey.sublist(0, 6)); // first 6 bytes of public key writer.writeString(text); 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::," Future sendChannelMessage({ required int channelIdx, required String text, + int textType = 0, // TXT_TYPE_PLAIN }) 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(); - writer.writeByte(MeshCoreConstants.cmdSendChannelTxtMsg); - writer.writeByte(MeshCoreConstants.txtTypePlain); - writer.writeByte(channelIdx); - writer.writeUInt32LE(DateTime.now().millisecondsSinceEpoch ~/ 1000); + writer.writeByte(MeshCoreConstants.cmdSendChannelTxtMsg); // 0x03 + writer.writeByte(textType); // TXT_TYPE_* + writer.writeByte(channelIdx); // 0 for 'public' channel + writer.writeUInt32LE(DateTime.now().millisecondsSinceEpoch ~/ 1000); // epoch seconds writer.writeString(text); await _writeData(writer.toBytes()); }