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>( + 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>( + 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>( + 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'); diff --git a/lib/services/location_tracking_service.dart b/lib/services/location_tracking_service.dart index 010083a..e6accef 100644 --- a/lib/services/location_tracking_service.dart +++ b/lib/services/location_tracking_service.dart @@ -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 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 diff --git a/lib/services/meshcore_ble_service.dart b/lib/services/meshcore_ble_service.dart index 7981306..f3fe785 100644 --- a/lib/services/meshcore_ble_service.dart +++ b/lib/services/meshcore_ble_service.dart @@ -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 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 _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>( + 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>( + 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 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 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 getChannel(int channelIdx) async { + await _commandSender.writeData(FrameBuilder.buildGetChannel(channelIdx)); + } + + /// Set the name for a specific channel + Future 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 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(); diff --git a/lib/services/protocol/frame_builder.dart b/lib/services/protocol/frame_builder.dart index 84611e9..30ea2dc 100644 --- a/lib/services/protocol/frame_builder.dart +++ b/lib/services/protocol/frame_builder.dart @@ -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(); + } } diff --git a/lib/services/protocol/frame_parser.dart b/lib/services/protocol/frame_parser.dart index b90f5fd..b3d4484 100644 --- a/lib/services/protocol/frame_parser.dart +++ b/lib/services/protocol/frame_parser.dart @@ -387,6 +387,28 @@ class FrameParser { return null; } + /// Parse ChannelInfo response + static Map 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) { diff --git a/lib/widgets/map/compass/compass_filters.dart b/lib/widgets/map/compass/compass_filters.dart index c14bd45..6b3ace4 100644 --- a/lib/widgets/map/compass/compass_filters.dart +++ b/lib/widgets/map/compass/compass_filters.dart @@ -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 { void _showFilterDialog() { final l10n = AppLocalizations.of(context)!; + final mapProvider = Provider.of(context, listen: false); + showDialog( context: context, builder: (context) => StatefulBuilder( @@ -54,6 +58,20 @@ class _CompassFiltersState extends State { 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, diff --git a/lib/widgets/map/location_trail_layer.dart b/lib/widgets/map/location_trail_layer.dart new file mode 100644 index 0000000..b4b9952 --- /dev/null +++ b/lib/widgets/map/location_trail_layer.dart @@ -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( + 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( + 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, + ), + ), + ], + ); + } +} diff --git a/lib/widgets/map/trail_controls.dart b/lib/widgets/map/trail_controls.dart new file mode 100644 index 0000000..a7056e9 --- /dev/null +++ b/lib/widgets/map/trail_controls.dart @@ -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(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), + ); + } +} diff --git a/lib/widgets/permission_request_dialog.dart b/lib/widgets/permission_request_dialog.dart new file mode 100644 index 0000000..53690a1 --- /dev/null +++ b/lib/widgets/permission_request_dialog.dart @@ -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 createState() => _PermissionRequestDialogState(); +} + +class _PermissionRequestDialogState extends State { + bool _isRequesting = false; + String? _errorMessage; + + @override + void initState() { + super.initState(); + // Automatically check and request permissions when dialog opens + _checkAndRequestPermissions(); + } + + Future _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), + ), + ), + ], + ); + } +}