diff --git a/.claude/settings.local.json b/.claude/settings.local.json
index 53da1e5..c2d104d 100644
--- a/.claude/settings.local.json
+++ b/.claude/settings.local.json
@@ -4,7 +4,19 @@
"Bash(magick Icon-App-1024x1024@1x.png -background white -alpha remove -alpha off Icon-App-1024x1024@1x.png)",
"Bash(convert Icon-App-1024x1024@1x.png -background white -alpha remove -alpha off Icon-App-1024x1024@1x-noalpha.png)",
"Bash(sips -s format jpeg Icon-App-1024x1024@1x.png --out /tmp/icon_flattened.jpg)",
- "Bash(sips -s format png /tmp/icon_flattened.jpg --out Icon-App-1024x1024@1x.png)"
+ "Bash(sips -s format png /tmp/icon_flattened.jpg --out Icon-App-1024x1024@1x.png)",
+ "Bash(flutter analyze lib/services/ble/)",
+ "Bash(flutter analyze lib/models/location_trail.dart lib/providers/map_provider.dart lib/widgets/map/location_trail_layer.dart lib/widgets/map/trail_controls.dart lib/widgets/map/compass/compass_filters.dart)",
+ "Bash(flutter analyze lib/screens/map_tab.dart)",
+ "Bash(flutter gen-l10n)",
+ "Bash(flutter analyze lib/screens/contacts_tab.dart)",
+ "Bash(flutter analyze lib/widgets/permission_request_dialog.dart lib/main.dart)",
+ "Bash(flutter analyze lib/screens/settings_screen.dart)",
+ "Bash(flutter analyze lib/services/location_tracking_service.dart)",
+ "Bash(flutter analyze lib/main.dart lib/screens/home_screen.dart)",
+ "Bash(flutter analyze lib/main.dart)",
+ "Bash(flutter analyze lib/providers/app_provider.dart lib/services/meshcore_ble_service.dart)",
+ "Bash(flutter analyze lib/services/meshcore_ble_service.dart)"
],
"deny": [],
"ask": []
diff --git a/ios/Runner.app.dSYM.zip b/ios/Runner.app.dSYM.zip
index 1d07b85..8d459db 100644
Binary files a/ios/Runner.app.dSYM.zip and b/ios/Runner.app.dSYM.zip differ
diff --git a/ios/Runner.ipa b/ios/Runner.ipa
index 58fd4f6..3f9725c 100644
Binary files a/ios/Runner.ipa and b/ios/Runner.ipa differ
diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj
index 0cfb72b..bd74033 100644
--- a/ios/Runner.xcodeproj/project.pbxproj
+++ b/ios/Runner.xcodeproj/project.pbxproj
@@ -489,7 +489,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
- CURRENT_PROJECT_VERSION = 28;
+ CURRENT_PROJECT_VERSION = 31;
DEVELOPMENT_TEAM = JND55328G8;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@@ -511,7 +511,7 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 28;
+ CURRENT_PROJECT_VERSION = 31;
DEVELOPMENT_TEAM = JND55328G8;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
@@ -530,7 +530,7 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 28;
+ CURRENT_PROJECT_VERSION = 31;
DEVELOPMENT_TEAM = JND55328G8;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
@@ -547,7 +547,7 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 28;
+ CURRENT_PROJECT_VERSION = 31;
DEVELOPMENT_TEAM = JND55328G8;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
@@ -679,7 +679,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
- CURRENT_PROJECT_VERSION = 28;
+ CURRENT_PROJECT_VERSION = 31;
DEVELOPMENT_TEAM = JND55328G8;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@@ -702,7 +702,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
- CURRENT_PROJECT_VERSION = 28;
+ CURRENT_PROJECT_VERSION = 31;
DEVELOPMENT_TEAM = JND55328G8;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist
index 180ed19..60a9edf 100644
--- a/ios/Runner/Info.plist
+++ b/ios/Runner/Info.plist
@@ -43,7 +43,7 @@
CFBundleSignature
????
CFBundleVersion
- 28
+ 31
LSRequiresIPhoneOS
NSBluetoothAlwaysUsageDescription
@@ -60,6 +60,8 @@
MeshCore SAR needs location access to display team members and SAR markers on the map
NSMotionUsageDescription
MeshCore SAR needs access to the compass to show your heading direction on the map
+ NSPhotoLibraryUsageDescription
+ MeshCore SAR may need access to your photo library to attach images to messages or save map screenshots for documentation during SAR operations
UIApplicationSupportsIndirectInputEvents
UIBackgroundModes
diff --git a/ios/fastlane/report.xml b/ios/fastlane/report.xml
index e729032..6e77260 100644
--- a/ios/fastlane/report.xml
+++ b/ios/fastlane/report.xml
@@ -5,24 +5,24 @@
-
+
-
+
-
+
-
+
-
+
diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb
index 8b23ee8..42b8d74 100644
--- a/lib/l10n/app_en.arb
+++ b/lib/l10n/app_en.arb
@@ -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",
diff --git a/lib/l10n/app_hr.arb b/lib/l10n/app_hr.arb
index 535f2b9..ef50437 100644
--- a/lib/l10n/app_hr.arb
+++ b/lib/l10n/app_hr.arb
@@ -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",
diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart
index 655248c..0acd34f 100644
--- a/lib/l10n/app_localizations.dart
+++ b/lib/l10n/app_localizations.dart
@@ -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:
diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart
index c7c8905..8d757b5 100644
--- a/lib/l10n/app_localizations_en.dart
+++ b/lib/l10n/app_localizations_en.dart
@@ -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';
diff --git a/lib/l10n/app_localizations_hr.dart b/lib/l10n/app_localizations_hr.dart
index 1d67be4..08b2d4f 100644
--- a/lib/l10n/app_localizations_hr.dart
+++ b/lib/l10n/app_localizations_hr.dart
@@ -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';
diff --git a/lib/l10n/app_localizations_sl.dart b/lib/l10n/app_localizations_sl.dart
index 4f34ee5..bc56795 100644
--- a/lib/l10n/app_localizations_sl.dart
+++ b/lib/l10n/app_localizations_sl.dart
@@ -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';
diff --git a/lib/l10n/app_sl.arb b/lib/l10n/app_sl.arb
index 8bbb858..2599399 100644
--- a/lib/l10n/app_sl.arb
+++ b/lib/l10n/app_sl.arb
@@ -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",
diff --git a/lib/main.dart b/lib/main.dart
index 6f7cddf..c13fcc4 100644
--- a/lib/main.dart
+++ b/lib/main.dart
@@ -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 {
AppThemeMode _themeMode = AppThemeMode.system;
Locale? _locale;
bool _isInitialized = false;
+ bool _shouldShowPermissionDialog = false;
@override
void initState() {
@@ -42,11 +45,29 @@ class _MeshCoreSarAppState extends State {
await _loadLocalePreference();
// Initialize notification service
await NotificationService().initialize();
+
+ // Check if we need to request location permissions
+ await _checkLocationPermissions();
+
setState(() {
_isInitialized = true;
});
}
+ Future _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 _loadThemePreference() async {
final prefs = await SharedPreferences.getInstance();
final themeName = prefs.getString('theme_mode') ?? 'system';
@@ -114,27 +135,30 @@ class _MeshCoreSarAppState extends State {
return provider;
},
),
+ ChangeNotifierProvider(create: (_) => ChannelsProvider()),
// Tile cache service
Provider(create: (_) => TileCacheService()),
// App provider that coordinates everything
- ChangeNotifierProxyProvider5(
+ ChangeNotifierProxyProvider6(
create: (context) => AppProvider(
connectionProvider: context.read(),
contactsProvider: context.read(),
messagesProvider: context.read(),
drawingProvider: context.read(),
+ channelsProvider: context.read(),
tileCacheService: context.read(),
),
- 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 {
onLocaleChanged: _handleLocaleChanged,
currentTheme: _themeMode,
currentLocale: _locale,
+ shouldShowPermissionDialog: _shouldShowPermissionDialog,
),
);
},
diff --git a/lib/models/channel.dart b/lib/models/channel.dart
new file mode 100644
index 0000000..e79c120
--- /dev/null
+++ b/lib/models/channel.dart
@@ -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 json) {
+ return Channel(
+ index: json['index'] as int,
+ name: json['name'] as String? ?? '',
+ flags: json['flags'] as int?,
+ );
+ }
+
+ /// Convert to JSON
+ Map 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);
+}
diff --git a/lib/models/location_trail.dart b/lib/models/location_trail.dart
new file mode 100644
index 0000000..66432f2
--- /dev/null
+++ b/lib/models/location_trail.dart
@@ -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 toJson() => {
+ 'lat': position.latitude,
+ 'lon': position.longitude,
+ 'timestamp': timestamp.toIso8601String(),
+ 'accuracy': accuracy,
+ 'speed': speed,
+ };
+
+ factory TrailPoint.fromJson(Map 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 points;
+ final DateTime startTime;
+ DateTime? endTime;
+ bool isActive;
+
+ LocationTrail({
+ required this.id,
+ List? 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 get latLngPoints => points.map((p) => p.position).toList();
+
+ Map toJson() => {
+ 'id': id,
+ 'points': points.map((p) => p.toJson()).toList(),
+ 'startTime': startTime.toIso8601String(),
+ 'endTime': endTime?.toIso8601String(),
+ 'isActive': isActive,
+ };
+
+ factory LocationTrail.fromJson(Map json) {
+ return LocationTrail(
+ id: json['id'] as String,
+ points: (json['points'] as List)
+ .map((p) => TrailPoint.fromJson(p as Map))
+ .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,
+ );
+ }
+}
diff --git a/lib/providers/app_provider.dart b/lib/providers/app_provider.dart
index 74bcde8..e66f893 100644
--- a/lib/providers/app_provider.dart
+++ b/lib/providers/app_provider.dart
@@ -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();
diff --git a/lib/providers/channels_provider.dart b/lib/providers/channels_provider.dart
new file mode 100644
index 0000000..3527285
--- /dev/null
+++ b/lib/providers/channels_provider.dart
@@ -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 _channels = {};
+
+ /// Get all channels
+ List 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();
+ }
+}
diff --git a/lib/providers/connection_provider.dart b/lib/providers/connection_provider.dart
index af157b3..788e92f 100644
--- a/lib/providers/connection_provider.dart
+++ b/lib/providers/connection_provider.dart
@@ -123,6 +123,7 @@ class ConnectionProvider with ChangeNotifier {
Function(List)? 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 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.
diff --git a/lib/providers/map_provider.dart b/lib/providers/map_provider.dart
index 3f0e67b..e47a3a2 100644
--- a/lib/providers/map_provider.dart
+++ b/lib/providers/map_provider.dart
@@ -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 _visibleContactPaths = {};
+ // Location trail tracking
+ LocationTrail? _currentTrail;
+ bool _isTrailVisible = true;
+ final List _trailHistory = [];
+
LatLng? get targetLocation => _targetLocation;
double? get targetZoom => _targetZoom;
bool get shouldAnimate => _shouldAnimate;
Set get visibleContactPaths => Set.unmodifiable(_visibleContactPaths);
+ // Trail getters
+ LocationTrail? get currentTrail => _currentTrail;
+ bool get isTrailVisible => _isTrailVisible;
+ List 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;
+ }
}
diff --git a/lib/screens/contacts_tab.dart b/lib/screens/contacts_tab.dart
index 8a41a90..c211015 100644
--- a/lib/screens/contacts_tab.dart
+++ b/lib/screens/contacts_tab.dart
@@ -92,8 +92,14 @@ class _ContactsTabState extends State {
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 {
const Divider(height: 32),
],
- // Rooms/Channels
+ // Rooms
if (rooms.isNotEmpty) ...[
_SectionHeader(
title: l10n.rooms,
@@ -175,6 +181,24 @@ class _ContactsTabState extends State {
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,
+ ),
+ ),
],
],
),
diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart
index 5e8e05d..37758c7 100644
--- a/lib/screens/home_screen.dart
+++ b/lib/screens/home_screen.dart
@@ -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
});
});
_loadRxTxPreference();
+
+ // Show permission dialog after the first frame if needed
+ if (widget.shouldShowPermissionDialog) {
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ _showPermissionDialog();
+ });
+ }
}
Future _loadRxTxPreference() async {
@@ -75,6 +85,34 @@ class _HomeScreenState extends State
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 _advertiseDevice(BuildContext context) async {
final connectionProvider = context.read();
diff --git a/lib/screens/map_tab.dart b/lib/screens/map_tab.dart
index 16a75bf..1480d34 100644
--- a/lib/screens/map_tab.dart
+++ b/lib/screens/map_tab.dart
@@ -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 with AutomaticKeepAliveClientMixin {
// Position updates trigger UI rebuild for markers
});
+ // Add location point to trail when tracking is active
+ final mapProvider = context.read();
+ 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 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 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 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),
diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart
index 8fab91d..ec3fc39 100644
--- a/lib/screens/settings_screen.dart
+++ b/lib/screens/settings_screen.dart
@@ -249,6 +249,89 @@ class _SettingsScreenState extends State {
}
}
+ Future _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 _startBackgroundTracking() async {
final success = await _locationService.startTracking(
distanceThreshold: _locationService.gpsUpdateDistance,
@@ -359,6 +442,54 @@ class _SettingsScreenState extends State {
),
const Divider(),
+ // Permissions Section
+ _buildSectionHeader('Permissions'),
+ ListTile(
+ leading: const Icon(Icons.location_on),
+ title: const Text('Location Permission'),
+ subtitle: FutureBuilder(
+ 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),
diff --git a/lib/services/ble/ble_command_queue.dart b/lib/services/ble/ble_command_queue.dart
new file mode 100644
index 0000000..faaacf0
--- /dev/null
+++ b/lib/services/ble/ble_command_queue.dart
@@ -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 {
+ /// 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 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 _queue = [];
+
+ // Mutex lock using Completer
+ Completer _lock = Completer()..complete();
+
+ // Whether queue is currently processing
+ bool _isProcessing = false;
+
+ // Pending responses mapped by command code
+ final Map _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 enqueue({
+ 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(
+ data: data,
+ commandCode: commandCode,
+ responseType: responseType,
+ expectedResponseCode: expectedResponseCode,
+ completer: Completer(),
+ 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 _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();
+
+ // 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(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();
+ }
+ }
+}
diff --git a/lib/services/ble/ble_command_sender.dart b/lib/services/ble/ble_command_sender.dart
index c04953b..c55276b 100644
--- a/lib/services/ble/ble_command_sender.dart
+++ b/lib/services/ble/ble_command_sender.dart
@@ -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 _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 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 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(
+ 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 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(
+ 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 writeDataAndWaitForResponse(
+ 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(
+ 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 _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();
}
diff --git a/lib/services/ble/ble_response_handler.dart b/lib/services/ble/ble_response_handler.dart
index ca33f55..2920a49 100644
--- a/lib/services/ble/ble_response_handler.dart
+++ b/lib/services/ble/ble_response_handler.dart
@@ -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 _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 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(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