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:
Janez T
2025-10-18 21:12:02 +02:00
parent f88c9cfdd3
commit c50a260263
35 changed files with 1854 additions and 59 deletions

View File

@@ -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",

View File

@@ -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",

View File

@@ -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:

View File

@@ -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';

View File

@@ -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';

View File

@@ -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';

View File

@@ -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",

View File

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

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

View File

@@ -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();

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

View File

@@ -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.

View File

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

View File

@@ -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,
),
),
],
],
),

View File

@@ -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>();

View File

@@ -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),

View File

@@ -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),

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

View File

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

View File

@@ -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');

View File

@@ -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

View File

@@ -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();

View File

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

View File

@@ -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) {

View File

@@ -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,

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

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

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