initial commit

This commit is contained in:
Janez T
2025-10-13 22:28:20 +02:00
commit 823a163123
175 changed files with 12697 additions and 0 deletions

104
lib/main.dart Normal file
View 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
View 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;
}

View 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
View 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
View 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
View 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
View 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;
}

View 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,
};
}
}

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

View 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,
};
}
}

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

View 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,
};
}
}

View 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;
}
}

View 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;
}
}

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

View 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;
}
}

View 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)';
}
}

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

View 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);
}
}

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

View 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
}

View 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;
}
}

View 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)';
}
}

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