mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
feat: Add Packet Log Screen for BLE packet logging and exporting
- Implemented PacketLogScreen to display and filter BLE packet logs. - Added functionality to export logs as CSV and text files. - Introduced clipboard copy feature for hex data. - Implemented clear logs functionality with confirmation dialog. - Enhanced MeshCoreBleService to log TX and RX packets with descriptions. - Added BufferReader methods for reading unsigned and signed 16-bit integers (big-endian). - Updated CayenneLppParser to read values as big-endian. - Created MessageStorageService for persisting messages to local storage. - Enhanced map markers to display telemetry data including voltage, humidity, and pressure.
This commit is contained in:
@@ -13,7 +13,9 @@
|
||||
"Bash(flutter pub get:*)",
|
||||
"Bash(flutter run:*)",
|
||||
"Bash(nc:*)",
|
||||
"Bash(pkill:*)"
|
||||
"Bash(pkill:*)",
|
||||
"WebFetch(domain:github.com)",
|
||||
"WebFetch(domain:raw.githubusercontent.com)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
|
||||
1224
MESHCORE_BLE_PROTOCOL.md
Normal file
1224
MESHCORE_BLE_PROTOCOL.md
Normal file
File diff suppressed because it is too large
Load Diff
1163
MESHCORE_PROTOCOL.md
Normal file
1163
MESHCORE_PROTOCOL.md
Normal file
File diff suppressed because it is too large
Load Diff
216
MESHCORE_QUICK_REFERENCE.md
Normal file
216
MESHCORE_QUICK_REFERENCE.md
Normal file
@@ -0,0 +1,216 @@
|
||||
# MeshCore Quick Reference Card
|
||||
|
||||
Quick lookup for MeshCore protocol constants and structures.
|
||||
|
||||
## BLE Service (App ↔ Device)
|
||||
|
||||
**Service UUID:** `6E400001-B5A3-F393-E0A9-E50E24DCCA9E`
|
||||
- **RX:** `6E400002-B5A3-F393-E0A9-E50E24DCCA9E` (Write - Commands)
|
||||
- **TX:** `6E400003-B5A3-F393-E0A9-E50E24DCCA9E` (Notify - Responses)
|
||||
|
||||
### BLE Commands (RX)
|
||||
|
||||
| Code | Command | Format |
|
||||
|------|---------|--------|
|
||||
| `0x04` | Get Contacts | `[0x04]` |
|
||||
| `0x02` | Send Message | `[0x02][32B pubkey][2B len][text]` |
|
||||
| `0x27` | Get Telemetry | `[0x27][32B pubkey]` |
|
||||
|
||||
### BLE Responses (TX)
|
||||
|
||||
| Code | Response | Format |
|
||||
|------|----------|--------|
|
||||
| `0x03` | Contact Info | `[0x03][32B pubkey][1B type][64B name][4B lat][4B lon]` |
|
||||
| `0x07` | Message | `[0x07][1B type][4B src][4B dest][2B len][text]` |
|
||||
| `0x8B` | Telemetry | `[0x8B][4B pubkey][Cayenne LPP data]` |
|
||||
|
||||
---
|
||||
|
||||
## Mesh Packet Structure (LoRa Network)
|
||||
|
||||
```
|
||||
[Header: 1B] [Path Len: 1B] [Path: 0-64B] [Payload: 0-184B]
|
||||
```
|
||||
|
||||
### Header Encoding
|
||||
|
||||
```
|
||||
Bits: [Ver:2][Type:4][Route:2]
|
||||
Route = header & 0x03
|
||||
Type = (header >> 2) & 0x0F
|
||||
Ver = (header >> 6) & 0x03
|
||||
```
|
||||
|
||||
### Route Types
|
||||
|
||||
| Code | Name | Description |
|
||||
|------|------|-------------|
|
||||
| `0x01` | FLOOD | Broadcast to all nodes |
|
||||
| `0x02` | DIRECT | Point-to-point via path |
|
||||
|
||||
### Payload Types
|
||||
|
||||
| Code | Name | Encrypted | Structure |
|
||||
|------|------|-----------|-----------|
|
||||
| `0x00` | REQ | ✓ | `[1B dest][1B src][2B MAC][encrypted]` |
|
||||
| `0x01` | RESPONSE | ✓ | `[1B dest][1B src][2B MAC][encrypted]` |
|
||||
| `0x02` | TXT_MSG | ✓ | `[1B dest][1B src][2B MAC][encrypted]` |
|
||||
| `0x03` | ACK | ✗ | `[ack data]` |
|
||||
| `0x04` | ADVERT | ✗ | `[32B pubkey][4B ts][app][64B sig]` |
|
||||
| `0x05` | GRP_TXT | ✓ | `[1B chan][2B MAC][encrypted]` |
|
||||
| `0x06` | GRP_DATA | ✓ | `[1B chan][2B MAC][encrypted]` |
|
||||
| `0x07` | ANON_REQ | ✓ | `[1B dest][32B ephemeral][2B MAC][enc]` |
|
||||
| `0x08` | PATH | ✓ | `[1B dest][1B src][2B MAC][encrypted]` |
|
||||
| `0x09` | TRACE | ✗ | `[trace data]` |
|
||||
| `0x0F` | RAW_CUSTOM | ? | Application-defined |
|
||||
|
||||
---
|
||||
|
||||
## Advertisement App Data
|
||||
|
||||
**Format:** `[Flags:1B][Lat:4B?][Lon:4B?][Battery:1B?][Temp:1B?][Name:NB?]`
|
||||
|
||||
### Flags Byte
|
||||
|
||||
```
|
||||
Bits: [Name:1][Temp:1][Batt:1][LatLon:1][Type:4]
|
||||
Type = flags & 0x0F
|
||||
Has GPS = flags & 0x10
|
||||
Has Batt = flags & 0x20
|
||||
Has Temp = flags & 0x40
|
||||
Has Name = flags & 0x80
|
||||
```
|
||||
|
||||
### Contact Types
|
||||
|
||||
| Code | Name | Description |
|
||||
|------|------|-------------|
|
||||
| `0x00` | NONE | Unknown |
|
||||
| `0x01` | CHAT | Team member (shown on map) |
|
||||
| `0x02` | REPEATER | Network node |
|
||||
| `0x03` | ROOM | Group channel |
|
||||
|
||||
---
|
||||
|
||||
## Cayenne LPP (Telemetry)
|
||||
|
||||
**Format:** `[Channel:1B][Type:1B][Data]`
|
||||
|
||||
| Type | Code | Data | Decoding |
|
||||
|------|------|------|----------|
|
||||
| GPS | `0x88` | 12B | lat/lon ÷ 10000, alt ÷ 100 |
|
||||
| Temp | `0x67` | 2B | int16 ÷ 10 for °C |
|
||||
| Analog | `0x02` | 2B | uint16 ÷ 100 for volts |
|
||||
|
||||
**Example:**
|
||||
```
|
||||
[01][88][A0C20600][30670200][2C010000]
|
||||
^ch ^gps ^-lat-^ ^-lon-^ ^-alt-^
|
||||
GPS: 44.304°N, 15.7488°E, 3.00m
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Constants
|
||||
|
||||
### Size Limits
|
||||
- Max Packet Payload: 184 bytes
|
||||
- Max Path Size: 64 bytes
|
||||
- Max Advert Data: 32 bytes
|
||||
- Public Key: 32 bytes
|
||||
- Private Key: 64 bytes
|
||||
- Signature: 64 bytes
|
||||
- MAC: 2 bytes
|
||||
- Cipher Block: 16 bytes
|
||||
|
||||
### Coordinate Encoding
|
||||
```dart
|
||||
// Encode
|
||||
int32 encoded = (double degrees * 10000).toInt();
|
||||
|
||||
// Decode
|
||||
double degrees = encoded / 10000.0;
|
||||
|
||||
// Precision: 4 decimal places (~11m accuracy)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Common Operations
|
||||
|
||||
### Parse BLE Contact Response
|
||||
```dart
|
||||
final pubkey = data.sublist(1, 33); // 32 bytes
|
||||
final type = data[33]; // 0-3
|
||||
final name = data.sublist(34, 98); // 64 bytes
|
||||
final lat = ByteData.view(data.buffer)
|
||||
.getInt32(98, Endian.little) / 10000.0;
|
||||
final lon = ByteData.view(data.buffer)
|
||||
.getInt32(102, Endian.little) / 10000.0;
|
||||
```
|
||||
|
||||
### Parse Mesh Packet Header
|
||||
```dart
|
||||
final header = packet[0];
|
||||
final routeType = header & 0x03;
|
||||
final payloadType = (header >> 2) & 0x0F;
|
||||
final version = (header >> 6) & 0x03;
|
||||
final isFlood = routeType == 0x01;
|
||||
final isTxtMsg = payloadType == 0x02;
|
||||
```
|
||||
|
||||
### Parse Advertisement Flags
|
||||
```dart
|
||||
final flags = appData[0];
|
||||
final contactType = flags & 0x0F;
|
||||
final hasGPS = (flags & 0x10) != 0;
|
||||
final hasBattery = (flags & 0x20) != 0;
|
||||
final hasTemp = (flags & 0x40) != 0;
|
||||
final hasName = (flags & 0x80) != 0;
|
||||
```
|
||||
|
||||
### Parse Cayenne LPP GPS
|
||||
```dart
|
||||
if (data[1] == 0x88) { // GPS type
|
||||
final lat = ByteData.view(data.buffer)
|
||||
.getInt32(2, Endian.little) / 10000.0;
|
||||
final lon = ByteData.view(data.buffer)
|
||||
.getInt32(6, Endian.little) / 10000.0;
|
||||
final alt = ByteData.view(data.buffer)
|
||||
.getInt32(10, Endian.little) / 100.0;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Security Notes
|
||||
|
||||
1. **Always verify signatures** on ADVERT packets
|
||||
2. **Validate MAC** before decrypting encrypted payloads
|
||||
3. **Check timestamps** to prevent replay attacks
|
||||
4. **Sanitize strings** before display (max length, UTF-8 validation)
|
||||
5. **Rate limit** packet processing to prevent DoS
|
||||
6. Use **constant-time comparison** for MAC validation
|
||||
|
||||
---
|
||||
|
||||
## Flutter Implementation
|
||||
|
||||
**Main Files:**
|
||||
- `lib/services/meshcore_ble_service.dart` - BLE protocol
|
||||
- `lib/services/buffer_reader.dart` - Binary parsing
|
||||
- `lib/services/buffer_writer.dart` - Binary encoding
|
||||
- `lib/services/cayenne_lpp_parser.dart` - Telemetry decoding
|
||||
|
||||
---
|
||||
|
||||
## Additional Documentation
|
||||
|
||||
- **[MESHCORE_PROTOCOL.md](MESHCORE_PROTOCOL.md)** - Complete mesh packet protocol specification
|
||||
- **[MESHCORE_BLE_PROTOCOL.md](MESHCORE_BLE_PROTOCOL.md)** - Complete BLE command/response protocol
|
||||
- **[CLAUDE.md](CLAUDE.md)** - Project overview and development guide
|
||||
|
||||
---
|
||||
|
||||
**Document Version:** 1.0
|
||||
**Last Updated:** 2025-10-14
|
||||
@@ -23,11 +23,19 @@ class MeshCoreSarApp extends StatefulWidget {
|
||||
|
||||
class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
||||
AppThemeMode _themeMode = AppThemeMode.system;
|
||||
bool _isInitialized = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadThemePreference();
|
||||
_initializeApp();
|
||||
}
|
||||
|
||||
Future<void> _initializeApp() async {
|
||||
await _loadThemePreference();
|
||||
setState(() {
|
||||
_isInitialized = true;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _loadThemePreference() async {
|
||||
@@ -46,12 +54,29 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!_isInitialized) {
|
||||
return const MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return MultiProvider(
|
||||
providers: [
|
||||
// Core providers
|
||||
ChangeNotifierProvider(create: (_) => ConnectionProvider()),
|
||||
ChangeNotifierProvider(create: (_) => ContactsProvider()),
|
||||
ChangeNotifierProvider(create: (_) => MessagesProvider()),
|
||||
ChangeNotifierProvider(
|
||||
create: (_) {
|
||||
final provider = MessagesProvider();
|
||||
// Initialize messages provider asynchronously
|
||||
provider.initialize();
|
||||
return provider;
|
||||
},
|
||||
),
|
||||
ChangeNotifierProvider(create: (_) => MapProvider()),
|
||||
|
||||
// Tile cache service
|
||||
|
||||
52
lib/models/ble_packet_log.dart
Normal file
52
lib/models/ble_packet_log.dart
Normal file
@@ -0,0 +1,52 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
/// Represents a logged BLE packet with timestamp and metadata
|
||||
class BlePacketLog {
|
||||
final DateTime timestamp;
|
||||
final Uint8List rawData;
|
||||
final PacketDirection direction;
|
||||
final int? responseCode;
|
||||
final String? description;
|
||||
|
||||
BlePacketLog({
|
||||
required this.timestamp,
|
||||
required this.rawData,
|
||||
required this.direction,
|
||||
this.responseCode,
|
||||
this.description,
|
||||
});
|
||||
|
||||
/// Convert raw data to hex string for display
|
||||
String get hexData {
|
||||
return rawData.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ');
|
||||
}
|
||||
|
||||
/// Get short summary of the packet
|
||||
String get summary {
|
||||
final dir = direction == PacketDirection.rx ? 'RX' : 'TX';
|
||||
final code = responseCode != null ? '0x${responseCode!.toRadixString(16).padLeft(2, '0')}' : 'N/A';
|
||||
return '[$dir] Code: $code, Size: ${rawData.length} bytes';
|
||||
}
|
||||
|
||||
/// Convert to CSV format for export
|
||||
String toCsvRow() {
|
||||
final dir = direction == PacketDirection.rx ? 'RX' : 'TX';
|
||||
final code = responseCode?.toString() ?? '';
|
||||
final hex = hexData;
|
||||
final desc = description ?? '';
|
||||
return '${timestamp.toIso8601String()},$dir,${rawData.length},$code,"$hex","$desc"';
|
||||
}
|
||||
|
||||
/// Convert to human-readable log format
|
||||
String toLogString() {
|
||||
final dir = direction == PacketDirection.rx ? 'RX' : 'TX';
|
||||
final code = responseCode != null ? ' [0x${responseCode!.toRadixString(16).padLeft(2, '0')}]' : '';
|
||||
final desc = description != null ? ' - $description' : '';
|
||||
return '${timestamp.toIso8601String()} [$dir]$code ${rawData.length} bytes: $hexData$desc';
|
||||
}
|
||||
}
|
||||
|
||||
enum PacketDirection {
|
||||
rx, // Received from device
|
||||
tx, // Sent to device
|
||||
}
|
||||
@@ -80,24 +80,57 @@ class AppProvider with ChangeNotifier {
|
||||
// Load contacts
|
||||
await connectionProvider.getContacts();
|
||||
|
||||
// Sync any waiting messages from device queue
|
||||
await _syncMessages();
|
||||
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
debugPrint('Initialization error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Sync messages from device queue
|
||||
Future<void> _syncMessages() async {
|
||||
if (!connectionProvider.deviceInfo.isConnected) return;
|
||||
|
||||
try {
|
||||
debugPrint('🔄 [AppProvider] Starting message sync...');
|
||||
final messageCount = await connectionProvider.syncAllMessages();
|
||||
debugPrint('✅ [AppProvider] Synced $messageCount messages');
|
||||
} catch (e) {
|
||||
debugPrint('❌ [AppProvider] Message sync error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Refresh data (contacts, messages)
|
||||
Future<void> refresh() async {
|
||||
if (!connectionProvider.deviceInfo.isConnected) return;
|
||||
|
||||
try {
|
||||
await connectionProvider.getContacts();
|
||||
await _syncMessages();
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
debugPrint('Refresh error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Manually sync messages (useful for pull-to-refresh)
|
||||
Future<int> syncMessages() async {
|
||||
if (!connectionProvider.deviceInfo.isConnected) return 0;
|
||||
|
||||
try {
|
||||
debugPrint('🔄 [AppProvider] Manual message sync requested');
|
||||
final messageCount = await connectionProvider.syncAllMessages();
|
||||
debugPrint('✅ [AppProvider] Synced $messageCount messages');
|
||||
notifyListeners();
|
||||
return messageCount;
|
||||
} catch (e) {
|
||||
debugPrint('❌ [AppProvider] Message sync error: $e');
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear all data
|
||||
void clearAllData() {
|
||||
contactsProvider.clearContacts();
|
||||
|
||||
@@ -41,6 +41,9 @@ class ConnectionProvider with ChangeNotifier {
|
||||
int get rxPacketCount => _bleService.rxPacketCount;
|
||||
int get txPacketCount => _bleService.txPacketCount;
|
||||
|
||||
// Message sync state
|
||||
bool _noMoreMessages = false;
|
||||
|
||||
// Callbacks for other providers
|
||||
Function(Contact)? onContactReceived;
|
||||
Function(List<Contact>)? onContactsComplete;
|
||||
@@ -104,6 +107,11 @@ class ConnectionProvider with ChangeNotifier {
|
||||
onTelemetryReceived?.call(publicKey, lppData);
|
||||
};
|
||||
|
||||
_bleService.onNoMoreMessages = () {
|
||||
print('📥 [Provider] Received NoMoreMessages signal');
|
||||
_noMoreMessages = true;
|
||||
};
|
||||
|
||||
_bleService.onSelfInfoReceived = (selfInfo) {
|
||||
print('📥 [Provider] Received SelfInfo:');
|
||||
print(' TX Power: ${selfInfo['txPower']} / ${selfInfo['maxTxPower']} dBm');
|
||||
@@ -297,7 +305,8 @@ class ConnectionProvider with ChangeNotifier {
|
||||
}
|
||||
|
||||
/// Request telemetry from contact
|
||||
Future<void> requestTelemetry(Uint8List contactPublicKey) async {
|
||||
/// [zeroHop] - if true, only direct connection (no mesh forwarding)
|
||||
Future<void> requestTelemetry(Uint8List contactPublicKey, {bool zeroHop = false}) async {
|
||||
if (!_bleService.isConnected) {
|
||||
_error = 'Not connected to device';
|
||||
notifyListeners();
|
||||
@@ -305,7 +314,7 @@ class ConnectionProvider with ChangeNotifier {
|
||||
}
|
||||
|
||||
try {
|
||||
await _bleService.requestTelemetry(contactPublicKey);
|
||||
await _bleService.requestTelemetry(contactPublicKey, zeroHop: zeroHop);
|
||||
} catch (e) {
|
||||
_error = 'Failed to request telemetry: $e';
|
||||
notifyListeners();
|
||||
@@ -421,6 +430,66 @@ class ConnectionProvider with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
/// Sync messages from device queue
|
||||
/// Call this repeatedly until no more messages are available
|
||||
Future<bool> syncNextMessage() async {
|
||||
if (!_bleService.isConnected) {
|
||||
_error = 'Not connected to device';
|
||||
notifyListeners();
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await _bleService.syncNextMessage();
|
||||
return true;
|
||||
} catch (e) {
|
||||
_error = 'Failed to sync message: $e';
|
||||
notifyListeners();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Sync all waiting messages from device
|
||||
Future<int> syncAllMessages() async {
|
||||
if (!_bleService.isConnected) {
|
||||
_error = 'Not connected to device';
|
||||
notifyListeners();
|
||||
return 0;
|
||||
}
|
||||
|
||||
int count = 0;
|
||||
_noMoreMessages = false; // Reset flag
|
||||
|
||||
try {
|
||||
print('🔄 [Provider] Starting message sync...');
|
||||
// Keep syncing until we get NoMoreMessages response
|
||||
// The device will send ContactMsgRecv or ChannelMsgRecv responses
|
||||
// until it sends NoMoreMessages
|
||||
for (int i = 0; i < 100; i++) { // Safety limit
|
||||
if (_noMoreMessages) {
|
||||
print('✅ [Provider] Message sync complete - NoMoreMessages received after $count requests');
|
||||
break;
|
||||
}
|
||||
|
||||
await _bleService.syncNextMessage();
|
||||
count++;
|
||||
|
||||
// Small delay to allow response to be processed
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
}
|
||||
|
||||
if (!_noMoreMessages && count >= 100) {
|
||||
print('⚠️ [Provider] Message sync stopped - reached safety limit of 100 requests');
|
||||
}
|
||||
|
||||
return count;
|
||||
} catch (e) {
|
||||
_error = 'Failed to sync messages: $e';
|
||||
notifyListeners();
|
||||
return count;
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear error message
|
||||
void clearError() {
|
||||
_error = null;
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../models/message.dart';
|
||||
import '../models/sar_marker.dart';
|
||||
import '../services/message_storage_service.dart';
|
||||
|
||||
/// Messages Provider - manages message history and SAR markers
|
||||
class MessagesProvider with ChangeNotifier {
|
||||
final List<Message> _messages = [];
|
||||
final Map<String, SarMarker> _sarMarkers = {};
|
||||
final MessageStorageService _storageService = MessageStorageService();
|
||||
bool _isInitialized = false;
|
||||
|
||||
List<Message> get messages => List.unmodifiable(_messages);
|
||||
|
||||
@@ -32,6 +35,38 @@ class MessagesProvider with ChangeNotifier {
|
||||
List<SarMarker> get objectMarkers =>
|
||||
sarMarkers.where((m) => m.type == SarMarkerType.object).toList();
|
||||
|
||||
bool get isInitialized => _isInitialized;
|
||||
|
||||
/// Initialize and load persisted messages
|
||||
Future<void> initialize() async {
|
||||
if (_isInitialized) return;
|
||||
|
||||
try {
|
||||
print('📦 [MessagesProvider] Loading persisted messages...');
|
||||
final storedMessages = await _storageService.loadMessages();
|
||||
|
||||
// Add stored messages
|
||||
_messages.addAll(storedMessages);
|
||||
|
||||
// Extract SAR markers from stored messages
|
||||
for (final message in storedMessages) {
|
||||
if (message.isSarMarker) {
|
||||
final marker = message.toSarMarker();
|
||||
if (marker != null) {
|
||||
_sarMarkers[marker.id] = marker;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_isInitialized = true;
|
||||
print('✅ [MessagesProvider] Loaded ${storedMessages.length} persisted messages');
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
print('❌ [MessagesProvider] Error initializing: $e');
|
||||
_isInitialized = true; // Mark as initialized even on error
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a message
|
||||
void addMessage(Message message) {
|
||||
_messages.add(message);
|
||||
@@ -44,6 +79,9 @@ class MessagesProvider with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
// Persist to storage asynchronously
|
||||
_persistMessages();
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@@ -59,9 +97,22 @@ class MessagesProvider with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Persist to storage asynchronously
|
||||
_persistMessages();
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Persist messages to storage (async, non-blocking)
|
||||
Future<void> _persistMessages() async {
|
||||
try {
|
||||
await _storageService.saveMessages(_messages);
|
||||
} catch (e) {
|
||||
print('❌ [MessagesProvider] Error persisting messages: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Get messages for a specific contact
|
||||
List<Message> getMessagesForContact(String senderKeyShort) {
|
||||
return _messages
|
||||
@@ -120,6 +171,7 @@ class MessagesProvider with ChangeNotifier {
|
||||
/// Clear all messages
|
||||
void clearMessages() {
|
||||
_messages.clear();
|
||||
_persistMessages();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@@ -133,9 +185,15 @@ class MessagesProvider with ChangeNotifier {
|
||||
void clearAll() {
|
||||
_messages.clear();
|
||||
_sarMarkers.clear();
|
||||
_persistMessages();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Get storage statistics
|
||||
Future<Map<String, dynamic>> getStorageStats() async {
|
||||
return await _storageService.getStorageStats();
|
||||
}
|
||||
|
||||
/// Get message statistics
|
||||
Map<String, int> get messageStats {
|
||||
return {
|
||||
|
||||
@@ -249,6 +249,16 @@ class _ContactTile extends StatelessWidget {
|
||||
tooltip: 'Request telemetry',
|
||||
),
|
||||
onTap: () => _showContactDetails(context, contact),
|
||||
onLongPress: () {
|
||||
final connectionProvider = context.read<ConnectionProvider>();
|
||||
connectionProvider.requestTelemetry(contact.publicKey, zeroHop: true);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Pinging ${contact.displayName} (direct connection)...'),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -344,11 +354,29 @@ class _ContactTile extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (contact.telemetry!.batteryPercentage != null)
|
||||
if (contact.telemetry!.batteryMilliVolts != null)
|
||||
_DetailRow(
|
||||
'Voltage',
|
||||
'${(contact.telemetry!.batteryMilliVolts! / 1000).toStringAsFixed(3)}V'
|
||||
'${contact.telemetry!.batteryPercentage != null ? ' (${contact.telemetry!.batteryPercentage!.toStringAsFixed(1)}%)' : ''}',
|
||||
)
|
||||
else if (contact.telemetry!.batteryPercentage != null)
|
||||
_DetailRow('Battery', '${contact.telemetry!.batteryPercentage!.toStringAsFixed(1)}%'),
|
||||
if (contact.telemetry!.temperature != null)
|
||||
_DetailRow('Temperature', '${contact.telemetry!.temperature!.toStringAsFixed(1)}°C'),
|
||||
_DetailRow('Updated', contact.telemetry!.isRecent ? 'Recently' : 'Stale'),
|
||||
if (contact.telemetry!.humidity != null)
|
||||
_DetailRow('Humidity', '${contact.telemetry!.humidity!.toStringAsFixed(1)}%'),
|
||||
if (contact.telemetry!.pressure != null)
|
||||
_DetailRow('Pressure', '${contact.telemetry!.pressure!.toStringAsFixed(1)} hPa'),
|
||||
if (contact.telemetry!.gpsLocation != null)
|
||||
_DetailRow(
|
||||
'GPS (Telemetry)',
|
||||
'${contact.telemetry!.gpsLocation!.latitude.toStringAsFixed(6)}, ${contact.telemetry!.gpsLocation!.longitude.toStringAsFixed(6)}',
|
||||
),
|
||||
_DetailRow(
|
||||
'Updated',
|
||||
'${_formatTimestamp(contact.telemetry!.timestamp)} (${_formatTimeAgo(contact.telemetry!.timestamp)})',
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
@@ -418,4 +446,35 @@ class _ContactTile extends StatelessWidget {
|
||||
if (percentage > 20) return Colors.orange;
|
||||
return Colors.red;
|
||||
}
|
||||
|
||||
String _formatTimestamp(DateTime timestamp) {
|
||||
final now = DateTime.now();
|
||||
final today = DateTime(now.year, now.month, now.day);
|
||||
final timestampDate = DateTime(timestamp.year, timestamp.month, timestamp.day);
|
||||
|
||||
if (timestampDate == today) {
|
||||
// Today - show time only
|
||||
return '${timestamp.hour.toString().padLeft(2, '0')}:${timestamp.minute.toString().padLeft(2, '0')}:${timestamp.second.toString().padLeft(2, '0')}';
|
||||
} else {
|
||||
// Another day - show date and time
|
||||
return '${timestamp.year}-${timestamp.month.toString().padLeft(2, '0')}-${timestamp.day.toString().padLeft(2, '0')} ${timestamp.hour.toString().padLeft(2, '0')}:${timestamp.minute.toString().padLeft(2, '0')}';
|
||||
}
|
||||
}
|
||||
|
||||
String _formatTimeAgo(DateTime timestamp) {
|
||||
final now = DateTime.now();
|
||||
final diff = now.difference(timestamp);
|
||||
|
||||
if (diff.inSeconds < 60) {
|
||||
return '${diff.inSeconds}s ago';
|
||||
} else if (diff.inMinutes < 60) {
|
||||
return '${diff.inMinutes}m ago';
|
||||
} else if (diff.inHours < 24) {
|
||||
return '${diff.inHours}h ago';
|
||||
} else if (diff.inDays == 1) {
|
||||
return 'yesterday';
|
||||
} else {
|
||||
return '${diff.inDays}d ago';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ import 'map_tab.dart';
|
||||
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;
|
||||
@@ -231,6 +233,25 @@ class _HomeScreenState extends State<HomeScreen> 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: [
|
||||
@@ -440,21 +461,34 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// Disconnect button (prominent, icon only)
|
||||
FilledButton(
|
||||
onPressed: () async {
|
||||
await provider.disconnect();
|
||||
if (context.mounted) {
|
||||
context.read<AppProvider>().clearAllData();
|
||||
}
|
||||
// Long press to open packet log viewer
|
||||
GestureDetector(
|
||||
onLongPress: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => PacketLogScreen(
|
||||
bleService: provider.bleService,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: Colors.red.shade700,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.all(10),
|
||||
minimumSize: const Size(40, 40),
|
||||
shape: const CircleBorder(),
|
||||
child: FilledButton(
|
||||
onPressed: () async {
|
||||
await provider.disconnect();
|
||||
if (context.mounted) {
|
||||
context.read<AppProvider>().clearAllData();
|
||||
}
|
||||
},
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: Colors.red.shade700,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.all(10),
|
||||
minimumSize: const Size(40, 40),
|
||||
shape: const CircleBorder(),
|
||||
),
|
||||
child: const Icon(Icons.power_settings_new, size: 20),
|
||||
),
|
||||
child: const Icon(Icons.power_settings_new, size: 20),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -1544,8 +1544,14 @@ class _DetailedCompassDialogState extends State<_DetailedCompassDialog> {
|
||||
);
|
||||
}
|
||||
|
||||
// Show battery if available
|
||||
if (_selectedContact!.telemetry?.batteryPercentage != null) {
|
||||
// Show voltage/battery if available
|
||||
if (_selectedContact!.telemetry?.batteryMilliVolts != null) {
|
||||
final volts = (_selectedContact!.telemetry!.batteryMilliVolts! / 1000).toStringAsFixed(3);
|
||||
final percent = _selectedContact!.telemetry!.batteryPercentage != null
|
||||
? ' (${_selectedContact!.telemetry!.batteryPercentage!.round()}%)'
|
||||
: '';
|
||||
additionalInfo = 'Voltage: ${volts}V$percent';
|
||||
} else if (_selectedContact!.telemetry?.batteryPercentage != null) {
|
||||
additionalInfo = 'Battery: ${_selectedContact!.telemetry!.batteryPercentage!.round()}%';
|
||||
}
|
||||
} else if (_selectedSarMarker != null) {
|
||||
|
||||
490
lib/screens/message_history_screen.dart
Normal file
490
lib/screens/message_history_screen.dart
Normal file
@@ -0,0 +1,490 @@
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import '../providers/messages_provider.dart';
|
||||
import '../providers/contacts_provider.dart';
|
||||
import '../providers/map_provider.dart';
|
||||
import '../providers/connection_provider.dart';
|
||||
import '../providers/app_provider.dart';
|
||||
import '../models/message.dart';
|
||||
import '../models/sar_marker.dart';
|
||||
|
||||
@@ -24,6 +25,10 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
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();
|
||||
@@ -62,30 +67,39 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
}
|
||||
|
||||
try {
|
||||
// Default to sending to room/channel (first available room)
|
||||
final rooms = contactsProvider.rooms;
|
||||
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 (rooms.isNotEmpty) {
|
||||
// Send to first available room
|
||||
final defaultRoom = rooms.first;
|
||||
final channelIdx = defaultRoom.outPath.isNotEmpty
|
||||
? defaultRoom.outPath[0]
|
||||
: 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,
|
||||
);
|
||||
} else {
|
||||
// No rooms available, show error
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('No channels available'),
|
||||
backgroundColor: Colors.orange,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
_textController.clear();
|
||||
@@ -110,6 +124,82 @@ 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 messages when recipient changes
|
||||
Future<void> _syncMessagesForRecipient() async {
|
||||
final appProvider = context.read<AppProvider>();
|
||||
|
||||
if (!appProvider.connectionProvider.deviceInfo.isConnected) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
debugPrint('🔄 [MessagesTab] Syncing messages after channel/room 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'} from ${_getRecipientDisplayName()}'),
|
||||
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() {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
@@ -151,30 +241,40 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
? '$sarMessage $notes'
|
||||
: sarMessage;
|
||||
|
||||
// Default to sending to room/channel (first available room)
|
||||
final rooms = contactsProvider.rooms;
|
||||
// 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 (rooms.isNotEmpty) {
|
||||
// Send to first available room
|
||||
final defaultRoom = rooms.first;
|
||||
final channelIdx = defaultRoom.outPath.isNotEmpty
|
||||
? defaultRoom.outPath[0]
|
||||
: 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,
|
||||
);
|
||||
} else {
|
||||
// No rooms available, show error
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('No channels available'),
|
||||
backgroundColor: Colors.orange,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
@@ -197,11 +297,80 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
}
|
||||
|
||||
|
||||
Future<void> _handleRefresh() async {
|
||||
final appProvider = context.read<AppProvider>();
|
||||
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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
List<Message> _getFilteredMessages(MessagesProvider messagesProvider) {
|
||||
// If viewing a specific contact, show all their messages indefinitely
|
||||
if (_recipientType == MessageRecipientType.contact && _selectedRecipientId != null) {
|
||||
final contactMessages = messagesProvider.contactMessages
|
||||
.where((m) => m.senderPublicKeyPrefix != null)
|
||||
.toList();
|
||||
|
||||
// Filter by selected contact
|
||||
return contactMessages
|
||||
.where((m) {
|
||||
final senderHex = m.senderPublicKeyPrefix!
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join();
|
||||
return _selectedRecipientId!.startsWith(senderHex);
|
||||
})
|
||||
.toList()
|
||||
..sort((a, b) => b.sentAt.compareTo(a.sentAt));
|
||||
}
|
||||
|
||||
// For channels/rooms, limit to recent 100 messages
|
||||
if (_recipientType == MessageRecipientType.room) {
|
||||
if (_selectedRecipientId != null) {
|
||||
// Filter by specific channel
|
||||
final contactsProvider = context.read<ContactsProvider>();
|
||||
try {
|
||||
final room = contactsProvider.rooms.firstWhere(
|
||||
(r) => r.publicKeyHex == _selectedRecipientId,
|
||||
);
|
||||
final channelIdx = room.outPath.isNotEmpty ? room.outPath[0] : 0;
|
||||
return messagesProvider
|
||||
.getMessagesForChannel(channelIdx)
|
||||
.take(100)
|
||||
.toList();
|
||||
} catch (e) {
|
||||
// Room not found, show all channel messages
|
||||
return messagesProvider.channelMessages
|
||||
.take(100)
|
||||
.toList()
|
||||
..sort((a, b) => b.sentAt.compareTo(a.sentAt));
|
||||
}
|
||||
}
|
||||
|
||||
// Default: show recent channel messages (public channel)
|
||||
return messagesProvider.channelMessages
|
||||
.take(100)
|
||||
.toList()
|
||||
..sort((a, b) => b.sentAt.compareTo(a.sentAt));
|
||||
}
|
||||
|
||||
// Fallback: show all recent messages
|
||||
return messagesProvider.getRecentMessages(count: 100);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Consumer<MessagesProvider>(
|
||||
builder: (context, messagesProvider, child) {
|
||||
final messages = messagesProvider.getRecentMessages(count: 100);
|
||||
final messages = _getFilteredMessages(messagesProvider);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
@@ -231,28 +400,31 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
],
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
reverse: true,
|
||||
padding: const EdgeInsets.all(8),
|
||||
itemCount: messages.length,
|
||||
itemBuilder: (context, index) {
|
||||
final message = messages[index];
|
||||
return _MessageBubble(
|
||||
message: message,
|
||||
onTap: message.isSarMarker &&
|
||||
message.sarGpsCoordinates != null
|
||||
? () {
|
||||
final mapProvider =
|
||||
context.read<MapProvider>();
|
||||
mapProvider.navigateToLocation(
|
||||
location: message.sarGpsCoordinates!,
|
||||
zoom: 15.0,
|
||||
);
|
||||
widget.onNavigateToMap();
|
||||
}
|
||||
: null,
|
||||
);
|
||||
},
|
||||
: RefreshIndicator(
|
||||
onRefresh: _handleRefresh,
|
||||
child: ListView.builder(
|
||||
reverse: true,
|
||||
padding: const EdgeInsets.all(8),
|
||||
itemCount: messages.length,
|
||||
itemBuilder: (context, index) {
|
||||
final message = messages[index];
|
||||
return _MessageBubble(
|
||||
message: message,
|
||||
onTap: message.isSarMarker &&
|
||||
message.sarGpsCoordinates != null
|
||||
? () {
|
||||
final mapProvider =
|
||||
context.read<MapProvider>();
|
||||
mapProvider.navigateToLocation(
|
||||
location: message.sarGpsCoordinates!,
|
||||
zoom: 15.0,
|
||||
);
|
||||
widget.onNavigateToMap();
|
||||
}
|
||||
: null,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -268,66 +440,115 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
child: Column(
|
||||
children: [
|
||||
// SAR quick action button
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add_location_alt),
|
||||
tooltip: 'Send SAR marker',
|
||||
onPressed: _showSarDialog,
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: Theme.of(context).colorScheme.primaryContainer,
|
||||
foregroundColor: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// Text field with embedded send button
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _textController,
|
||||
focusNode: _focusNode,
|
||||
maxLength: _maxCharacters,
|
||||
maxLines: null,
|
||||
maxLengthEnforcement: MaxLengthEnforcement.enforced,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Message to channel...',
|
||||
hintStyle: const TextStyle(fontSize: 14),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 10,
|
||||
),
|
||||
isDense: true,
|
||||
counterText: _characterCount >= 150
|
||||
? '$_characterCount/$_maxCharacters'
|
||||
: '',
|
||||
counterStyle: TextStyle(
|
||||
fontSize: 10,
|
||||
color: _characterCount > _maxCharacters * 0.9
|
||||
? Colors.orange
|
||||
: Theme.of(context).textTheme.bodySmall?.color,
|
||||
),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
Icons.send_rounded,
|
||||
size: 22,
|
||||
color: _textController.text.trim().isEmpty
|
||||
? Theme.of(context).disabledColor
|
||||
: Theme.of(context).colorScheme.primary,
|
||||
// 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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
onPressed: _textController.text.trim().isEmpty
|
||||
? null
|
||||
: _sendMessage,
|
||||
tooltip: 'Send',
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
// Message input row
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
// SAR quick action button
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add_location_alt),
|
||||
tooltip: 'Send SAR marker',
|
||||
onPressed: _showSarDialog,
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: Theme.of(context).colorScheme.primaryContainer,
|
||||
foregroundColor: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
textInputAction: TextInputAction.send,
|
||||
onSubmitted: (_) => _sendMessage(),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// Text field with embedded send button
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _textController,
|
||||
focusNode: _focusNode,
|
||||
maxLength: _maxCharacters,
|
||||
maxLines: null,
|
||||
maxLengthEnforcement: MaxLengthEnforcement.enforced,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Type a message...',
|
||||
hintStyle: const TextStyle(fontSize: 14),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 10,
|
||||
),
|
||||
isDense: true,
|
||||
counterText: _characterCount >= 150
|
||||
? '$_characterCount/$_maxCharacters'
|
||||
: '',
|
||||
counterStyle: TextStyle(
|
||||
fontSize: 10,
|
||||
color: _characterCount > _maxCharacters * 0.9
|
||||
? Colors.orange
|
||||
: Theme.of(context).textTheme.bodySmall?.color,
|
||||
),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
Icons.send_rounded,
|
||||
size: 22,
|
||||
color: _textController.text.trim().isEmpty
|
||||
? Theme.of(context).disabledColor
|
||||
: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
onPressed: _textController.text.trim().isEmpty
|
||||
? null
|
||||
: _sendMessage,
|
||||
tooltip: 'Send',
|
||||
),
|
||||
),
|
||||
textInputAction: TextInputAction.send,
|
||||
onSubmitted: (_) => _sendMessage(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -948,3 +1169,211 @@ 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);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
568
lib/screens/packet_log_screen.dart
Normal file
568
lib/screens/packet_log_screen.dart
Normal file
@@ -0,0 +1,568 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
import 'dart:io';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import '../models/ble_packet_log.dart';
|
||||
import '../services/meshcore_ble_service.dart';
|
||||
|
||||
class PacketLogScreen extends StatefulWidget {
|
||||
final MeshCoreBleService bleService;
|
||||
|
||||
const PacketLogScreen({
|
||||
super.key,
|
||||
required this.bleService,
|
||||
});
|
||||
|
||||
@override
|
||||
State<PacketLogScreen> createState() => _PacketLogScreenState();
|
||||
}
|
||||
|
||||
class _PacketLogScreenState extends State<PacketLogScreen> {
|
||||
bool _autoScroll = true;
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
String _searchQuery = '';
|
||||
PacketDirection? _filterDirection;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
List<BlePacketLog> get _filteredLogs {
|
||||
var logs = widget.bleService.packetLogs;
|
||||
|
||||
// Filter by direction
|
||||
if (_filterDirection != null) {
|
||||
logs = logs.where((log) => log.direction == _filterDirection).toList();
|
||||
}
|
||||
|
||||
// Filter by search query
|
||||
if (_searchQuery.isNotEmpty) {
|
||||
final query = _searchQuery.toLowerCase();
|
||||
logs = logs.where((log) {
|
||||
return log.hexData.toLowerCase().contains(query) ||
|
||||
(log.description?.toLowerCase().contains(query) ?? false) ||
|
||||
log.summary.toLowerCase().contains(query);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
return logs;
|
||||
}
|
||||
|
||||
Future<void> _exportLogs(BuildContext context) async {
|
||||
try {
|
||||
final logs = _filteredLogs;
|
||||
if (logs.isEmpty) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('No logs to export')),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Create CSV content
|
||||
final buffer = StringBuffer();
|
||||
buffer.writeln('Timestamp,Direction,Size (bytes),Code,Hex Data,Description');
|
||||
for (final log in logs) {
|
||||
buffer.writeln(log.toCsvRow());
|
||||
}
|
||||
|
||||
// Save to temporary file
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final file = File('${tempDir.path}/ble_packets_${DateTime.now().millisecondsSinceEpoch}.csv');
|
||||
await file.writeAsString(buffer.toString());
|
||||
|
||||
// Share the file
|
||||
await Share.shareXFiles(
|
||||
[XFile(file.path)],
|
||||
subject: 'MeshCore BLE Packet Logs',
|
||||
text: 'Exported ${logs.length} BLE packets from MeshCore SAR app',
|
||||
);
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Export failed: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _exportAsText(BuildContext context) async {
|
||||
try {
|
||||
final logs = _filteredLogs;
|
||||
if (logs.isEmpty) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('No logs to export')),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Create text content
|
||||
final buffer = StringBuffer();
|
||||
buffer.writeln('MeshCore BLE Packet Logs');
|
||||
buffer.writeln('=' * 80);
|
||||
buffer.writeln('Exported: ${DateTime.now().toIso8601String()}');
|
||||
buffer.writeln('Total packets: ${logs.length}');
|
||||
buffer.writeln('=' * 80);
|
||||
buffer.writeln();
|
||||
|
||||
for (final log in logs) {
|
||||
buffer.writeln(log.toLogString());
|
||||
}
|
||||
|
||||
// Save to temporary file
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final file = File('${tempDir.path}/ble_packets_${DateTime.now().millisecondsSinceEpoch}.txt');
|
||||
await file.writeAsString(buffer.toString());
|
||||
|
||||
// Share the file
|
||||
await Share.shareXFiles(
|
||||
[XFile(file.path)],
|
||||
subject: 'MeshCore BLE Packet Logs',
|
||||
text: 'Exported ${logs.length} BLE packets from MeshCore SAR app',
|
||||
);
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Export failed: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _copyToClipboard(BuildContext context, BlePacketLog log) {
|
||||
Clipboard.setData(ClipboardData(text: log.hexData));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Hex data copied to clipboard'),
|
||||
duration: Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _clearLogs(BuildContext context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Clear Packet Logs'),
|
||||
content: const Text('Are you sure you want to clear all packet logs? This cannot be undone.'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
widget.bleService.clearPacketLogs();
|
||||
Navigator.pop(context);
|
||||
setState(() {});
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Packet logs cleared')),
|
||||
);
|
||||
},
|
||||
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||
child: const Text('Clear'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final logs = _filteredLogs;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('BLE Packet Logs'),
|
||||
Text(
|
||||
'${logs.length} packets',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
// Direction filter
|
||||
PopupMenuButton<PacketDirection?>(
|
||||
icon: Icon(_filterDirection == null
|
||||
? Icons.filter_list
|
||||
: _filterDirection == PacketDirection.rx
|
||||
? Icons.arrow_downward
|
||||
: Icons.arrow_upward),
|
||||
tooltip: 'Filter by direction',
|
||||
onSelected: (direction) {
|
||||
setState(() {
|
||||
_filterDirection = direction;
|
||||
});
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem(
|
||||
value: null,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.filter_list,
|
||||
color: _filterDirection == null ? Theme.of(context).colorScheme.primary : null),
|
||||
const SizedBox(width: 8),
|
||||
Text('All',
|
||||
style: TextStyle(
|
||||
fontWeight: _filterDirection == null ? FontWeight.bold : FontWeight.normal)),
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: PacketDirection.rx,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.arrow_downward,
|
||||
color: _filterDirection == PacketDirection.rx
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: null),
|
||||
const SizedBox(width: 8),
|
||||
Text('RX (Received)',
|
||||
style: TextStyle(
|
||||
fontWeight:
|
||||
_filterDirection == PacketDirection.rx ? FontWeight.bold : FontWeight.normal)),
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: PacketDirection.tx,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.arrow_upward,
|
||||
color: _filterDirection == PacketDirection.tx
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: null),
|
||||
const SizedBox(width: 8),
|
||||
Text('TX (Sent)',
|
||||
style: TextStyle(
|
||||
fontWeight:
|
||||
_filterDirection == PacketDirection.tx ? FontWeight.bold : FontWeight.normal)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
// Auto-scroll toggle
|
||||
IconButton(
|
||||
icon: Icon(_autoScroll ? Icons.vertical_align_bottom : Icons.vertical_align_center),
|
||||
tooltip: _autoScroll ? 'Disable auto-scroll' : 'Enable auto-scroll',
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_autoScroll = !_autoScroll;
|
||||
});
|
||||
},
|
||||
),
|
||||
// Export menu
|
||||
PopupMenuButton(
|
||||
icon: const Icon(Icons.share),
|
||||
tooltip: 'Export logs',
|
||||
itemBuilder: (context) => [
|
||||
const PopupMenuItem(
|
||||
value: 'csv',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.table_chart),
|
||||
SizedBox(width: 8),
|
||||
Text('Export as CSV'),
|
||||
],
|
||||
),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: 'txt',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.text_snippet),
|
||||
SizedBox(width: 8),
|
||||
Text('Export as Text'),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
onSelected: (value) {
|
||||
if (value == 'csv') {
|
||||
_exportLogs(context);
|
||||
} else if (value == 'txt') {
|
||||
_exportAsText(context);
|
||||
}
|
||||
},
|
||||
),
|
||||
// Clear logs
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
tooltip: 'Clear logs',
|
||||
onPressed: () => _clearLogs(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
// Search bar
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: TextField(
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Search logs...',
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
suffixIcon: _searchQuery.isNotEmpty
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_searchQuery = '';
|
||||
});
|
||||
},
|
||||
)
|
||||
: null,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_searchQuery = value;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
// Logs list
|
||||
Expanded(
|
||||
child: logs.isEmpty
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.list_alt,
|
||||
size: 64,
|
||||
color: Colors.grey[400],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_searchQuery.isNotEmpty || _filterDirection != null
|
||||
? 'No matching packets found'
|
||||
: 'No packets logged yet',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
if (_searchQuery.isNotEmpty || _filterDirection != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
TextButton.icon(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_searchQuery = '';
|
||||
_filterDirection = null;
|
||||
});
|
||||
},
|
||||
icon: const Icon(Icons.clear_all),
|
||||
label: const Text('Clear filters'),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
controller: _scrollController,
|
||||
itemCount: logs.length,
|
||||
itemBuilder: (context, index) {
|
||||
final log = logs[index];
|
||||
|
||||
// Auto-scroll to bottom
|
||||
if (_autoScroll && index == logs.length - 1) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (_scrollController.hasClients) {
|
||||
_scrollController.animateTo(
|
||||
_scrollController.position.maxScrollExtent,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return _PacketLogCard(
|
||||
log: log,
|
||||
onCopy: () => _copyToClipboard(context, log),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PacketLogCard extends StatelessWidget {
|
||||
final BlePacketLog log;
|
||||
final VoidCallback onCopy;
|
||||
|
||||
const _PacketLogCard({
|
||||
required this.log,
|
||||
required this.onCopy,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isRx = log.direction == PacketDirection.rx;
|
||||
final directionColor = isRx ? Colors.green : Colors.blue;
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
child: ExpansionTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: directionColor.withOpacity(0.2),
|
||||
child: Icon(
|
||||
isRx ? Icons.arrow_downward : Icons.arrow_upward,
|
||||
color: directionColor,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
title: Row(
|
||||
children: [
|
||||
Text(
|
||||
isRx ? 'RX' : 'TX',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: directionColor,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
if (log.description != null)
|
||||
Flexible(
|
||||
child: Text(
|
||||
log.description!,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
)
|
||||
else
|
||||
Text(
|
||||
'Code: ${log.responseCode != null ? "0x${log.responseCode!.toRadixString(16).padLeft(2, '0')}" : "N/A"}',
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
],
|
||||
),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${log.rawData.length} bytes • ${_formatTimestamp(log.timestamp)}',
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
|
||||
),
|
||||
],
|
||||
),
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Hex data
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Hex: ',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.grey[700],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: SelectableText(
|
||||
log.hexData,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.copy, size: 18),
|
||||
tooltip: 'Copy hex data',
|
||||
onPressed: onCopy,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Metadata
|
||||
Wrap(
|
||||
spacing: 16,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
_InfoChip(
|
||||
icon: Icons.schedule,
|
||||
label: log.timestamp.toIso8601String(),
|
||||
),
|
||||
_InfoChip(
|
||||
icon: Icons.data_usage,
|
||||
label: '${log.rawData.length} bytes',
|
||||
),
|
||||
if (log.responseCode != null)
|
||||
_InfoChip(
|
||||
icon: Icons.tag,
|
||||
label: 'Code: 0x${log.responseCode!.toRadixString(16).padLeft(2, '0')} (${log.responseCode})',
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatTimestamp(DateTime timestamp) {
|
||||
final now = DateTime.now();
|
||||
final diff = now.difference(timestamp);
|
||||
|
||||
if (diff.inSeconds < 60) {
|
||||
return '${diff.inSeconds}s ago';
|
||||
} else if (diff.inMinutes < 60) {
|
||||
return '${diff.inMinutes}m ago';
|
||||
} else if (diff.inHours < 24) {
|
||||
return '${diff.inHours}h ago';
|
||||
} else {
|
||||
return '${timestamp.hour.toString().padLeft(2, '0')}:${timestamp.minute.toString().padLeft(2, '0')}:${timestamp.second.toString().padLeft(2, '0')}';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _InfoChip extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
|
||||
const _InfoChip({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Chip(
|
||||
avatar: Icon(icon, size: 16),
|
||||
label: Text(
|
||||
label,
|
||||
style: const TextStyle(fontSize: 11),
|
||||
),
|
||||
padding: const EdgeInsets.all(4),
|
||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,22 @@ class BufferReader {
|
||||
return value > 32767 ? value - 65536 : value;
|
||||
}
|
||||
|
||||
/// Read unsigned 16-bit integer (big-endian)
|
||||
int readUInt16BE() {
|
||||
if (_offset + 2 > _buffer.length) {
|
||||
throw Exception('Buffer overflow: attempting to read beyond buffer length');
|
||||
}
|
||||
final value = (_buffer[_offset] << 8) | _buffer[_offset + 1];
|
||||
_offset += 2;
|
||||
return value;
|
||||
}
|
||||
|
||||
/// Read signed 16-bit integer (big-endian)
|
||||
int readInt16BE() {
|
||||
final value = readUInt16BE();
|
||||
return value > 32767 ? value - 65536 : value;
|
||||
}
|
||||
|
||||
/// Read unsigned 32-bit integer (little-endian)
|
||||
int readUInt32LE() {
|
||||
if (_offset + 4 > _buffer.length) {
|
||||
|
||||
@@ -49,7 +49,7 @@ class CayenneLppParser {
|
||||
break;
|
||||
|
||||
case MeshCoreConstants.lppAnalogInput:
|
||||
final rawValue = reader.readInt16LE();
|
||||
final rawValue = reader.readInt16BE();
|
||||
final value = rawValue / 100.0;
|
||||
print(' Analog Input (raw): $rawValue');
|
||||
print(' Analog Input (volts): ${value}V');
|
||||
@@ -63,7 +63,7 @@ class CayenneLppParser {
|
||||
break;
|
||||
|
||||
case MeshCoreConstants.lppAnalogOutput:
|
||||
final rawValue = reader.readInt16LE();
|
||||
final rawValue = reader.readInt16BE();
|
||||
final value = rawValue / 100.0;
|
||||
print(' Analog Output (raw): $rawValue');
|
||||
print(' Analog Output (volts): ${value}V');
|
||||
@@ -71,7 +71,7 @@ class CayenneLppParser {
|
||||
break;
|
||||
|
||||
case MeshCoreConstants.lppIlluminanceSensor:
|
||||
final value = reader.readUInt16LE();
|
||||
final value = reader.readUInt16BE();
|
||||
print(' Illuminance: $value lux');
|
||||
extraSensorData['illuminance_$channel'] = value;
|
||||
break;
|
||||
@@ -83,7 +83,7 @@ class CayenneLppParser {
|
||||
break;
|
||||
|
||||
case MeshCoreConstants.lppTemperatureSensor:
|
||||
final rawValue = reader.readInt16LE();
|
||||
final rawValue = reader.readInt16BE();
|
||||
temperature = rawValue / 10.0;
|
||||
print(' Temperature (raw): $rawValue');
|
||||
print(' Temperature: ${temperature?.toStringAsFixed(1)}°C');
|
||||
@@ -97,22 +97,22 @@ class CayenneLppParser {
|
||||
break;
|
||||
|
||||
case MeshCoreConstants.lppAccelerometer:
|
||||
final x = reader.readInt16LE() / 1000.0;
|
||||
final y = reader.readInt16LE() / 1000.0;
|
||||
final z = reader.readInt16LE() / 1000.0;
|
||||
final x = reader.readInt16BE() / 1000.0;
|
||||
final y = reader.readInt16BE() / 1000.0;
|
||||
final z = reader.readInt16BE() / 1000.0;
|
||||
print(' Accelerometer: x=$x, y=$y, z=$z');
|
||||
extraSensorData['accelerometer_$channel'] = {'x': x, 'y': y, 'z': z};
|
||||
break;
|
||||
|
||||
case MeshCoreConstants.lppBarometer:
|
||||
final rawValue = reader.readUInt16LE();
|
||||
final rawValue = reader.readUInt16BE();
|
||||
pressure = rawValue / 10.0;
|
||||
print(' Barometer (raw): $rawValue');
|
||||
print(' Barometer: ${pressure?.toStringAsFixed(1)} hPa');
|
||||
break;
|
||||
|
||||
case MeshCoreConstants.lppVoltageSensor:
|
||||
final rawValue = reader.readUInt16LE();
|
||||
final rawValue = reader.readUInt16BE();
|
||||
final value = rawValue / 100.0;
|
||||
print(' Voltage (raw): $rawValue');
|
||||
print(' Voltage: ${value}V');
|
||||
@@ -123,9 +123,9 @@ class CayenneLppParser {
|
||||
break;
|
||||
|
||||
case MeshCoreConstants.lppGyrometer:
|
||||
final x = reader.readInt16LE() / 100.0;
|
||||
final y = reader.readInt16LE() / 100.0;
|
||||
final z = reader.readInt16LE() / 100.0;
|
||||
final x = reader.readInt16BE() / 100.0;
|
||||
final y = reader.readInt16BE() / 100.0;
|
||||
final z = reader.readInt16BE() / 100.0;
|
||||
print(' Gyrometer: x=$x, y=$y, z=$z');
|
||||
extraSensorData['gyrometer_$channel'] = {'x': x, 'y': y, 'z': z};
|
||||
break;
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:flutter_blue_plus/flutter_blue_plus.dart';
|
||||
import '../models/contact.dart';
|
||||
import '../models/contact_telemetry.dart';
|
||||
import '../models/message.dart';
|
||||
import '../models/ble_packet_log.dart';
|
||||
import 'buffer_reader.dart';
|
||||
import 'buffer_writer.dart';
|
||||
import 'meshcore_constants.dart';
|
||||
@@ -15,6 +16,7 @@ typedef OnContactsCompleteCallback = void Function(List<Contact> contacts);
|
||||
typedef OnMessageCallback = void Function(Message message);
|
||||
typedef OnTelemetryCallback = void Function(Uint8List publicKey, Uint8List lppData);
|
||||
typedef OnSelfInfoCallback = void Function(Map<String, dynamic> selfInfo);
|
||||
typedef OnNoMoreMessagesCallback = void Function();
|
||||
typedef OnErrorCallback = void Function(String error);
|
||||
typedef OnConnectionStateCallback = void Function(bool isConnected);
|
||||
|
||||
@@ -32,6 +34,7 @@ class MeshCoreBleService {
|
||||
OnMessageCallback? onMessageReceived;
|
||||
OnTelemetryCallback? onTelemetryReceived;
|
||||
OnSelfInfoCallback? onSelfInfoReceived;
|
||||
OnNoMoreMessagesCallback? onNoMoreMessages;
|
||||
OnErrorCallback? onError;
|
||||
|
||||
// Internal state
|
||||
@@ -49,6 +52,11 @@ class MeshCoreBleService {
|
||||
VoidCallback? onRxActivity;
|
||||
VoidCallback? onTxActivity;
|
||||
|
||||
// Packet logging
|
||||
final List<BlePacketLog> _packetLogs = [];
|
||||
List<BlePacketLog> get packetLogs => List.unmodifiable(_packetLogs);
|
||||
static const int _maxLogSize = 1000; // Keep last 1000 packets
|
||||
|
||||
/// Scan for MeshCore devices
|
||||
Stream<BluetoothDevice> scanForDevices({Duration timeout = const Duration(seconds: 10)}) async* {
|
||||
try {
|
||||
@@ -238,6 +246,10 @@ class MeshCoreBleService {
|
||||
throw Exception('Characteristic does not support write operations');
|
||||
}
|
||||
|
||||
// Log TX packet (extract command code from first byte)
|
||||
final commandCode = data.isNotEmpty ? data[0] : null;
|
||||
_logPacket(data, PacketDirection.tx, responseCode: commandCode);
|
||||
|
||||
// Increment TX packet counter and trigger activity indicator
|
||||
_txPacketCount++;
|
||||
onTxActivity?.call();
|
||||
@@ -261,17 +273,22 @@ class MeshCoreBleService {
|
||||
return;
|
||||
}
|
||||
|
||||
final dataBytes = Uint8List.fromList(data);
|
||||
|
||||
// Increment RX packet counter and trigger activity indicator
|
||||
_rxPacketCount++;
|
||||
onRxActivity?.call();
|
||||
|
||||
print(' Raw data: ${data.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')}');
|
||||
|
||||
final reader = BufferReader(Uint8List.fromList(data));
|
||||
final reader = BufferReader(dataBytes);
|
||||
final responseCode = reader.readByte();
|
||||
print(' Response code: $responseCode (0x${responseCode.toRadixString(16)})');
|
||||
print(' Remaining bytes: ${reader.remainingBytesCount}');
|
||||
|
||||
// Log RX packet (before processing so we capture everything)
|
||||
_logPacket(dataBytes, PacketDirection.rx, responseCode: responseCode);
|
||||
|
||||
switch (responseCode) {
|
||||
case MeshCoreConstants.respContactsStart:
|
||||
print(' → Handling ContactsStart');
|
||||
@@ -317,6 +334,14 @@ class MeshCoreBleService {
|
||||
print(' → Handling LogRxData push');
|
||||
_handleLogRxData(reader);
|
||||
break;
|
||||
case MeshCoreConstants.pushNewAdvert:
|
||||
print(' → Handling NewAdvert push');
|
||||
_handleNewAdvert(reader);
|
||||
break;
|
||||
case MeshCoreConstants.respNoMoreMessages:
|
||||
print(' → Response: No More Messages');
|
||||
onNoMoreMessages?.call();
|
||||
break;
|
||||
case MeshCoreConstants.respOk:
|
||||
print(' → Response: OK');
|
||||
break;
|
||||
@@ -708,6 +733,79 @@ class MeshCoreBleService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle NewAdvert push
|
||||
void _handleNewAdvert(BufferReader reader) {
|
||||
try {
|
||||
print(' [NewAdvert] Parsing new advertisement...');
|
||||
print(' Remaining bytes: ${reader.remainingBytesCount}');
|
||||
|
||||
// NewAdvert format is identical to Contact response:
|
||||
// - 32 bytes: public key
|
||||
// - 1 byte: type
|
||||
// - 1 byte: flags
|
||||
// - 1 byte: outPathLen
|
||||
// - 64 bytes: outPath
|
||||
// - 32 bytes: advName (null-terminated string)
|
||||
// - 4 bytes: lastAdvert (uint32)
|
||||
// - 4 bytes: advLat (int32)
|
||||
// - 4 bytes: advLon (int32)
|
||||
// - 4 bytes: lastMod (uint32)
|
||||
|
||||
final publicKey = reader.readBytes(32);
|
||||
print(' Public key prefix: ${publicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}');
|
||||
|
||||
final typeByte = reader.readByte();
|
||||
final type = ContactType.fromValue(typeByte);
|
||||
print(' Type byte: $typeByte → Type: $type');
|
||||
|
||||
final flags = reader.readByte();
|
||||
print(' Flags: $flags (0x${flags.toRadixString(16).padLeft(2, '0')})');
|
||||
|
||||
final outPathLen = reader.readInt8();
|
||||
print(' Out path length: $outPathLen');
|
||||
|
||||
final outPath = reader.readBytes(64);
|
||||
print(' Out path: ${outPath.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}...');
|
||||
|
||||
final advName = reader.readCString(32);
|
||||
print(' Advertised name: "$advName"');
|
||||
|
||||
final lastAdvert = reader.readUInt32LE();
|
||||
print(' Last advert timestamp: $lastAdvert');
|
||||
|
||||
final advLat = reader.readInt32LE();
|
||||
print(' Latitude (raw int32): $advLat');
|
||||
print(' Latitude (decimal): ${advLat / 1000000.0}°');
|
||||
|
||||
final advLon = reader.readInt32LE();
|
||||
print(' Longitude (raw int32): $advLon');
|
||||
print(' Longitude (decimal): ${advLon / 1000000.0}°');
|
||||
|
||||
final lastMod = reader.readUInt32LE();
|
||||
print(' Last modified timestamp: $lastMod');
|
||||
|
||||
final contact = Contact(
|
||||
publicKey: publicKey,
|
||||
type: type,
|
||||
flags: flags,
|
||||
outPathLen: outPathLen,
|
||||
outPath: outPath,
|
||||
advName: advName,
|
||||
lastAdvert: lastAdvert,
|
||||
advLat: advLat,
|
||||
advLon: advLon,
|
||||
lastMod: lastMod,
|
||||
);
|
||||
|
||||
print(' ✅ [NewAdvert] Parsed successfully - new contact advertised on network');
|
||||
// Call the contact received callback to add/update the contact
|
||||
onContactReceived?.call(contact);
|
||||
} catch (e) {
|
||||
print(' ❌ [NewAdvert] Parsing error: $e');
|
||||
onError?.call('NewAdvert parsing error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Send AppStart command
|
||||
Future<void> _sendAppStart() async {
|
||||
print('📤 [BLE] Preparing AppStart command...');
|
||||
@@ -780,10 +878,11 @@ class MeshCoreBleService {
|
||||
}
|
||||
|
||||
/// Request telemetry from contact
|
||||
Future<void> requestTelemetry(Uint8List contactPublicKey) async {
|
||||
/// [zeroHop] - if true, only direct connection (no mesh forwarding)
|
||||
Future<void> requestTelemetry(Uint8List contactPublicKey, {bool zeroHop = false}) async {
|
||||
final writer = BufferWriter();
|
||||
writer.writeByte(MeshCoreConstants.cmdSendTelemetryReq);
|
||||
writer.writeByte(0); // reserved
|
||||
writer.writeByte(zeroHop ? 0 : 255); // hop count: 0 = direct only, 255 = unlimited
|
||||
writer.writeByte(0); // reserved
|
||||
writer.writeByte(0); // reserved
|
||||
writer.writeBytes(contactPublicKey);
|
||||
@@ -797,6 +896,14 @@ class MeshCoreBleService {
|
||||
await _writeData(writer.toBytes());
|
||||
}
|
||||
|
||||
/// Sync next message from device queue
|
||||
/// Returns true if a message was retrieved, false if no more messages
|
||||
Future<void> syncNextMessage() async {
|
||||
final writer = BufferWriter();
|
||||
writer.writeByte(MeshCoreConstants.cmdSyncNextMessage);
|
||||
await _writeData(writer.toBytes());
|
||||
}
|
||||
|
||||
/// Set device time
|
||||
Future<void> setDeviceTime() async {
|
||||
final writer = BufferWriter();
|
||||
@@ -862,6 +969,87 @@ class MeshCoreBleService {
|
||||
await _writeData(writer.toBytes());
|
||||
}
|
||||
|
||||
/// Log a packet
|
||||
void _logPacket(Uint8List data, PacketDirection direction, {int? responseCode}) {
|
||||
// Add new packet
|
||||
_packetLogs.add(BlePacketLog(
|
||||
timestamp: DateTime.now(),
|
||||
rawData: data,
|
||||
direction: direction,
|
||||
responseCode: responseCode,
|
||||
description: _getPacketDescription(responseCode, direction),
|
||||
));
|
||||
|
||||
// Limit log size to prevent memory issues
|
||||
if (_packetLogs.length > _maxLogSize) {
|
||||
_packetLogs.removeAt(0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get human-readable description of packet
|
||||
String? _getPacketDescription(int? code, PacketDirection direction) {
|
||||
if (direction == PacketDirection.tx) {
|
||||
// TX packets - command codes
|
||||
switch (code) {
|
||||
case MeshCoreConstants.cmdGetContacts:
|
||||
return 'Get Contacts';
|
||||
case MeshCoreConstants.cmdSendTxtMsg:
|
||||
return 'Send Text Message';
|
||||
case MeshCoreConstants.cmdSendChannelTxtMsg:
|
||||
return 'Send Channel Message';
|
||||
case MeshCoreConstants.cmdSendTelemetryReq:
|
||||
return 'Request Telemetry';
|
||||
case MeshCoreConstants.cmdDeviceQuery:
|
||||
return 'Device Query';
|
||||
case MeshCoreConstants.cmdAppStart:
|
||||
return 'App Start';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
// RX packets - response codes
|
||||
switch (code) {
|
||||
case MeshCoreConstants.respContactsStart:
|
||||
return 'Contacts Start';
|
||||
case MeshCoreConstants.respContact:
|
||||
return 'Contact Info';
|
||||
case MeshCoreConstants.respEndOfContacts:
|
||||
return 'End of Contacts';
|
||||
case MeshCoreConstants.respSent:
|
||||
return 'Message Sent';
|
||||
case MeshCoreConstants.respContactMsgRecv:
|
||||
return 'Contact Message';
|
||||
case MeshCoreConstants.respChannelMsgRecv:
|
||||
return 'Channel Message';
|
||||
case MeshCoreConstants.pushTelemetryResponse:
|
||||
return 'Telemetry Data';
|
||||
case MeshCoreConstants.respDeviceInfo:
|
||||
return 'Device Info';
|
||||
case MeshCoreConstants.respSelfInfo:
|
||||
return 'Self Info';
|
||||
case MeshCoreConstants.pushAdvert:
|
||||
return 'Advertisement';
|
||||
case MeshCoreConstants.pushLogRxData:
|
||||
return 'Log RX Data';
|
||||
case MeshCoreConstants.pushNewAdvert:
|
||||
return 'New Advertisement';
|
||||
case MeshCoreConstants.respNoMoreMessages:
|
||||
return 'No More Messages';
|
||||
case MeshCoreConstants.respOk:
|
||||
return 'OK';
|
||||
case MeshCoreConstants.respErr:
|
||||
return 'ERROR';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear packet logs
|
||||
void clearPacketLogs() {
|
||||
_packetLogs.clear();
|
||||
}
|
||||
|
||||
/// Reset packet counters
|
||||
void resetCounters() {
|
||||
_rxPacketCount = 0;
|
||||
@@ -872,5 +1060,6 @@ class MeshCoreBleService {
|
||||
void dispose() {
|
||||
_txSubscription?.cancel();
|
||||
_pendingContacts.clear();
|
||||
_packetLogs.clear();
|
||||
}
|
||||
}
|
||||
|
||||
164
lib/services/message_storage_service.dart
Normal file
164
lib/services/message_storage_service.dart
Normal file
@@ -0,0 +1,164 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../models/message.dart';
|
||||
import '../models/sar_marker.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
/// Service for persisting messages to local storage
|
||||
class MessageStorageService {
|
||||
static const String _messagesKey = 'stored_messages';
|
||||
static const int _maxStoredMessages = 1000; // Store up to 1000 messages
|
||||
|
||||
/// Save messages to persistent storage
|
||||
Future<void> saveMessages(List<Message> messages) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
// Convert messages to JSON
|
||||
final jsonList = messages.map((msg) => _messageToJson(msg)).toList();
|
||||
|
||||
// Limit to max stored messages (keep most recent)
|
||||
final limitedList = jsonList.length > _maxStoredMessages
|
||||
? jsonList.sublist(jsonList.length - _maxStoredMessages)
|
||||
: jsonList;
|
||||
|
||||
final jsonString = jsonEncode(limitedList);
|
||||
await prefs.setString(_messagesKey, jsonString);
|
||||
|
||||
print('✅ [MessageStorage] Saved ${limitedList.length} messages to storage');
|
||||
} catch (e) {
|
||||
print('❌ [MessageStorage] Error saving messages: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Load messages from persistent storage
|
||||
Future<List<Message>> loadMessages() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final jsonString = prefs.getString(_messagesKey);
|
||||
|
||||
if (jsonString == null || jsonString.isEmpty) {
|
||||
print('ℹ️ [MessageStorage] No stored messages found');
|
||||
return [];
|
||||
}
|
||||
|
||||
final jsonList = jsonDecode(jsonString) as List<dynamic>;
|
||||
final messages = jsonList
|
||||
.map((json) => _messageFromJson(json as Map<String, dynamic>))
|
||||
.where((msg) => msg != null)
|
||||
.cast<Message>()
|
||||
.toList();
|
||||
|
||||
print('✅ [MessageStorage] Loaded ${messages.length} messages from storage');
|
||||
return messages;
|
||||
} catch (e) {
|
||||
print('❌ [MessageStorage] Error loading messages: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear all stored messages
|
||||
Future<void> clearMessages() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_messagesKey);
|
||||
print('✅ [MessageStorage] Cleared all stored messages');
|
||||
} catch (e) {
|
||||
print('❌ [MessageStorage] Error clearing messages: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Get storage statistics
|
||||
Future<Map<String, dynamic>> getStorageStats() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final jsonString = prefs.getString(_messagesKey);
|
||||
|
||||
if (jsonString == null || jsonString.isEmpty) {
|
||||
return {
|
||||
'messageCount': 0,
|
||||
'storageSizeBytes': 0,
|
||||
'storageSizeKB': 0,
|
||||
};
|
||||
}
|
||||
|
||||
final sizeBytes = jsonString.length;
|
||||
final jsonList = jsonDecode(jsonString) as List<dynamic>;
|
||||
|
||||
return {
|
||||
'messageCount': jsonList.length,
|
||||
'storageSizeBytes': sizeBytes,
|
||||
'storageSizeKB': (sizeBytes / 1024).toStringAsFixed(2),
|
||||
};
|
||||
} catch (e) {
|
||||
print('❌ [MessageStorage] Error getting storage stats: $e');
|
||||
return {
|
||||
'messageCount': 0,
|
||||
'storageSizeBytes': 0,
|
||||
'storageSizeKB': 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert Message to JSON
|
||||
Map<String, dynamic> _messageToJson(Message message) {
|
||||
return {
|
||||
'id': message.id,
|
||||
'messageType': message.messageType.name,
|
||||
'senderPublicKeyPrefix': message.senderPublicKeyPrefix != null
|
||||
? base64Encode(message.senderPublicKeyPrefix!)
|
||||
: null,
|
||||
'channelIdx': message.channelIdx,
|
||||
'pathLen': message.pathLen,
|
||||
'textType': message.textType.value,
|
||||
'senderTimestamp': message.senderTimestamp,
|
||||
'text': message.text,
|
||||
'isSarMarker': message.isSarMarker,
|
||||
'sarMarkerType': message.sarMarkerType?.name,
|
||||
'sarGpsLat': message.sarGpsCoordinates?.latitude,
|
||||
'sarGpsLon': message.sarGpsCoordinates?.longitude,
|
||||
'receivedAtMillis': message.receivedAt.millisecondsSinceEpoch,
|
||||
'senderName': message.senderName,
|
||||
};
|
||||
}
|
||||
|
||||
/// Convert JSON to Message
|
||||
Message? _messageFromJson(Map<String, dynamic> json) {
|
||||
try {
|
||||
return Message(
|
||||
id: json['id'] as String,
|
||||
messageType: MessageType.values.firstWhere(
|
||||
(e) => e.name == json['messageType'],
|
||||
orElse: () => MessageType.contact,
|
||||
),
|
||||
senderPublicKeyPrefix: json['senderPublicKeyPrefix'] != null
|
||||
? Uint8List.fromList(
|
||||
base64Decode(json['senderPublicKeyPrefix'] as String))
|
||||
: null,
|
||||
channelIdx: json['channelIdx'] as int?,
|
||||
pathLen: json['pathLen'] as int,
|
||||
textType: MessageTextType.fromValue(json['textType'] as int),
|
||||
senderTimestamp: json['senderTimestamp'] as int,
|
||||
text: json['text'] as String,
|
||||
isSarMarker: json['isSarMarker'] as bool? ?? false,
|
||||
sarMarkerType: json['sarMarkerType'] != null
|
||||
? SarMarkerType.values.firstWhere(
|
||||
(e) => e.name == json['sarMarkerType'],
|
||||
orElse: () => SarMarkerType.unknown,
|
||||
)
|
||||
: null,
|
||||
sarGpsCoordinates: json['sarGpsLat'] != null &&
|
||||
json['sarGpsLon'] != null
|
||||
? LatLng(json['sarGpsLat'] as double, json['sarGpsLon'] as double)
|
||||
: null,
|
||||
receivedAt: DateTime.fromMillisecondsSinceEpoch(
|
||||
json['receivedAtMillis'] as int),
|
||||
senderName: json['senderName'] as String?,
|
||||
);
|
||||
} catch (e) {
|
||||
print('❌ [MessageStorage] Error parsing message from JSON: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -216,11 +216,21 @@ class MapMarkers {
|
||||
'${contact.displayLocation!.latitude.toStringAsFixed(6)}, ${contact.displayLocation!.longitude.toStringAsFixed(6)}',
|
||||
),
|
||||
],
|
||||
if (contact.displayBattery != null)
|
||||
if (contact.telemetry?.batteryMilliVolts != null)
|
||||
_InfoRow(
|
||||
'Voltage',
|
||||
'${(contact.telemetry!.batteryMilliVolts! / 1000).toStringAsFixed(3)}V'
|
||||
'${contact.telemetry!.batteryPercentage != null ? ' (${contact.telemetry!.batteryPercentage!.toStringAsFixed(1)}%)' : ''}',
|
||||
)
|
||||
else if (contact.displayBattery != null)
|
||||
_InfoRow('Battery', '${contact.displayBattery!.round()}%'),
|
||||
if (contact.telemetry?.temperature != null)
|
||||
_InfoRow(
|
||||
'Temperature', '${contact.telemetry!.temperature!.toStringAsFixed(1)}°C'),
|
||||
if (contact.telemetry?.humidity != null)
|
||||
_InfoRow('Humidity', '${contact.telemetry!.humidity!.toStringAsFixed(1)}%'),
|
||||
if (contact.telemetry?.pressure != null)
|
||||
_InfoRow('Pressure', '${contact.telemetry!.pressure!.toStringAsFixed(1)} hPa'),
|
||||
_InfoRow('Last Seen', contact.timeSinceLastSeen),
|
||||
_InfoRow('Public Key', contact.publicKeyShort),
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user