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

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