mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
initial commit
This commit is contained in:
104
lib/main.dart
Normal file
104
lib/main.dart
Normal file
@@ -0,0 +1,104 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'providers/connection_provider.dart';
|
||||
import 'providers/contacts_provider.dart';
|
||||
import 'providers/messages_provider.dart';
|
||||
import 'providers/map_provider.dart';
|
||||
import 'providers/app_provider.dart';
|
||||
import 'services/tile_cache_service.dart';
|
||||
import 'screens/home_screen.dart';
|
||||
|
||||
void main() {
|
||||
runApp(const MeshCoreSarApp());
|
||||
}
|
||||
|
||||
class MeshCoreSarApp extends StatelessWidget {
|
||||
const MeshCoreSarApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MultiProvider(
|
||||
providers: [
|
||||
// Core providers
|
||||
ChangeNotifierProvider(create: (_) => ConnectionProvider()),
|
||||
ChangeNotifierProvider(create: (_) => ContactsProvider()),
|
||||
ChangeNotifierProvider(create: (_) => MessagesProvider()),
|
||||
ChangeNotifierProvider(create: (_) => MapProvider()),
|
||||
|
||||
// Tile cache service
|
||||
Provider(create: (_) => TileCacheService()),
|
||||
|
||||
// App provider that coordinates everything
|
||||
ChangeNotifierProxyProvider4<ConnectionProvider, ContactsProvider,
|
||||
MessagesProvider, TileCacheService, AppProvider>(
|
||||
create: (context) => AppProvider(
|
||||
connectionProvider: context.read<ConnectionProvider>(),
|
||||
contactsProvider: context.read<ContactsProvider>(),
|
||||
messagesProvider: context.read<MessagesProvider>(),
|
||||
tileCacheService: context.read<TileCacheService>(),
|
||||
),
|
||||
update: (context, conn, contacts, messages, tileCache, previous) =>
|
||||
previous ??
|
||||
AppProvider(
|
||||
connectionProvider: conn,
|
||||
contactsProvider: contacts,
|
||||
messagesProvider: messages,
|
||||
tileCacheService: tileCache,
|
||||
),
|
||||
),
|
||||
],
|
||||
child: MaterialApp(
|
||||
title: 'MeshCore SAR',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: ThemeData(
|
||||
useMaterial3: true,
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: Colors.orange,
|
||||
brightness: Brightness.light,
|
||||
),
|
||||
appBarTheme: const AppBarTheme(
|
||||
centerTitle: true,
|
||||
elevation: 0,
|
||||
),
|
||||
cardTheme: CardThemeData(
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
filled: true,
|
||||
),
|
||||
),
|
||||
darkTheme: ThemeData(
|
||||
useMaterial3: true,
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: Colors.orange,
|
||||
brightness: Brightness.dark,
|
||||
),
|
||||
appBarTheme: const AppBarTheme(
|
||||
centerTitle: true,
|
||||
elevation: 0,
|
||||
),
|
||||
cardTheme: CardThemeData(
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
filled: true,
|
||||
),
|
||||
),
|
||||
themeMode: ThemeMode.system,
|
||||
home: const HomeScreen(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
174
lib/models/contact.dart
Normal file
174
lib/models/contact.dart
Normal file
@@ -0,0 +1,174 @@
|
||||
import 'dart:typed_data';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'contact_telemetry.dart';
|
||||
|
||||
/// MeshCore contact types
|
||||
enum ContactType {
|
||||
none(0),
|
||||
chat(1),
|
||||
repeater(2),
|
||||
room(3);
|
||||
|
||||
const ContactType(this.value);
|
||||
final int value;
|
||||
|
||||
static ContactType fromValue(int value) {
|
||||
return ContactType.values.firstWhere(
|
||||
(e) => e.value == value,
|
||||
orElse: () => ContactType.none,
|
||||
);
|
||||
}
|
||||
|
||||
String get displayName {
|
||||
switch (this) {
|
||||
case ContactType.chat:
|
||||
return 'Chat';
|
||||
case ContactType.repeater:
|
||||
return 'Repeater';
|
||||
case ContactType.room:
|
||||
return 'Room';
|
||||
default:
|
||||
return 'Unknown';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// MeshCore contact model
|
||||
class Contact {
|
||||
final Uint8List publicKey;
|
||||
final ContactType type;
|
||||
final int flags;
|
||||
final int outPathLen;
|
||||
final Uint8List outPath;
|
||||
final String advName;
|
||||
final int lastAdvert; // Unix timestamp
|
||||
final int advLat; // Latitude as int32
|
||||
final int advLon; // Longitude as int32
|
||||
final int lastMod; // Unix timestamp
|
||||
|
||||
// Telemetry data (updated separately)
|
||||
ContactTelemetry? telemetry;
|
||||
|
||||
Contact({
|
||||
required this.publicKey,
|
||||
required this.type,
|
||||
required this.flags,
|
||||
required this.outPathLen,
|
||||
required this.outPath,
|
||||
required this.advName,
|
||||
required this.lastAdvert,
|
||||
required this.advLat,
|
||||
required this.advLon,
|
||||
required this.lastMod,
|
||||
this.telemetry,
|
||||
});
|
||||
|
||||
/// Get public key as hex string (first 8 bytes)
|
||||
String get publicKeyShort {
|
||||
if (publicKey.length < 8) return '';
|
||||
return publicKey.sublist(0, 8).map((b) => b.toRadixString(16).padLeft(2, '0')).join('');
|
||||
}
|
||||
|
||||
/// Get full public key as hex string
|
||||
String get publicKeyHex {
|
||||
return publicKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join('');
|
||||
}
|
||||
|
||||
/// Convert advLat/advLon to LatLng
|
||||
LatLng? get advertLocation {
|
||||
if (advLat == 0 && advLon == 0) return null;
|
||||
// Convert from int32 to double (degrees)
|
||||
final lat = advLat / 1e7;
|
||||
final lon = advLon / 1e7;
|
||||
return LatLng(lat, lon);
|
||||
}
|
||||
|
||||
/// Get display location (prefer telemetry over advert)
|
||||
LatLng? get displayLocation {
|
||||
if (telemetry?.gpsLocation != null && telemetry!.isRecent) {
|
||||
return telemetry!.gpsLocation;
|
||||
}
|
||||
return advertLocation;
|
||||
}
|
||||
|
||||
/// Get display battery (from telemetry or null)
|
||||
double? get displayBattery {
|
||||
return telemetry?.batteryPercentage;
|
||||
}
|
||||
|
||||
/// Check if contact is a chat type (team member)
|
||||
bool get isChat => type == ContactType.chat;
|
||||
|
||||
/// Check if contact is a repeater
|
||||
bool get isRepeater => type == ContactType.repeater;
|
||||
|
||||
/// Check if contact is a room/channel
|
||||
bool get isRoom => type == ContactType.room;
|
||||
|
||||
/// Get last seen time
|
||||
DateTime get lastSeenTime {
|
||||
return DateTime.fromMillisecondsSinceEpoch(lastAdvert * 1000);
|
||||
}
|
||||
|
||||
/// Get last modified time
|
||||
DateTime get lastModifiedTime {
|
||||
return DateTime.fromMillisecondsSinceEpoch(lastMod * 1000);
|
||||
}
|
||||
|
||||
/// Check if contact was seen recently (within last 10 minutes)
|
||||
bool get isRecentlySeen {
|
||||
return DateTime.now().difference(lastSeenTime).inMinutes < 10;
|
||||
}
|
||||
|
||||
/// Get friendly time since last seen
|
||||
String get timeSinceLastSeen {
|
||||
final diff = DateTime.now().difference(lastSeenTime);
|
||||
if (diff.inMinutes < 1) return 'Just now';
|
||||
if (diff.inMinutes < 60) return '${diff.inMinutes}m ago';
|
||||
if (diff.inHours < 24) return '${diff.inHours}h ago';
|
||||
return '${diff.inDays}d ago';
|
||||
}
|
||||
|
||||
Contact copyWith({
|
||||
Uint8List? publicKey,
|
||||
ContactType? type,
|
||||
int? flags,
|
||||
int? outPathLen,
|
||||
Uint8List? outPath,
|
||||
String? advName,
|
||||
int? lastAdvert,
|
||||
int? advLat,
|
||||
int? advLon,
|
||||
int? lastMod,
|
||||
ContactTelemetry? telemetry,
|
||||
}) {
|
||||
return Contact(
|
||||
publicKey: publicKey ?? this.publicKey,
|
||||
type: type ?? this.type,
|
||||
flags: flags ?? this.flags,
|
||||
outPathLen: outPathLen ?? this.outPathLen,
|
||||
outPath: outPath ?? this.outPath,
|
||||
advName: advName ?? this.advName,
|
||||
lastAdvert: lastAdvert ?? this.lastAdvert,
|
||||
advLat: advLat ?? this.advLat,
|
||||
advLon: advLon ?? this.advLon,
|
||||
lastMod: lastMod ?? this.lastMod,
|
||||
telemetry: telemetry ?? this.telemetry,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'Contact(name: $advName, type: ${type.displayName}, key: $publicKeyShort)';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
return other is Contact &&
|
||||
publicKeyHex == other.publicKeyHex;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => publicKeyHex.hashCode;
|
||||
}
|
||||
76
lib/models/contact_telemetry.dart
Normal file
76
lib/models/contact_telemetry.dart
Normal file
@@ -0,0 +1,76 @@
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
/// Contact telemetry data from MeshCore device
|
||||
class ContactTelemetry {
|
||||
final LatLng? gpsLocation;
|
||||
final double? batteryPercentage;
|
||||
final double? batteryMilliVolts;
|
||||
final double? temperature;
|
||||
final DateTime timestamp;
|
||||
|
||||
// Additional sensor data
|
||||
final double? humidity;
|
||||
final double? pressure;
|
||||
final Map<String, dynamic>? extraSensorData;
|
||||
|
||||
ContactTelemetry({
|
||||
this.gpsLocation,
|
||||
this.batteryPercentage,
|
||||
this.batteryMilliVolts,
|
||||
this.temperature,
|
||||
required this.timestamp,
|
||||
this.humidity,
|
||||
this.pressure,
|
||||
this.extraSensorData,
|
||||
});
|
||||
|
||||
/// Check if telemetry data is recent (within last 5 minutes)
|
||||
bool get isRecent {
|
||||
return DateTime.now().difference(timestamp).inMinutes < 5;
|
||||
}
|
||||
|
||||
/// Check if battery level is low (< 20%)
|
||||
bool get isLowBattery {
|
||||
return batteryPercentage != null && batteryPercentage! < 20.0;
|
||||
}
|
||||
|
||||
/// Check if battery level is critical (< 10%)
|
||||
bool get isCriticalBattery {
|
||||
return batteryPercentage != null && batteryPercentage! < 10.0;
|
||||
}
|
||||
|
||||
/// Get battery status color indicator
|
||||
String get batteryStatus {
|
||||
if (batteryPercentage == null) return 'unknown';
|
||||
if (batteryPercentage! > 50) return 'good';
|
||||
if (batteryPercentage! > 20) return 'medium';
|
||||
return 'low';
|
||||
}
|
||||
|
||||
ContactTelemetry copyWith({
|
||||
LatLng? gpsLocation,
|
||||
double? batteryPercentage,
|
||||
double? batteryMilliVolts,
|
||||
double? temperature,
|
||||
DateTime? timestamp,
|
||||
double? humidity,
|
||||
double? pressure,
|
||||
Map<String, dynamic>? extraSensorData,
|
||||
}) {
|
||||
return ContactTelemetry(
|
||||
gpsLocation: gpsLocation ?? this.gpsLocation,
|
||||
batteryPercentage: batteryPercentage ?? this.batteryPercentage,
|
||||
batteryMilliVolts: batteryMilliVolts ?? this.batteryMilliVolts,
|
||||
temperature: temperature ?? this.temperature,
|
||||
timestamp: timestamp ?? this.timestamp,
|
||||
humidity: humidity ?? this.humidity,
|
||||
pressure: pressure ?? this.pressure,
|
||||
extraSensorData: extraSensorData ?? this.extraSensorData,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'ContactTelemetry(gps: $gpsLocation, battery: $batteryPercentage%, temp: $temperature°C, time: $timestamp)';
|
||||
}
|
||||
}
|
||||
173
lib/models/device_info.dart
Normal file
173
lib/models/device_info.dart
Normal file
@@ -0,0 +1,173 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
/// BLE connection state
|
||||
enum ConnectionState {
|
||||
disconnected,
|
||||
connecting,
|
||||
connected,
|
||||
disconnecting,
|
||||
error,
|
||||
}
|
||||
|
||||
/// MeshCore device information
|
||||
class DeviceInfo {
|
||||
final String? deviceId;
|
||||
final String? deviceName;
|
||||
final ConnectionState connectionState;
|
||||
final int? batteryMilliVolts;
|
||||
final double? batteryPercentage;
|
||||
final int? signalRssi;
|
||||
final double? signalSnr;
|
||||
final DateTime? lastUpdate;
|
||||
|
||||
// Self info from MeshCore device
|
||||
final int? deviceType;
|
||||
final int? txPower;
|
||||
final int? maxTxPower;
|
||||
final Uint8List? publicKey;
|
||||
final int? advLat;
|
||||
final int? advLon;
|
||||
final bool? manualAddContacts;
|
||||
final int? radioFreq;
|
||||
final int? radioBw;
|
||||
final int? radioSf;
|
||||
final int? radioCr;
|
||||
final String? selfName;
|
||||
|
||||
// Firmware info
|
||||
final int? firmwareVersion;
|
||||
final String? firmwareBuildDate;
|
||||
final String? manufacturerModel;
|
||||
|
||||
DeviceInfo({
|
||||
this.deviceId,
|
||||
this.deviceName,
|
||||
this.connectionState = ConnectionState.disconnected,
|
||||
this.batteryMilliVolts,
|
||||
this.batteryPercentage,
|
||||
this.signalRssi,
|
||||
this.signalSnr,
|
||||
this.lastUpdate,
|
||||
this.deviceType,
|
||||
this.txPower,
|
||||
this.maxTxPower,
|
||||
this.publicKey,
|
||||
this.advLat,
|
||||
this.advLon,
|
||||
this.manualAddContacts,
|
||||
this.radioFreq,
|
||||
this.radioBw,
|
||||
this.radioSf,
|
||||
this.radioCr,
|
||||
this.selfName,
|
||||
this.firmwareVersion,
|
||||
this.firmwareBuildDate,
|
||||
this.manufacturerModel,
|
||||
});
|
||||
|
||||
/// Check if device is connected
|
||||
bool get isConnected => connectionState == ConnectionState.connected;
|
||||
|
||||
/// Check if device is connecting
|
||||
bool get isConnecting => connectionState == ConnectionState.connecting;
|
||||
|
||||
/// Check if device has error
|
||||
bool get hasError => connectionState == ConnectionState.error;
|
||||
|
||||
/// Get battery percentage (calculated or provided)
|
||||
double? get batteryPercent {
|
||||
if (batteryPercentage != null) return batteryPercentage!;
|
||||
if (batteryMilliVolts == null) return null;
|
||||
|
||||
// Rough conversion from mV to percentage (3.0V = 0%, 4.2V = 100%)
|
||||
final voltage = batteryMilliVolts! / 1000.0;
|
||||
if (voltage <= 3.0) return 0.0;
|
||||
if (voltage >= 4.2) return 100.0;
|
||||
return ((voltage - 3.0) / 1.2) * 100.0;
|
||||
}
|
||||
|
||||
/// Get battery status
|
||||
String get batteryStatus {
|
||||
final percent = batteryPercent;
|
||||
if (percent == null) return 'Unknown';
|
||||
if (percent > 80) return 'Excellent';
|
||||
if (percent > 50) return 'Good';
|
||||
if (percent > 20) return 'Low';
|
||||
return 'Critical';
|
||||
}
|
||||
|
||||
/// Get signal strength category
|
||||
String get signalStrength {
|
||||
if (signalRssi == null) return 'Unknown';
|
||||
if (signalRssi! > -60) return 'Excellent';
|
||||
if (signalRssi! > -70) return 'Good';
|
||||
if (signalRssi! > -80) return 'Fair';
|
||||
return 'Poor';
|
||||
}
|
||||
|
||||
/// Get public key as hex string (short)
|
||||
String? get publicKeyShort {
|
||||
if (publicKey == null || publicKey!.length < 8) return null;
|
||||
return publicKey!
|
||||
.sublist(0, 8)
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join('');
|
||||
}
|
||||
|
||||
DeviceInfo copyWith({
|
||||
String? deviceId,
|
||||
String? deviceName,
|
||||
ConnectionState? connectionState,
|
||||
int? batteryMilliVolts,
|
||||
double? batteryPercentage,
|
||||
int? signalRssi,
|
||||
double? signalSnr,
|
||||
DateTime? lastUpdate,
|
||||
int? deviceType,
|
||||
int? txPower,
|
||||
int? maxTxPower,
|
||||
Uint8List? publicKey,
|
||||
int? advLat,
|
||||
int? advLon,
|
||||
bool? manualAddContacts,
|
||||
int? radioFreq,
|
||||
int? radioBw,
|
||||
int? radioSf,
|
||||
int? radioCr,
|
||||
String? selfName,
|
||||
int? firmwareVersion,
|
||||
String? firmwareBuildDate,
|
||||
String? manufacturerModel,
|
||||
}) {
|
||||
return DeviceInfo(
|
||||
deviceId: deviceId ?? this.deviceId,
|
||||
deviceName: deviceName ?? this.deviceName,
|
||||
connectionState: connectionState ?? this.connectionState,
|
||||
batteryMilliVolts: batteryMilliVolts ?? this.batteryMilliVolts,
|
||||
batteryPercentage: batteryPercentage ?? this.batteryPercentage,
|
||||
signalRssi: signalRssi ?? this.signalRssi,
|
||||
signalSnr: signalSnr ?? this.signalSnr,
|
||||
lastUpdate: lastUpdate ?? this.lastUpdate,
|
||||
deviceType: deviceType ?? this.deviceType,
|
||||
txPower: txPower ?? this.txPower,
|
||||
maxTxPower: maxTxPower ?? this.maxTxPower,
|
||||
publicKey: publicKey ?? this.publicKey,
|
||||
advLat: advLat ?? this.advLat,
|
||||
advLon: advLon ?? this.advLon,
|
||||
manualAddContacts: manualAddContacts ?? this.manualAddContacts,
|
||||
radioFreq: radioFreq ?? this.radioFreq,
|
||||
radioBw: radioBw ?? this.radioBw,
|
||||
radioSf: radioSf ?? this.radioSf,
|
||||
radioCr: radioCr ?? this.radioCr,
|
||||
selfName: selfName ?? this.selfName,
|
||||
firmwareVersion: firmwareVersion ?? this.firmwareVersion,
|
||||
firmwareBuildDate: firmwareBuildDate ?? this.firmwareBuildDate,
|
||||
manufacturerModel: manufacturerModel ?? this.manufacturerModel,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'DeviceInfo(name: $deviceName, state: $connectionState, battery: ${batteryPercent?.toStringAsFixed(0)}%, signal: $signalRssi dBm)';
|
||||
}
|
||||
}
|
||||
56
lib/models/map_layer.dart
Normal file
56
lib/models/map_layer.dart
Normal file
@@ -0,0 +1,56 @@
|
||||
enum MapLayerType {
|
||||
openStreetMap,
|
||||
openTopoMap,
|
||||
esriWorldImagery,
|
||||
}
|
||||
|
||||
class MapLayer {
|
||||
final MapLayerType type;
|
||||
final String name;
|
||||
final String urlTemplate;
|
||||
final String attribution;
|
||||
final int maxZoom;
|
||||
|
||||
const MapLayer({
|
||||
required this.type,
|
||||
required this.name,
|
||||
required this.urlTemplate,
|
||||
required this.attribution,
|
||||
required this.maxZoom,
|
||||
});
|
||||
|
||||
static const openStreetMap = MapLayer(
|
||||
type: MapLayerType.openStreetMap,
|
||||
name: 'OpenStreetMap',
|
||||
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
attribution: '© OpenStreetMap contributors',
|
||||
maxZoom: 19,
|
||||
);
|
||||
|
||||
static const openTopoMap = MapLayer(
|
||||
type: MapLayerType.openTopoMap,
|
||||
name: 'OpenTopoMap',
|
||||
urlTemplate: 'https://a.tile.opentopomap.org/{z}/{x}/{y}.png',
|
||||
attribution: '© OpenTopoMap (CC-BY-SA)',
|
||||
maxZoom: 17,
|
||||
);
|
||||
|
||||
static const esriWorldImagery = MapLayer(
|
||||
type: MapLayerType.esriWorldImagery,
|
||||
name: 'ESRI Satellite',
|
||||
urlTemplate:
|
||||
'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
|
||||
attribution: '© Esri',
|
||||
maxZoom: 19,
|
||||
);
|
||||
|
||||
static const List<MapLayer> allLayers = [
|
||||
openStreetMap,
|
||||
openTopoMap,
|
||||
esriWorldImagery,
|
||||
];
|
||||
|
||||
static MapLayer fromType(MapLayerType type) {
|
||||
return allLayers.firstWhere((layer) => layer.type == type);
|
||||
}
|
||||
}
|
||||
171
lib/models/message.dart
Normal file
171
lib/models/message.dart
Normal file
@@ -0,0 +1,171 @@
|
||||
import 'dart:typed_data';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'sar_marker.dart';
|
||||
|
||||
/// Message text types from MeshCore protocol
|
||||
enum MessageTextType {
|
||||
plain(0),
|
||||
cliData(1),
|
||||
signedPlain(2);
|
||||
|
||||
const MessageTextType(this.value);
|
||||
final int value;
|
||||
|
||||
static MessageTextType fromValue(int value) {
|
||||
return MessageTextType.values.firstWhere(
|
||||
(e) => e.value == value,
|
||||
orElse: () => MessageTextType.plain,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Message type (contact or channel)
|
||||
enum MessageType {
|
||||
contact,
|
||||
channel,
|
||||
}
|
||||
|
||||
/// MeshCore message model
|
||||
class Message {
|
||||
final String id;
|
||||
final MessageType messageType;
|
||||
final Uint8List? senderPublicKeyPrefix; // 6 bytes for contact messages
|
||||
final int? channelIdx; // For channel messages
|
||||
final int pathLen;
|
||||
final MessageTextType textType;
|
||||
final int senderTimestamp; // Unix timestamp
|
||||
final String text;
|
||||
|
||||
// SAR marker data (if this is a SAR message)
|
||||
final bool isSarMarker;
|
||||
final SarMarkerType? sarMarkerType;
|
||||
final LatLng? sarGpsCoordinates;
|
||||
|
||||
// Display metadata
|
||||
final DateTime receivedAt;
|
||||
final String? senderName;
|
||||
|
||||
Message({
|
||||
required this.id,
|
||||
required this.messageType,
|
||||
this.senderPublicKeyPrefix,
|
||||
this.channelIdx,
|
||||
required this.pathLen,
|
||||
required this.textType,
|
||||
required this.senderTimestamp,
|
||||
required this.text,
|
||||
this.isSarMarker = false,
|
||||
this.sarMarkerType,
|
||||
this.sarGpsCoordinates,
|
||||
required this.receivedAt,
|
||||
this.senderName,
|
||||
});
|
||||
|
||||
/// Get sender public key as hex string
|
||||
String? get senderKeyShort {
|
||||
if (senderPublicKeyPrefix == null) return null;
|
||||
return senderPublicKeyPrefix!
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join('');
|
||||
}
|
||||
|
||||
/// Get sender timestamp as DateTime
|
||||
DateTime get sentAt {
|
||||
return DateTime.fromMillisecondsSinceEpoch(senderTimestamp * 1000);
|
||||
}
|
||||
|
||||
/// Check if message is from a channel
|
||||
bool get isChannelMessage => messageType == MessageType.channel;
|
||||
|
||||
/// Check if message is from a contact
|
||||
bool get isContactMessage => messageType == MessageType.contact;
|
||||
|
||||
/// Get friendly time since message was sent
|
||||
String get timeAgo {
|
||||
final diff = DateTime.now().difference(sentAt);
|
||||
if (diff.inMinutes < 1) return 'Just now';
|
||||
if (diff.inMinutes < 60) return '${diff.inMinutes}m ago';
|
||||
if (diff.inHours < 24) return '${diff.inHours}h ago';
|
||||
return '${diff.inDays}d ago';
|
||||
}
|
||||
|
||||
/// Get display name for sender
|
||||
String get displaySender {
|
||||
if (senderName != null && senderName!.isNotEmpty) {
|
||||
return senderName!;
|
||||
}
|
||||
if (senderKeyShort != null) {
|
||||
return senderKeyShort!.substring(0, 8);
|
||||
}
|
||||
if (isChannelMessage && channelIdx != null) {
|
||||
return 'Channel $channelIdx';
|
||||
}
|
||||
return 'Unknown';
|
||||
}
|
||||
|
||||
/// Convert to SAR marker if applicable
|
||||
SarMarker? toSarMarker() {
|
||||
if (!isSarMarker || sarMarkerType == null || sarGpsCoordinates == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return SarMarker(
|
||||
id: id,
|
||||
type: sarMarkerType!,
|
||||
location: sarGpsCoordinates!,
|
||||
timestamp: sentAt,
|
||||
senderPublicKey: senderPublicKeyPrefix,
|
||||
senderName: senderName,
|
||||
notes: text,
|
||||
);
|
||||
}
|
||||
|
||||
Message copyWith({
|
||||
String? id,
|
||||
MessageType? messageType,
|
||||
Uint8List? senderPublicKeyPrefix,
|
||||
int? channelIdx,
|
||||
int? pathLen,
|
||||
MessageTextType? textType,
|
||||
int? senderTimestamp,
|
||||
String? text,
|
||||
bool? isSarMarker,
|
||||
SarMarkerType? sarMarkerType,
|
||||
LatLng? sarGpsCoordinates,
|
||||
DateTime? receivedAt,
|
||||
String? senderName,
|
||||
}) {
|
||||
return Message(
|
||||
id: id ?? this.id,
|
||||
messageType: messageType ?? this.messageType,
|
||||
senderPublicKeyPrefix: senderPublicKeyPrefix ?? this.senderPublicKeyPrefix,
|
||||
channelIdx: channelIdx ?? this.channelIdx,
|
||||
pathLen: pathLen ?? this.pathLen,
|
||||
textType: textType ?? this.textType,
|
||||
senderTimestamp: senderTimestamp ?? this.senderTimestamp,
|
||||
text: text ?? this.text,
|
||||
isSarMarker: isSarMarker ?? this.isSarMarker,
|
||||
sarMarkerType: sarMarkerType ?? this.sarMarkerType,
|
||||
sarGpsCoordinates: sarGpsCoordinates ?? this.sarGpsCoordinates,
|
||||
receivedAt: receivedAt ?? this.receivedAt,
|
||||
senderName: senderName ?? this.senderName,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
if (isSarMarker) {
|
||||
return 'Message(SAR: ${sarMarkerType?.displayName}, from: $displaySender)';
|
||||
}
|
||||
return 'Message(from: $displaySender, text: ${text.length > 30 ? '${text.substring(0, 30)}...' : text})';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
return other is Message && id == other.id;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => id.hashCode;
|
||||
}
|
||||
126
lib/models/sar_marker.dart
Normal file
126
lib/models/sar_marker.dart
Normal file
@@ -0,0 +1,126 @@
|
||||
import 'dart:typed_data';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
/// SAR (Search & Rescue) marker types
|
||||
enum SarMarkerType {
|
||||
foundPerson('🧑', 'Found Person'),
|
||||
fire('🔥', 'Fire'),
|
||||
stagingArea('🏕️', 'Staging Area'),
|
||||
unknown('❓', 'Unknown');
|
||||
|
||||
const SarMarkerType(this.emoji, this.displayName);
|
||||
final String emoji;
|
||||
final String displayName;
|
||||
|
||||
static SarMarkerType fromEmoji(String emoji) {
|
||||
switch (emoji) {
|
||||
case '🧑':
|
||||
case '👤':
|
||||
return SarMarkerType.foundPerson;
|
||||
case '🔥':
|
||||
return SarMarkerType.fire;
|
||||
case '🏕️':
|
||||
case '⛺':
|
||||
return SarMarkerType.stagingArea;
|
||||
default:
|
||||
return SarMarkerType.unknown;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get map marker color
|
||||
String get markerColor {
|
||||
switch (this) {
|
||||
case SarMarkerType.foundPerson:
|
||||
return '#4CAF50'; // Green
|
||||
case SarMarkerType.fire:
|
||||
return '#F44336'; // Red
|
||||
case SarMarkerType.stagingArea:
|
||||
return '#2196F3'; // Blue
|
||||
default:
|
||||
return '#9E9E9E'; // Gray
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// SAR marker from special messages
|
||||
class SarMarker {
|
||||
final String id;
|
||||
final SarMarkerType type;
|
||||
final LatLng location;
|
||||
final DateTime timestamp;
|
||||
final Uint8List? senderPublicKey;
|
||||
final String? senderName;
|
||||
final String? notes;
|
||||
|
||||
SarMarker({
|
||||
required this.id,
|
||||
required this.type,
|
||||
required this.location,
|
||||
required this.timestamp,
|
||||
this.senderPublicKey,
|
||||
this.senderName,
|
||||
this.notes,
|
||||
});
|
||||
|
||||
/// Get sender public key as hex string (short)
|
||||
String? get senderKeyShort {
|
||||
if (senderPublicKey == null || senderPublicKey!.length < 8) return null;
|
||||
return senderPublicKey!
|
||||
.sublist(0, 8)
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join('');
|
||||
}
|
||||
|
||||
/// Get friendly time since marker was created
|
||||
String get timeAgo {
|
||||
final diff = DateTime.now().difference(timestamp);
|
||||
if (diff.inMinutes < 1) return 'Just now';
|
||||
if (diff.inMinutes < 60) return '${diff.inMinutes}m ago';
|
||||
if (diff.inHours < 24) return '${diff.inHours}h ago';
|
||||
return '${diff.inDays}d ago';
|
||||
}
|
||||
|
||||
/// Check if marker is recent (within last hour)
|
||||
bool get isRecent {
|
||||
return DateTime.now().difference(timestamp).inHours < 1;
|
||||
}
|
||||
|
||||
/// Get display name
|
||||
String get displayName {
|
||||
return '${type.emoji} ${type.displayName}';
|
||||
}
|
||||
|
||||
SarMarker copyWith({
|
||||
String? id,
|
||||
SarMarkerType? type,
|
||||
LatLng? location,
|
||||
DateTime? timestamp,
|
||||
Uint8List? senderPublicKey,
|
||||
String? senderName,
|
||||
String? notes,
|
||||
}) {
|
||||
return SarMarker(
|
||||
id: id ?? this.id,
|
||||
type: type ?? this.type,
|
||||
location: location ?? this.location,
|
||||
timestamp: timestamp ?? this.timestamp,
|
||||
senderPublicKey: senderPublicKey ?? this.senderPublicKey,
|
||||
senderName: senderName ?? this.senderName,
|
||||
notes: notes ?? this.notes,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'SarMarker(type: ${type.displayName}, location: $location, sender: $senderName, time: $timeAgo)';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
return other is SarMarker && id == other.id;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => id.hashCode;
|
||||
}
|
||||
121
lib/providers/app_provider.dart
Normal file
121
lib/providers/app_provider.dart
Normal file
@@ -0,0 +1,121 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'connection_provider.dart';
|
||||
import 'contacts_provider.dart';
|
||||
import 'messages_provider.dart';
|
||||
import '../services/tile_cache_service.dart';
|
||||
|
||||
/// Main App Provider - coordinates all other providers
|
||||
class AppProvider with ChangeNotifier {
|
||||
final ConnectionProvider connectionProvider;
|
||||
final ContactsProvider contactsProvider;
|
||||
final MessagesProvider messagesProvider;
|
||||
final TileCacheService tileCacheService;
|
||||
|
||||
bool _isInitialized = false;
|
||||
bool get isInitialized => _isInitialized;
|
||||
|
||||
AppProvider({
|
||||
required this.connectionProvider,
|
||||
required this.contactsProvider,
|
||||
required this.messagesProvider,
|
||||
required this.tileCacheService,
|
||||
}) {
|
||||
_setupCallbacks();
|
||||
_initializeTileCache();
|
||||
_isInitialized = true;
|
||||
}
|
||||
|
||||
/// Initialize tile cache service
|
||||
Future<void> _initializeTileCache() async {
|
||||
try {
|
||||
await tileCacheService.initialize();
|
||||
debugPrint('Tile cache initialized');
|
||||
} catch (e) {
|
||||
debugPrint('Error initializing tile cache: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Setup callbacks between providers
|
||||
void _setupCallbacks() {
|
||||
// When a contact is received from BLE
|
||||
connectionProvider.onContactReceived = (contact) {
|
||||
contactsProvider.addOrUpdateContact(contact);
|
||||
};
|
||||
|
||||
// When all contacts are received
|
||||
connectionProvider.onContactsComplete = (contacts) {
|
||||
contactsProvider.addContacts(contacts);
|
||||
debugPrint('Received ${contacts.length} contacts');
|
||||
};
|
||||
|
||||
// When a message is received
|
||||
connectionProvider.onMessageReceived = (message) {
|
||||
messagesProvider.addMessage(message);
|
||||
|
||||
// Optionally update sender name from contacts
|
||||
if (message.senderPublicKeyPrefix != null) {
|
||||
final contact = contactsProvider
|
||||
.findContactByKey(message.senderPublicKeyPrefix!);
|
||||
if (contact != null) {
|
||||
final updatedMessage = message.copyWith(senderName: contact.advName);
|
||||
// Note: You might want to update the message in the list
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// When telemetry is received
|
||||
connectionProvider.onTelemetryReceived = (publicKey, lppData) {
|
||||
contactsProvider.updateTelemetry(publicKey, lppData);
|
||||
};
|
||||
}
|
||||
|
||||
/// Initialize the app (load contacts, sync time, etc.)
|
||||
Future<void> initialize() async {
|
||||
if (!connectionProvider.deviceInfo.isConnected) return;
|
||||
|
||||
try {
|
||||
// Sync device time
|
||||
await connectionProvider.syncDeviceTime();
|
||||
|
||||
// Load contacts
|
||||
await connectionProvider.getContacts();
|
||||
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
debugPrint('Initialization error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Refresh data (contacts, messages)
|
||||
Future<void> refresh() async {
|
||||
if (!connectionProvider.deviceInfo.isConnected) return;
|
||||
|
||||
try {
|
||||
await connectionProvider.getContacts();
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
debugPrint('Refresh error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear all data
|
||||
void clearAllData() {
|
||||
contactsProvider.clearContacts();
|
||||
messagesProvider.clearAll();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Get app statistics
|
||||
Map<String, dynamic> get statistics {
|
||||
return {
|
||||
'connection': {
|
||||
'isConnected': connectionProvider.deviceInfo.isConnected,
|
||||
'deviceName': connectionProvider.deviceInfo.deviceName,
|
||||
'battery': connectionProvider.deviceInfo.batteryPercent,
|
||||
},
|
||||
'contacts': contactsProvider.contactCounts,
|
||||
'messages': messagesProvider.messageStats,
|
||||
'sarMarkers': messagesProvider.sarMarkerStats,
|
||||
};
|
||||
}
|
||||
}
|
||||
240
lib/providers/connection_provider.dart
Normal file
240
lib/providers/connection_provider.dart
Normal file
@@ -0,0 +1,240 @@
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
|
||||
import '../models/device_info.dart';
|
||||
import '../models/contact.dart';
|
||||
import '../models/message.dart';
|
||||
import '../services/meshcore_ble_service.dart';
|
||||
import '../services/cayenne_lpp_parser.dart';
|
||||
import '../utils/sar_message_parser.dart';
|
||||
|
||||
/// Connection Provider - manages MeshCore BLE connection
|
||||
class ConnectionProvider with ChangeNotifier {
|
||||
final MeshCoreBleService _bleService = MeshCoreBleService();
|
||||
|
||||
DeviceInfo _deviceInfo = DeviceInfo();
|
||||
DeviceInfo get deviceInfo => _deviceInfo;
|
||||
|
||||
List<BluetoothDevice> _scannedDevices = [];
|
||||
List<BluetoothDevice> get scannedDevices => _scannedDevices;
|
||||
|
||||
bool _isScanning = false;
|
||||
bool get isScanning => _isScanning;
|
||||
|
||||
String? _error;
|
||||
String? get error => _error;
|
||||
|
||||
// Callbacks for other providers
|
||||
Function(Contact)? onContactReceived;
|
||||
Function(List<Contact>)? onContactsComplete;
|
||||
Function(Message)? onMessageReceived;
|
||||
Function(Uint8List publicKey, Uint8List lppData)? onTelemetryReceived;
|
||||
|
||||
ConnectionProvider() {
|
||||
_initializeBleService();
|
||||
}
|
||||
|
||||
void _initializeBleService() {
|
||||
_bleService.onConnectionStateChanged = (isConnected) {
|
||||
_deviceInfo = _deviceInfo.copyWith(
|
||||
connectionState: isConnected
|
||||
? ConnectionState.connected
|
||||
: ConnectionState.disconnected,
|
||||
lastUpdate: DateTime.now(),
|
||||
);
|
||||
notifyListeners();
|
||||
};
|
||||
|
||||
_bleService.onError = (error) {
|
||||
_error = error;
|
||||
_deviceInfo = _deviceInfo.copyWith(
|
||||
connectionState: ConnectionState.error,
|
||||
);
|
||||
notifyListeners();
|
||||
};
|
||||
|
||||
_bleService.onContactReceived = (contact) {
|
||||
onContactReceived?.call(contact);
|
||||
};
|
||||
|
||||
_bleService.onContactsComplete = (contacts) {
|
||||
onContactsComplete?.call(contacts);
|
||||
};
|
||||
|
||||
_bleService.onMessageReceived = (message) {
|
||||
// Parse SAR markers
|
||||
final enhancedMessage = SarMessageParser.enhanceMessage(message);
|
||||
onMessageReceived?.call(enhancedMessage);
|
||||
};
|
||||
|
||||
_bleService.onTelemetryReceived = (publicKey, lppData) {
|
||||
onTelemetryReceived?.call(publicKey, lppData);
|
||||
};
|
||||
}
|
||||
|
||||
/// Start scanning for MeshCore devices
|
||||
Future<void> startScan() async {
|
||||
_isScanning = true;
|
||||
_scannedDevices.clear();
|
||||
_error = null;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
await for (final device
|
||||
in _bleService.scanForDevices(timeout: const Duration(seconds: 10))) {
|
||||
if (!_scannedDevices.any((d) => d.remoteId == device.remoteId)) {
|
||||
_scannedDevices.add(device);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
_error = 'Scan error: $e';
|
||||
} finally {
|
||||
_isScanning = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop scanning
|
||||
Future<void> stopScan() async {
|
||||
await FlutterBluePlus.stopScan();
|
||||
_isScanning = false;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Connect to a device
|
||||
Future<bool> connect(BluetoothDevice device) async {
|
||||
_deviceInfo = _deviceInfo.copyWith(
|
||||
deviceId: device.remoteId.toString(),
|
||||
deviceName: device.platformName.isNotEmpty ? device.platformName : 'Unknown',
|
||||
connectionState: ConnectionState.connecting,
|
||||
);
|
||||
_error = null;
|
||||
notifyListeners();
|
||||
|
||||
final success = await _bleService.connect(device);
|
||||
if (!success) {
|
||||
_deviceInfo = _deviceInfo.copyWith(
|
||||
connectionState: ConnectionState.error,
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
return success;
|
||||
}
|
||||
|
||||
/// Disconnect from device
|
||||
Future<void> disconnect() async {
|
||||
_deviceInfo = _deviceInfo.copyWith(
|
||||
connectionState: ConnectionState.disconnecting,
|
||||
);
|
||||
notifyListeners();
|
||||
|
||||
await _bleService.disconnect();
|
||||
|
||||
_deviceInfo = DeviceInfo(
|
||||
connectionState: ConnectionState.disconnected,
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Get contacts from device
|
||||
Future<void> getContacts() async {
|
||||
if (!_bleService.isConnected) {
|
||||
_error = 'Not connected to device';
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await _bleService.getContacts();
|
||||
} catch (e) {
|
||||
_error = 'Failed to get contacts: $e';
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Send text message to contact
|
||||
Future<void> sendTextMessage({
|
||||
required Uint8List contactPublicKey,
|
||||
required String text,
|
||||
}) async {
|
||||
if (!_bleService.isConnected) {
|
||||
_error = 'Not connected to device';
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await _bleService.sendTextMessage(
|
||||
contactPublicKey: contactPublicKey,
|
||||
text: text,
|
||||
);
|
||||
} catch (e) {
|
||||
_error = 'Failed to send message: $e';
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Send channel message
|
||||
Future<void> sendChannelMessage({
|
||||
required int channelIdx,
|
||||
required String text,
|
||||
}) async {
|
||||
if (!_bleService.isConnected) {
|
||||
_error = 'Not connected to device';
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await _bleService.sendChannelMessage(
|
||||
channelIdx: channelIdx,
|
||||
text: text,
|
||||
);
|
||||
} catch (e) {
|
||||
_error = 'Failed to send channel message: $e';
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Request telemetry from contact
|
||||
Future<void> requestTelemetry(Uint8List contactPublicKey) async {
|
||||
if (!_bleService.isConnected) {
|
||||
_error = 'Not connected to device';
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await _bleService.requestTelemetry(contactPublicKey);
|
||||
} catch (e) {
|
||||
_error = 'Failed to request telemetry: $e';
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Set device time to current time
|
||||
Future<void> syncDeviceTime() async {
|
||||
if (!_bleService.isConnected) return;
|
||||
|
||||
try {
|
||||
await _bleService.setDeviceTime();
|
||||
} catch (e) {
|
||||
_error = 'Failed to sync time: $e';
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear error message
|
||||
void clearError() {
|
||||
_error = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_bleService.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
135
lib/providers/contacts_provider.dart
Normal file
135
lib/providers/contacts_provider.dart
Normal file
@@ -0,0 +1,135 @@
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../models/contact.dart';
|
||||
import '../models/contact_telemetry.dart';
|
||||
import '../services/cayenne_lpp_parser.dart';
|
||||
|
||||
/// Contacts Provider - manages contact list and telemetry
|
||||
class ContactsProvider with ChangeNotifier {
|
||||
final Map<String, Contact> _contacts = {};
|
||||
|
||||
List<Contact> get contacts => _contacts.values.toList();
|
||||
|
||||
List<Contact> get chatContacts =>
|
||||
contacts.where((c) => c.isChat).toList()..sort(_sortByLastSeen);
|
||||
|
||||
List<Contact> get repeaters =>
|
||||
contacts.where((c) => c.isRepeater).toList()..sort(_sortByLastSeen);
|
||||
|
||||
List<Contact> get rooms =>
|
||||
contacts.where((c) => c.isRoom).toList()..sort(_sortByLastSeen);
|
||||
|
||||
/// Get contacts with location (for map display)
|
||||
List<Contact> get contactsWithLocation =>
|
||||
contacts.where((c) => c.displayLocation != null).toList();
|
||||
|
||||
/// Get chat contacts with location (team members on map)
|
||||
List<Contact> get chatContactsWithLocation =>
|
||||
chatContacts.where((c) => c.displayLocation != null).toList();
|
||||
|
||||
/// Sort contacts by last seen (most recent first)
|
||||
int _sortByLastSeen(Contact a, Contact b) {
|
||||
return b.lastSeenTime.compareTo(a.lastSeenTime);
|
||||
}
|
||||
|
||||
/// Add or update a contact
|
||||
void addOrUpdateContact(Contact contact) {
|
||||
_contacts[contact.publicKeyHex] = contact;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Add multiple contacts
|
||||
void addContacts(List<Contact> contacts) {
|
||||
for (final contact in contacts) {
|
||||
_contacts[contact.publicKeyHex] = contact;
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Update contact telemetry
|
||||
void updateTelemetry(Uint8List publicKeyPrefix, Uint8List lppData) {
|
||||
// Find contact by public key prefix
|
||||
final contact = _findContactByPrefix(publicKeyPrefix);
|
||||
if (contact == null) return;
|
||||
|
||||
try {
|
||||
// Parse Cayenne LPP data
|
||||
final telemetry = CayenneLppParser.parse(lppData);
|
||||
|
||||
// Update contact with new telemetry
|
||||
final updatedContact = contact.copyWith(telemetry: telemetry);
|
||||
_contacts[contact.publicKeyHex] = updatedContact;
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
debugPrint('Failed to parse telemetry: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Find contact by public key prefix (6 bytes)
|
||||
Contact? _findContactByPrefix(Uint8List prefix) {
|
||||
if (prefix.length < 6) return null;
|
||||
|
||||
final prefixHex = prefix
|
||||
.sublist(0, 6)
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join('');
|
||||
|
||||
for (final contact in contacts) {
|
||||
if (contact.publicKeyHex.startsWith(prefixHex)) {
|
||||
return contact;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Find contact by public key
|
||||
Contact? findContactByKey(Uint8List publicKey) {
|
||||
final keyHex =
|
||||
publicKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join('');
|
||||
return _contacts[keyHex];
|
||||
}
|
||||
|
||||
/// Find contact by name
|
||||
Contact? findContactByName(String name) {
|
||||
return contacts.firstWhere(
|
||||
(c) => c.advName == name,
|
||||
orElse: () => contacts.first,
|
||||
);
|
||||
}
|
||||
|
||||
/// Get contacts with low battery
|
||||
List<Contact> get lowBatteryContacts {
|
||||
return contacts.where((c) {
|
||||
final battery = c.displayBattery;
|
||||
return battery != null && battery < 20.0;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
/// Get recently seen contacts (within last 10 minutes)
|
||||
List<Contact> get recentlySeenContacts {
|
||||
return contacts.where((c) => c.isRecentlySeen).toList();
|
||||
}
|
||||
|
||||
/// Clear all contacts
|
||||
void clearContacts() {
|
||||
_contacts.clear();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Remove a contact
|
||||
void removeContact(String publicKeyHex) {
|
||||
_contacts.remove(publicKeyHex);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Get contact count by type
|
||||
Map<String, int> get contactCounts {
|
||||
return {
|
||||
'chat': chatContacts.length,
|
||||
'repeater': repeaters.length,
|
||||
'room': rooms.length,
|
||||
'total': contacts.length,
|
||||
};
|
||||
}
|
||||
}
|
||||
35
lib/providers/map_provider.dart
Normal file
35
lib/providers/map_provider.dart
Normal file
@@ -0,0 +1,35 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
class MapProvider with ChangeNotifier {
|
||||
LatLng? _targetLocation;
|
||||
double? _targetZoom;
|
||||
bool _shouldAnimate = false;
|
||||
|
||||
LatLng? get targetLocation => _targetLocation;
|
||||
double? get targetZoom => _targetZoom;
|
||||
bool get shouldAnimate => _shouldAnimate;
|
||||
|
||||
void navigateToLocation({
|
||||
required LatLng location,
|
||||
double zoom = 15.0,
|
||||
bool animate = true,
|
||||
}) {
|
||||
_targetLocation = location;
|
||||
_targetZoom = zoom;
|
||||
_shouldAnimate = animate;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void clearNavigation() {
|
||||
_targetLocation = null;
|
||||
_targetZoom = null;
|
||||
_shouldAnimate = false;
|
||||
// Don't notify listeners to avoid rebuilds
|
||||
}
|
||||
|
||||
void updateZoom(double zoom) {
|
||||
_targetZoom = zoom;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
156
lib/providers/messages_provider.dart
Normal file
156
lib/providers/messages_provider.dart
Normal file
@@ -0,0 +1,156 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../models/message.dart';
|
||||
import '../models/sar_marker.dart';
|
||||
|
||||
/// Messages Provider - manages message history and SAR markers
|
||||
class MessagesProvider with ChangeNotifier {
|
||||
final List<Message> _messages = [];
|
||||
final Map<String, SarMarker> _sarMarkers = {};
|
||||
|
||||
List<Message> get messages => List.unmodifiable(_messages);
|
||||
|
||||
List<Message> get contactMessages =>
|
||||
_messages.where((m) => m.isContactMessage).toList();
|
||||
|
||||
List<Message> get channelMessages =>
|
||||
_messages.where((m) => m.isChannelMessage).toList();
|
||||
|
||||
List<Message> get sarMarkerMessages =>
|
||||
_messages.where((m) => m.isSarMarker).toList();
|
||||
|
||||
List<SarMarker> get sarMarkers => _sarMarkers.values.toList();
|
||||
|
||||
List<SarMarker> get foundPersonMarkers =>
|
||||
sarMarkers.where((m) => m.type == SarMarkerType.foundPerson).toList();
|
||||
|
||||
List<SarMarker> get fireMarkers =>
|
||||
sarMarkers.where((m) => m.type == SarMarkerType.fire).toList();
|
||||
|
||||
List<SarMarker> get stagingAreaMarkers =>
|
||||
sarMarkers.where((m) => m.type == SarMarkerType.stagingArea).toList();
|
||||
|
||||
/// Add a message
|
||||
void addMessage(Message message) {
|
||||
_messages.add(message);
|
||||
|
||||
// If it's a SAR marker message, extract and store the marker
|
||||
if (message.isSarMarker) {
|
||||
final marker = message.toSarMarker();
|
||||
if (marker != null) {
|
||||
_sarMarkers[marker.id] = marker;
|
||||
}
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Add multiple messages
|
||||
void addMessages(List<Message> messages) {
|
||||
for (final message in messages) {
|
||||
_messages.add(message);
|
||||
|
||||
if (message.isSarMarker) {
|
||||
final marker = message.toSarMarker();
|
||||
if (marker != null) {
|
||||
_sarMarkers[marker.id] = marker;
|
||||
}
|
||||
}
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Get messages for a specific contact
|
||||
List<Message> getMessagesForContact(String senderKeyShort) {
|
||||
return _messages
|
||||
.where((m) =>
|
||||
m.isContactMessage &&
|
||||
m.senderKeyShort != null &&
|
||||
m.senderKeyShort!.startsWith(senderKeyShort))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Get messages for a specific channel
|
||||
List<Message> getMessagesForChannel(int channelIdx) {
|
||||
return _messages
|
||||
.where((m) => m.isChannelMessage && m.channelIdx == channelIdx)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Get recent messages (last N messages)
|
||||
List<Message> getRecentMessages({int count = 50}) {
|
||||
final sorted = List<Message>.from(_messages)
|
||||
..sort((a, b) => b.sentAt.compareTo(a.sentAt));
|
||||
return sorted.take(count).toList();
|
||||
}
|
||||
|
||||
/// Get messages from last N hours
|
||||
List<Message> getMessagesSince(Duration duration) {
|
||||
final cutoff = DateTime.now().subtract(duration);
|
||||
return _messages.where((m) => m.sentAt.isAfter(cutoff)).toList();
|
||||
}
|
||||
|
||||
/// Search messages by text
|
||||
List<Message> searchMessages(String query) {
|
||||
if (query.isEmpty) return [];
|
||||
final lowerQuery = query.toLowerCase();
|
||||
return _messages
|
||||
.where((m) => m.text.toLowerCase().contains(lowerQuery))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Get SAR marker by ID
|
||||
SarMarker? getSarMarker(String id) {
|
||||
return _sarMarkers[id];
|
||||
}
|
||||
|
||||
/// Get recent SAR markers (within last hour)
|
||||
List<SarMarker> getRecentSarMarkers() {
|
||||
return sarMarkers.where((m) => m.isRecent).toList();
|
||||
}
|
||||
|
||||
/// Remove a SAR marker
|
||||
void removeSarMarker(String id) {
|
||||
_sarMarkers.remove(id);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Clear all messages
|
||||
void clearMessages() {
|
||||
_messages.clear();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Clear all SAR markers
|
||||
void clearSarMarkers() {
|
||||
_sarMarkers.clear();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Clear all data
|
||||
void clearAll() {
|
||||
_messages.clear();
|
||||
_sarMarkers.clear();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Get message statistics
|
||||
Map<String, int> get messageStats {
|
||||
return {
|
||||
'total': _messages.length,
|
||||
'contact': contactMessages.length,
|
||||
'channel': channelMessages.length,
|
||||
'sar': sarMarkerMessages.length,
|
||||
'sarMarkers': sarMarkers.length,
|
||||
};
|
||||
}
|
||||
|
||||
/// Get SAR marker statistics
|
||||
Map<String, int> get sarMarkerStats {
|
||||
return {
|
||||
'total': sarMarkers.length,
|
||||
'foundPerson': foundPersonMarkers.length,
|
||||
'fire': fireMarkers.length,
|
||||
'stagingArea': stagingAreaMarkers.length,
|
||||
};
|
||||
}
|
||||
}
|
||||
351
lib/screens/contacts_tab.dart
Normal file
351
lib/screens/contacts_tab.dart
Normal file
@@ -0,0 +1,351 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../providers/contacts_provider.dart';
|
||||
import '../providers/connection_provider.dart';
|
||||
import '../models/contact.dart';
|
||||
|
||||
class ContactsTab extends StatelessWidget {
|
||||
const ContactsTab({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Consumer<ContactsProvider>(
|
||||
builder: (context, contactsProvider, child) {
|
||||
final chatContacts = contactsProvider.chatContacts;
|
||||
final repeaters = contactsProvider.repeaters;
|
||||
final rooms = contactsProvider.rooms;
|
||||
|
||||
if (contactsProvider.contacts.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.contacts_outlined,
|
||||
size: 64,
|
||||
color: Theme.of(context).disabledColor,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'No contacts yet',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Connect to a device and refresh to load contacts',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(8),
|
||||
children: [
|
||||
// Team Members (Chat contacts)
|
||||
if (chatContacts.isNotEmpty) ...[
|
||||
_SectionHeader(
|
||||
title: 'Team Members',
|
||||
count: chatContacts.length,
|
||||
icon: Icons.people,
|
||||
),
|
||||
...chatContacts.map((contact) => _ContactTile(contact: contact)),
|
||||
const Divider(height: 32),
|
||||
],
|
||||
|
||||
// Repeaters
|
||||
if (repeaters.isNotEmpty) ...[
|
||||
_SectionHeader(
|
||||
title: 'Repeaters',
|
||||
count: repeaters.length,
|
||||
icon: Icons.router,
|
||||
),
|
||||
...repeaters.map((contact) => _ContactTile(contact: contact)),
|
||||
const Divider(height: 32),
|
||||
],
|
||||
|
||||
// Rooms/Channels
|
||||
if (rooms.isNotEmpty) ...[
|
||||
_SectionHeader(
|
||||
title: 'Rooms/Channels',
|
||||
count: rooms.length,
|
||||
icon: Icons.tag,
|
||||
),
|
||||
...rooms.map((contact) => _ContactTile(contact: contact)),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionHeader extends StatelessWidget {
|
||||
final String title;
|
||||
final int count;
|
||||
final IconData icon;
|
||||
|
||||
const _SectionHeader({
|
||||
required this.title,
|
||||
required this.count,
|
||||
required this.icon,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
count.toString(),
|
||||
style: Theme.of(context).textTheme.labelSmall,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ContactTile extends StatelessWidget {
|
||||
final Contact contact;
|
||||
|
||||
const _ContactTile({required this.contact});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final hasTelemetry = contact.telemetry != null && contact.telemetry!.isRecent;
|
||||
final battery = contact.displayBattery;
|
||||
final location = contact.displayLocation;
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
child: ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: _getTypeColor(contact.type),
|
||||
child: Icon(
|
||||
_getTypeIcon(contact.type),
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
title: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
contact.advName,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
// Battery indicator
|
||||
if (battery != null) ...[
|
||||
Icon(
|
||||
_getBatteryIcon(battery),
|
||||
size: 16,
|
||||
color: _getBatteryColor(battery),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${battery.round()}%',
|
||||
style: Theme.of(context).textTheme.labelSmall,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 4),
|
||||
// Type and last seen
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: _getTypeColor(contact.type).withOpacity(0.2),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
contact.type.displayName,
|
||||
style: Theme.of(context).textTheme.labelSmall,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Icon(
|
||||
Icons.access_time,
|
||||
size: 12,
|
||||
color: contact.isRecentlySeen ? Colors.green : Colors.grey,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
contact.timeSinceLastSeen,
|
||||
style: Theme.of(context).textTheme.labelSmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
// Telemetry info
|
||||
Row(
|
||||
children: [
|
||||
if (hasTelemetry)
|
||||
const Icon(Icons.sensors, size: 12, color: Colors.green)
|
||||
else
|
||||
const Icon(Icons.sensors_off, size: 12, color: Colors.grey),
|
||||
const SizedBox(width: 4),
|
||||
if (location != null)
|
||||
Expanded(
|
||||
child: Text(
|
||||
'GPS: ${location.latitude.toStringAsFixed(4)}, ${location.longitude.toStringAsFixed(4)}',
|
||||
style: Theme.of(context).textTheme.labelSmall,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
)
|
||||
else
|
||||
Text(
|
||||
'No GPS data',
|
||||
style: Theme.of(context).textTheme.labelSmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.refresh, size: 20),
|
||||
onPressed: () {
|
||||
final connectionProvider = context.read<ConnectionProvider>();
|
||||
connectionProvider.requestTelemetry(contact.publicKey);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Requesting telemetry from ${contact.advName}'),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
},
|
||||
tooltip: 'Request telemetry',
|
||||
),
|
||||
onTap: () => _showContactDetails(context, contact),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showContactDetails(BuildContext context, Contact contact) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(contact.advName),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_DetailRow('Type', contact.type.displayName),
|
||||
_DetailRow('Public Key', contact.publicKeyShort),
|
||||
_DetailRow('Last Seen', contact.timeSinceLastSeen),
|
||||
const Divider(),
|
||||
if (contact.displayLocation != null) ...[
|
||||
const Text('Location:', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
_DetailRow('Latitude', contact.displayLocation!.latitude.toStringAsFixed(6)),
|
||||
_DetailRow('Longitude', contact.displayLocation!.longitude.toStringAsFixed(6)),
|
||||
const Divider(),
|
||||
],
|
||||
if (contact.telemetry != null) ...[
|
||||
const Text('Telemetry:', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
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'),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Close'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _DetailRow(String label, String value) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 100,
|
||||
child: Text(
|
||||
'$label:',
|
||||
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(value),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
IconData _getTypeIcon(ContactType type) {
|
||||
switch (type) {
|
||||
case ContactType.chat:
|
||||
return Icons.person;
|
||||
case ContactType.repeater:
|
||||
return Icons.router;
|
||||
case ContactType.room:
|
||||
return Icons.tag;
|
||||
default:
|
||||
return Icons.help;
|
||||
}
|
||||
}
|
||||
|
||||
Color _getTypeColor(ContactType type) {
|
||||
switch (type) {
|
||||
case ContactType.chat:
|
||||
return Colors.blue;
|
||||
case ContactType.repeater:
|
||||
return Colors.green;
|
||||
case ContactType.room:
|
||||
return Colors.orange;
|
||||
default:
|
||||
return Colors.grey;
|
||||
}
|
||||
}
|
||||
|
||||
IconData _getBatteryIcon(double percentage) {
|
||||
if (percentage > 80) return Icons.battery_full;
|
||||
if (percentage > 50) return Icons.battery_5_bar;
|
||||
if (percentage > 20) return Icons.battery_3_bar;
|
||||
return Icons.battery_1_bar;
|
||||
}
|
||||
|
||||
Color _getBatteryColor(double percentage) {
|
||||
if (percentage > 50) return Colors.green;
|
||||
if (percentage > 20) return Colors.orange;
|
||||
return Colors.red;
|
||||
}
|
||||
}
|
||||
514
lib/screens/home_screen.dart
Normal file
514
lib/screens/home_screen.dart
Normal file
@@ -0,0 +1,514 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../providers/connection_provider.dart';
|
||||
import '../providers/app_provider.dart';
|
||||
import '../models/device_info.dart' as models;
|
||||
import '../services/tile_cache_service.dart';
|
||||
import 'messages_tab.dart';
|
||||
import 'contacts_tab.dart';
|
||||
import 'map_tab.dart';
|
||||
import 'map_management_screen.dart';
|
||||
|
||||
class HomeScreen extends StatefulWidget {
|
||||
const HomeScreen({super.key});
|
||||
|
||||
@override
|
||||
State<HomeScreen> createState() => _HomeScreenState();
|
||||
}
|
||||
|
||||
class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateMixin {
|
||||
late TabController _tabController;
|
||||
int _currentIndex = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tabController = TabController(length: 3, vsync: this);
|
||||
_tabController.addListener(() {
|
||||
setState(() {
|
||||
_currentIndex = _tabController.index;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tabController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _showConnectionDialog(BuildContext context) {
|
||||
final connectionProvider = context.read<ConnectionProvider>();
|
||||
|
||||
// Start scanning immediately
|
||||
connectionProvider.startScan();
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (context) => Container(
|
||||
height: MediaQuery.of(context).size.height * 0.9,
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0xFF1E1E1E),
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// Header
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.arrow_back, color: Colors.white),
|
||||
onPressed: () {
|
||||
connectionProvider.stopScan();
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
const Expanded(
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
'MeshCore',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'Scanning for devices...',
|
||||
style: TextStyle(
|
||||
color: Colors.grey,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.more_vert, color: Colors.white),
|
||||
onPressed: () {},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Info banner
|
||||
Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Row(
|
||||
children: [
|
||||
Icon(Icons.info_outline, color: Colors.white),
|
||||
SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'The default pin for devices without a screen is 123456. Trouble pairing? Forget the bluetooth device in system settings.',
|
||||
style: TextStyle(color: Colors.white, fontSize: 13),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Device list
|
||||
Expanded(
|
||||
child: Consumer<ConnectionProvider>(
|
||||
builder: (context, provider, child) {
|
||||
if (provider.isScanning && provider.scannedDevices.isEmpty) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
);
|
||||
}
|
||||
|
||||
if (provider.scannedDevices.isEmpty) {
|
||||
return const Center(
|
||||
child: Text(
|
||||
'No devices found',
|
||||
style: TextStyle(color: Colors.grey, fontSize: 16),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
itemCount: provider.scannedDevices.length,
|
||||
itemBuilder: (context, index) {
|
||||
final device = provider.scannedDevices[index];
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF2D2D2D),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: ListTile(
|
||||
leading: const Icon(
|
||||
Icons.bluetooth,
|
||||
color: Colors.white,
|
||||
size: 32,
|
||||
),
|
||||
title: Text(
|
||||
device.platformName.isNotEmpty
|
||||
? device.platformName
|
||||
: 'Unknown Device',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
subtitle: const Text(
|
||||
'Tap to connect',
|
||||
style: TextStyle(color: Colors.grey, fontSize: 14),
|
||||
),
|
||||
trailing: const Icon(
|
||||
Icons.chevron_right,
|
||||
color: Colors.white,
|
||||
),
|
||||
onTap: () async {
|
||||
Navigator.pop(context);
|
||||
await provider.connect(device);
|
||||
if (context.mounted &&
|
||||
provider.deviceInfo.isConnected) {
|
||||
final appProvider = context.read<AppProvider>();
|
||||
await appProvider.initialize();
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: _buildCompactStatusBar(),
|
||||
actions: [
|
||||
PopupMenuButton(
|
||||
icon: const Icon(Icons.more_vert),
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem(
|
||||
child: const Row(
|
||||
children: [
|
||||
Icon(Icons.refresh),
|
||||
SizedBox(width: 8),
|
||||
Text('Refresh Contacts'),
|
||||
],
|
||||
),
|
||||
onTap: () async {
|
||||
final appProvider = context.read<AppProvider>();
|
||||
await appProvider.refresh();
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Refreshed contacts')),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
PopupMenuItem(
|
||||
child: const Row(
|
||||
children: [
|
||||
Icon(Icons.map),
|
||||
SizedBox(width: 8),
|
||||
Text('Map Management'),
|
||||
],
|
||||
),
|
||||
onTap: () {
|
||||
Future.delayed(Duration.zero, () {
|
||||
final appProvider = context.read<AppProvider>();
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => MapManagementScreen(
|
||||
tileCacheService: appProvider.tileCacheService,
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
body: TabBarView(
|
||||
controller: _tabController,
|
||||
children: [
|
||||
MessagesTab(onNavigateToMap: () => _tabController.animateTo(2)),
|
||||
const ContactsTab(),
|
||||
const MapTab(),
|
||||
],
|
||||
),
|
||||
bottomNavigationBar: Container(
|
||||
decoration: BoxDecoration(
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, -2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: TabBar(
|
||||
controller: _tabController,
|
||||
tabs: const [
|
||||
Tab(icon: Icon(Icons.message), text: 'Messages'),
|
||||
Tab(icon: Icon(Icons.contacts), text: 'Contacts'),
|
||||
Tab(icon: Icon(Icons.map), text: 'Map'),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCompactStatusBar() {
|
||||
return Consumer<ConnectionProvider>(
|
||||
builder: (context, provider, child) {
|
||||
final deviceInfo = provider.deviceInfo;
|
||||
final isConnected = deviceInfo.isConnected;
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text(
|
||||
'MeshCore',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
isConnected
|
||||
? deviceInfo.deviceName ?? 'Connected'
|
||||
: 'Disconnected',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (!isConnected)
|
||||
ElevatedButton.icon(
|
||||
onPressed: () => _showConnectionDialog(context),
|
||||
icon: const Icon(Icons.bluetooth, size: 18),
|
||||
label: const Text('Connect'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: Colors.black87,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
OutlinedButton(
|
||||
onPressed: () async {
|
||||
await provider.disconnect();
|
||||
if (context.mounted) {
|
||||
context.read<AppProvider>().clearAllData();
|
||||
}
|
||||
},
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: Colors.white,
|
||||
side: const BorderSide(color: Colors.white),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
),
|
||||
child: const Text('Disconnect'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatusBar() {
|
||||
return Consumer<ConnectionProvider>(
|
||||
builder: (context, provider, child) {
|
||||
final deviceInfo = provider.deviceInfo;
|
||||
final isConnected = deviceInfo.isConnected;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
// Connection status
|
||||
Icon(
|
||||
isConnected ? Icons.bluetooth_connected : Icons.bluetooth_disabled,
|
||||
color: isConnected ? Colors.green : Colors.grey,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
isConnected
|
||||
? deviceInfo.deviceName ?? 'Connected'
|
||||
: 'Not Connected',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
),
|
||||
// Battery indicator
|
||||
if (deviceInfo.batteryPercent != null) ...[
|
||||
Icon(
|
||||
_getBatteryIcon(deviceInfo.batteryPercent!),
|
||||
color: _getBatteryColor(deviceInfo.batteryPercent!),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${deviceInfo.batteryPercent!.round()}%',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
// Signal strength
|
||||
if (deviceInfo.signalRssi != null) ...[
|
||||
const SizedBox(width: 12),
|
||||
Icon(
|
||||
Icons.signal_cellular_alt,
|
||||
color: _getSignalColor(deviceInfo.signalRssi!),
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${deviceInfo.signalRssi} dBm',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Connection buttons
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: isConnected
|
||||
? null
|
||||
: () => _showConnectionDialog(context),
|
||||
icon: const Icon(Icons.bluetooth_searching, size: 18),
|
||||
label: const Text('Connect'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: !isConnected
|
||||
? null
|
||||
: () async {
|
||||
await provider.disconnect();
|
||||
if (context.mounted) {
|
||||
context.read<AppProvider>().clearAllData();
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.bluetooth_disabled, size: 18),
|
||||
label: const Text('Disconnect'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (isConnected) ...[
|
||||
const SizedBox(width: 8),
|
||||
IconButton(
|
||||
onPressed: () async {
|
||||
final appProvider = context.read<AppProvider>();
|
||||
await appProvider.refresh();
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Refreshed contacts')),
|
||||
);
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.refresh),
|
||||
tooltip: 'Refresh',
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
// Error message
|
||||
if (provider.error != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.error, color: Colors.red, size: 16),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
provider.error!,
|
||||
style: const TextStyle(color: Colors.red, fontSize: 12),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close, size: 16),
|
||||
onPressed: provider.clearError,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
IconData _getBatteryIcon(double percentage) {
|
||||
if (percentage > 80) return Icons.battery_full;
|
||||
if (percentage > 50) return Icons.battery_5_bar;
|
||||
if (percentage > 20) return Icons.battery_3_bar;
|
||||
return Icons.battery_1_bar;
|
||||
}
|
||||
|
||||
Color _getBatteryColor(double percentage) {
|
||||
if (percentage > 50) return Colors.green;
|
||||
if (percentage > 20) return Colors.orange;
|
||||
return Colors.red;
|
||||
}
|
||||
|
||||
Color _getSignalColor(int rssi) {
|
||||
if (rssi > -60) return Colors.green;
|
||||
if (rssi > -70) return Colors.orange;
|
||||
return Colors.red;
|
||||
}
|
||||
}
|
||||
728
lib/screens/map_management_screen.dart
Normal file
728
lib/screens/map_management_screen.dart
Normal file
@@ -0,0 +1,728 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
import '../services/tile_cache_service.dart';
|
||||
import '../models/map_layer.dart';
|
||||
|
||||
class MapManagementScreen extends StatefulWidget {
|
||||
final TileCacheService tileCacheService;
|
||||
final MapLayer? initialLayer;
|
||||
final LatLngBounds? initialBounds;
|
||||
final int? initialZoom;
|
||||
|
||||
const MapManagementScreen({
|
||||
super.key,
|
||||
required this.tileCacheService,
|
||||
this.initialLayer,
|
||||
this.initialBounds,
|
||||
this.initialZoom,
|
||||
});
|
||||
|
||||
@override
|
||||
State<MapManagementScreen> createState() => _MapManagementScreenState();
|
||||
}
|
||||
|
||||
class _MapManagementScreenState extends State<MapManagementScreen> {
|
||||
bool _isLoading = false;
|
||||
String? _statusMessage;
|
||||
Map<String, dynamic>? _cacheStats;
|
||||
|
||||
// Download parameters
|
||||
late MapLayer _selectedLayer;
|
||||
late TextEditingController _northController;
|
||||
late TextEditingController _southController;
|
||||
late TextEditingController _eastController;
|
||||
late TextEditingController _westController;
|
||||
late int _minZoom;
|
||||
late int _maxZoom;
|
||||
double _downloadProgress = 0.0;
|
||||
bool _isDownloading = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// Initialize with provided values or defaults
|
||||
_selectedLayer = widget.initialLayer ?? MapLayer.openStreetMap;
|
||||
|
||||
if (widget.initialBounds != null) {
|
||||
_northController = TextEditingController(
|
||||
text: widget.initialBounds!.north.toStringAsFixed(4),
|
||||
);
|
||||
_southController = TextEditingController(
|
||||
text: widget.initialBounds!.south.toStringAsFixed(4),
|
||||
);
|
||||
_eastController = TextEditingController(
|
||||
text: widget.initialBounds!.east.toStringAsFixed(4),
|
||||
);
|
||||
_westController = TextEditingController(
|
||||
text: widget.initialBounds!.west.toStringAsFixed(4),
|
||||
);
|
||||
} else {
|
||||
_northController = TextEditingController(text: '46.1');
|
||||
_southController = TextEditingController(text: '46.0');
|
||||
_eastController = TextEditingController(text: '14.6');
|
||||
_westController = TextEditingController(text: '14.4');
|
||||
}
|
||||
|
||||
// Set zoom levels
|
||||
if (widget.initialZoom != null) {
|
||||
_minZoom = (widget.initialZoom! - 2).clamp(1, 19);
|
||||
_maxZoom = (widget.initialZoom! + 2).clamp(1, 19);
|
||||
} else {
|
||||
_minZoom = 10;
|
||||
_maxZoom = 16;
|
||||
}
|
||||
|
||||
_loadCacheStats();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_northController.dispose();
|
||||
_southController.dispose();
|
||||
_eastController.dispose();
|
||||
_westController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadCacheStats() async {
|
||||
if (!mounted) return;
|
||||
setState(() => _isLoading = true);
|
||||
try {
|
||||
final stats = await widget.tileCacheService.getStoreStats();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_cacheStats = stats;
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_statusMessage = 'Error loading stats: $e';
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _downloadRegion() async {
|
||||
try {
|
||||
final north = double.tryParse(_northController.text);
|
||||
final south = double.tryParse(_southController.text);
|
||||
final east = double.tryParse(_eastController.text);
|
||||
final west = double.tryParse(_westController.text);
|
||||
|
||||
if (north == null || south == null || east == null || west == null) {
|
||||
_showError('Invalid coordinates. Please enter valid numbers.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (north <= south || east <= west) {
|
||||
_showError('Invalid bounds. North must be > South, East must be > West.');
|
||||
return;
|
||||
}
|
||||
|
||||
final bounds = LatLngBounds(
|
||||
LatLng(south, west),
|
||||
LatLng(north, east),
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isDownloading = true;
|
||||
_downloadProgress = 0.0;
|
||||
_statusMessage = 'Starting download...';
|
||||
});
|
||||
|
||||
await widget.tileCacheService.downloadRegion(
|
||||
layer: _selectedLayer,
|
||||
bounds: bounds,
|
||||
minZoom: _minZoom,
|
||||
maxZoom: _maxZoom,
|
||||
onProgress: (progress) {
|
||||
print('UI received progress update: $progress%');
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_downloadProgress = progress;
|
||||
_statusMessage = 'Downloading map tiles...';
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isDownloading = false;
|
||||
_statusMessage = 'Download completed successfully!';
|
||||
});
|
||||
|
||||
await _loadCacheStats();
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Map download completed!'),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isDownloading = false;
|
||||
_statusMessage = 'Download failed: $e';
|
||||
});
|
||||
_showError('Download failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _cancelDownload() async {
|
||||
try {
|
||||
if (!mounted) return;
|
||||
setState(() => _statusMessage = 'Cancelling download...');
|
||||
|
||||
await widget.tileCacheService.cancelDownload();
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isDownloading = false;
|
||||
_statusMessage = 'Download cancelled';
|
||||
});
|
||||
|
||||
await _loadCacheStats();
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Download cancelled'),
|
||||
backgroundColor: Colors.orange,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isDownloading = false;
|
||||
_statusMessage = 'Cancel failed: $e';
|
||||
});
|
||||
_showError('Cancel failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _exportMaps() async {
|
||||
if (!mounted) return;
|
||||
setState(() => _isLoading = true);
|
||||
try {
|
||||
final exportPath = await widget.tileCacheService.exportCache();
|
||||
if (!mounted) return;
|
||||
setState(() => _isLoading = false);
|
||||
|
||||
if (mounted) {
|
||||
await Share.shareXFiles(
|
||||
[XFile(exportPath)],
|
||||
subject: 'MeshCore SAR Maps Export',
|
||||
text: 'Offline maps export from MeshCore SAR',
|
||||
);
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Maps exported to: $exportPath'),
|
||||
backgroundColor: Colors.green,
|
||||
duration: const Duration(seconds: 5),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _isLoading = false);
|
||||
_showError('Export failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _importMaps() async {
|
||||
try {
|
||||
final result = await FilePicker.platform.pickFiles(
|
||||
type: FileType.custom,
|
||||
allowedExtensions: ['fmtc'],
|
||||
allowMultiple: false,
|
||||
);
|
||||
|
||||
if (result == null || result.files.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() => _isLoading = true);
|
||||
|
||||
final filePath = result.files.first.path;
|
||||
if (filePath == null) {
|
||||
throw Exception('Invalid file path');
|
||||
}
|
||||
|
||||
await widget.tileCacheService.importCache(filePath);
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() => _isLoading = false);
|
||||
await _loadCacheStats();
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Maps imported successfully!'),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _isLoading = false);
|
||||
_showError('Import failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _clearCache() async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Clear Cache'),
|
||||
content: const Text(
|
||||
'Are you sure you want to delete all downloaded maps? This action cannot be undone.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||
child: const Text('Clear'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed != true) return;
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() => _isLoading = true);
|
||||
try {
|
||||
await widget.tileCacheService.clearCache();
|
||||
if (!mounted) return;
|
||||
setState(() => _isLoading = false);
|
||||
await _loadCacheStats();
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Cache cleared successfully!'),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
setState(() => _isLoading = false);
|
||||
_showError('Clear cache failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void _showError(String message) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message),
|
||||
backgroundColor: Colors.red,
|
||||
duration: const Duration(seconds: 4),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Map Management'),
|
||||
),
|
||||
body: _isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Cache Statistics
|
||||
_buildStatisticsCard(),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Download Region
|
||||
_buildDownloadCard(),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Import/Export/Clear
|
||||
_buildActionsCard(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatisticsCard() {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Cache Statistics',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: _loadCacheStats,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (_cacheStats != null) ...[
|
||||
_buildStatRow(
|
||||
'Total Tiles',
|
||||
'${_cacheStats!['tileCount'] ?? 0}',
|
||||
Icons.grid_on,
|
||||
),
|
||||
_buildStatRow(
|
||||
'Cache Size',
|
||||
'${(_cacheStats!['sizeMB'] ?? 0.0).toStringAsFixed(2)} MB',
|
||||
Icons.storage,
|
||||
),
|
||||
_buildStatRow(
|
||||
'Store Name',
|
||||
_cacheStats!['storeName'] ?? 'Unknown',
|
||||
Icons.folder,
|
||||
),
|
||||
] else
|
||||
const Text('No cache statistics available'),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatRow(String label, String value, IconData icon) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, size: 20, color: Colors.grey[600]),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(label, style: const TextStyle(fontWeight: FontWeight.w500)),
|
||||
),
|
||||
Text(value, style: TextStyle(color: Colors.grey[600])),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDownloadCard() {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Download Region',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Map Layer Selection
|
||||
DropdownButtonFormField<MapLayer>(
|
||||
value: _selectedLayer,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Map Layer',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
items: MapLayer.allLayers.map((layer) {
|
||||
return DropdownMenuItem(
|
||||
value: layer,
|
||||
child: Text(layer.name),
|
||||
);
|
||||
}).toList(),
|
||||
onChanged: _isDownloading ? null : (layer) {
|
||||
if (layer != null) {
|
||||
setState(() => _selectedLayer = layer);
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Coordinates
|
||||
Text(
|
||||
'Region Bounds',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _northController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'North',
|
||||
border: OutlineInputBorder(),
|
||||
hintText: '46.1',
|
||||
),
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
enabled: !_isDownloading,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _southController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'South',
|
||||
border: OutlineInputBorder(),
|
||||
hintText: '46.0',
|
||||
),
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
enabled: !_isDownloading,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _eastController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'East',
|
||||
border: OutlineInputBorder(),
|
||||
hintText: '14.6',
|
||||
),
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
enabled: !_isDownloading,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _westController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'West',
|
||||
border: OutlineInputBorder(),
|
||||
hintText: '14.4',
|
||||
),
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
enabled: !_isDownloading,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Zoom Levels
|
||||
Text(
|
||||
'Zoom Levels',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Min: $_minZoom'),
|
||||
Slider(
|
||||
value: _minZoom.toDouble(),
|
||||
min: 1,
|
||||
max: 19,
|
||||
divisions: 18,
|
||||
label: '$_minZoom',
|
||||
onChanged: _isDownloading ? null : (value) {
|
||||
setState(() {
|
||||
_minZoom = value.toInt();
|
||||
if (_minZoom > _maxZoom) {
|
||||
_maxZoom = _minZoom;
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Max: $_maxZoom'),
|
||||
Slider(
|
||||
value: _maxZoom.toDouble(),
|
||||
min: 1,
|
||||
max: 19,
|
||||
divisions: 18,
|
||||
label: '$_maxZoom',
|
||||
onChanged: _isDownloading ? null : (value) {
|
||||
setState(() {
|
||||
_maxZoom = value.toInt();
|
||||
if (_maxZoom < _minZoom) {
|
||||
_minZoom = _maxZoom;
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// Download Progress
|
||||
if (_isDownloading) ...[
|
||||
const SizedBox(height: 16),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.primary.withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
_statusMessage ?? 'Downloading...',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${_downloadProgress.toStringAsFixed(1)}%',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: LinearProgressIndicator(
|
||||
value: _downloadProgress / 100,
|
||||
minHeight: 8,
|
||||
backgroundColor: Theme.of(context).colorScheme.primary.withOpacity(0.2),
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Download/Cancel Button
|
||||
if (_isDownloading)
|
||||
ElevatedButton.icon(
|
||||
onPressed: _cancelDownload,
|
||||
icon: const Icon(Icons.cancel),
|
||||
label: const Text('Cancel Download'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(48),
|
||||
backgroundColor: Colors.red,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
)
|
||||
else
|
||||
ElevatedButton.icon(
|
||||
onPressed: _downloadRegion,
|
||||
icon: const Icon(Icons.download),
|
||||
label: const Text('Download Region'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(48),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Note: Large regions or high zoom levels may take significant time and storage.',
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildActionsCard() {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Map Actions',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Export Button
|
||||
ElevatedButton.icon(
|
||||
onPressed: _isDownloading ? null : _exportMaps,
|
||||
icon: const Icon(Icons.upload),
|
||||
label: const Text('Export Maps'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(48),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Import Button
|
||||
ElevatedButton.icon(
|
||||
onPressed: _isDownloading ? null : _importMaps,
|
||||
icon: const Icon(Icons.download),
|
||||
label: const Text('Import Maps'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(48),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Clear Cache Button
|
||||
OutlinedButton.icon(
|
||||
onPressed: _isDownloading ? null : _clearCache,
|
||||
icon: const Icon(Icons.delete_forever),
|
||||
label: const Text('Clear All Maps'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: Colors.red,
|
||||
minimumSize: const Size.fromHeight(48),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
626
lib/screens/map_tab.dart
Normal file
626
lib/screens/map_tab.dart
Normal file
@@ -0,0 +1,626 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import '../providers/contacts_provider.dart';
|
||||
import '../providers/messages_provider.dart';
|
||||
import '../providers/map_provider.dart';
|
||||
import '../providers/app_provider.dart';
|
||||
import '../models/contact.dart';
|
||||
import '../models/sar_marker.dart';
|
||||
import '../models/map_layer.dart';
|
||||
import '../services/tile_cache_service.dart';
|
||||
import '../widgets/map_markers.dart';
|
||||
import 'map_management_screen.dart';
|
||||
|
||||
class MapTab extends StatefulWidget {
|
||||
const MapTab({super.key});
|
||||
|
||||
@override
|
||||
State<MapTab> createState() => _MapTabState();
|
||||
}
|
||||
|
||||
class _MapTabState extends State<MapTab> {
|
||||
final MapController _mapController = MapController();
|
||||
final TileCacheService _tileCache = TileCacheService();
|
||||
bool _isInitialized = false;
|
||||
MapLayer _currentLayer = MapLayer.openStreetMap;
|
||||
Position? _currentPosition;
|
||||
bool _showLegend = true;
|
||||
double _gpsUpdateDistance = 3.0; // meters
|
||||
StreamSubscription<Position>? _positionStreamSubscription;
|
||||
|
||||
// Default center point (will be updated based on markers)
|
||||
static const LatLng _defaultCenter = LatLng(46.0569, 14.5058); // Ljubljana, Slovenia
|
||||
static const double _defaultZoom = 13.0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initializeTileCache();
|
||||
_requestLocationPermission();
|
||||
|
||||
// Listen to map provider for navigation requests
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
final mapProvider = context.read<MapProvider>();
|
||||
mapProvider.addListener(_handleMapNavigation);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _requestLocationPermission() async {
|
||||
bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
|
||||
if (!serviceEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
LocationPermission permission = await Geolocator.checkPermission();
|
||||
if (permission == LocationPermission.denied) {
|
||||
permission = await Geolocator.requestPermission();
|
||||
if (permission == LocationPermission.denied) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (permission == LocationPermission.deniedForever) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get initial position
|
||||
try {
|
||||
final position = await Geolocator.getCurrentPosition(
|
||||
locationSettings: const LocationSettings(
|
||||
accuracy: LocationAccuracy.best,
|
||||
distanceFilter: 0,
|
||||
),
|
||||
);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_currentPosition = position;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Error getting location: $e');
|
||||
}
|
||||
|
||||
// Start listening to location updates
|
||||
_positionStreamSubscription = Geolocator.getPositionStream(
|
||||
locationSettings: LocationSettings(
|
||||
accuracy: LocationAccuracy.best,
|
||||
distanceFilter: _gpsUpdateDistance.toInt(),
|
||||
),
|
||||
).listen((Position position) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_currentPosition = position;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _handleMapNavigation() {
|
||||
final mapProvider = context.read<MapProvider>();
|
||||
if (mapProvider.targetLocation != null && _isInitialized) {
|
||||
_mapController.move(
|
||||
mapProvider.targetLocation!,
|
||||
mapProvider.targetZoom ?? _defaultZoom,
|
||||
);
|
||||
// Clear the navigation request after handling
|
||||
mapProvider.clearNavigation();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _initializeTileCache() async {
|
||||
try {
|
||||
await _tileCache.initialize();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isInitialized = true;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Error initializing tile cache: $e');
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isInitialized = true; // Continue without caching
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
final mapProvider = context.read<MapProvider>();
|
||||
mapProvider.removeListener(_handleMapNavigation);
|
||||
_positionStreamSubscription?.cancel();
|
||||
_mapController.dispose();
|
||||
_tileCache.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
LatLng _calculateCenter(List<Contact> contacts, List<SarMarker> sarMarkers) {
|
||||
final allPoints = <LatLng>[];
|
||||
|
||||
for (final contact in contacts) {
|
||||
if (contact.displayLocation != null) {
|
||||
allPoints.add(contact.displayLocation!);
|
||||
}
|
||||
}
|
||||
|
||||
for (final marker in sarMarkers) {
|
||||
allPoints.add(marker.location);
|
||||
}
|
||||
|
||||
if (allPoints.isEmpty) return _defaultCenter;
|
||||
|
||||
double lat = 0, lng = 0;
|
||||
for (final point in allPoints) {
|
||||
lat += point.latitude;
|
||||
lng += point.longitude;
|
||||
}
|
||||
|
||||
return LatLng(lat / allPoints.length, lng / allPoints.length);
|
||||
}
|
||||
|
||||
void _showLayerSelector(BuildContext context) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (context) => Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.layers),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Select Map Layer',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.download),
|
||||
tooltip: 'Download visible area',
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
_navigateToDownload(context);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
...MapLayer.allLayers.map((layer) => ListTile(
|
||||
leading: _currentLayer.type == layer.type
|
||||
? const Icon(Icons.check_circle, color: Colors.green)
|
||||
: const Icon(Icons.radio_button_unchecked),
|
||||
title: Text(layer.name),
|
||||
subtitle: Text(layer.attribution),
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_currentLayer = layer;
|
||||
});
|
||||
Navigator.pop(context);
|
||||
},
|
||||
)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _navigateToDownload(BuildContext context) {
|
||||
// Get current map bounds
|
||||
final bounds = _mapController.camera.visibleBounds;
|
||||
final currentZoom = _mapController.camera.zoom.round();
|
||||
|
||||
// Navigate to Map Management screen with pre-populated data
|
||||
final appProvider = context.read<AppProvider>();
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => MapManagementScreen(
|
||||
tileCacheService: appProvider.tileCacheService,
|
||||
initialLayer: _currentLayer,
|
||||
initialBounds: bounds,
|
||||
initialZoom: currentZoom,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showOptionsMenu(BuildContext context) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (context) => StatefulBuilder(
|
||||
builder: (context, setModalState) => Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.settings),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
'Map Options',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
// Legend toggle
|
||||
SwitchListTile(
|
||||
secondary: const Icon(Icons.info_outline),
|
||||
title: const Text('Show Legend'),
|
||||
subtitle: const Text('Display marker type counts'),
|
||||
value: _showLegend,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_showLegend = value;
|
||||
});
|
||||
setModalState(() {});
|
||||
},
|
||||
),
|
||||
const Divider(),
|
||||
// GPS Update Distance
|
||||
ListTile(
|
||||
leading: const Icon(Icons.gps_fixed),
|
||||
title: const Text('GPS Update Distance'),
|
||||
subtitle: Text('${_gpsUpdateDistance.toStringAsFixed(0)} meters'),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Column(
|
||||
children: [
|
||||
Slider(
|
||||
value: _gpsUpdateDistance,
|
||||
min: 1,
|
||||
max: 20,
|
||||
divisions: 19,
|
||||
label: '${_gpsUpdateDistance.toStringAsFixed(0)}m',
|
||||
onChanged: (value) {
|
||||
setModalState(() {
|
||||
_gpsUpdateDistance = value;
|
||||
});
|
||||
},
|
||||
onChangeEnd: (value) {
|
||||
setState(() {
|
||||
_gpsUpdateDistance = value;
|
||||
});
|
||||
// Restart location stream with new distance
|
||||
_restartLocationStream();
|
||||
},
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'1m',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
Text(
|
||||
'20m',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _restartLocationStream() {
|
||||
// Cancel existing subscription
|
||||
_positionStreamSubscription?.cancel();
|
||||
|
||||
// Start new stream with updated distance
|
||||
_positionStreamSubscription = Geolocator.getPositionStream(
|
||||
locationSettings: LocationSettings(
|
||||
accuracy: LocationAccuracy.best,
|
||||
distanceFilter: _gpsUpdateDistance.toInt(),
|
||||
),
|
||||
).listen((Position position) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_currentPosition = position;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Consumer2<ContactsProvider, MessagesProvider>(
|
||||
builder: (context, contactsProvider, messagesProvider, child) {
|
||||
final contactsWithLocation = contactsProvider.chatContactsWithLocation;
|
||||
final sarMarkers = messagesProvider.sarMarkers;
|
||||
final center = _calculateCenter(contactsWithLocation, sarMarkers);
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
// Map widget
|
||||
_isInitialized
|
||||
? FlutterMap(
|
||||
mapController: _mapController,
|
||||
options: MapOptions(
|
||||
initialCenter: center,
|
||||
initialZoom: _defaultZoom,
|
||||
minZoom: 5,
|
||||
maxZoom: 18,
|
||||
interactionOptions: const InteractionOptions(
|
||||
flags: InteractiveFlag.all,
|
||||
),
|
||||
),
|
||||
children: [
|
||||
TileLayer(
|
||||
urlTemplate: _currentLayer.urlTemplate,
|
||||
tileProvider: _tileCache.getTileProvider(_currentLayer),
|
||||
userAgentPackageName: 'com.meshcore.sar',
|
||||
maxZoom: _currentLayer.maxZoom.toDouble(),
|
||||
),
|
||||
MarkerLayer(
|
||||
markers: [
|
||||
...MapMarkers.createTeamMemberMarkers(
|
||||
contactsWithLocation,
|
||||
context,
|
||||
),
|
||||
...MapMarkers.createSarMarkers(
|
||||
sarMarkers,
|
||||
context,
|
||||
),
|
||||
// User location marker
|
||||
if (_currentPosition != null)
|
||||
Marker(
|
||||
point: LatLng(
|
||||
_currentPosition!.latitude,
|
||||
_currentPosition!.longitude,
|
||||
),
|
||||
width: 40,
|
||||
height: 40,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue.withOpacity(0.3),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Container(
|
||||
margin: const EdgeInsets.all(8),
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.blue,
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black26,
|
||||
blurRadius: 4,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.navigation,
|
||||
color: Colors.white,
|
||||
size: 16,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const CircularProgressIndicator(),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Initializing map...',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Map legend overlay
|
||||
if (_showLegend)
|
||||
Positioned(
|
||||
top: 16,
|
||||
right: 16,
|
||||
child: _MapLegend(
|
||||
teamMemberCount: contactsWithLocation.length,
|
||||
foundPersonCount: messagesProvider.foundPersonMarkers.length,
|
||||
fireCount: messagesProvider.fireMarkers.length,
|
||||
stagingAreaCount: messagesProvider.stagingAreaMarkers.length,
|
||||
),
|
||||
),
|
||||
// Map controls - right side
|
||||
Positioned(
|
||||
bottom: 16,
|
||||
right: 16,
|
||||
child: Column(
|
||||
children: [
|
||||
FloatingActionButton.small(
|
||||
heroTag: 'center_map',
|
||||
onPressed: () async {
|
||||
// Force update GPS location and jump to it
|
||||
try {
|
||||
final position = await Geolocator.getCurrentPosition(
|
||||
locationSettings: const LocationSettings(
|
||||
accuracy: LocationAccuracy.best,
|
||||
distanceFilter: 0,
|
||||
),
|
||||
);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_currentPosition = position;
|
||||
});
|
||||
_mapController.move(
|
||||
LatLng(position.latitude, position.longitude),
|
||||
16,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Error getting location: $e');
|
||||
// Fallback to cached position or default center
|
||||
if (_currentPosition != null) {
|
||||
_mapController.move(
|
||||
LatLng(
|
||||
_currentPosition!.latitude,
|
||||
_currentPosition!.longitude,
|
||||
),
|
||||
16,
|
||||
);
|
||||
} else {
|
||||
_mapController.move(center, _defaultZoom);
|
||||
}
|
||||
}
|
||||
},
|
||||
child: const Icon(Icons.my_location),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
FloatingActionButton.small(
|
||||
heroTag: 'layer_selector',
|
||||
onPressed: () => _showLayerSelector(context),
|
||||
child: const Icon(Icons.layers),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
FloatingActionButton.small(
|
||||
heroTag: 'options_menu',
|
||||
onPressed: () => _showOptionsMenu(context),
|
||||
child: const Icon(Icons.more_vert),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MapLegend extends StatelessWidget {
|
||||
final int teamMemberCount;
|
||||
final int foundPersonCount;
|
||||
final int fireCount;
|
||||
final int stagingAreaCount;
|
||||
|
||||
const _MapLegend({
|
||||
required this.teamMemberCount,
|
||||
required this.foundPersonCount,
|
||||
required this.fireCount,
|
||||
required this.stagingAreaCount,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'Legend',
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_LegendItem(
|
||||
icon: Icons.person,
|
||||
color: Colors.blue,
|
||||
label: 'Team',
|
||||
count: teamMemberCount,
|
||||
),
|
||||
_LegendItem(
|
||||
icon: Icons.person_pin,
|
||||
color: Colors.green,
|
||||
label: 'Found',
|
||||
count: foundPersonCount,
|
||||
),
|
||||
_LegendItem(
|
||||
icon: Icons.local_fire_department,
|
||||
color: Colors.red,
|
||||
label: 'Fire',
|
||||
count: fireCount,
|
||||
),
|
||||
_LegendItem(
|
||||
icon: Icons.home_work,
|
||||
color: Colors.orange,
|
||||
label: 'Staging',
|
||||
count: stagingAreaCount,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LegendItem extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
final String label;
|
||||
final int count;
|
||||
|
||||
const _LegendItem({
|
||||
required this.icon,
|
||||
required this.color,
|
||||
required this.label,
|
||||
required this.count,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 16, color: color),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
label,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.2),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
count.toString(),
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
205
lib/screens/messages_tab.dart
Normal file
205
lib/screens/messages_tab.dart
Normal file
@@ -0,0 +1,205 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../providers/messages_provider.dart';
|
||||
import '../providers/contacts_provider.dart';
|
||||
import '../providers/map_provider.dart';
|
||||
import '../models/message.dart';
|
||||
import '../utils/sar_message_parser.dart';
|
||||
|
||||
class MessagesTab extends StatelessWidget {
|
||||
final VoidCallback onNavigateToMap;
|
||||
|
||||
const MessagesTab({super.key, required this.onNavigateToMap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Consumer<MessagesProvider>(
|
||||
builder: (context, messagesProvider, child) {
|
||||
final messages = messagesProvider.getRecentMessages(count: 100);
|
||||
|
||||
if (messages.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.message_outlined,
|
||||
size: 64,
|
||||
color: Theme.of(context).disabledColor,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'No messages yet',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Connect to a device to start receiving messages',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return 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,
|
||||
);
|
||||
onNavigateToMap();
|
||||
}
|
||||
: null,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MessageBubble extends StatelessWidget {
|
||||
final Message message;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const _MessageBubble({
|
||||
required this.message,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isSarMarker = message.isSarMarker;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: isSarMarker
|
||||
? _getSarMarkerColor(context)
|
||||
: Theme.of(context).colorScheme.surfaceVariant,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: isSarMarker
|
||||
? Border.all(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
width: 2,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Header: Sender and time
|
||||
Row(
|
||||
children: [
|
||||
if (message.isChannelMessage)
|
||||
const Icon(Icons.tag, size: 16)
|
||||
else
|
||||
const Icon(Icons.person, size: 16),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
message.displaySender,
|
||||
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (isSarMarker)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
'SAR',
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onPrimary,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
message.timeAgo,
|
||||
style: Theme.of(context).textTheme.labelSmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// SAR marker content
|
||||
if (isSarMarker && message.sarMarkerType != null) ...[
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
message.sarMarkerType!.emoji,
|
||||
style: const TextStyle(fontSize: 32),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
message.sarMarkerType!.displayName,
|
||||
style:
|
||||
Theme.of(context).textTheme.titleMedium?.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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
Icons.chevron_right,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Tap to view on map',
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
]
|
||||
// Regular message content
|
||||
else
|
||||
Text(
|
||||
message.text,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Color _getSarMarkerColor(BuildContext context) {
|
||||
return Theme.of(context).colorScheme.primaryContainer;
|
||||
}
|
||||
}
|
||||
139
lib/services/buffer_reader.dart
Normal file
139
lib/services/buffer_reader.dart
Normal file
@@ -0,0 +1,139 @@
|
||||
import 'dart:typed_data';
|
||||
import 'dart:convert';
|
||||
|
||||
/// Buffer reader for parsing MeshCore protocol binary data
|
||||
class BufferReader {
|
||||
final Uint8List _buffer;
|
||||
int _offset = 0;
|
||||
|
||||
BufferReader(this._buffer);
|
||||
|
||||
/// Get remaining bytes count
|
||||
int get remainingBytesCount => _buffer.length - _offset;
|
||||
|
||||
/// Check if there are bytes remaining
|
||||
bool get hasRemaining => _offset < _buffer.length;
|
||||
|
||||
/// Get current offset
|
||||
int get offset => _offset;
|
||||
|
||||
/// Set offset
|
||||
set offset(int value) => _offset = value;
|
||||
|
||||
/// Read a single byte (uint8)
|
||||
int readByte() {
|
||||
if (_offset >= _buffer.length) {
|
||||
throw Exception('Buffer overflow: attempting to read beyond buffer length');
|
||||
}
|
||||
return _buffer[_offset++];
|
||||
}
|
||||
|
||||
/// Read a signed byte (int8)
|
||||
int readInt8() {
|
||||
final value = readByte();
|
||||
return value > 127 ? value - 256 : value;
|
||||
}
|
||||
|
||||
/// Read unsigned 16-bit integer (little-endian)
|
||||
int readUInt16LE() {
|
||||
if (_offset + 2 > _buffer.length) {
|
||||
throw Exception('Buffer overflow: attempting to read beyond buffer length');
|
||||
}
|
||||
final value = _buffer[_offset] | (_buffer[_offset + 1] << 8);
|
||||
_offset += 2;
|
||||
return value;
|
||||
}
|
||||
|
||||
/// Read signed 16-bit integer (little-endian)
|
||||
int readInt16LE() {
|
||||
final value = readUInt16LE();
|
||||
return value > 32767 ? value - 65536 : value;
|
||||
}
|
||||
|
||||
/// Read unsigned 32-bit integer (little-endian)
|
||||
int readUInt32LE() {
|
||||
if (_offset + 4 > _buffer.length) {
|
||||
throw Exception('Buffer overflow: attempting to read beyond buffer length');
|
||||
}
|
||||
final value = _buffer[_offset] |
|
||||
(_buffer[_offset + 1] << 8) |
|
||||
(_buffer[_offset + 2] << 16) |
|
||||
(_buffer[_offset + 3] << 24);
|
||||
_offset += 4;
|
||||
return value;
|
||||
}
|
||||
|
||||
/// Read signed 32-bit integer (little-endian)
|
||||
int readInt32LE() {
|
||||
final value = readUInt32LE();
|
||||
return value > 2147483647 ? value - 4294967296 : value;
|
||||
}
|
||||
|
||||
/// Read a fixed number of bytes
|
||||
Uint8List readBytes(int length) {
|
||||
if (_offset + length > _buffer.length) {
|
||||
throw Exception('Buffer overflow: attempting to read beyond buffer length');
|
||||
}
|
||||
final bytes = _buffer.sublist(_offset, _offset + length);
|
||||
_offset += length;
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/// Read remaining bytes
|
||||
Uint8List readRemainingBytes() {
|
||||
final bytes = _buffer.sublist(_offset);
|
||||
_offset = _buffer.length;
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/// Read null-terminated string (C-string) with max length
|
||||
String readCString(int maxLength) {
|
||||
if (_offset + maxLength > _buffer.length) {
|
||||
throw Exception('Buffer overflow: attempting to read beyond buffer length');
|
||||
}
|
||||
|
||||
final bytes = _buffer.sublist(_offset, _offset + maxLength);
|
||||
_offset += maxLength;
|
||||
|
||||
// Find null terminator
|
||||
int nullIndex = bytes.indexOf(0);
|
||||
if (nullIndex == -1) {
|
||||
nullIndex = maxLength;
|
||||
}
|
||||
|
||||
// Decode string up to null terminator
|
||||
return utf8.decode(bytes.sublist(0, nullIndex));
|
||||
}
|
||||
|
||||
/// Read length-prefixed string (remaining bytes as UTF-8)
|
||||
String readString() {
|
||||
final bytes = readRemainingBytes();
|
||||
return utf8.decode(bytes);
|
||||
}
|
||||
|
||||
/// Peek at next byte without advancing offset
|
||||
int peekByte() {
|
||||
if (_offset >= _buffer.length) {
|
||||
throw Exception('Buffer overflow: attempting to peek beyond buffer length');
|
||||
}
|
||||
return _buffer[_offset];
|
||||
}
|
||||
|
||||
/// Skip bytes
|
||||
void skip(int count) {
|
||||
if (_offset + count > _buffer.length) {
|
||||
throw Exception('Buffer overflow: attempting to skip beyond buffer length');
|
||||
}
|
||||
_offset += count;
|
||||
}
|
||||
|
||||
/// Reset offset to beginning
|
||||
void reset() {
|
||||
_offset = 0;
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'BufferReader(length: ${_buffer.length}, offset: $_offset, remaining: $remainingBytesCount)';
|
||||
}
|
||||
}
|
||||
129
lib/services/buffer_writer.dart
Normal file
129
lib/services/buffer_writer.dart
Normal file
@@ -0,0 +1,129 @@
|
||||
import 'dart:typed_data';
|
||||
import 'dart:convert';
|
||||
|
||||
/// Buffer writer for creating MeshCore protocol binary data
|
||||
class BufferWriter {
|
||||
final List<int> _buffer = [];
|
||||
|
||||
/// Get current buffer length
|
||||
int get length => _buffer.length;
|
||||
|
||||
/// Write a single byte (uint8)
|
||||
void writeByte(int value) {
|
||||
if (value < 0 || value > 255) {
|
||||
throw ArgumentError('Byte value must be between 0 and 255');
|
||||
}
|
||||
_buffer.add(value);
|
||||
}
|
||||
|
||||
/// Write a signed byte (int8)
|
||||
void writeInt8(int value) {
|
||||
if (value < -128 || value > 127) {
|
||||
throw ArgumentError('Int8 value must be between -128 and 127');
|
||||
}
|
||||
_buffer.add(value < 0 ? value + 256 : value);
|
||||
}
|
||||
|
||||
/// Write unsigned 16-bit integer (little-endian)
|
||||
void writeUInt16LE(int value) {
|
||||
if (value < 0 || value > 65535) {
|
||||
throw ArgumentError('UInt16 value must be between 0 and 65535');
|
||||
}
|
||||
_buffer.add(value & 0xFF);
|
||||
_buffer.add((value >> 8) & 0xFF);
|
||||
}
|
||||
|
||||
/// Write signed 16-bit integer (little-endian)
|
||||
void writeInt16LE(int value) {
|
||||
if (value < -32768 || value > 32767) {
|
||||
throw ArgumentError('Int16 value must be between -32768 and 32767');
|
||||
}
|
||||
final unsigned = value < 0 ? value + 65536 : value;
|
||||
writeUInt16LE(unsigned);
|
||||
}
|
||||
|
||||
/// Write unsigned 32-bit integer (little-endian)
|
||||
void writeUInt32LE(int value) {
|
||||
if (value < 0 || value > 4294967295) {
|
||||
throw ArgumentError('UInt32 value must be between 0 and 4294967295');
|
||||
}
|
||||
_buffer.add(value & 0xFF);
|
||||
_buffer.add((value >> 8) & 0xFF);
|
||||
_buffer.add((value >> 16) & 0xFF);
|
||||
_buffer.add((value >> 24) & 0xFF);
|
||||
}
|
||||
|
||||
/// Write signed 32-bit integer (little-endian)
|
||||
void writeInt32LE(int value) {
|
||||
if (value < -2147483648 || value > 2147483647) {
|
||||
throw ArgumentError('Int32 value must be between -2147483648 and 2147483647');
|
||||
}
|
||||
final unsigned = value < 0 ? value + 4294967296 : value;
|
||||
writeUInt32LE(unsigned);
|
||||
}
|
||||
|
||||
/// Write bytes from Uint8List
|
||||
void writeBytes(Uint8List bytes) {
|
||||
_buffer.addAll(bytes);
|
||||
}
|
||||
|
||||
/// Write bytes from List<int>
|
||||
void writeBytesFromList(List<int> bytes) {
|
||||
_buffer.addAll(bytes);
|
||||
}
|
||||
|
||||
/// Write null-terminated string (C-string) with fixed length
|
||||
/// Pads with zeros if string is shorter than maxLength
|
||||
void writeCString(String str, int maxLength) {
|
||||
final bytes = utf8.encode(str);
|
||||
|
||||
// Ensure we don't exceed max length
|
||||
final length = bytes.length < maxLength ? bytes.length : maxLength;
|
||||
|
||||
// Write string bytes
|
||||
for (int i = 0; i < length; i++) {
|
||||
_buffer.add(bytes[i]);
|
||||
}
|
||||
|
||||
// Pad with zeros
|
||||
for (int i = length; i < maxLength; i++) {
|
||||
_buffer.add(0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Write length-prefixed string
|
||||
void writeString(String str) {
|
||||
final bytes = utf8.encode(str);
|
||||
_buffer.addAll(bytes);
|
||||
}
|
||||
|
||||
/// Write string with length prefix (1 byte)
|
||||
void writeLengthPrefixedString(String str) {
|
||||
final bytes = utf8.encode(str);
|
||||
if (bytes.length > 255) {
|
||||
throw ArgumentError('String too long for length-prefixed format (max 255 bytes)');
|
||||
}
|
||||
writeByte(bytes.length);
|
||||
_buffer.addAll(bytes);
|
||||
}
|
||||
|
||||
/// Get buffer as Uint8List
|
||||
Uint8List toBytes() {
|
||||
return Uint8List.fromList(_buffer);
|
||||
}
|
||||
|
||||
/// Clear the buffer
|
||||
void clear() {
|
||||
_buffer.clear();
|
||||
}
|
||||
|
||||
/// Get buffer as hex string (for debugging)
|
||||
String toHexString() {
|
||||
return _buffer.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ');
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'BufferWriter(length: $length, hex: ${toHexString()})';
|
||||
}
|
||||
}
|
||||
186
lib/services/cayenne_lpp_parser.dart
Normal file
186
lib/services/cayenne_lpp_parser.dart
Normal file
@@ -0,0 +1,186 @@
|
||||
import 'dart:typed_data';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import '../models/contact_telemetry.dart';
|
||||
import 'buffer_reader.dart';
|
||||
import 'meshcore_constants.dart';
|
||||
|
||||
/// Cayenne LPP (Low Power Payload) data parser
|
||||
/// Used for decoding telemetry sensor data from MeshCore devices
|
||||
class CayenneLppParser {
|
||||
/// Parse Cayenne LPP data into ContactTelemetry
|
||||
static ContactTelemetry parse(Uint8List data) {
|
||||
final reader = BufferReader(data);
|
||||
|
||||
LatLng? gpsLocation;
|
||||
double? batteryPercentage;
|
||||
double? batteryMilliVolts;
|
||||
double? temperature;
|
||||
double? humidity;
|
||||
double? pressure;
|
||||
final extraSensorData = <String, dynamic>{};
|
||||
|
||||
while (reader.hasRemaining) {
|
||||
try {
|
||||
final channel = reader.readByte();
|
||||
final type = reader.readByte();
|
||||
|
||||
switch (type) {
|
||||
case MeshCoreConstants.lppDigitalInput:
|
||||
final value = reader.readByte();
|
||||
extraSensorData['digital_input_$channel'] = value;
|
||||
break;
|
||||
|
||||
case MeshCoreConstants.lppDigitalOutput:
|
||||
final value = reader.readByte();
|
||||
extraSensorData['digital_output_$channel'] = value;
|
||||
break;
|
||||
|
||||
case MeshCoreConstants.lppAnalogInput:
|
||||
final value = reader.readInt16LE() / 100.0;
|
||||
extraSensorData['analog_input_$channel'] = value;
|
||||
// If this is a battery reading
|
||||
if (channel == 0 || channel == 1) {
|
||||
batteryMilliVolts = value * 1000;
|
||||
batteryPercentage = _calculateBatteryPercentage(value);
|
||||
}
|
||||
break;
|
||||
|
||||
case MeshCoreConstants.lppAnalogOutput:
|
||||
final value = reader.readInt16LE() / 100.0;
|
||||
extraSensorData['analog_output_$channel'] = value;
|
||||
break;
|
||||
|
||||
case MeshCoreConstants.lppIlluminanceSensor:
|
||||
final value = reader.readUInt16LE();
|
||||
extraSensorData['illuminance_$channel'] = value;
|
||||
break;
|
||||
|
||||
case MeshCoreConstants.lppPresenceSensor:
|
||||
final value = reader.readByte();
|
||||
extraSensorData['presence_$channel'] = value;
|
||||
break;
|
||||
|
||||
case MeshCoreConstants.lppTemperatureSensor:
|
||||
temperature = reader.readInt16LE() / 10.0;
|
||||
break;
|
||||
|
||||
case MeshCoreConstants.lppHumiditySensor:
|
||||
humidity = reader.readByte() / 2.0;
|
||||
break;
|
||||
|
||||
case MeshCoreConstants.lppAccelerometer:
|
||||
final x = reader.readInt16LE() / 1000.0;
|
||||
final y = reader.readInt16LE() / 1000.0;
|
||||
final z = reader.readInt16LE() / 1000.0;
|
||||
extraSensorData['accelerometer_$channel'] = {'x': x, 'y': y, 'z': z};
|
||||
break;
|
||||
|
||||
case MeshCoreConstants.lppBarometer:
|
||||
pressure = reader.readUInt16LE() / 10.0;
|
||||
break;
|
||||
|
||||
case MeshCoreConstants.lppGyrometer:
|
||||
final x = reader.readInt16LE() / 100.0;
|
||||
final y = reader.readInt16LE() / 100.0;
|
||||
final z = reader.readInt16LE() / 100.0;
|
||||
extraSensorData['gyrometer_$channel'] = {'x': x, 'y': y, 'z': z};
|
||||
break;
|
||||
|
||||
case MeshCoreConstants.lppGps:
|
||||
final lat = reader.readInt32LE() / 10000.0;
|
||||
final lon = reader.readInt32LE() / 10000.0;
|
||||
final alt = reader.readInt32LE() / 100.0;
|
||||
gpsLocation = LatLng(lat, lon);
|
||||
extraSensorData['altitude_$channel'] = alt;
|
||||
break;
|
||||
|
||||
default:
|
||||
// Unknown type, skip remaining to avoid parsing errors
|
||||
reader.skip(reader.remainingBytesCount);
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
// If we encounter a parsing error, break and return what we have
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return ContactTelemetry(
|
||||
gpsLocation: gpsLocation,
|
||||
batteryPercentage: batteryPercentage,
|
||||
batteryMilliVolts: batteryMilliVolts,
|
||||
temperature: temperature,
|
||||
humidity: humidity,
|
||||
pressure: pressure,
|
||||
timestamp: DateTime.now(),
|
||||
extraSensorData: extraSensorData.isNotEmpty ? extraSensorData : null,
|
||||
);
|
||||
}
|
||||
|
||||
/// Calculate battery percentage from voltage (V)
|
||||
static double _calculateBatteryPercentage(double voltage) {
|
||||
// Standard lithium battery curve: 3.0V = 0%, 4.2V = 100%
|
||||
if (voltage <= 3.0) return 0.0;
|
||||
if (voltage >= 4.2) return 100.0;
|
||||
return ((voltage - 3.0) / 1.2) * 100.0;
|
||||
}
|
||||
|
||||
/// Create Cayenne LPP data for GPS location
|
||||
static Uint8List createGpsData({
|
||||
required double latitude,
|
||||
required double longitude,
|
||||
double altitude = 0.0,
|
||||
int channel = 0,
|
||||
}) {
|
||||
final buffer = <int>[];
|
||||
|
||||
buffer.add(channel);
|
||||
buffer.add(MeshCoreConstants.lppGps);
|
||||
|
||||
// Latitude (3 bytes, signed, 0.0001° precision)
|
||||
final lat = (latitude * 10000).round();
|
||||
buffer.add((lat >> 16) & 0xFF);
|
||||
buffer.add((lat >> 8) & 0xFF);
|
||||
buffer.add(lat & 0xFF);
|
||||
|
||||
// Longitude (3 bytes, signed, 0.0001° precision)
|
||||
final lon = (longitude * 10000).round();
|
||||
buffer.add((lon >> 16) & 0xFF);
|
||||
buffer.add((lon >> 8) & 0xFF);
|
||||
buffer.add(lon & 0xFF);
|
||||
|
||||
// Altitude (3 bytes, signed, 0.01m precision)
|
||||
final alt = (altitude * 100).round();
|
||||
buffer.add((alt >> 16) & 0xFF);
|
||||
buffer.add((alt >> 8) & 0xFF);
|
||||
buffer.add(alt & 0xFF);
|
||||
|
||||
return Uint8List.fromList(buffer);
|
||||
}
|
||||
|
||||
/// Create Cayenne LPP data for temperature
|
||||
static Uint8List createTemperatureData(double celsius, {int channel = 0}) {
|
||||
final buffer = <int>[];
|
||||
buffer.add(channel);
|
||||
buffer.add(MeshCoreConstants.lppTemperatureSensor);
|
||||
|
||||
final temp = (celsius * 10).round();
|
||||
buffer.add((temp >> 8) & 0xFF);
|
||||
buffer.add(temp & 0xFF);
|
||||
|
||||
return Uint8List.fromList(buffer);
|
||||
}
|
||||
|
||||
/// Create Cayenne LPP data for battery voltage
|
||||
static Uint8List createBatteryData(double voltage, {int channel = 0}) {
|
||||
final buffer = <int>[];
|
||||
buffer.add(channel);
|
||||
buffer.add(MeshCoreConstants.lppAnalogInput);
|
||||
|
||||
final volts = (voltage * 100).round();
|
||||
buffer.add((volts >> 8) & 0xFF);
|
||||
buffer.add(volts & 0xFF);
|
||||
|
||||
return Uint8List.fromList(buffer);
|
||||
}
|
||||
}
|
||||
394
lib/services/meshcore_ble_service.dart
Normal file
394
lib/services/meshcore_ble_service.dart
Normal file
@@ -0,0 +1,394 @@
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
|
||||
import '../models/contact.dart';
|
||||
import '../models/contact_telemetry.dart';
|
||||
import '../models/message.dart';
|
||||
import 'buffer_reader.dart';
|
||||
import 'buffer_writer.dart';
|
||||
import 'meshcore_constants.dart';
|
||||
|
||||
/// Callback types for MeshCore events
|
||||
typedef OnContactCallback = void Function(Contact contact);
|
||||
typedef OnContactsCompleteCallback = void Function(List<Contact> contacts);
|
||||
typedef OnMessageCallback = void Function(Message message);
|
||||
typedef OnTelemetryCallback = void Function(Uint8List publicKey, Uint8List lppData);
|
||||
typedef OnErrorCallback = void Function(String error);
|
||||
typedef OnConnectionStateCallback = void Function(bool isConnected);
|
||||
|
||||
/// MeshCore BLE Service - handles all BLE communication
|
||||
class MeshCoreBleService {
|
||||
BluetoothDevice? _device;
|
||||
BluetoothCharacteristic? _rxCharacteristic;
|
||||
BluetoothCharacteristic? _txCharacteristic;
|
||||
StreamSubscription? _txSubscription;
|
||||
|
||||
// Event callbacks
|
||||
OnConnectionStateCallback? onConnectionStateChanged;
|
||||
OnContactCallback? onContactReceived;
|
||||
OnContactsCompleteCallback? onContactsComplete;
|
||||
OnMessageCallback? onMessageReceived;
|
||||
OnTelemetryCallback? onTelemetryReceived;
|
||||
OnErrorCallback? onError;
|
||||
|
||||
// Internal state
|
||||
final List<Contact> _pendingContacts = [];
|
||||
bool _isConnected = false;
|
||||
bool get isConnected => _isConnected;
|
||||
|
||||
/// Scan for MeshCore devices
|
||||
Stream<BluetoothDevice> scanForDevices({Duration timeout = const Duration(seconds: 10)}) async* {
|
||||
try {
|
||||
// Start scanning
|
||||
await FlutterBluePlus.startScan(
|
||||
timeout: timeout,
|
||||
withServices: [Guid(MeshCoreConstants.bleServiceUuid)],
|
||||
);
|
||||
|
||||
// Listen to scan results
|
||||
await for (final scanResult in FlutterBluePlus.scanResults) {
|
||||
for (final result in scanResult) {
|
||||
if (result.advertisementData.serviceUuids
|
||||
.contains(Guid(MeshCoreConstants.bleServiceUuid))) {
|
||||
yield result.device;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
onError?.call('Scan error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Connect to a MeshCore device
|
||||
Future<bool> connect(BluetoothDevice device) async {
|
||||
try {
|
||||
_device = device;
|
||||
|
||||
// Connect to device
|
||||
await device.connect(
|
||||
license: License.free,
|
||||
timeout: const Duration(seconds: 15),
|
||||
mtu: 512,
|
||||
);
|
||||
|
||||
// Discover services
|
||||
final services = await device.discoverServices();
|
||||
|
||||
// Find MeshCore service
|
||||
BluetoothService? meshCoreService;
|
||||
for (final service in services) {
|
||||
if (service.uuid.toString().toLowerCase() ==
|
||||
MeshCoreConstants.bleServiceUuid.toLowerCase()) {
|
||||
meshCoreService = service;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (meshCoreService == null) {
|
||||
throw Exception('MeshCore service not found');
|
||||
}
|
||||
|
||||
// Find RX and TX characteristics
|
||||
for (final characteristic in meshCoreService.characteristics) {
|
||||
final uuid = characteristic.uuid.toString().toLowerCase();
|
||||
if (uuid == MeshCoreConstants.bleCharacteristicRxUuid.toLowerCase()) {
|
||||
_rxCharacteristic = characteristic;
|
||||
} else if (uuid ==
|
||||
MeshCoreConstants.bleCharacteristicTxUuid.toLowerCase()) {
|
||||
_txCharacteristic = characteristic;
|
||||
}
|
||||
}
|
||||
|
||||
if (_rxCharacteristic == null || _txCharacteristic == null) {
|
||||
throw Exception('Required characteristics not found');
|
||||
}
|
||||
|
||||
// Enable notifications on TX characteristic
|
||||
await _txCharacteristic!.setNotifyValue(true);
|
||||
|
||||
// Listen to TX characteristic
|
||||
_txSubscription = _txCharacteristic!.lastValueStream.listen(
|
||||
_onDataReceived,
|
||||
onError: (error) => onError?.call('TX notification error: $error'),
|
||||
);
|
||||
|
||||
_isConnected = true;
|
||||
onConnectionStateChanged?.call(true);
|
||||
|
||||
// Send initial device query
|
||||
await _sendDeviceQuery();
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
onError?.call('Connection error: $e');
|
||||
_isConnected = false;
|
||||
onConnectionStateChanged?.call(false);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Disconnect from device
|
||||
Future<void> disconnect() async {
|
||||
try {
|
||||
await _txSubscription?.cancel();
|
||||
await _device?.disconnect();
|
||||
_isConnected = false;
|
||||
_device = null;
|
||||
_rxCharacteristic = null;
|
||||
_txCharacteristic = null;
|
||||
onConnectionStateChanged?.call(false);
|
||||
} catch (e) {
|
||||
onError?.call('Disconnect error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Write data to RX characteristic
|
||||
Future<void> _writeData(Uint8List data) async {
|
||||
if (_rxCharacteristic == null) {
|
||||
throw Exception('Not connected');
|
||||
}
|
||||
try {
|
||||
await _rxCharacteristic!.write(data, withoutResponse: true);
|
||||
} catch (e) {
|
||||
onError?.call('Write error: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle incoming data from TX characteristic
|
||||
void _onDataReceived(List<int> data) {
|
||||
try {
|
||||
final reader = BufferReader(Uint8List.fromList(data));
|
||||
final responseCode = reader.readByte();
|
||||
|
||||
switch (responseCode) {
|
||||
case MeshCoreConstants.respContactsStart:
|
||||
_handleContactsStart(reader);
|
||||
break;
|
||||
case MeshCoreConstants.respContact:
|
||||
_handleContact(reader);
|
||||
break;
|
||||
case MeshCoreConstants.respEndOfContacts:
|
||||
_handleEndOfContacts(reader);
|
||||
break;
|
||||
case MeshCoreConstants.respContactMsgRecv:
|
||||
_handleContactMessage(reader);
|
||||
break;
|
||||
case MeshCoreConstants.respChannelMsgRecv:
|
||||
_handleChannelMessage(reader);
|
||||
break;
|
||||
case MeshCoreConstants.pushTelemetryResponse:
|
||||
_handleTelemetryResponse(reader);
|
||||
break;
|
||||
case MeshCoreConstants.respOk:
|
||||
case MeshCoreConstants.respErr:
|
||||
// Handle OK/Error responses if needed
|
||||
break;
|
||||
default:
|
||||
// Unknown response code
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
onError?.call('Data parsing error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle ContactsStart response
|
||||
void _handleContactsStart(BufferReader reader) {
|
||||
_pendingContacts.clear();
|
||||
final count = reader.readUInt32LE();
|
||||
// Optional: notify about expected count
|
||||
}
|
||||
|
||||
/// Handle Contact response
|
||||
void _handleContact(BufferReader reader) {
|
||||
try {
|
||||
final publicKey = reader.readBytes(32);
|
||||
final type = ContactType.fromValue(reader.readByte());
|
||||
final flags = reader.readByte();
|
||||
final outPathLen = reader.readInt8();
|
||||
final outPath = reader.readBytes(64);
|
||||
final advName = reader.readCString(32);
|
||||
final lastAdvert = reader.readUInt32LE();
|
||||
final advLat = reader.readInt32LE();
|
||||
final advLon = reader.readInt32LE();
|
||||
final lastMod = reader.readUInt32LE();
|
||||
|
||||
final contact = Contact(
|
||||
publicKey: publicKey,
|
||||
type: type,
|
||||
flags: flags,
|
||||
outPathLen: outPathLen,
|
||||
outPath: outPath,
|
||||
advName: advName,
|
||||
lastAdvert: lastAdvert,
|
||||
advLat: advLat,
|
||||
advLon: advLon,
|
||||
lastMod: lastMod,
|
||||
);
|
||||
|
||||
_pendingContacts.add(contact);
|
||||
onContactReceived?.call(contact);
|
||||
} catch (e) {
|
||||
onError?.call('Contact parsing error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle EndOfContacts response
|
||||
void _handleEndOfContacts(BufferReader reader) {
|
||||
onContactsComplete?.call(List.from(_pendingContacts));
|
||||
_pendingContacts.clear();
|
||||
}
|
||||
|
||||
/// Handle ContactMsgRecv response
|
||||
void _handleContactMessage(BufferReader reader) {
|
||||
try {
|
||||
final pubKeyPrefix = reader.readBytes(6);
|
||||
final pathLen = reader.readByte();
|
||||
final txtType = MessageTextType.fromValue(reader.readByte());
|
||||
final senderTimestamp = reader.readUInt32LE();
|
||||
final text = reader.readString();
|
||||
|
||||
final message = Message(
|
||||
id: '${DateTime.now().millisecondsSinceEpoch}_${pubKeyPrefix.map((b) => b.toRadixString(16)).join()}',
|
||||
messageType: MessageType.contact,
|
||||
senderPublicKeyPrefix: pubKeyPrefix,
|
||||
pathLen: pathLen,
|
||||
textType: txtType,
|
||||
senderTimestamp: senderTimestamp,
|
||||
text: text,
|
||||
receivedAt: DateTime.now(),
|
||||
);
|
||||
|
||||
onMessageReceived?.call(message);
|
||||
} catch (e) {
|
||||
onError?.call('Contact message parsing error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle ChannelMsgRecv response
|
||||
void _handleChannelMessage(BufferReader reader) {
|
||||
try {
|
||||
final channelIdx = reader.readInt8();
|
||||
final pathLen = reader.readByte();
|
||||
final txtType = MessageTextType.fromValue(reader.readByte());
|
||||
final senderTimestamp = reader.readUInt32LE();
|
||||
final text = reader.readString();
|
||||
|
||||
final message = Message(
|
||||
id: '${DateTime.now().millisecondsSinceEpoch}_ch$channelIdx',
|
||||
messageType: MessageType.channel,
|
||||
channelIdx: channelIdx,
|
||||
pathLen: pathLen,
|
||||
textType: txtType,
|
||||
senderTimestamp: senderTimestamp,
|
||||
text: text,
|
||||
receivedAt: DateTime.now(),
|
||||
);
|
||||
|
||||
onMessageReceived?.call(message);
|
||||
} catch (e) {
|
||||
onError?.call('Channel message parsing error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle TelemetryResponse push
|
||||
void _handleTelemetryResponse(BufferReader reader) {
|
||||
try {
|
||||
reader.readByte(); // reserved
|
||||
final pubKeyPrefix = reader.readBytes(6);
|
||||
final lppSensorData = reader.readRemainingBytes();
|
||||
|
||||
onTelemetryReceived?.call(pubKeyPrefix, lppSensorData);
|
||||
} catch (e) {
|
||||
onError?.call('Telemetry parsing error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Send AppStart command
|
||||
Future<void> _sendAppStart() async {
|
||||
final writer = BufferWriter();
|
||||
writer.writeByte(MeshCoreConstants.cmdAppStart);
|
||||
writer.writeByte(1); // appVer
|
||||
writer.writeBytes(Uint8List(6)); // reserved
|
||||
writer.writeString('MeshCore SAR'); // appName
|
||||
await _writeData(writer.toBytes());
|
||||
}
|
||||
|
||||
/// Send DeviceQuery command
|
||||
Future<void> _sendDeviceQuery() async {
|
||||
final writer = BufferWriter();
|
||||
writer.writeByte(MeshCoreConstants.cmdDeviceQuery);
|
||||
writer.writeByte(MeshCoreConstants.supportedCompanionProtocolVersion);
|
||||
await _writeData(writer.toBytes());
|
||||
await _sendAppStart();
|
||||
}
|
||||
|
||||
/// Get contacts from device
|
||||
Future<void> getContacts() async {
|
||||
final writer = BufferWriter();
|
||||
writer.writeByte(MeshCoreConstants.cmdGetContacts);
|
||||
await _writeData(writer.toBytes());
|
||||
}
|
||||
|
||||
/// Send text message to contact
|
||||
Future<void> sendTextMessage({
|
||||
required Uint8List contactPublicKey,
|
||||
required String text,
|
||||
}) async {
|
||||
final writer = BufferWriter();
|
||||
writer.writeByte(MeshCoreConstants.cmdSendTxtMsg);
|
||||
writer.writeByte(MeshCoreConstants.txtTypePlain);
|
||||
writer.writeByte(0); // attempt
|
||||
writer.writeUInt32LE(DateTime.now().millisecondsSinceEpoch ~/ 1000);
|
||||
writer.writeBytes(contactPublicKey.sublist(0, 6));
|
||||
writer.writeString(text);
|
||||
await _writeData(writer.toBytes());
|
||||
}
|
||||
|
||||
/// Send channel text message
|
||||
Future<void> sendChannelMessage({
|
||||
required int channelIdx,
|
||||
required String text,
|
||||
}) async {
|
||||
final writer = BufferWriter();
|
||||
writer.writeByte(MeshCoreConstants.cmdSendChannelTxtMsg);
|
||||
writer.writeByte(MeshCoreConstants.txtTypePlain);
|
||||
writer.writeByte(channelIdx);
|
||||
writer.writeUInt32LE(DateTime.now().millisecondsSinceEpoch ~/ 1000);
|
||||
writer.writeString(text);
|
||||
await _writeData(writer.toBytes());
|
||||
}
|
||||
|
||||
/// Request telemetry from contact
|
||||
Future<void> requestTelemetry(Uint8List contactPublicKey) async {
|
||||
final writer = BufferWriter();
|
||||
writer.writeByte(MeshCoreConstants.cmdSendTelemetryReq);
|
||||
writer.writeByte(0); // reserved
|
||||
writer.writeByte(0); // reserved
|
||||
writer.writeByte(0); // reserved
|
||||
writer.writeBytes(contactPublicKey);
|
||||
await _writeData(writer.toBytes());
|
||||
}
|
||||
|
||||
/// Get battery voltage
|
||||
Future<void> getBatteryVoltage() async {
|
||||
final writer = BufferWriter();
|
||||
writer.writeByte(MeshCoreConstants.cmdGetBatteryVoltage);
|
||||
await _writeData(writer.toBytes());
|
||||
}
|
||||
|
||||
/// Set device time
|
||||
Future<void> setDeviceTime() async {
|
||||
final writer = BufferWriter();
|
||||
writer.writeByte(MeshCoreConstants.cmdSetDeviceTime);
|
||||
writer.writeUInt32LE(DateTime.now().millisecondsSinceEpoch ~/ 1000);
|
||||
await _writeData(writer.toBytes());
|
||||
}
|
||||
|
||||
/// Dispose resources
|
||||
void dispose() {
|
||||
_txSubscription?.cancel();
|
||||
_pendingContacts.clear();
|
||||
}
|
||||
}
|
||||
138
lib/services/meshcore_constants.dart
Normal file
138
lib/services/meshcore_constants.dart
Normal file
@@ -0,0 +1,138 @@
|
||||
/// MeshCore BLE and Protocol Constants
|
||||
class MeshCoreConstants {
|
||||
// Supported protocol version
|
||||
static const int supportedCompanionProtocolVersion = 1;
|
||||
|
||||
// BLE Service and Characteristic UUIDs
|
||||
static const String bleServiceUuid =
|
||||
'6E400001-B5A3-F393-E0A9-E50E24DCCA9E';
|
||||
static const String bleCharacteristicRxUuid =
|
||||
'6E400002-B5A3-F393-E0A9-E50E24DCCA9E'; // Write
|
||||
static const String bleCharacteristicTxUuid =
|
||||
'6E400003-B5A3-F393-E0A9-E50E24DCCA9E'; // Notify
|
||||
|
||||
// Command Codes (App -> Device)
|
||||
static const int cmdAppStart = 1;
|
||||
static const int cmdSendTxtMsg = 2;
|
||||
static const int cmdSendChannelTxtMsg = 3;
|
||||
static const int cmdGetContacts = 4;
|
||||
static const int cmdGetDeviceTime = 5;
|
||||
static const int cmdSetDeviceTime = 6;
|
||||
static const int cmdSendSelfAdvert = 7;
|
||||
static const int cmdSetAdvertName = 8;
|
||||
static const int cmdAddUpdateContact = 9;
|
||||
static const int cmdSyncNextMessage = 10;
|
||||
static const int cmdSetRadioParams = 11;
|
||||
static const int cmdSetTxPower = 12;
|
||||
static const int cmdResetPath = 13;
|
||||
static const int cmdSetAdvertLatLon = 14;
|
||||
static const int cmdRemoveContact = 15;
|
||||
static const int cmdShareContact = 16;
|
||||
static const int cmdExportContact = 17;
|
||||
static const int cmdImportContact = 18;
|
||||
static const int cmdReboot = 19;
|
||||
static const int cmdGetBatteryVoltage = 20;
|
||||
static const int cmdSetTuningParams = 21;
|
||||
static const int cmdDeviceQuery = 22;
|
||||
static const int cmdExportPrivateKey = 23;
|
||||
static const int cmdImportPrivateKey = 24;
|
||||
static const int cmdSendRawData = 25;
|
||||
static const int cmdSendLogin = 26;
|
||||
static const int cmdSendStatusReq = 27;
|
||||
static const int cmdGetChannel = 31;
|
||||
static const int cmdSetChannel = 32;
|
||||
static const int cmdSignStart = 33;
|
||||
static const int cmdSignData = 34;
|
||||
static const int cmdSignFinish = 35;
|
||||
static const int cmdSendTracePath = 36;
|
||||
static const int cmdSetOtherParams = 38;
|
||||
static const int cmdSendTelemetryReq = 39;
|
||||
static const int cmdSendBinaryReq = 50;
|
||||
|
||||
// Response Codes (Device -> App)
|
||||
static const int respOk = 0;
|
||||
static const int respErr = 1;
|
||||
static const int respContactsStart = 2;
|
||||
static const int respContact = 3;
|
||||
static const int respEndOfContacts = 4;
|
||||
static const int respSelfInfo = 5;
|
||||
static const int respSent = 6;
|
||||
static const int respContactMsgRecv = 7;
|
||||
static const int respChannelMsgRecv = 8;
|
||||
static const int respCurrTime = 9;
|
||||
static const int respNoMoreMessages = 10;
|
||||
static const int respExportContact = 11;
|
||||
static const int respBatteryVoltage = 12;
|
||||
static const int respDeviceInfo = 13;
|
||||
static const int respPrivateKey = 14;
|
||||
static const int respDisabled = 15;
|
||||
static const int respChannelInfo = 18;
|
||||
static const int respSignStart = 19;
|
||||
static const int respSignature = 20;
|
||||
|
||||
// Push Codes (Device -> App, unsolicited)
|
||||
static const int pushAdvert = 0x80;
|
||||
static const int pushPathUpdated = 0x81;
|
||||
static const int pushSendConfirmed = 0x82;
|
||||
static const int pushMsgWaiting = 0x83;
|
||||
static const int pushRawData = 0x84;
|
||||
static const int pushLoginSuccess = 0x85;
|
||||
static const int pushLoginFail = 0x86;
|
||||
static const int pushStatusResponse = 0x87;
|
||||
static const int pushLogRxData = 0x88;
|
||||
static const int pushTraceData = 0x89;
|
||||
static const int pushNewAdvert = 0x8A;
|
||||
static const int pushTelemetryResponse = 0x8B;
|
||||
static const int pushBinaryResponse = 0x8C;
|
||||
|
||||
// Error Codes
|
||||
static const int errUnsupportedCmd = 1;
|
||||
static const int errNotFound = 2;
|
||||
static const int errTableFull = 3;
|
||||
static const int errBadState = 4;
|
||||
static const int errFileIoError = 5;
|
||||
static const int errIllegalArg = 6;
|
||||
|
||||
// Advert Types
|
||||
static const int advTypeNone = 0;
|
||||
static const int advTypeChat = 1;
|
||||
static const int advTypeRepeater = 2;
|
||||
static const int advTypeRoom = 3;
|
||||
|
||||
// Self Advert Types
|
||||
static const int selfAdvertZeroHop = 0;
|
||||
static const int selfAdvertFlood = 1;
|
||||
|
||||
// Text Types
|
||||
static const int txtTypePlain = 0;
|
||||
static const int txtTypeCliData = 1;
|
||||
static const int txtTypeSignedPlain = 2;
|
||||
|
||||
// Binary Request Types
|
||||
static const int binaryReqGetTelemetryData = 0x03;
|
||||
static const int binaryReqGetAvgMinMax = 0x04;
|
||||
static const int binaryReqGetAccessList = 0x05;
|
||||
static const int binaryReqGetNeighbours = 0x06;
|
||||
|
||||
// Cayenne LPP Data Types
|
||||
static const int lppDigitalInput = 0;
|
||||
static const int lppDigitalOutput = 1;
|
||||
static const int lppAnalogInput = 2;
|
||||
static const int lppAnalogOutput = 3;
|
||||
static const int lppIlluminanceSensor = 101;
|
||||
static const int lppPresenceSensor = 102;
|
||||
static const int lppTemperatureSensor = 103;
|
||||
static const int lppHumiditySensor = 104;
|
||||
static const int lppAccelerometer = 113;
|
||||
static const int lppBarometer = 115;
|
||||
static const int lppGyrometer = 134;
|
||||
static const int lppGps = 136;
|
||||
|
||||
// MTU and timing
|
||||
static const int maxMtuSize = 512;
|
||||
static const int defaultTimeout = 5000; // 5 seconds
|
||||
static const int reconnectDelay = 2000; // 2 seconds
|
||||
static const int telemetryUpdateInterval = 300000; // 5 minutes
|
||||
|
||||
MeshCoreConstants._(); // Private constructor to prevent instantiation
|
||||
}
|
||||
170
lib/services/tile_cache_service.dart
Normal file
170
lib/services/tile_cache_service.dart
Normal file
@@ -0,0 +1,170 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart';
|
||||
import 'package:flutter_map_tile_caching/custom_backend_api.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import '../models/map_layer.dart';
|
||||
|
||||
class TileCacheService {
|
||||
static const String _storeName = 'meshcore_sar_tiles';
|
||||
|
||||
late final FMTCStore _store;
|
||||
bool _isInitialized = false;
|
||||
bool _isDownloading = false;
|
||||
|
||||
Future<void> initialize() async {
|
||||
if (_isInitialized) return;
|
||||
|
||||
try {
|
||||
await FMTCObjectBoxBackend().initialise();
|
||||
_store = FMTCStore(_storeName);
|
||||
await _store.manage.create();
|
||||
_isInitialized = true;
|
||||
} catch (e) {
|
||||
// Store might already exist
|
||||
_store = FMTCStore(_storeName);
|
||||
_isInitialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
FMTCTileProvider getTileProvider(MapLayer layer) {
|
||||
if (!_isInitialized) {
|
||||
throw StateError('TileCacheService not initialized. Call initialize() first.');
|
||||
}
|
||||
return _store.getTileProvider(
|
||||
loadingStrategy: BrowseLoadingStrategy.cacheFirst,
|
||||
cachedValidDuration: const Duration(days: 30),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> downloadRegion({
|
||||
required MapLayer layer,
|
||||
required LatLngBounds bounds,
|
||||
required int minZoom,
|
||||
required int maxZoom,
|
||||
Function(double progress)? onProgress,
|
||||
}) async {
|
||||
if (!_isInitialized) {
|
||||
throw StateError('TileCacheService not initialized. Call initialize() first.');
|
||||
}
|
||||
|
||||
if (_isDownloading) {
|
||||
throw StateError('A download is already in progress. Cancel it first.');
|
||||
}
|
||||
|
||||
_isDownloading = true;
|
||||
|
||||
try {
|
||||
final region = RectangleRegion(bounds);
|
||||
|
||||
final downloadable = region.toDownloadable(
|
||||
minZoom: minZoom,
|
||||
maxZoom: maxZoom,
|
||||
options: TileLayer(
|
||||
urlTemplate: layer.urlTemplate,
|
||||
),
|
||||
);
|
||||
|
||||
final download = _store.download.startForeground(
|
||||
region: downloadable,
|
||||
);
|
||||
|
||||
await for (final progress in download.downloadProgress) {
|
||||
if (onProgress != null && progress.maxTilesCount > 0) {
|
||||
// Use attemptedTilesCount instead of successfulTilesCount
|
||||
// attemptedTilesCount includes successful + buffered + skipped tiles
|
||||
final percentage = progress.percentageProgress;
|
||||
print('Download progress: ${progress.attemptedTilesCount}/${progress.maxTilesCount} = ${percentage.toStringAsFixed(1)}% (successful: ${progress.successfulTilesCount}, buffered: ${progress.bufferedTilesCount}, skipped: ${progress.skippedTilesCount})');
|
||||
onProgress(percentage);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
_isDownloading = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> cancelDownload() async {
|
||||
if (!_isInitialized) return;
|
||||
await _store.download.cancel();
|
||||
}
|
||||
|
||||
Future<void> clearCache() async {
|
||||
if (!_isInitialized) return;
|
||||
await _store.manage.delete();
|
||||
await _store.manage.create();
|
||||
}
|
||||
|
||||
Future<int> getCachedTileCount() async {
|
||||
if (!_isInitialized) return 0;
|
||||
final stats = await _store.stats.length;
|
||||
return stats;
|
||||
}
|
||||
|
||||
Future<double> getCacheSizeMB() async {
|
||||
if (!_isInitialized) return 0.0;
|
||||
final stats = await _store.stats.size;
|
||||
return stats / (1024 * 1024);
|
||||
}
|
||||
|
||||
Future<String> exportCache() async {
|
||||
if (!_isInitialized) {
|
||||
throw StateError('TileCacheService not initialized. Call initialize() first.');
|
||||
}
|
||||
|
||||
final directory = await getApplicationDocumentsDirectory();
|
||||
final timestamp = DateTime.now().millisecondsSinceEpoch;
|
||||
final exportPath = '${directory.path}/meshcore_maps_$timestamp.fmtc';
|
||||
|
||||
// Export using FMTCBackendAccess
|
||||
await FMTCBackendAccess.internal.exportStores(
|
||||
storeNames: [_storeName],
|
||||
path: exportPath,
|
||||
);
|
||||
|
||||
return exportPath;
|
||||
}
|
||||
|
||||
Future<void> importCache(String filePath) async {
|
||||
if (!_isInitialized) {
|
||||
throw StateError('TileCacheService not initialized. Call initialize() first.');
|
||||
}
|
||||
|
||||
final file = File(filePath);
|
||||
if (!await file.exists()) {
|
||||
throw Exception('Import file not found: $filePath');
|
||||
}
|
||||
|
||||
// Import using FMTCBackendAccess
|
||||
await FMTCBackendAccess.internal.importStores(
|
||||
storeNames: [_storeName],
|
||||
path: filePath,
|
||||
strategy: ImportConflictStrategy.rename,
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<String>> getAvailableStores() async {
|
||||
if (!_isInitialized) {
|
||||
throw StateError('TileCacheService not initialized. Call initialize() first.');
|
||||
}
|
||||
|
||||
final stores = await FMTCRoot.stats.storesAvailable;
|
||||
return stores.map((store) => store.storeName).toList();
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> getStoreStats() async {
|
||||
if (!_isInitialized) return {};
|
||||
|
||||
final length = await _store.stats.length;
|
||||
final size = await _store.stats.size;
|
||||
|
||||
return {
|
||||
'tileCount': length,
|
||||
'sizeMB': size / (1024 * 1024),
|
||||
'storeName': _storeName,
|
||||
};
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_isInitialized = false;
|
||||
}
|
||||
}
|
||||
153
lib/utils/sar_message_parser.dart
Normal file
153
lib/utils/sar_message_parser.dart
Normal file
@@ -0,0 +1,153 @@
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import '../models/sar_marker.dart';
|
||||
import '../models/message.dart';
|
||||
|
||||
/// Parser for SAR (Search & Rescue) special messages
|
||||
/// Format: S:<emoji>:<latitude>,<longitude>
|
||||
/// Examples:
|
||||
/// S:🧑:37.7749,-122.4194
|
||||
/// S:🔥:40.7128,-74.0060
|
||||
/// S:🏕️:34.0522,-118.2437
|
||||
class SarMessageParser {
|
||||
static final RegExp _sarPattern = RegExp(
|
||||
r'^S:(.):(-?\d+\.?\d*),(-?\d+\.?\d*)$',
|
||||
multiLine: false,
|
||||
);
|
||||
|
||||
/// Check if a message is a SAR marker message
|
||||
static bool isSarMessage(String text) {
|
||||
return text.trim().startsWith('S:') && _sarPattern.hasMatch(text.trim());
|
||||
}
|
||||
|
||||
/// Parse a SAR message and extract marker information
|
||||
/// Returns null if the message is not a valid SAR message
|
||||
static SarMarkerInfo? parse(String text) {
|
||||
final trimmed = text.trim();
|
||||
if (!trimmed.startsWith('S:')) return null;
|
||||
|
||||
final match = _sarPattern.firstMatch(trimmed);
|
||||
if (match == null) return null;
|
||||
|
||||
try {
|
||||
final emoji = match.group(1)!;
|
||||
final latitude = double.parse(match.group(2)!);
|
||||
final longitude = double.parse(match.group(3)!);
|
||||
|
||||
// Validate coordinates
|
||||
if (latitude < -90 || latitude > 90) return null;
|
||||
if (longitude < -180 || longitude > 180) return null;
|
||||
|
||||
final markerType = SarMarkerType.fromEmoji(emoji);
|
||||
final location = LatLng(latitude, longitude);
|
||||
|
||||
return SarMarkerInfo(
|
||||
type: markerType,
|
||||
location: location,
|
||||
emoji: emoji,
|
||||
);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Enhance a Message with SAR marker information
|
||||
static Message enhanceMessage(Message message) {
|
||||
final sarInfo = parse(message.text);
|
||||
if (sarInfo == null) return message;
|
||||
|
||||
return message.copyWith(
|
||||
isSarMarker: true,
|
||||
sarMarkerType: sarInfo.type,
|
||||
sarGpsCoordinates: sarInfo.location,
|
||||
);
|
||||
}
|
||||
|
||||
/// Create a SAR marker message text
|
||||
static String createSarMessage({
|
||||
required SarMarkerType type,
|
||||
required LatLng location,
|
||||
String? notes,
|
||||
}) {
|
||||
final text = 'S:${type.emoji}:${location.latitude},${location.longitude}';
|
||||
if (notes != null && notes.isNotEmpty) {
|
||||
return '$text\n$notes';
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
/// Extract additional notes from SAR message (text after the marker)
|
||||
static String? extractNotes(String text) {
|
||||
final trimmed = text.trim();
|
||||
final lines = trimmed.split('\n');
|
||||
if (lines.length <= 1) return null;
|
||||
|
||||
// Everything after the first line is considered notes
|
||||
return lines.sublist(1).join('\n').trim();
|
||||
}
|
||||
|
||||
/// Validate SAR message format
|
||||
static bool isValidFormat(String text) {
|
||||
return isSarMessage(text) && parse(text) != null;
|
||||
}
|
||||
|
||||
/// Get a user-friendly error message for invalid SAR format
|
||||
static String? getFormatError(String text) {
|
||||
if (!text.trim().startsWith('S:')) {
|
||||
return 'SAR message must start with "S:"';
|
||||
}
|
||||
|
||||
final parts = text.trim().split(':');
|
||||
if (parts.length < 3) {
|
||||
return 'Invalid format. Use: S:<emoji>:<latitude>,<longitude>';
|
||||
}
|
||||
|
||||
final emoji = parts[1];
|
||||
if (emoji.isEmpty) {
|
||||
return 'Missing emoji marker (🧑, 🔥, or 🏕️)';
|
||||
}
|
||||
|
||||
final coords = parts[2];
|
||||
if (!coords.contains(',')) {
|
||||
return 'Coordinates must be separated by comma';
|
||||
}
|
||||
|
||||
final coordParts = coords.split(',');
|
||||
if (coordParts.length != 2) {
|
||||
return 'Invalid coordinates format';
|
||||
}
|
||||
|
||||
try {
|
||||
final lat = double.parse(coordParts[0]);
|
||||
final lon = double.parse(coordParts[1]);
|
||||
|
||||
if (lat < -90 || lat > 90) {
|
||||
return 'Latitude must be between -90 and 90';
|
||||
}
|
||||
if (lon < -180 || lon > 180) {
|
||||
return 'Longitude must be between -180 and 180';
|
||||
}
|
||||
} catch (e) {
|
||||
return 'Invalid coordinate values';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Parsed SAR marker information
|
||||
class SarMarkerInfo {
|
||||
final SarMarkerType type;
|
||||
final LatLng location;
|
||||
final String emoji;
|
||||
|
||||
SarMarkerInfo({
|
||||
required this.type,
|
||||
required this.location,
|
||||
required this.emoji,
|
||||
});
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'SarMarkerInfo(type: ${type.displayName}, location: $location)';
|
||||
}
|
||||
}
|
||||
286
lib/widgets/map_markers.dart
Normal file
286
lib/widgets/map_markers.dart
Normal file
@@ -0,0 +1,286 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import '../models/contact.dart';
|
||||
import '../models/sar_marker.dart';
|
||||
|
||||
class MapMarkers {
|
||||
static List<Marker> createTeamMemberMarkers(
|
||||
List<Contact> contacts,
|
||||
BuildContext context,
|
||||
) {
|
||||
return contacts.map((contact) {
|
||||
final location = contact.displayLocation;
|
||||
if (location == null) return null;
|
||||
|
||||
return Marker(
|
||||
point: location,
|
||||
width: 60,
|
||||
height: 80,
|
||||
child: GestureDetector(
|
||||
onTap: () => _showContactInfo(context, contact),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Battery indicator
|
||||
if (contact.displayBattery != null)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: _getBatteryColor(contact.displayBattery!),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
'${contact.displayBattery!.round()}%',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
// Marker icon
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.white, width: 3),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.3),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: const Icon(
|
||||
Icons.person,
|
||||
color: Colors.white,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
// Name label
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withOpacity(0.7),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
contact.advName,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}).whereType<Marker>().toList();
|
||||
}
|
||||
|
||||
static List<Marker> createSarMarkers(
|
||||
List<SarMarker> sarMarkers,
|
||||
BuildContext context,
|
||||
) {
|
||||
return sarMarkers.map((marker) {
|
||||
return Marker(
|
||||
point: marker.location,
|
||||
width: 60,
|
||||
height: 80,
|
||||
child: GestureDetector(
|
||||
onTap: () => _showSarMarkerInfo(context, marker),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Time ago label
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: _getSarMarkerColor(marker.type),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
marker.timeAgo,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
// Marker emoji/icon
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: _getSarMarkerColor(marker.type),
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.white, width: 3),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.3),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Text(
|
||||
marker.type.emoji,
|
||||
style: const TextStyle(fontSize: 20),
|
||||
),
|
||||
),
|
||||
// Type label
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withOpacity(0.7),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
marker.type.displayName,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
static void _showContactInfo(BuildContext context, Contact contact) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Row(
|
||||
children: [
|
||||
const Icon(Icons.person, color: Colors.blue),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: Text(contact.advName)),
|
||||
],
|
||||
),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (contact.displayLocation != null) ...[
|
||||
_InfoRow(
|
||||
'Location',
|
||||
'${contact.displayLocation!.latitude.toStringAsFixed(6)}, ${contact.displayLocation!.longitude.toStringAsFixed(6)}',
|
||||
),
|
||||
],
|
||||
if (contact.displayBattery != null)
|
||||
_InfoRow('Battery', '${contact.displayBattery!.round()}%'),
|
||||
if (contact.telemetry?.temperature != null)
|
||||
_InfoRow(
|
||||
'Temperature', '${contact.telemetry!.temperature!.toStringAsFixed(1)}°C'),
|
||||
_InfoRow('Last Seen', contact.timeSinceLastSeen),
|
||||
_InfoRow('Public Key', contact.publicKeyShort),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Close'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static void _showSarMarkerInfo(BuildContext context, SarMarker marker) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Row(
|
||||
children: [
|
||||
Text(marker.type.emoji, style: const TextStyle(fontSize: 24)),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: Text(marker.type.displayName)),
|
||||
],
|
||||
),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_InfoRow(
|
||||
'Location',
|
||||
'${marker.location.latitude.toStringAsFixed(6)}, ${marker.location.longitude.toStringAsFixed(6)}',
|
||||
),
|
||||
_InfoRow('Reported', marker.timeAgo),
|
||||
if (marker.senderName != null)
|
||||
_InfoRow('Reporter', marker.senderName!),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Close'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static Color _getBatteryColor(double percentage) {
|
||||
if (percentage > 50) return Colors.green;
|
||||
if (percentage > 20) return Colors.orange;
|
||||
return Colors.red;
|
||||
}
|
||||
|
||||
static Color _getSarMarkerColor(SarMarkerType type) {
|
||||
switch (type) {
|
||||
case SarMarkerType.foundPerson:
|
||||
return Colors.green;
|
||||
case SarMarkerType.fire:
|
||||
return Colors.red;
|
||||
case SarMarkerType.stagingArea:
|
||||
return Colors.orange;
|
||||
case SarMarkerType.unknown:
|
||||
return Colors.grey;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _InfoRow extends StatelessWidget {
|
||||
final String label;
|
||||
final String value;
|
||||
|
||||
const _InfoRow(this.label, this.value);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 90,
|
||||
child: Text(
|
||||
'$label:',
|
||||
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(value),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user