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

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