mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
feat: Implement command queue for BLE command handling
- Added BleCommandQueue to manage command serialization and responses in BleCommandSender. - Updated writeData, writeDataAndWaitForAck, and writeDataAndWaitForResponse methods to utilize the command queue. - Enhanced BleResponseHandler to complete commands based on responses received from the BLE device. - Introduced new commands for channel management, including getChannel and setChannel. - Created LocationTrailLayer and TrailControls widgets for displaying and managing location trails on the map. - Added PermissionRequestDialog to handle location permission requests on app startup. - Updated LocationTrackingService to allow GPS tracking without a BLE connection.
This commit is contained in:
@@ -71,6 +71,11 @@
|
||||
"description": "Error when location permission is permanently denied"
|
||||
},
|
||||
|
||||
"locationPermissionRequired": "Location permission is required for GPS tracking and team coordination. You can enable it later in Settings.",
|
||||
"@locationPermissionRequired": {
|
||||
"description": "Message when location permission is needed"
|
||||
},
|
||||
|
||||
"locationServicesDisabled": "Location services are disabled. Please enable them in Settings.",
|
||||
"@locationServicesDisabled": {
|
||||
"description": "Error when location services are disabled"
|
||||
@@ -1197,7 +1202,12 @@
|
||||
|
||||
"rooms": "Rooms",
|
||||
"@rooms": {
|
||||
"description": "Section header for rooms/channels"
|
||||
"description": "Section header for rooms"
|
||||
},
|
||||
|
||||
"channels": "Channels",
|
||||
"@channels": {
|
||||
"description": "Section header for broadcast channels"
|
||||
},
|
||||
|
||||
"cacheStatistics": "Cache Statistics",
|
||||
|
||||
@@ -29,6 +29,8 @@
|
||||
|
||||
"locationPermissionPermanentlyDenied": "Dopuštenje za lokaciju trajno odbijeno. Molimo omogućite u Postavkama.",
|
||||
|
||||
"locationPermissionRequired": "Dopuštenje za lokaciju potrebno je za GPS praćenje i koordinaciju tima. Možete ga omogućiti kasnije u Postavkama.",
|
||||
|
||||
"locationServicesDisabled": "Usluge lokacije su onemogućene. Molimo omogućite ih u Postavkama.",
|
||||
|
||||
"failedToGetGpsLocation": "Neuspjelo dobivanje GPS lokacije",
|
||||
@@ -137,7 +139,7 @@
|
||||
|
||||
"sampleDataDescription": "Učitajte ili očistite primjere kontakata, poruka kanala i SAR markera za testiranje",
|
||||
|
||||
"loadSampleData": "Učitaj primjer podataka",
|
||||
"loadSampleData": "Učitaj primjer",
|
||||
|
||||
"clearAllData": "Očisti sve podatke",
|
||||
|
||||
@@ -405,6 +407,8 @@
|
||||
|
||||
"rooms": "Sobe",
|
||||
|
||||
"channels": "Kanali",
|
||||
|
||||
"cacheStatistics": "Statistika predmemorije",
|
||||
|
||||
"totalTiles": "Ukupno pločica",
|
||||
|
||||
@@ -184,6 +184,12 @@ abstract class AppLocalizations {
|
||||
/// **'Location permission permanently denied. Please enable in Settings.'**
|
||||
String get locationPermissionPermanentlyDenied;
|
||||
|
||||
/// Message when location permission is needed
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Location permission is required for GPS tracking and team coordination. You can enable it later in Settings.'**
|
||||
String get locationPermissionRequired;
|
||||
|
||||
/// Error when location services are disabled
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -1311,12 +1317,18 @@ abstract class AppLocalizations {
|
||||
/// **'Repeaters'**
|
||||
String get repeaters;
|
||||
|
||||
/// Section header for rooms/channels
|
||||
/// Section header for rooms
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Rooms'**
|
||||
String get rooms;
|
||||
|
||||
/// Section header for broadcast channels
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Channels'**
|
||||
String get channels;
|
||||
|
||||
/// Title for cache statistics section
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
||||
@@ -51,6 +51,10 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String get locationPermissionPermanentlyDenied =>
|
||||
'Location permission permanently denied. Please enable in Settings.';
|
||||
|
||||
@override
|
||||
String get locationPermissionRequired =>
|
||||
'Location permission is required for GPS tracking and team coordination. You can enable it later in Settings.';
|
||||
|
||||
@override
|
||||
String get locationServicesDisabled =>
|
||||
'Location services are disabled. Please enable them in Settings.';
|
||||
@@ -701,6 +705,9 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get rooms => 'Rooms';
|
||||
|
||||
@override
|
||||
String get channels => 'Channels';
|
||||
|
||||
@override
|
||||
String get cacheStatistics => 'Cache Statistics';
|
||||
|
||||
|
||||
@@ -51,6 +51,10 @@ class AppLocalizationsHr extends AppLocalizations {
|
||||
String get locationPermissionPermanentlyDenied =>
|
||||
'Dopuštenje za lokaciju trajno odbijeno. Molimo omogućite u Postavkama.';
|
||||
|
||||
@override
|
||||
String get locationPermissionRequired =>
|
||||
'Dopuštenje za lokaciju potrebno je za GPS praćenje i koordinaciju tima. Možete ga omogućiti kasnije u Postavkama.';
|
||||
|
||||
@override
|
||||
String get locationServicesDisabled =>
|
||||
'Usluge lokacije su onemogućene. Molimo omogućite ih u Postavkama.';
|
||||
@@ -233,7 +237,7 @@ class AppLocalizationsHr extends AppLocalizations {
|
||||
'Učitajte ili očistite primjere kontakata, poruka kanala i SAR markera za testiranje';
|
||||
|
||||
@override
|
||||
String get loadSampleData => 'Učitaj primjer podataka';
|
||||
String get loadSampleData => 'Učitaj primjer';
|
||||
|
||||
@override
|
||||
String get clearAllData => 'Očisti sve podatke';
|
||||
@@ -701,6 +705,9 @@ class AppLocalizationsHr extends AppLocalizations {
|
||||
@override
|
||||
String get rooms => 'Sobe';
|
||||
|
||||
@override
|
||||
String get channels => 'Kanali';
|
||||
|
||||
@override
|
||||
String get cacheStatistics => 'Statistika predmemorije';
|
||||
|
||||
|
||||
@@ -51,6 +51,10 @@ class AppLocalizationsSl extends AppLocalizations {
|
||||
String get locationPermissionPermanentlyDenied =>
|
||||
'Dovoljenje za lokacijo trajno zavrnjeno. Prosimo, omogočite v Nastavitvah.';
|
||||
|
||||
@override
|
||||
String get locationPermissionRequired =>
|
||||
'Dovoljenje za lokacijo je potrebno za GPS sledenje in usklajevanje ekipe. Lahko ga omogočite kasneje v Nastavitvah.';
|
||||
|
||||
@override
|
||||
String get locationServicesDisabled =>
|
||||
'Lokacijske storitve so onemogočene. Prosimo, omogočite jih v Nastavitvah.';
|
||||
@@ -233,7 +237,7 @@ class AppLocalizationsSl extends AppLocalizations {
|
||||
'Naložite ali počistite vzorčne stike, sporočila kanalov in SAR označevalce za testiranje';
|
||||
|
||||
@override
|
||||
String get loadSampleData => 'Naloži vzorčne podatke';
|
||||
String get loadSampleData => 'Naloži vzorec';
|
||||
|
||||
@override
|
||||
String get clearAllData => 'Počisti vse podatke';
|
||||
@@ -701,6 +705,9 @@ class AppLocalizationsSl extends AppLocalizations {
|
||||
@override
|
||||
String get rooms => 'Sobe';
|
||||
|
||||
@override
|
||||
String get channels => 'Kanali';
|
||||
|
||||
@override
|
||||
String get cacheStatistics => 'Statistika predpomnilnika';
|
||||
|
||||
|
||||
@@ -29,6 +29,8 @@
|
||||
|
||||
"locationPermissionPermanentlyDenied": "Dovoljenje za lokacijo trajno zavrnjeno. Prosimo, omogočite v Nastavitvah.",
|
||||
|
||||
"locationPermissionRequired": "Dovoljenje za lokacijo je potrebno za GPS sledenje in usklajevanje ekipe. Lahko ga omogočite kasneje v Nastavitvah.",
|
||||
|
||||
"locationServicesDisabled": "Lokacijske storitve so onemogočene. Prosimo, omogočite jih v Nastavitvah.",
|
||||
|
||||
"failedToGetGpsLocation": "Pridobitev GPS lokacije ni uspela",
|
||||
@@ -137,7 +139,7 @@
|
||||
|
||||
"sampleDataDescription": "Naložite ali počistite vzorčne stike, sporočila kanalov in SAR označevalce za testiranje",
|
||||
|
||||
"loadSampleData": "Naloži vzorčne podatke",
|
||||
"loadSampleData": "Naloži vzorec",
|
||||
|
||||
"clearAllData": "Počisti vse podatke",
|
||||
|
||||
@@ -405,6 +407,8 @@
|
||||
|
||||
"rooms": "Sobe",
|
||||
|
||||
"channels": "Kanali",
|
||||
|
||||
"cacheStatistics": "Statistika predpomnilnika",
|
||||
|
||||
"totalTiles": "Skupaj ploščic",
|
||||
|
||||
@@ -2,11 +2,13 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'providers/connection_provider.dart';
|
||||
import 'providers/contacts_provider.dart';
|
||||
import 'providers/messages_provider.dart';
|
||||
import 'providers/map_provider.dart';
|
||||
import 'providers/drawing_provider.dart';
|
||||
import 'providers/channels_provider.dart';
|
||||
import 'providers/app_provider.dart';
|
||||
import 'services/tile_cache_service.dart';
|
||||
import 'services/notification_service.dart';
|
||||
@@ -30,6 +32,7 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
||||
AppThemeMode _themeMode = AppThemeMode.system;
|
||||
Locale? _locale;
|
||||
bool _isInitialized = false;
|
||||
bool _shouldShowPermissionDialog = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -42,11 +45,29 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
||||
await _loadLocalePreference();
|
||||
// Initialize notification service
|
||||
await NotificationService().initialize();
|
||||
|
||||
// Check if we need to request location permissions
|
||||
await _checkLocationPermissions();
|
||||
|
||||
setState(() {
|
||||
_isInitialized = true;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _checkLocationPermissions() async {
|
||||
try {
|
||||
final permission = await Geolocator.checkPermission();
|
||||
|
||||
// Show dialog if permission is denied or not determined
|
||||
if (permission == LocationPermission.denied ||
|
||||
permission == LocationPermission.deniedForever) {
|
||||
_shouldShowPermissionDialog = true;
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Error checking location permissions: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadThemePreference() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final themeName = prefs.getString('theme_mode') ?? 'system';
|
||||
@@ -114,27 +135,30 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
||||
return provider;
|
||||
},
|
||||
),
|
||||
ChangeNotifierProvider(create: (_) => ChannelsProvider()),
|
||||
|
||||
// Tile cache service
|
||||
Provider(create: (_) => TileCacheService()),
|
||||
|
||||
// App provider that coordinates everything
|
||||
ChangeNotifierProxyProvider5<ConnectionProvider, ContactsProvider,
|
||||
MessagesProvider, DrawingProvider, TileCacheService, AppProvider>(
|
||||
ChangeNotifierProxyProvider6<ConnectionProvider, ContactsProvider,
|
||||
MessagesProvider, DrawingProvider, ChannelsProvider, TileCacheService, AppProvider>(
|
||||
create: (context) => AppProvider(
|
||||
connectionProvider: context.read<ConnectionProvider>(),
|
||||
contactsProvider: context.read<ContactsProvider>(),
|
||||
messagesProvider: context.read<MessagesProvider>(),
|
||||
drawingProvider: context.read<DrawingProvider>(),
|
||||
channelsProvider: context.read<ChannelsProvider>(),
|
||||
tileCacheService: context.read<TileCacheService>(),
|
||||
),
|
||||
update: (context, conn, contacts, messages, drawings, tileCache, previous) =>
|
||||
update: (context, conn, contacts, messages, drawings, channels, tileCache, previous) =>
|
||||
previous ??
|
||||
AppProvider(
|
||||
connectionProvider: conn,
|
||||
contactsProvider: contacts,
|
||||
messagesProvider: messages,
|
||||
drawingProvider: drawings,
|
||||
channelsProvider: channels,
|
||||
tileCacheService: tileCache,
|
||||
),
|
||||
),
|
||||
@@ -165,6 +189,7 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
||||
onLocaleChanged: _handleLocaleChanged,
|
||||
currentTheme: _themeMode,
|
||||
currentLocale: _locale,
|
||||
shouldShowPermissionDialog: _shouldShowPermissionDialog,
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
75
lib/models/channel.dart
Normal file
75
lib/models/channel.dart
Normal file
@@ -0,0 +1,75 @@
|
||||
/// Channel model - represents a communication channel
|
||||
class Channel {
|
||||
final int index;
|
||||
final String name;
|
||||
final int? flags;
|
||||
|
||||
Channel({
|
||||
required this.index,
|
||||
required this.name,
|
||||
this.flags,
|
||||
});
|
||||
|
||||
/// Display name for the channel
|
||||
/// Returns "Public" for channel 0, otherwise returns the custom name or "Channel N"
|
||||
String get displayName {
|
||||
if (index == 0) {
|
||||
return name.isEmpty ? 'Public' : name;
|
||||
}
|
||||
return name.isEmpty ? 'Channel $index' : name;
|
||||
}
|
||||
|
||||
/// Check if channel is the public channel (index 0)
|
||||
bool get isPublicChannel => index == 0;
|
||||
|
||||
/// Check if channel has a custom name
|
||||
bool get hasCustomName => name.isNotEmpty;
|
||||
|
||||
/// Create from JSON
|
||||
factory Channel.fromJson(Map<String, dynamic> json) {
|
||||
return Channel(
|
||||
index: json['index'] as int,
|
||||
name: json['name'] as String? ?? '',
|
||||
flags: json['flags'] as int?,
|
||||
);
|
||||
}
|
||||
|
||||
/// Convert to JSON
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'index': index,
|
||||
'name': name,
|
||||
'flags': flags,
|
||||
};
|
||||
}
|
||||
|
||||
/// Create a copy with modified fields
|
||||
Channel copyWith({
|
||||
int? index,
|
||||
String? name,
|
||||
int? flags,
|
||||
}) {
|
||||
return Channel(
|
||||
index: index ?? this.index,
|
||||
name: name ?? this.name,
|
||||
flags: flags ?? this.flags,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'Channel(index: $index, name: $name, flags: $flags)';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
return other is Channel &&
|
||||
other.index == index &&
|
||||
other.name == name &&
|
||||
other.flags == flags;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(index, name, flags);
|
||||
}
|
||||
106
lib/models/location_trail.dart
Normal file
106
lib/models/location_trail.dart
Normal file
@@ -0,0 +1,106 @@
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
/// Represents a single point in a location trail
|
||||
class TrailPoint {
|
||||
final LatLng position;
|
||||
final DateTime timestamp;
|
||||
final double? accuracy;
|
||||
final double? speed;
|
||||
|
||||
TrailPoint({
|
||||
required this.position,
|
||||
required this.timestamp,
|
||||
this.accuracy,
|
||||
this.speed,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'lat': position.latitude,
|
||||
'lon': position.longitude,
|
||||
'timestamp': timestamp.toIso8601String(),
|
||||
'accuracy': accuracy,
|
||||
'speed': speed,
|
||||
};
|
||||
|
||||
factory TrailPoint.fromJson(Map<String, dynamic> json) {
|
||||
return TrailPoint(
|
||||
position: LatLng(json['lat'] as double, json['lon'] as double),
|
||||
timestamp: DateTime.parse(json['timestamp'] as String),
|
||||
accuracy: json['accuracy'] as double?,
|
||||
speed: json['speed'] as double?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents a location trail (breadcrumb trail) on the map
|
||||
class LocationTrail {
|
||||
final String id;
|
||||
final List<TrailPoint> points;
|
||||
final DateTime startTime;
|
||||
DateTime? endTime;
|
||||
bool isActive;
|
||||
|
||||
LocationTrail({
|
||||
required this.id,
|
||||
List<TrailPoint>? points,
|
||||
DateTime? startTime,
|
||||
this.endTime,
|
||||
this.isActive = true,
|
||||
}) : points = points ?? [],
|
||||
startTime = startTime ?? DateTime.now();
|
||||
|
||||
/// Add a new point to the trail
|
||||
void addPoint(TrailPoint point) {
|
||||
points.add(point);
|
||||
}
|
||||
|
||||
/// Get total distance traveled in meters
|
||||
double get totalDistance {
|
||||
if (points.length < 2) return 0;
|
||||
|
||||
final distance = Distance();
|
||||
double total = 0;
|
||||
|
||||
for (int i = 0; i < points.length - 1; i++) {
|
||||
total += distance.as(
|
||||
LengthUnit.Meter,
|
||||
points[i].position,
|
||||
points[i + 1].position,
|
||||
);
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
|
||||
/// Get duration of the trail
|
||||
Duration get duration {
|
||||
if (points.isEmpty) return Duration.zero;
|
||||
final end = endTime ?? DateTime.now();
|
||||
return end.difference(startTime);
|
||||
}
|
||||
|
||||
/// Get list of LatLng points for rendering
|
||||
List<LatLng> get latLngPoints => points.map((p) => p.position).toList();
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'points': points.map((p) => p.toJson()).toList(),
|
||||
'startTime': startTime.toIso8601String(),
|
||||
'endTime': endTime?.toIso8601String(),
|
||||
'isActive': isActive,
|
||||
};
|
||||
|
||||
factory LocationTrail.fromJson(Map<String, dynamic> json) {
|
||||
return LocationTrail(
|
||||
id: json['id'] as String,
|
||||
points: (json['points'] as List)
|
||||
.map((p) => TrailPoint.fromJson(p as Map<String, dynamic>))
|
||||
.toList(),
|
||||
startTime: DateTime.parse(json['startTime'] as String),
|
||||
endTime: json['endTime'] != null
|
||||
? DateTime.parse(json['endTime'] as String)
|
||||
: null,
|
||||
isActive: json['isActive'] as bool? ?? true,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import 'connection_provider.dart';
|
||||
import 'contacts_provider.dart';
|
||||
import 'messages_provider.dart';
|
||||
import 'drawing_provider.dart';
|
||||
import 'channels_provider.dart';
|
||||
import '../services/tile_cache_service.dart';
|
||||
import '../services/location_tracking_service.dart';
|
||||
import '../models/contact.dart';
|
||||
@@ -16,6 +17,7 @@ class AppProvider with ChangeNotifier {
|
||||
final ContactsProvider contactsProvider;
|
||||
final MessagesProvider messagesProvider;
|
||||
final DrawingProvider drawingProvider;
|
||||
final ChannelsProvider channelsProvider;
|
||||
final TileCacheService tileCacheService;
|
||||
final LocationTrackingService locationTrackingService = LocationTrackingService();
|
||||
|
||||
@@ -27,6 +29,7 @@ class AppProvider with ChangeNotifier {
|
||||
required this.contactsProvider,
|
||||
required this.messagesProvider,
|
||||
required this.drawingProvider,
|
||||
required this.channelsProvider,
|
||||
required this.tileCacheService,
|
||||
}) {
|
||||
_setupCallbacks();
|
||||
@@ -97,6 +100,12 @@ class AppProvider with ChangeNotifier {
|
||||
debugPrint('Received ${contacts.length} contacts');
|
||||
};
|
||||
|
||||
// When channel info is received
|
||||
connectionProvider.onChannelInfoReceived = (channelIdx, channelName) {
|
||||
channelsProvider.addOrUpdateChannel(channelIdx, channelName);
|
||||
debugPrint('📻 [AppProvider] Channel $channelIdx: "$channelName"');
|
||||
};
|
||||
|
||||
// When a message is received
|
||||
connectionProvider.onMessageReceived = (message) {
|
||||
// Check if message is a drawing broadcast
|
||||
@@ -242,6 +251,11 @@ class AppProvider with ChangeNotifier {
|
||||
// Small delay to ensure contacts are fully loaded
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
|
||||
// Sync all channels to get channel names
|
||||
debugPrint('📻 [AppProvider] Syncing channels...');
|
||||
await connectionProvider.syncChannels();
|
||||
debugPrint('✅ [AppProvider] Channel sync complete');
|
||||
|
||||
// Automatically login to all saved rooms
|
||||
await _autoLoginToRooms();
|
||||
|
||||
@@ -253,6 +267,10 @@ class AppProvider with ChangeNotifier {
|
||||
|
||||
// Note: Future messages are synced automatically via PUSH_CODE_MSG_WAITING events
|
||||
|
||||
// Start location tracking AFTER all initialization is complete
|
||||
debugPrint('📍 [AppProvider] Starting location tracking after successful initialization');
|
||||
await _startLocationTracking();
|
||||
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
debugPrint('Initialization error: $e');
|
||||
@@ -395,11 +413,9 @@ class AppProvider with ChangeNotifier {
|
||||
final isConnected = connectionProvider.deviceInfo.isConnected;
|
||||
final wasTracking = locationTrackingService.isTracking;
|
||||
|
||||
if (isConnected && !wasTracking) {
|
||||
// Connection established - start location tracking
|
||||
debugPrint('🔵 [AppProvider] BLE connected - starting location tracking');
|
||||
_startLocationTracking();
|
||||
} else if (!isConnected && wasTracking) {
|
||||
// Only stop tracking on disconnect - DON'T start on connect
|
||||
// Location tracking will be started AFTER initialization completes
|
||||
if (!isConnected && wasTracking) {
|
||||
// Connection lost - stop location tracking
|
||||
debugPrint('🔴 [AppProvider] BLE disconnected - stopping location tracking');
|
||||
_stopLocationTracking();
|
||||
|
||||
51
lib/providers/channels_provider.dart
Normal file
51
lib/providers/channels_provider.dart
Normal file
@@ -0,0 +1,51 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../models/channel.dart';
|
||||
|
||||
/// Manages channel information from the MeshCore device
|
||||
class ChannelsProvider with ChangeNotifier {
|
||||
final Map<int, Channel> _channels = {};
|
||||
|
||||
/// Get all channels
|
||||
List<Channel> get channels => _channels.values.toList()..sort((a, b) => a.index.compareTo(b.index));
|
||||
|
||||
/// Get a specific channel by index
|
||||
Channel? getChannel(int index) => _channels[index];
|
||||
|
||||
/// Get the display name for a channel
|
||||
String getChannelDisplayName(int index) {
|
||||
final channel = _channels[index];
|
||||
if (channel != null) {
|
||||
return channel.displayName;
|
||||
}
|
||||
// Fallback if channel hasn't been synced yet
|
||||
return index == 0 ? 'Public' : 'Channel $index';
|
||||
}
|
||||
|
||||
/// Add or update a channel
|
||||
void addOrUpdateChannel(int index, String name, {int? flags}) {
|
||||
_channels[index] = Channel(
|
||||
index: index,
|
||||
name: name,
|
||||
flags: flags,
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Clear all channels
|
||||
void clear() {
|
||||
_channels.clear();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Check if channels have been loaded
|
||||
bool get hasChannels => _channels.isNotEmpty;
|
||||
|
||||
/// Get the number of channels
|
||||
int get channelCount => _channels.length;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_channels.clear();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -123,6 +123,7 @@ class ConnectionProvider with ChangeNotifier {
|
||||
Function(List<Contact>)? onContactsComplete;
|
||||
Function(Message)? onMessageReceived;
|
||||
Function(Uint8List publicKey, Uint8List lppData)? onTelemetryReceived;
|
||||
Function(int channelIdx, String channelName)? onChannelInfoReceived;
|
||||
Function(Uint8List publicKeyPrefix, int tag, Uint8List responseData)?
|
||||
onBinaryResponse;
|
||||
Function(Uint8List publicKey)? onPathUpdated;
|
||||
@@ -249,6 +250,10 @@ class ConnectionProvider with ChangeNotifier {
|
||||
onContactsComplete?.call(contacts);
|
||||
};
|
||||
|
||||
_bleService.onChannelInfoReceived = (channelIdx, channelName) {
|
||||
onChannelInfoReceived?.call(channelIdx, channelName);
|
||||
};
|
||||
|
||||
_bleService.onMessageReceived = (message) {
|
||||
// Parse SAR markers
|
||||
final enhancedMessage = SarMessageParser.enhanceMessage(message);
|
||||
@@ -628,6 +633,24 @@ class ConnectionProvider with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
/// Sync all channels from device
|
||||
Future<void> syncChannels({int? maxChannels}) async {
|
||||
if (!_bleService.isConnected) {
|
||||
_error = 'Not connected to device';
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Use maxChannels from device info if available, otherwise default to 40
|
||||
final channelCount = maxChannels ?? _deviceInfo.maxChannels ?? 40;
|
||||
await _bleService.syncAllChannels(maxChannels: channelCount);
|
||||
} catch (e) {
|
||||
_error = 'Failed to sync channels: $e';
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Add or update a contact on the companion radio
|
||||
///
|
||||
/// This manually adds a contact to the radio's internal contact table.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import '../models/location_trail.dart';
|
||||
|
||||
class MapProvider with ChangeNotifier {
|
||||
LatLng? _targetLocation;
|
||||
@@ -9,11 +10,22 @@ class MapProvider with ChangeNotifier {
|
||||
// Track which contact paths are currently visible
|
||||
final Set<String> _visibleContactPaths = {};
|
||||
|
||||
// Location trail tracking
|
||||
LocationTrail? _currentTrail;
|
||||
bool _isTrailVisible = true;
|
||||
final List<LocationTrail> _trailHistory = [];
|
||||
|
||||
LatLng? get targetLocation => _targetLocation;
|
||||
double? get targetZoom => _targetZoom;
|
||||
bool get shouldAnimate => _shouldAnimate;
|
||||
Set<String> get visibleContactPaths => Set.unmodifiable(_visibleContactPaths);
|
||||
|
||||
// Trail getters
|
||||
LocationTrail? get currentTrail => _currentTrail;
|
||||
bool get isTrailVisible => _isTrailVisible;
|
||||
List<LocationTrail> get trailHistory => List.unmodifiable(_trailHistory);
|
||||
bool get isTrailActive => _currentTrail?.isActive ?? false;
|
||||
|
||||
void navigateToLocation({
|
||||
required LatLng location,
|
||||
double zoom = 15.0,
|
||||
@@ -64,4 +76,80 @@ class MapProvider with ChangeNotifier {
|
||||
_visibleContactPaths.add(publicKeyHex);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Start a new location trail
|
||||
void startTrail() {
|
||||
// End current trail if active
|
||||
if (_currentTrail != null && _currentTrail!.isActive) {
|
||||
endTrail();
|
||||
}
|
||||
|
||||
_currentTrail = LocationTrail(
|
||||
id: DateTime.now().millisecondsSinceEpoch.toString(),
|
||||
startTime: DateTime.now(),
|
||||
);
|
||||
_isTrailVisible = true;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Add a point to the current trail
|
||||
void addTrailPoint(LatLng position, {double? accuracy, double? speed}) {
|
||||
if (_currentTrail == null || !_currentTrail!.isActive) {
|
||||
startTrail();
|
||||
}
|
||||
|
||||
_currentTrail!.addPoint(TrailPoint(
|
||||
position: position,
|
||||
timestamp: DateTime.now(),
|
||||
accuracy: accuracy,
|
||||
speed: speed,
|
||||
));
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// End the current trail
|
||||
void endTrail() {
|
||||
if (_currentTrail != null) {
|
||||
_currentTrail!.isActive = false;
|
||||
_currentTrail!.endTime = DateTime.now();
|
||||
if (_currentTrail!.points.isNotEmpty) {
|
||||
_trailHistory.add(_currentTrail!);
|
||||
}
|
||||
_currentTrail = null;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Toggle trail visibility
|
||||
void toggleTrailVisibility() {
|
||||
_isTrailVisible = !_isTrailVisible;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Clear the current trail
|
||||
void clearCurrentTrail() {
|
||||
if (_currentTrail != null) {
|
||||
_currentTrail = null;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear all trail history
|
||||
void clearAllTrails() {
|
||||
_currentTrail = null;
|
||||
_trailHistory.clear();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Get total trail distance in meters
|
||||
double get totalTrailDistance {
|
||||
if (_currentTrail == null) return 0;
|
||||
return _currentTrail!.totalDistance;
|
||||
}
|
||||
|
||||
/// Get trail duration
|
||||
Duration get trailDuration {
|
||||
if (_currentTrail == null) return Duration.zero;
|
||||
return _currentTrail!.duration;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,8 +92,14 @@ class _ContactsTabState extends State<ContactsTab> {
|
||||
final chatContacts = contactsProvider.chatContacts;
|
||||
final repeaters = contactsProvider.repeaters;
|
||||
final rooms = contactsProvider.rooms;
|
||||
final channels = contactsProvider.channels;
|
||||
|
||||
if (contactsProvider.contacts.isEmpty) {
|
||||
// Check if there are any displayable contacts (excluding channels)
|
||||
final hasDisplayableContacts = chatContacts.isNotEmpty ||
|
||||
repeaters.isNotEmpty ||
|
||||
rooms.isNotEmpty;
|
||||
|
||||
if (!hasDisplayableContacts) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
@@ -160,7 +166,7 @@ class _ContactsTabState extends State<ContactsTab> {
|
||||
const Divider(height: 32),
|
||||
],
|
||||
|
||||
// Rooms/Channels
|
||||
// Rooms
|
||||
if (rooms.isNotEmpty) ...[
|
||||
_SectionHeader(
|
||||
title: l10n.rooms,
|
||||
@@ -175,6 +181,24 @@ class _ContactsTabState extends State<ContactsTab> {
|
||||
formatDistance: _formatDistance,
|
||||
),
|
||||
),
|
||||
const Divider(height: 32),
|
||||
],
|
||||
|
||||
// Channels
|
||||
if (channels.isNotEmpty) ...[
|
||||
_SectionHeader(
|
||||
title: l10n.channels,
|
||||
count: channels.length,
|
||||
icon: Icons.broadcast_on_personal,
|
||||
),
|
||||
...channels.map(
|
||||
(contact) => ContactTile(
|
||||
contact: contact,
|
||||
currentPosition: _currentPosition,
|
||||
calculateDistance: _calculateDistanceInMeters,
|
||||
formatDistance: _formatDistance,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
|
||||
@@ -18,12 +18,14 @@ import 'device_config_screen.dart';
|
||||
import 'packet_log_screen.dart';
|
||||
import '../utils/toast_logger.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../widgets/permission_request_dialog.dart';
|
||||
|
||||
class HomeScreen extends StatefulWidget {
|
||||
final Function(AppThemeMode) onThemeChanged;
|
||||
final Function(Locale?) onLocaleChanged;
|
||||
final AppThemeMode currentTheme;
|
||||
final Locale? currentLocale;
|
||||
final bool shouldShowPermissionDialog;
|
||||
|
||||
const HomeScreen({
|
||||
super.key,
|
||||
@@ -31,6 +33,7 @@ class HomeScreen extends StatefulWidget {
|
||||
required this.onLocaleChanged,
|
||||
required this.currentTheme,
|
||||
required this.currentLocale,
|
||||
this.shouldShowPermissionDialog = false,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -58,6 +61,13 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
});
|
||||
});
|
||||
_loadRxTxPreference();
|
||||
|
||||
// Show permission dialog after the first frame if needed
|
||||
if (widget.shouldShowPermissionDialog) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_showPermissionDialog();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadRxTxPreference() async {
|
||||
@@ -75,6 +85,34 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _showPermissionDialog() {
|
||||
if (!mounted) return;
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => PermissionRequestDialog(
|
||||
onPermissionsGranted: () {
|
||||
debugPrint('✅ Location permissions granted');
|
||||
},
|
||||
onPermissionsDenied: () {
|
||||
debugPrint('⚠️ Location permissions denied');
|
||||
// Show a snackbar to inform the user
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
AppLocalizations.of(context)!.locationPermissionRequired,
|
||||
),
|
||||
duration: const Duration(seconds: 5),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _advertiseDevice(BuildContext context) async {
|
||||
final connectionProvider = context.read<ConnectionProvider>();
|
||||
|
||||
|
||||
@@ -33,6 +33,8 @@ import '../widgets/map/compass_widget.dart';
|
||||
import '../widgets/map/detailed_compass_dialog.dart';
|
||||
import '../widgets/map/drawing_layer.dart';
|
||||
import '../widgets/map/drawing_toolbar.dart';
|
||||
import '../widgets/map/location_trail_layer.dart';
|
||||
import '../widgets/map/trail_controls.dart';
|
||||
import '../widgets/messages/sar_update_sheet.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import 'map_management_screen.dart';
|
||||
@@ -135,6 +137,16 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
|
||||
// Position updates trigger UI rebuild for markers
|
||||
});
|
||||
|
||||
// Add location point to trail when tracking is active
|
||||
final mapProvider = context.read<MapProvider>();
|
||||
if (_locationService.isTracking) {
|
||||
mapProvider.addTrailPoint(
|
||||
LatLng(position.latitude, position.longitude),
|
||||
accuracy: position.accuracy,
|
||||
speed: position.speed,
|
||||
);
|
||||
}
|
||||
|
||||
// Rotate map if rotation mode is enabled and heading is available
|
||||
if (_isMapReady && _rotateMarkerWithHeading && position.heading >= 0) {
|
||||
try {
|
||||
@@ -1149,6 +1161,8 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
|
||||
);
|
||||
},
|
||||
),
|
||||
// Location trail layer (rendered after paths, before drawings)
|
||||
const LocationTrailLayer(),
|
||||
// Drawing layer (rendered after paths, before markers)
|
||||
DrawingLayer(
|
||||
drawings: drawingProvider.drawings,
|
||||
@@ -1339,6 +1353,9 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
|
||||
objectCount: messagesProvider.objectMarkers.length,
|
||||
),
|
||||
),
|
||||
// Trail stats overlay (top-left, below legend if shown)
|
||||
if (!_isFullscreen)
|
||||
const TrailStatsOverlay(),
|
||||
// Map controls - right side (hidden in fullscreen mode)
|
||||
if (!_isFullscreen)
|
||||
Positioned(
|
||||
@@ -1383,6 +1400,9 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
|
||||
child: const Icon(Icons.my_location),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Trail controls button
|
||||
const TrailControls(),
|
||||
const SizedBox(height: 8),
|
||||
FloatingActionButton.small(
|
||||
heroTag: 'layer_selector',
|
||||
onPressed: () => _showLayerSelector(context),
|
||||
|
||||
@@ -249,6 +249,89 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleLocationPermissionTap() async {
|
||||
try {
|
||||
final permission = await Geolocator.checkPermission();
|
||||
|
||||
if (permission == LocationPermission.deniedForever) {
|
||||
// Show dialog to open app settings
|
||||
if (!mounted) return;
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Row(
|
||||
children: [
|
||||
Icon(Icons.settings, size: 24),
|
||||
SizedBox(width: 12),
|
||||
Text('Location Permission'),
|
||||
],
|
||||
),
|
||||
content: const Text(
|
||||
'Location permission is permanently denied. Please enable it in your device settings to use GPS tracking and location sharing features.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
Navigator.pop(context);
|
||||
await Geolocator.openAppSettings();
|
||||
},
|
||||
child: const Text('Open Settings'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
} else if (permission == LocationPermission.denied) {
|
||||
// Request permission
|
||||
final newPermission = await Geolocator.requestPermission();
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
if (newPermission == LocationPermission.whileInUse ||
|
||||
newPermission == LocationPermission.always) {
|
||||
// Permission granted
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Location permission granted!'),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
setState(() {}); // Refresh UI to show new status
|
||||
} else {
|
||||
// Permission denied
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Location permission is required for GPS tracking and location sharing.'),
|
||||
backgroundColor: Colors.orange,
|
||||
duration: Duration(seconds: 4),
|
||||
),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Already granted - show info
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Location permission is already granted.'),
|
||||
backgroundColor: Colors.blue,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Error handling location permission: $e');
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Error: $e'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _startBackgroundTracking() async {
|
||||
final success = await _locationService.startTracking(
|
||||
distanceThreshold: _locationService.gpsUpdateDistance,
|
||||
@@ -359,6 +442,54 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
),
|
||||
const Divider(),
|
||||
|
||||
// Permissions Section
|
||||
_buildSectionHeader('Permissions'),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.location_on),
|
||||
title: const Text('Location Permission'),
|
||||
subtitle: FutureBuilder<LocationPermission>(
|
||||
future: Geolocator.checkPermission(),
|
||||
builder: (context, snapshot) {
|
||||
if (!snapshot.hasData) {
|
||||
return const Text('Checking...');
|
||||
}
|
||||
final permission = snapshot.data!;
|
||||
String statusText;
|
||||
Color statusColor;
|
||||
|
||||
switch (permission) {
|
||||
case LocationPermission.always:
|
||||
statusText = 'Granted (Always)';
|
||||
statusColor = Colors.green;
|
||||
break;
|
||||
case LocationPermission.whileInUse:
|
||||
statusText = 'Granted (While In Use)';
|
||||
statusColor = Colors.green;
|
||||
break;
|
||||
case LocationPermission.denied:
|
||||
statusText = 'Denied - Tap to request';
|
||||
statusColor = Colors.orange;
|
||||
break;
|
||||
case LocationPermission.deniedForever:
|
||||
statusText = 'Permanently Denied - Open Settings';
|
||||
statusColor = Colors.red;
|
||||
break;
|
||||
default:
|
||||
statusText = 'Unknown';
|
||||
statusColor = Colors.grey;
|
||||
}
|
||||
|
||||
return Text(
|
||||
statusText,
|
||||
style: TextStyle(color: statusColor),
|
||||
);
|
||||
},
|
||||
),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => _handleLocationPermissionTap(),
|
||||
),
|
||||
const Divider(),
|
||||
|
||||
// Location Settings Section
|
||||
_buildSectionHeader(AppLocalizations.of(context)!.locationBroadcasting),
|
||||
|
||||
|
||||
269
lib/services/ble/ble_command_queue.dart
Normal file
269
lib/services/ble/ble_command_queue.dart
Normal file
@@ -0,0 +1,269 @@
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// Type of response expected from a command
|
||||
enum CommandResponseType {
|
||||
/// No response expected (fire-and-forget)
|
||||
none,
|
||||
|
||||
/// Wait for RESP_CODE_OK (0) or RESP_CODE_ERR (1)
|
||||
ack,
|
||||
|
||||
/// Wait for specific response code with data
|
||||
data,
|
||||
}
|
||||
|
||||
/// Represents a queued BLE command
|
||||
class QueuedCommand<T> {
|
||||
/// The command data to send
|
||||
final Uint8List data;
|
||||
|
||||
/// Command code (first byte of data)
|
||||
final int commandCode;
|
||||
|
||||
/// Type of response expected
|
||||
final CommandResponseType responseType;
|
||||
|
||||
/// Expected response code (for data type commands)
|
||||
final int? expectedResponseCode;
|
||||
|
||||
/// Completer to signal command completion
|
||||
final Completer<T> completer;
|
||||
|
||||
/// Timeout duration for this command
|
||||
final Duration timeout;
|
||||
|
||||
/// Timestamp when command was enqueued
|
||||
final DateTime enqueuedAt;
|
||||
|
||||
QueuedCommand({
|
||||
required this.data,
|
||||
required this.commandCode,
|
||||
required this.responseType,
|
||||
this.expectedResponseCode,
|
||||
required this.completer,
|
||||
required this.timeout,
|
||||
}) : enqueuedAt = DateTime.now();
|
||||
}
|
||||
|
||||
/// BLE command queue with mutex lock and inter-command delays
|
||||
///
|
||||
/// Ensures that:
|
||||
/// - Only one command executes at a time
|
||||
/// - 100ms delay between all commands
|
||||
/// - Commands can wait for ACK or specific responses
|
||||
/// - Timeouts are enforced
|
||||
class BleCommandQueue {
|
||||
// Queue of pending commands
|
||||
final List<QueuedCommand> _queue = [];
|
||||
|
||||
// Mutex lock using Completer
|
||||
Completer<void> _lock = Completer<void>()..complete();
|
||||
|
||||
// Whether queue is currently processing
|
||||
bool _isProcessing = false;
|
||||
|
||||
// Pending responses mapped by command code
|
||||
final Map<int, QueuedCommand> _pendingResponses = {};
|
||||
|
||||
// Last command execution timestamp
|
||||
DateTime? _lastCommandTime;
|
||||
|
||||
// Minimum delay between commands (milliseconds)
|
||||
static const int _minDelayMs = 100;
|
||||
|
||||
// Callbacks
|
||||
VoidCallback? onQueueEmpty;
|
||||
void Function(int queueSize)? onQueueSizeChanged;
|
||||
|
||||
/// Enqueue a command and wait for it to complete
|
||||
///
|
||||
/// [data] - The command data to send
|
||||
/// [commandCode] - Command code (first byte)
|
||||
/// [responseType] - Type of response expected
|
||||
/// [expectedResponseCode] - For data responses, the expected response code
|
||||
/// [timeout] - Maximum time to wait for response
|
||||
///
|
||||
/// Returns a Future that completes when the command receives its response
|
||||
/// or throws TimeoutException if timeout expires.
|
||||
Future<T> enqueue<T>({
|
||||
required Uint8List data,
|
||||
required int commandCode,
|
||||
required CommandResponseType responseType,
|
||||
int? expectedResponseCode,
|
||||
Duration? timeout,
|
||||
}) async {
|
||||
// Determine timeout based on response type
|
||||
final cmdTimeout = timeout ??
|
||||
(responseType == CommandResponseType.data
|
||||
? const Duration(seconds: 10)
|
||||
: const Duration(seconds: 5));
|
||||
|
||||
// Create queued command
|
||||
final command = QueuedCommand<T>(
|
||||
data: data,
|
||||
commandCode: commandCode,
|
||||
responseType: responseType,
|
||||
expectedResponseCode: expectedResponseCode,
|
||||
completer: Completer<T>(),
|
||||
timeout: cmdTimeout,
|
||||
);
|
||||
|
||||
// Add to queue
|
||||
_queue.add(command);
|
||||
onQueueSizeChanged?.call(_queue.length);
|
||||
|
||||
debugPrint('📋 [CommandQueue] Enqueued command 0x${commandCode.toRadixString(16).padLeft(2, '0')} (queue size: ${_queue.length})');
|
||||
|
||||
// Start processing if not already running
|
||||
if (!_isProcessing) {
|
||||
_processQueue();
|
||||
}
|
||||
|
||||
// Wait for command to complete or timeout
|
||||
return command.completer.future.timeout(
|
||||
cmdTimeout,
|
||||
onTimeout: () {
|
||||
debugPrint('⏱️ [CommandQueue] Command 0x${commandCode.toRadixString(16).padLeft(2, '0')} timed out after ${cmdTimeout.inSeconds}s');
|
||||
_pendingResponses.remove(commandCode);
|
||||
throw TimeoutException('Command 0x${commandCode.toRadixString(16).padLeft(2, '0')} timed out');
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Process the command queue
|
||||
Future<void> _processQueue() async {
|
||||
if (_isProcessing) return;
|
||||
_isProcessing = true;
|
||||
|
||||
while (_queue.isNotEmpty) {
|
||||
// Wait for lock
|
||||
await _lock.future;
|
||||
|
||||
// Get next command
|
||||
final command = _queue.removeAt(0);
|
||||
onQueueSizeChanged?.call(_queue.length);
|
||||
|
||||
try {
|
||||
// Enforce minimum delay between commands
|
||||
if (_lastCommandTime != null) {
|
||||
final elapsed = DateTime.now().difference(_lastCommandTime!);
|
||||
final remainingDelay = _minDelayMs - elapsed.inMilliseconds;
|
||||
|
||||
if (remainingDelay > 0) {
|
||||
debugPrint('⏸️ [CommandQueue] Waiting ${remainingDelay}ms before next command');
|
||||
await Future.delayed(Duration(milliseconds: remainingDelay));
|
||||
}
|
||||
}
|
||||
|
||||
// Create new lock for next command
|
||||
_lock = Completer<void>();
|
||||
|
||||
// Register for response if needed
|
||||
if (command.responseType != CommandResponseType.none) {
|
||||
final responseKey = command.responseType == CommandResponseType.ack
|
||||
? command.commandCode
|
||||
: (command.expectedResponseCode ?? command.commandCode);
|
||||
_pendingResponses[responseKey] = command;
|
||||
}
|
||||
|
||||
// Execute command (handled by BleCommandSender)
|
||||
// The completer will be completed by completeCommand() when response arrives
|
||||
debugPrint('📤 [CommandQueue] Executing command 0x${command.commandCode.toRadixString(16).padLeft(2, '0')}');
|
||||
|
||||
// For fire-and-forget commands, complete immediately
|
||||
if (command.responseType == CommandResponseType.none) {
|
||||
command.completer.complete(null);
|
||||
}
|
||||
|
||||
// Update last command time
|
||||
_lastCommandTime = DateTime.now();
|
||||
|
||||
// Release lock after minimum delay
|
||||
Future.delayed(const Duration(milliseconds: _minDelayMs), () {
|
||||
if (!_lock.isCompleted) {
|
||||
_lock.complete();
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
debugPrint('❌ [CommandQueue] Error processing command: $e');
|
||||
if (!command.completer.isCompleted) {
|
||||
command.completer.completeError(e);
|
||||
}
|
||||
// Release lock on error
|
||||
if (!_lock.isCompleted) {
|
||||
_lock.complete();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_isProcessing = false;
|
||||
onQueueEmpty?.call();
|
||||
debugPrint('✅ [CommandQueue] Queue empty');
|
||||
}
|
||||
|
||||
/// Complete a pending command with response data
|
||||
///
|
||||
/// Called by BleResponseHandler when a response is received
|
||||
void completeCommand<T>(int responseCode, T data) {
|
||||
final command = _pendingResponses.remove(responseCode);
|
||||
if (command != null) {
|
||||
debugPrint('✅ [CommandQueue] Completing command 0x${command.commandCode.toRadixString(16).padLeft(2, '0')} with response 0x${responseCode.toRadixString(16).padLeft(2, '0')}');
|
||||
if (!command.completer.isCompleted) {
|
||||
command.completer.complete(data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Complete a pending command with error
|
||||
///
|
||||
/// Called by BleResponseHandler when RESP_CODE_ERR is received
|
||||
void completeCommandWithError(int commandCode, String error, {int? errorCode}) {
|
||||
final command = _pendingResponses.remove(commandCode);
|
||||
if (command != null) {
|
||||
debugPrint('❌ [CommandQueue] Command 0x${commandCode.toRadixString(16).padLeft(2, '0')} failed: $error (code: $errorCode)');
|
||||
if (!command.completer.isCompleted) {
|
||||
command.completer.completeError(
|
||||
Exception('Command failed: $error (error code: $errorCode)'),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get current queue size
|
||||
int get queueSize => _queue.length;
|
||||
|
||||
/// Get number of pending responses
|
||||
int get pendingResponseCount => _pendingResponses.length;
|
||||
|
||||
/// Check if queue is empty
|
||||
bool get isEmpty => _queue.isEmpty;
|
||||
|
||||
/// Check if queue is processing
|
||||
bool get isProcessing => _isProcessing;
|
||||
|
||||
/// Clear all pending commands (use with caution)
|
||||
void clear() {
|
||||
debugPrint('🗑️ [CommandQueue] Clearing queue (${_queue.length} commands, ${_pendingResponses.length} pending responses)');
|
||||
|
||||
// Complete all pending commands with error
|
||||
for (final command in _pendingResponses.values) {
|
||||
if (!command.completer.isCompleted) {
|
||||
command.completer.completeError(Exception('Queue cleared'));
|
||||
}
|
||||
}
|
||||
|
||||
_queue.clear();
|
||||
_pendingResponses.clear();
|
||||
onQueueSizeChanged?.call(0);
|
||||
}
|
||||
|
||||
/// Dispose resources
|
||||
void dispose() {
|
||||
clear();
|
||||
if (!_lock.isCompleted) {
|
||||
_lock.complete();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
|
||||
import '../meshcore_opcode_names.dart';
|
||||
import '../../models/ble_packet_log.dart';
|
||||
import 'ble_command_queue.dart';
|
||||
|
||||
/// Callback types for sender events
|
||||
typedef OnErrorCallback = void Function(String error);
|
||||
@@ -14,6 +15,9 @@ class BleCommandSender {
|
||||
final List<BlePacketLog> _packetLogs = [];
|
||||
static const int _maxLogSize = 1000;
|
||||
|
||||
// Command queue for serialization and response waiting
|
||||
final BleCommandQueue _commandQueue = BleCommandQueue();
|
||||
|
||||
// Callbacks
|
||||
OnErrorCallback? onError;
|
||||
VoidCallback? onTxActivity;
|
||||
@@ -21,17 +25,99 @@ class BleCommandSender {
|
||||
// Getters
|
||||
int get txPacketCount => _txPacketCount;
|
||||
List<BlePacketLog> get packetLogs => List.unmodifiable(_packetLogs);
|
||||
BleCommandQueue get commandQueue => _commandQueue;
|
||||
|
||||
/// Set the RX characteristic to write to
|
||||
void setRxCharacteristic(BluetoothCharacteristic? characteristic) {
|
||||
_rxCharacteristic = characteristic;
|
||||
}
|
||||
|
||||
/// Write data to RX characteristic
|
||||
/// Write data to RX characteristic (fire-and-forget, no response expected)
|
||||
///
|
||||
/// This method is for commands that don't expect any response.
|
||||
/// The command is queued and executed with proper spacing, but we don't wait
|
||||
/// for any acknowledgment.
|
||||
Future<void> writeData(Uint8List data) async {
|
||||
if (_rxCharacteristic == null) {
|
||||
throw Exception('Not connected');
|
||||
}
|
||||
|
||||
final commandCode = data.isNotEmpty ? data[0] : 0;
|
||||
|
||||
// Enqueue the command (fire-and-forget)
|
||||
await _commandQueue.enqueue<void>(
|
||||
data: data,
|
||||
commandCode: commandCode,
|
||||
responseType: CommandResponseType.none,
|
||||
);
|
||||
|
||||
// Actually send the data
|
||||
await _sendToDevice(data);
|
||||
}
|
||||
|
||||
/// Write data and wait for ACK (RESP_CODE_OK or RESP_CODE_ERR)
|
||||
///
|
||||
/// This method should be used for setup commands that return RESP_CODE_OK (0)
|
||||
/// on success or RESP_CODE_ERR (1) on failure.
|
||||
///
|
||||
/// Examples: setAdvertLatLon, setAdvertName, setRadioParams, etc.
|
||||
Future<void> writeDataAndWaitForAck(Uint8List data) async {
|
||||
if (_rxCharacteristic == null) {
|
||||
throw Exception('Not connected');
|
||||
}
|
||||
|
||||
final commandCode = data.isNotEmpty ? data[0] : 0;
|
||||
|
||||
// Enqueue the command (wait for ACK)
|
||||
await _commandQueue.enqueue<void>(
|
||||
data: data,
|
||||
commandCode: commandCode,
|
||||
responseType: CommandResponseType.ack,
|
||||
);
|
||||
|
||||
// Actually send the data
|
||||
await _sendToDevice(data);
|
||||
}
|
||||
|
||||
/// Write data and wait for specific response
|
||||
///
|
||||
/// This method should be used for query commands that return specific data.
|
||||
///
|
||||
/// Examples:
|
||||
/// - CMD_DEVICE_QUERY → RESP_CODE_DEVICE_INFO
|
||||
/// - CMD_APP_START → RESP_CODE_SELF_INFO
|
||||
/// - CMD_GET_CONTACTS → RESP_CODE_CONTACTS_START
|
||||
Future<T> writeDataAndWaitForResponse<T>(
|
||||
Uint8List data,
|
||||
int expectedResponseCode,
|
||||
) async {
|
||||
if (_rxCharacteristic == null) {
|
||||
throw Exception('Not connected');
|
||||
}
|
||||
|
||||
final commandCode = data.isNotEmpty ? data[0] : 0;
|
||||
|
||||
// Enqueue the command (wait for specific response)
|
||||
final responseFuture = _commandQueue.enqueue<T>(
|
||||
data: data,
|
||||
commandCode: commandCode,
|
||||
responseType: CommandResponseType.data,
|
||||
expectedResponseCode: expectedResponseCode,
|
||||
);
|
||||
|
||||
// Actually send the data
|
||||
await _sendToDevice(data);
|
||||
|
||||
// Wait for response
|
||||
return responseFuture;
|
||||
}
|
||||
|
||||
/// Internal method to actually send data to the BLE device
|
||||
Future<void> _sendToDevice(Uint8List data) async {
|
||||
if (_rxCharacteristic == null) {
|
||||
throw Exception('Not connected');
|
||||
}
|
||||
|
||||
try {
|
||||
// Extract command code from first byte
|
||||
final commandCode = data.isNotEmpty ? data[0] : null;
|
||||
@@ -125,6 +211,7 @@ class BleCommandSender {
|
||||
|
||||
/// Dispose resources
|
||||
void dispose() {
|
||||
_commandQueue.dispose();
|
||||
_rxCharacteristic = null;
|
||||
_packetLogs.clear();
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import '../buffer_reader.dart';
|
||||
import '../meshcore_constants.dart';
|
||||
import '../meshcore_opcode_names.dart';
|
||||
import '../protocol/frame_parser.dart';
|
||||
import 'ble_command_queue.dart';
|
||||
|
||||
/// Callback types for response events
|
||||
typedef OnContactCallback = void Function(Contact contact);
|
||||
@@ -31,6 +32,7 @@ typedef OnBinaryResponseCallback = void Function(Uint8List publicKeyPrefix, int
|
||||
typedef OnBatteryAndStorageCallback = void Function(int millivolts, int? usedKb, int? totalKb);
|
||||
typedef OnErrorCallback = void Function(String error, {int? errorCode});
|
||||
typedef OnContactNotFoundCallback = void Function(Uint8List? contactPublicKey);
|
||||
typedef OnChannelInfoCallback = void Function(int channelIdx, String channelName);
|
||||
|
||||
/// Processes incoming responses from the BLE device
|
||||
class BleResponseHandler {
|
||||
@@ -40,6 +42,9 @@ class BleResponseHandler {
|
||||
final List<BlePacketLog> _packetLogs = [];
|
||||
static const int _maxLogSize = 1000;
|
||||
|
||||
// Reference to command queue for completing pending commands
|
||||
BleCommandQueue? _commandQueue;
|
||||
|
||||
// Callbacks
|
||||
OnContactCallback? onContactReceived;
|
||||
OnContactsCompleteCallback? onContactsComplete;
|
||||
@@ -60,6 +65,7 @@ class BleResponseHandler {
|
||||
OnBatteryAndStorageCallback? onBatteryAndStorage;
|
||||
OnErrorCallback? onError;
|
||||
OnContactNotFoundCallback? onContactNotFound;
|
||||
OnChannelInfoCallback? onChannelInfoReceived;
|
||||
VoidCallback? onRxActivity;
|
||||
|
||||
// Track the last command that was sent, so we can retry if it fails with ERR_CODE_NOT_FOUND
|
||||
@@ -69,6 +75,11 @@ class BleResponseHandler {
|
||||
int get rxPacketCount => _rxPacketCount;
|
||||
List<BlePacketLog> get packetLogs => List.unmodifiable(_packetLogs);
|
||||
|
||||
/// Set the command queue for completing pending commands
|
||||
void setCommandQueue(BleCommandQueue? queue) {
|
||||
_commandQueue = queue;
|
||||
}
|
||||
|
||||
/// Subscribe to TX characteristic notifications
|
||||
void subscribeToNotifications(BluetoothCharacteristic txCharacteristic) {
|
||||
_txSubscription = txCharacteristic.lastValueStream.listen(
|
||||
@@ -195,12 +206,18 @@ class BleResponseHandler {
|
||||
print(' → Handling BatteryAndStorage');
|
||||
_handleBatteryAndStorage(reader);
|
||||
break;
|
||||
case MeshCoreConstants.respChannelInfo:
|
||||
print(' → Handling ChannelInfo');
|
||||
_handleChannelInfo(reader);
|
||||
break;
|
||||
case MeshCoreConstants.respNoMoreMessages:
|
||||
print(' → Response: No More Messages');
|
||||
onNoMoreMessages?.call();
|
||||
break;
|
||||
case MeshCoreConstants.respOk:
|
||||
print(' → Response: OK');
|
||||
// Complete any pending ACK command
|
||||
_commandQueue?.completeCommand<void>(MeshCoreConstants.respOk, null);
|
||||
break;
|
||||
case MeshCoreConstants.respErr:
|
||||
print(' → Response: ERROR');
|
||||
@@ -249,6 +266,13 @@ class BleResponseHandler {
|
||||
final result = FrameParser.parseSentConfirmation(reader);
|
||||
if (result.isNotEmpty) {
|
||||
print(' ✅ [Sent] Message sent successfully');
|
||||
|
||||
// Complete any pending command waiting for sent confirmation
|
||||
_commandQueue?.completeCommand<Map<String, dynamic>>(
|
||||
MeshCoreConstants.respSent,
|
||||
result,
|
||||
);
|
||||
|
||||
onMessageSent?.call(
|
||||
result['expectedAckTag'] as int,
|
||||
result['suggestedTimeout'] as int,
|
||||
@@ -319,6 +343,13 @@ class BleResponseHandler {
|
||||
void _handleDeviceInfo(BufferReader reader) {
|
||||
try {
|
||||
final info = FrameParser.parseDeviceInfo(reader);
|
||||
|
||||
// Complete any pending command waiting for device info
|
||||
_commandQueue?.completeCommand<Map<String, dynamic>>(
|
||||
MeshCoreConstants.respDeviceInfo,
|
||||
info,
|
||||
);
|
||||
|
||||
onDeviceInfoReceived?.call(info);
|
||||
print(' ✅ [DeviceInfo] Parsed successfully');
|
||||
} catch (e) {
|
||||
@@ -331,9 +362,16 @@ class BleResponseHandler {
|
||||
void _handleSelfInfo(BufferReader reader) {
|
||||
try {
|
||||
final info = FrameParser.parseSelfInfo(reader);
|
||||
|
||||
// Complete any pending command waiting for self info
|
||||
if (info.isNotEmpty) {
|
||||
_commandQueue?.completeCommand<Map<String, dynamic>>(
|
||||
MeshCoreConstants.respSelfInfo,
|
||||
info,
|
||||
);
|
||||
onSelfInfoReceived?.call(info);
|
||||
}
|
||||
|
||||
print(' ✅ [SelfInfo] Parsed successfully');
|
||||
} catch (e) {
|
||||
print(' ❌ [SelfInfo] Parsing error: $e');
|
||||
@@ -572,6 +610,23 @@ class BleResponseHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle ChannelInfo response
|
||||
void _handleChannelInfo(BufferReader reader) {
|
||||
try {
|
||||
final info = FrameParser.parseChannelInfo(reader);
|
||||
if (info.isNotEmpty) {
|
||||
final channelIdx = info['channelIdx'] as int;
|
||||
final channelName = info['channelName'] as String;
|
||||
|
||||
print(' ✅ [ChannelInfo] Channel $channelIdx: "${channelName}"');
|
||||
onChannelInfoReceived?.call(channelIdx, channelName);
|
||||
}
|
||||
} catch (e) {
|
||||
print(' ❌ [ChannelInfo] Parsing error: $e');
|
||||
onError?.call('ChannelInfo parsing error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle Error response
|
||||
void _handleError(BufferReader reader) {
|
||||
try {
|
||||
@@ -580,6 +635,13 @@ class BleResponseHandler {
|
||||
final errorMsg = FrameParser.getErrorMessage(errorCode);
|
||||
print(' ❌ [Error] $errorMsg');
|
||||
|
||||
// Complete any pending ACK command with error
|
||||
_commandQueue?.completeCommandWithError(
|
||||
MeshCoreConstants.respOk, // Command was expecting OK, got ERR
|
||||
errorMsg,
|
||||
errorCode: errorCode,
|
||||
);
|
||||
|
||||
// Special handling for ERR_CODE_NOT_FOUND (2) - contact not in radio
|
||||
if (errorCode == 2) { // ERR_CODE_NOT_FOUND
|
||||
print(' ⚠️ [Error] Contact not found in radio - attempting auto-recovery');
|
||||
|
||||
@@ -246,19 +246,20 @@ class LocationTrackingService {
|
||||
/// Returns true if successful, false otherwise.
|
||||
/// Note: This method returns immediately after starting the position stream.
|
||||
/// Initial position acquisition happens asynchronously in the background.
|
||||
///
|
||||
/// GPS tracking works WITHOUT BLE connection - device broadcasts are simply skipped.
|
||||
Future<bool> startTracking({double? distanceThreshold}) async {
|
||||
if (!_isInitialized || _bleService == null) {
|
||||
if (!_isInitialized) {
|
||||
debugPrint(
|
||||
'⚠️ [LocationTracking] Service not initialized or BLE service null',
|
||||
'⚠️ [LocationTracking] Service not initialized',
|
||||
);
|
||||
onError?.call('Location tracking service not initialized');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!_bleService!.isConnected) {
|
||||
debugPrint('⚠️ [LocationTracking] BLE not connected');
|
||||
onError?.call('Not connected to mesh device');
|
||||
return false;
|
||||
// Allow tracking without BLE connection - broadcasts will be skipped
|
||||
if (_bleService == null || !_bleService!.isConnected) {
|
||||
debugPrint('ℹ️ [LocationTracking] Starting GPS tracking without BLE connection (broadcasts disabled)');
|
||||
}
|
||||
|
||||
// Check permissions
|
||||
|
||||
@@ -9,6 +9,7 @@ import 'ble/ble_connection_manager.dart';
|
||||
import 'ble/ble_command_sender.dart';
|
||||
import 'ble/ble_response_handler.dart';
|
||||
import 'protocol/frame_builder.dart';
|
||||
import 'meshcore_constants.dart';
|
||||
|
||||
/// Callback types for MeshCore events
|
||||
typedef OnContactCallback = void Function(Contact contact);
|
||||
@@ -30,6 +31,7 @@ typedef OnBinaryResponseCallback = void Function(Uint8List publicKeyPrefix, int
|
||||
typedef OnBatteryAndStorageCallback = void Function(int millivolts, int? usedKb, int? totalKb);
|
||||
typedef OnErrorCallback = void Function(String error, {int? errorCode});
|
||||
typedef OnContactNotFoundCallback = void Function(Uint8List? contactPublicKey);
|
||||
typedef OnChannelInfoCallback = void Function(int channelIdx, String channelName);
|
||||
typedef OnConnectionStateCallback = void Function(bool isConnected);
|
||||
typedef OnReconnectionAttemptCallback = void Function(int attemptNumber, int maxAttempts);
|
||||
typedef OnRssiUpdateCallback = void Function(int rssi);
|
||||
@@ -64,6 +66,7 @@ class MeshCoreBleService {
|
||||
OnBatteryAndStorageCallback? onBatteryAndStorage;
|
||||
OnErrorCallback? onError;
|
||||
OnContactNotFoundCallback? onContactNotFound;
|
||||
OnChannelInfoCallback? onChannelInfoReceived;
|
||||
|
||||
// Activity callbacks (for blinking indicators)
|
||||
VoidCallback? onRxActivity;
|
||||
@@ -157,6 +160,9 @@ class MeshCoreBleService {
|
||||
_responseHandler.onContactNotFound = (contactPublicKey) {
|
||||
onContactNotFound?.call(contactPublicKey);
|
||||
};
|
||||
_responseHandler.onChannelInfoReceived = (channelIdx, channelName) {
|
||||
onChannelInfoReceived?.call(channelIdx, channelName);
|
||||
};
|
||||
_responseHandler.onRxActivity = () {
|
||||
onRxActivity?.call();
|
||||
};
|
||||
@@ -185,16 +191,30 @@ class MeshCoreBleService {
|
||||
Future<bool> connect(BluetoothDevice device) async {
|
||||
final success = await _connectionManager.connect(device);
|
||||
if (success) {
|
||||
// Setup command sender with RX characteristic
|
||||
_commandSender.setRxCharacteristic(_connectionManager.rxCharacteristic);
|
||||
try {
|
||||
// Setup command sender with RX characteristic
|
||||
_commandSender.setRxCharacteristic(_connectionManager.rxCharacteristic);
|
||||
|
||||
// Setup response handler with TX characteristic
|
||||
if (_connectionManager.txCharacteristic != null) {
|
||||
_responseHandler.subscribeToNotifications(_connectionManager.txCharacteristic!);
|
||||
// Wire up command queue between sender and response handler
|
||||
_responseHandler.setCommandQueue(_commandSender.commandQueue);
|
||||
|
||||
// Setup response handler with TX characteristic
|
||||
if (_connectionManager.txCharacteristic != null) {
|
||||
_responseHandler.subscribeToNotifications(_connectionManager.txCharacteristic!);
|
||||
}
|
||||
|
||||
// Send initial device query and wait for responses
|
||||
await _sendDeviceQuery();
|
||||
|
||||
print('✅ [Service] Device initialization complete');
|
||||
return true;
|
||||
} catch (e) {
|
||||
print('❌ [Service] Device initialization failed: $e');
|
||||
// Disconnect on initialization failure
|
||||
await disconnect();
|
||||
onError?.call('Device initialization failed: $e');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Send initial device query
|
||||
await _sendDeviceQuery();
|
||||
}
|
||||
return success;
|
||||
}
|
||||
@@ -206,17 +226,30 @@ class MeshCoreBleService {
|
||||
|
||||
/// Send initial device query and sync clock
|
||||
Future<void> _sendDeviceQuery() async {
|
||||
// CRITICAL: Set device clock FIRST, before any other commands
|
||||
// This ensures the device has correct timestamps for all operations
|
||||
print('⏰ [Service] Setting device clock before device query');
|
||||
// STEP 1: Send device query FIRST to get device capabilities
|
||||
// This is the first command to send per protocol documentation
|
||||
print('🔍 [Service] Querying device information (CMD_DEVICE_QUERY)...');
|
||||
final deviceInfo = await _commandSender.writeDataAndWaitForResponse<Map<String, dynamic>>(
|
||||
FrameBuilder.buildDeviceQuery(),
|
||||
MeshCoreConstants.respDeviceInfo,
|
||||
);
|
||||
print('✅ [Service] Device info received: firmware=${deviceInfo['firmwareVersion']}');
|
||||
|
||||
// STEP 2: Send app start to initialize the app session
|
||||
// This is the first command after connection per protocol documentation
|
||||
print('🚀 [Service] Sending app start (CMD_APP_START)...');
|
||||
final selfInfo = await _commandSender.writeDataAndWaitForResponse<Map<String, dynamic>>(
|
||||
FrameBuilder.buildAppStart(),
|
||||
MeshCoreConstants.respSelfInfo,
|
||||
);
|
||||
print('✅ [Service] Self info received: node initialized');
|
||||
|
||||
// STEP 3: Set device clock AFTER initialization
|
||||
// This ensures the device has correct timestamps for all subsequent operations
|
||||
// Note: This command does not return an ACK, so we use writeData (fire-and-forget)
|
||||
print('⏰ [Service] Setting device clock (CMD_SET_DEVICE_TIME)...');
|
||||
await _commandSender.writeData(FrameBuilder.buildSetDeviceTime());
|
||||
|
||||
// Small delay to ensure clock is set before proceeding
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
// Now send device query and app start
|
||||
await _commandSender.writeData(FrameBuilder.buildDeviceQuery());
|
||||
await _commandSender.writeData(FrameBuilder.buildAppStart());
|
||||
print('✅ [Service] Device clock sent (no ACK expected)');
|
||||
}
|
||||
|
||||
/// Refresh device info (public method)
|
||||
@@ -333,7 +366,7 @@ class MeshCoreBleService {
|
||||
|
||||
/// Set advertised name
|
||||
Future<void> setAdvertName(String name) async {
|
||||
await _commandSender.writeData(FrameBuilder.buildSetAdvertName(name));
|
||||
await _commandSender.writeDataAndWaitForAck(FrameBuilder.buildSetAdvertName(name));
|
||||
}
|
||||
|
||||
/// Set advertised latitude and longitude
|
||||
@@ -341,7 +374,7 @@ class MeshCoreBleService {
|
||||
required double latitude,
|
||||
required double longitude,
|
||||
}) async {
|
||||
await _commandSender.writeData(FrameBuilder.buildSetAdvertLatLon(
|
||||
await _commandSender.writeDataAndWaitForAck(FrameBuilder.buildSetAdvertLatLon(
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
));
|
||||
@@ -354,7 +387,7 @@ class MeshCoreBleService {
|
||||
required int spreadingFactor,
|
||||
required int codingRate,
|
||||
}) async {
|
||||
await _commandSender.writeData(FrameBuilder.buildSetRadioParams(
|
||||
await _commandSender.writeDataAndWaitForAck(FrameBuilder.buildSetRadioParams(
|
||||
frequency: frequency,
|
||||
bandwidth: bandwidth,
|
||||
spreadingFactor: spreadingFactor,
|
||||
@@ -364,7 +397,7 @@ class MeshCoreBleService {
|
||||
|
||||
/// Set transmit power
|
||||
Future<void> setTxPower(int powerDbm) async {
|
||||
await _commandSender.writeData(FrameBuilder.buildSetTxPower(powerDbm));
|
||||
await _commandSender.writeDataAndWaitForAck(FrameBuilder.buildSetTxPower(powerDbm));
|
||||
}
|
||||
|
||||
/// Set other parameters
|
||||
@@ -374,7 +407,7 @@ class MeshCoreBleService {
|
||||
required int advertLocationPolicy,
|
||||
int multiAcks = 0,
|
||||
}) async {
|
||||
await _commandSender.writeData(FrameBuilder.buildSetOtherParams(
|
||||
await _commandSender.writeDataAndWaitForAck(FrameBuilder.buildSetOtherParams(
|
||||
manualAddContacts: manualAddContacts,
|
||||
telemetryModes: telemetryModes,
|
||||
advertLocationPolicy: advertLocationPolicy,
|
||||
@@ -426,6 +459,41 @@ class MeshCoreBleService {
|
||||
print('✅ [BLE] CMD_REMOVE_CONTACT sent');
|
||||
}
|
||||
|
||||
/// Get information for a specific channel
|
||||
Future<void> getChannel(int channelIdx) async {
|
||||
await _commandSender.writeData(FrameBuilder.buildGetChannel(channelIdx));
|
||||
}
|
||||
|
||||
/// Set the name for a specific channel
|
||||
Future<void> setChannel({
|
||||
required int channelIdx,
|
||||
required String channelName,
|
||||
}) async {
|
||||
print('📻 [BLE] Setting channel name:');
|
||||
print(' Channel index: $channelIdx');
|
||||
print(' Channel name: $channelName');
|
||||
|
||||
await _commandSender.writeDataAndWaitForAck(FrameBuilder.buildSetChannel(
|
||||
channelIdx: channelIdx,
|
||||
channelName: channelName,
|
||||
));
|
||||
print('✅ [BLE] CMD_SET_CHANNEL sent');
|
||||
}
|
||||
|
||||
/// Sync all channels from the device (typically 0-39)
|
||||
/// This queries each channel to get its name and metadata
|
||||
Future<void> syncAllChannels({int maxChannels = 40}) async {
|
||||
print('📻 [Service] Syncing channels (0-${maxChannels - 1})...');
|
||||
|
||||
for (int i = 0; i < maxChannels; i++) {
|
||||
await getChannel(i);
|
||||
// Small delay to avoid overwhelming the device
|
||||
await Future.delayed(const Duration(milliseconds: 50));
|
||||
}
|
||||
|
||||
print('✅ [Service] Channel sync complete');
|
||||
}
|
||||
|
||||
/// Clear packet logs
|
||||
void clearPacketLogs() {
|
||||
_commandSender.clearPacketLogs();
|
||||
|
||||
@@ -243,4 +243,31 @@ class FrameBuilder {
|
||||
writer.writeBytes(contactPublicKey); // 32 bytes
|
||||
return writer.toBytes();
|
||||
}
|
||||
|
||||
/// Build GetChannel command - retrieves information for a specific channel
|
||||
static Uint8List buildGetChannel(int channelIdx) {
|
||||
final writer = BufferWriter();
|
||||
writer.writeByte(MeshCoreConstants.cmdGetChannel); // 0x1F (31)
|
||||
writer.writeByte(channelIdx); // 0-39 typically
|
||||
return writer.toBytes();
|
||||
}
|
||||
|
||||
/// Build SetChannel command - sets the name for a specific channel
|
||||
static Uint8List buildSetChannel({
|
||||
required int channelIdx,
|
||||
required String channelName,
|
||||
}) {
|
||||
final writer = BufferWriter();
|
||||
writer.writeByte(MeshCoreConstants.cmdSetChannel); // 0x20 (32)
|
||||
writer.writeByte(channelIdx); // 0-39 typically
|
||||
|
||||
// Write channel name as null-terminated string in 32-byte field
|
||||
final nameBytes = Uint8List(32);
|
||||
final encoded = utf8.encode(channelName);
|
||||
final copyLen = encoded.length > 31 ? 31 : encoded.length;
|
||||
nameBytes.setRange(0, copyLen, encoded);
|
||||
writer.writeBytes(nameBytes);
|
||||
|
||||
return writer.toBytes();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -387,6 +387,28 @@ class FrameParser {
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Parse ChannelInfo response
|
||||
static Map<String, dynamic> parseChannelInfo(BufferReader reader) {
|
||||
if (reader.remainingBytesCount < 33) {
|
||||
return {};
|
||||
}
|
||||
|
||||
final channelIdx = reader.readByte();
|
||||
final channelName = reader.readCString(32);
|
||||
|
||||
// Additional fields if present in protocol
|
||||
int? flags;
|
||||
if (reader.hasRemaining) {
|
||||
flags = reader.readByte();
|
||||
}
|
||||
|
||||
return {
|
||||
'channelIdx': channelIdx,
|
||||
'channelName': channelName,
|
||||
'flags': flags,
|
||||
};
|
||||
}
|
||||
|
||||
/// Get error message from error code
|
||||
static String getErrorMessage(int errorCode) {
|
||||
switch (errorCode) {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
import '../../../providers/map_provider.dart';
|
||||
|
||||
/// Filter controls for the compass dialog.
|
||||
/// Allows filtering of contacts and SAR marker types.
|
||||
@@ -38,6 +40,8 @@ class CompassFilters extends StatefulWidget {
|
||||
class _CompassFiltersState extends State<CompassFilters> {
|
||||
void _showFilterDialog() {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final mapProvider = Provider.of<MapProvider>(context, listen: false);
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => StatefulBuilder(
|
||||
@@ -54,6 +58,20 @@ class _CompassFiltersState extends State<CompassFilters> {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Trail visibility toggle
|
||||
_CompactFilterItem(
|
||||
icon: Icons.timeline,
|
||||
color: Colors.blue,
|
||||
label: 'Location Trail',
|
||||
value: mapProvider.isTrailVisible,
|
||||
onChanged: (value) {
|
||||
mapProvider.toggleTrailVisibility();
|
||||
setDialogState(() {});
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
const Divider(height: 8),
|
||||
const SizedBox(height: 4),
|
||||
// Contacts filter
|
||||
_CompactFilterItem(
|
||||
icon: Icons.person,
|
||||
|
||||
155
lib/widgets/map/location_trail_layer.dart
Normal file
155
lib/widgets/map/location_trail_layer.dart
Normal file
@@ -0,0 +1,155 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../providers/map_provider.dart';
|
||||
|
||||
/// Widget that renders the user's location trail on the map
|
||||
class LocationTrailLayer extends StatelessWidget {
|
||||
const LocationTrailLayer({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Consumer<MapProvider>(
|
||||
builder: (context, mapProvider, child) {
|
||||
final trail = mapProvider.currentTrail;
|
||||
final isVisible = mapProvider.isTrailVisible;
|
||||
|
||||
// Don't render if trail is hidden or empty
|
||||
if (!isVisible || trail == null || trail.points.length < 2) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final points = trail.latLngPoints;
|
||||
|
||||
return PolylineLayer(
|
||||
polylines: [
|
||||
Polyline(
|
||||
points: points,
|
||||
strokeWidth: 4.0,
|
||||
color: Colors.blue.withValues(alpha: 0.7),
|
||||
borderStrokeWidth: 2.0,
|
||||
borderColor: Colors.white.withValues(alpha: 0.5),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Widget that shows trail statistics overlay
|
||||
class TrailStatsOverlay extends StatelessWidget {
|
||||
const TrailStatsOverlay({super.key});
|
||||
|
||||
String _formatDistance(double meters) {
|
||||
if (meters < 1000) {
|
||||
return '${meters.toStringAsFixed(0)} m';
|
||||
} else {
|
||||
return '${(meters / 1000).toStringAsFixed(2)} km';
|
||||
}
|
||||
}
|
||||
|
||||
String _formatDuration(Duration duration) {
|
||||
final hours = duration.inHours;
|
||||
final minutes = duration.inMinutes.remainder(60);
|
||||
final seconds = duration.inSeconds.remainder(60);
|
||||
|
||||
if (hours > 0) {
|
||||
return '${hours}h ${minutes}m';
|
||||
} else if (minutes > 0) {
|
||||
return '${minutes}m ${seconds}s';
|
||||
} else {
|
||||
return '${seconds}s';
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Consumer<MapProvider>(
|
||||
builder: (context, mapProvider, child) {
|
||||
final trail = mapProvider.currentTrail;
|
||||
final isVisible = mapProvider.isTrailVisible;
|
||||
|
||||
// Don't show if trail is hidden or doesn't exist
|
||||
if (!isVisible || trail == null || trail.points.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final distance = mapProvider.totalTrailDistance;
|
||||
final duration = mapProvider.trailDuration;
|
||||
final pointCount = trail.points.length;
|
||||
|
||||
return Positioned(
|
||||
top: 16,
|
||||
left: 16,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.7),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.2),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.timeline,
|
||||
color: Colors.blue,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Text(
|
||||
'Location Trail',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_buildStatRow(Icons.straighten, _formatDistance(distance)),
|
||||
const SizedBox(height: 4),
|
||||
_buildStatRow(Icons.access_time, _formatDuration(duration)),
|
||||
const SizedBox(height: 4),
|
||||
_buildStatRow(Icons.place, '$pointCount points'),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatRow(IconData icon, String text) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
color: Colors.white70,
|
||||
size: 16,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
text,
|
||||
style: const TextStyle(
|
||||
color: Colors.white70,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
214
lib/widgets/map/trail_controls.dart
Normal file
214
lib/widgets/map/trail_controls.dart
Normal file
@@ -0,0 +1,214 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../providers/map_provider.dart';
|
||||
|
||||
/// Trail management controls widget
|
||||
class TrailControls extends StatelessWidget {
|
||||
const TrailControls({super.key});
|
||||
|
||||
void _showTrailMenu(BuildContext context) {
|
||||
final mapProvider = Provider.of<MapProvider>(context, listen: false);
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (context) => Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.timeline, size: 24),
|
||||
const SizedBox(width: 12),
|
||||
const Text(
|
||||
'Location Trail',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Trail stats
|
||||
if (mapProvider.currentTrail != null && mapProvider.currentTrail!.points.isNotEmpty)
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.blue.withValues(alpha: 0.3)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildStatRow(
|
||||
icon: Icons.straighten,
|
||||
label: 'Distance',
|
||||
value: _formatDistance(mapProvider.totalTrailDistance),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_buildStatRow(
|
||||
icon: Icons.access_time,
|
||||
label: 'Duration',
|
||||
value: _formatDuration(mapProvider.trailDuration),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_buildStatRow(
|
||||
icon: Icons.place,
|
||||
label: 'Points',
|
||||
value: '${mapProvider.currentTrail!.points.length}',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Clear trail button
|
||||
if (mapProvider.currentTrail != null && mapProvider.currentTrail!.points.isNotEmpty)
|
||||
ElevatedButton.icon(
|
||||
onPressed: () {
|
||||
_showClearConfirmation(context, mapProvider);
|
||||
},
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
label: const Text('Clear Trail'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.red,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.all(16),
|
||||
),
|
||||
),
|
||||
|
||||
// No trail message
|
||||
if (mapProvider.currentTrail == null || mapProvider.currentTrail!.points.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Center(
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(Icons.timeline, size: 48, color: Colors.grey),
|
||||
SizedBox(height: 8),
|
||||
Text(
|
||||
'No trail recorded yet',
|
||||
style: TextStyle(
|
||||
color: Colors.grey,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
Text(
|
||||
'Start location tracking to record your trail',
|
||||
style: TextStyle(
|
||||
color: Colors.grey,
|
||||
fontSize: 12,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Close button
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Close'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showClearConfirmation(BuildContext context, MapProvider mapProvider) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Clear Trail?'),
|
||||
content: const Text(
|
||||
'Are you sure you want to clear the current location trail? This action cannot be undone.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
mapProvider.clearCurrentTrail();
|
||||
Navigator.pop(context); // Close dialog
|
||||
Navigator.pop(context); // Close bottom sheet
|
||||
},
|
||||
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||
child: const Text('Clear'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatRow({
|
||||
required IconData icon,
|
||||
required String label,
|
||||
required String value,
|
||||
}) {
|
||||
return Row(
|
||||
children: [
|
||||
Icon(icon, size: 18, color: Colors.blue),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
value,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
String _formatDistance(double meters) {
|
||||
if (meters < 1000) {
|
||||
return '${meters.toStringAsFixed(0)} m';
|
||||
} else {
|
||||
return '${(meters / 1000).toStringAsFixed(2)} km';
|
||||
}
|
||||
}
|
||||
|
||||
String _formatDuration(Duration duration) {
|
||||
final hours = duration.inHours;
|
||||
final minutes = duration.inMinutes.remainder(60);
|
||||
final seconds = duration.inSeconds.remainder(60);
|
||||
|
||||
if (hours > 0) {
|
||||
return '${hours}h ${minutes}m ${seconds}s';
|
||||
} else if (minutes > 0) {
|
||||
return '${minutes}m ${seconds}s';
|
||||
} else {
|
||||
return '${seconds}s';
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FloatingActionButton.small(
|
||||
heroTag: 'trail_controls',
|
||||
tooltip: 'Trail Controls',
|
||||
onPressed: () => _showTrailMenu(context),
|
||||
child: const Icon(Icons.timeline),
|
||||
);
|
||||
}
|
||||
}
|
||||
210
lib/widgets/permission_request_dialog.dart
Normal file
210
lib/widgets/permission_request_dialog.dart
Normal file
@@ -0,0 +1,210 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
|
||||
/// Dialog that requests location permissions on app startup
|
||||
class PermissionRequestDialog extends StatefulWidget {
|
||||
final VoidCallback onPermissionsGranted;
|
||||
final VoidCallback? onPermissionsDenied;
|
||||
|
||||
const PermissionRequestDialog({
|
||||
super.key,
|
||||
required this.onPermissionsGranted,
|
||||
this.onPermissionsDenied,
|
||||
});
|
||||
|
||||
@override
|
||||
State<PermissionRequestDialog> createState() => _PermissionRequestDialogState();
|
||||
}
|
||||
|
||||
class _PermissionRequestDialogState extends State<PermissionRequestDialog> {
|
||||
bool _isRequesting = false;
|
||||
String? _errorMessage;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Automatically check and request permissions when dialog opens
|
||||
_checkAndRequestPermissions();
|
||||
}
|
||||
|
||||
Future<void> _checkAndRequestPermissions() async {
|
||||
if (_isRequesting) return;
|
||||
|
||||
setState(() {
|
||||
_isRequesting = true;
|
||||
_errorMessage = null;
|
||||
});
|
||||
|
||||
try {
|
||||
// Check if location service is enabled
|
||||
final serviceEnabled = await Geolocator.isLocationServiceEnabled();
|
||||
if (!serviceEnabled) {
|
||||
setState(() {
|
||||
_errorMessage = 'Location services are disabled. Please enable location services in your device settings.';
|
||||
_isRequesting = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check current permission
|
||||
LocationPermission permission = await Geolocator.checkPermission();
|
||||
|
||||
if (permission == LocationPermission.denied) {
|
||||
// Request permission
|
||||
permission = await Geolocator.requestPermission();
|
||||
}
|
||||
|
||||
if (permission == LocationPermission.denied) {
|
||||
setState(() {
|
||||
_errorMessage = 'Location permission denied. This app requires location access to track your position and share it with your team.';
|
||||
_isRequesting = false;
|
||||
});
|
||||
widget.onPermissionsDenied?.call();
|
||||
return;
|
||||
}
|
||||
|
||||
if (permission == LocationPermission.deniedForever) {
|
||||
setState(() {
|
||||
_errorMessage = 'Location permission permanently denied. Please enable location access in your device settings.';
|
||||
_isRequesting = false;
|
||||
});
|
||||
widget.onPermissionsDenied?.call();
|
||||
return;
|
||||
}
|
||||
|
||||
// Permission granted!
|
||||
setState(() {
|
||||
_isRequesting = false;
|
||||
});
|
||||
|
||||
// Close dialog and notify parent
|
||||
if (mounted) {
|
||||
Navigator.of(context).pop();
|
||||
widget.onPermissionsGranted();
|
||||
}
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_errorMessage = 'Error requesting permissions: $e';
|
||||
_isRequesting = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PopScope(
|
||||
// Prevent dismissing dialog by tapping outside
|
||||
canPop: false,
|
||||
child: AlertDialog(
|
||||
title: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.location_on,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
const Text('Location Permission'),
|
||||
],
|
||||
),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'MeshCore SAR needs access to your location to:',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildPermissionReason(
|
||||
icon: Icons.track_changes,
|
||||
text: 'Track your position during search and rescue operations',
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_buildPermissionReason(
|
||||
icon: Icons.share_location,
|
||||
text: 'Share your location with team members via mesh network',
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_buildPermissionReason(
|
||||
icon: Icons.map,
|
||||
text: 'Display your location and trail on the map',
|
||||
),
|
||||
if (_errorMessage != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.red.withValues(alpha: 0.3)),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Icon(Icons.error_outline, color: Colors.red, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_errorMessage!,
|
||||
style: const TextStyle(color: Colors.red, fontSize: 12),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
if (_isRequesting) ...[
|
||||
const SizedBox(height: 16),
|
||||
const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
if (_errorMessage != null && !_isRequesting)
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
widget.onPermissionsDenied?.call();
|
||||
},
|
||||
child: const Text('Skip'),
|
||||
),
|
||||
if (_errorMessage != null && !_isRequesting)
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
// Open app settings
|
||||
await Geolocator.openLocationSettings();
|
||||
},
|
||||
child: const Text('Open Settings'),
|
||||
),
|
||||
if (_errorMessage != null && !_isRequesting &&
|
||||
!_errorMessage!.contains('permanently denied'))
|
||||
ElevatedButton(
|
||||
onPressed: _checkAndRequestPermissions,
|
||||
child: const Text('Retry'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPermissionReason({
|
||||
required IconData icon,
|
||||
required String text,
|
||||
}) {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(icon, size: 20, color: Colors.grey),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
text,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user