diff --git a/lib/providers/app_provider.dart b/lib/providers/app_provider.dart index 30ba659..f0acd85 100644 --- a/lib/providers/app_provider.dart +++ b/lib/providers/app_provider.dart @@ -118,6 +118,34 @@ enum ContactsTabSection { channels, } +enum ChannelLocationSharingMode { hardware, appFallback } + +class ChannelLocationSharingState { + final ChannelLocationSharingMode mode; + final bool isSharing; + final bool hardwareSupported; + final bool isConnected; + + const ChannelLocationSharingState({ + required this.mode, + required this.isSharing, + required this.hardwareSupported, + required this.isConnected, + }); + + bool get usesHardware => mode == ChannelLocationSharingMode.hardware; +} + +class ChannelLocationSharingResult { + final ChannelLocationSharingState state; + final String message; + + const ChannelLocationSharingResult({ + required this.state, + required this.message, + }); +} + /// Main App Provider - coordinates all other providers class AppProvider with ChangeNotifier { static const int _maxDirectPayloadHops = 3; @@ -219,6 +247,8 @@ class AppProvider with ChangeNotifier { final Map _voiceMissingRetryAttempts = {}; final Map _imageMissingRetryTimers = {}; final Map _imageMissingRetryAttempts = {}; + bool _hardwareChannelLocationSharingSupported = false; + int? _hardwareChannelLocationSharingChannelIdx; final FragmentAckWaitRegistry _rawProbeWaiters = FragmentAckWaitRegistry(); final Map> _pendingRawRouteProbes = {}; final Map> _pendingMediaSwarmFetches = {}; @@ -2544,6 +2574,7 @@ class AppProvider with ChangeNotifier { '📍 [AppProvider] Starting location tracking after successful initialization', ); await _startLocationTracking(); + await refreshChannelLocationSharingState(); // Sync drawing messages with DrawingProvider // This restores any drawings that may be missing from storage @@ -2587,6 +2618,7 @@ class AppProvider with ChangeNotifier { await connectionProvider.syncChannels( maxChannels: connectionProvider.deviceInfo.maxChannels, ); + await refreshChannelLocationSharingState(); final messageCount = await connectionProvider.syncAllMessages( force: true, @@ -2744,6 +2776,229 @@ class AppProvider with ChangeNotifier { ]); } + Future refreshChannelLocationSharingState() async { + if (!connectionProvider.deviceInfo.isConnected) { + return; + } + + final vars = await connectionProvider.getCustomVars(); + final hardwareSupported = _supportsHardwareChannelLocationSharing(vars); + final rawChannelIdx = hardwareSupported + ? int.tryParse(vars['fast_gps_channel'] ?? '') + : null; + final normalizedChannelIdx = rawChannelIdx != null && rawChannelIdx > 0 + ? rawChannelIdx + : null; + + if (_hardwareChannelLocationSharingSupported == hardwareSupported && + _hardwareChannelLocationSharingChannelIdx == normalizedChannelIdx) { + return; + } + + _hardwareChannelLocationSharingSupported = hardwareSupported; + _hardwareChannelLocationSharingChannelIdx = normalizedChannelIdx; + notifyListeners(); + } + + ChannelLocationSharingMode? channelLocationSharingModeForChannel( + int channelIdx, + ) { + if (channelIdx <= 0) { + return null; + } + if (_hardwareChannelLocationSharingChannelIdx == channelIdx) { + return ChannelLocationSharingMode.hardware; + } + if (_isAppFallbackLocationSharingActiveForChannel(channelIdx)) { + return ChannelLocationSharingMode.appFallback; + } + return null; + } + + void notifyChannelLocationSharingChanged() { + notifyListeners(); + } + + bool _isAppFallbackLocationSharingActiveForChannel(int channelIdx) { + return locationTrackingService.fastLocationUpdatesEnabled && + locationTrackingService.fastLocationChannelIdx == channelIdx; + } + + bool _supportsHardwareChannelLocationSharing(Map vars) { + return vars.containsKey('gps') && vars.containsKey('fast_gps_channel'); + } + + int? _hardwareChannelLocationSharingIdxFromVars(Map vars) { + final rawChannelIdx = int.tryParse(vars['fast_gps_channel'] ?? ''); + return rawChannelIdx != null && rawChannelIdx > 0 ? rawChannelIdx : null; + } + + Future _setDeviceCustomVarOrThrow(String key, String value) async { + connectionProvider.clearError(); + await connectionProvider.setCustomVar(key, value); + final error = connectionProvider.error; + if (error != null) { + connectionProvider.clearError(); + throw StateError(error); + } + } + + Future getChannelLocationSharingState( + int channelIdx, + ) async { + if (channelIdx <= 0) { + return const ChannelLocationSharingState( + mode: ChannelLocationSharingMode.appFallback, + isSharing: false, + hardwareSupported: false, + isConnected: false, + ); + } + + if (connectionProvider.deviceInfo.isConnected) { + await refreshChannelLocationSharingState(); + } + + final sharingMode = channelLocationSharingModeForChannel(channelIdx); + + return ChannelLocationSharingState( + mode: sharingMode ?? + (_hardwareChannelLocationSharingSupported + ? ChannelLocationSharingMode.hardware + : ChannelLocationSharingMode.appFallback), + isSharing: sharingMode != null, + hardwareSupported: _hardwareChannelLocationSharingSupported, + isConnected: connectionProvider.deviceInfo.isConnected, + ); + } + + Future setChannelLocationSharingEnabled( + int channelIdx, + bool enabled, + ) async { + if (channelIdx <= 0) { + throw ArgumentError.value( + channelIdx, + 'channelIdx', + 'Location sharing requires a non-public channel', + ); + } + + if (!connectionProvider.deviceInfo.isConnected) { + throw StateError('Connect to a device first'); + } + + final vars = await connectionProvider.getCustomVars(); + final hardwareSupported = _supportsHardwareChannelLocationSharing(vars); + if (enabled) { + if (hardwareSupported) { + await _setDeviceCustomVarOrThrow('gps', '1'); + await _setDeviceCustomVarOrThrow( + 'fast_gps_channel', + channelIdx.toString(), + ); + _hardwareChannelLocationSharingSupported = true; + _hardwareChannelLocationSharingChannelIdx = channelIdx; + await locationTrackingService.updateFastLocationChannelIdx(null); + await locationTrackingService.setFastLocationUpdatesEnabled(false); + await refreshChannelLocationSharingState(); + if (_hardwareChannelLocationSharingChannelIdx != channelIdx) { + throw StateError('Radio location sharing did not enable for this channel'); + } + notifyListeners(); + return const ChannelLocationSharingResult( + state: ChannelLocationSharingState( + mode: ChannelLocationSharingMode.hardware, + isSharing: true, + hardwareSupported: true, + isConnected: true, + ), + message: 'Sharing location on this channel from the radio.', + ); + } + + _hardwareChannelLocationSharingSupported = false; + _hardwareChannelLocationSharingChannelIdx = null; + final previousEnabled = locationTrackingService.fastLocationUpdatesEnabled; + final previousChannelIdx = locationTrackingService.fastLocationChannelIdx; + try { + await locationTrackingService.updateFastLocationChannelIdx(channelIdx); + await locationTrackingService.setFastLocationUpdatesEnabled(true); + if (!locationTrackingService.isTracking) { + final started = await locationTrackingService.startTracking(); + if (!started) { + throw StateError('Phone location tracking could not be started'); + } + } + } catch (e) { + await locationTrackingService.updateFastLocationChannelIdx( + previousChannelIdx, + ); + await locationTrackingService.setFastLocationUpdatesEnabled( + previousEnabled, + ); + rethrow; + } + notifyListeners(); + return const ChannelLocationSharingResult( + state: ChannelLocationSharingState( + mode: ChannelLocationSharingMode.appFallback, + isSharing: true, + hardwareSupported: false, + isConnected: true, + ), + message: 'Sharing location on this channel from the phone.', + ); + } + + final activeHardwareChannelIdx = _hardwareChannelLocationSharingIdxFromVars( + vars, + ); + final shouldAttemptHardwareStop = + hardwareSupported || + _hardwareChannelLocationSharingSupported || + activeHardwareChannelIdx == channelIdx || + _hardwareChannelLocationSharingChannelIdx == channelIdx; + + if (locationTrackingService.fastLocationChannelIdx == channelIdx) { + await locationTrackingService.updateFastLocationChannelIdx(null); + await locationTrackingService.setFastLocationUpdatesEnabled(false); + } + + if (shouldAttemptHardwareStop) { + await _setDeviceCustomVarOrThrow('fast_gps_channel', '-1'); + _hardwareChannelLocationSharingSupported = true; + _hardwareChannelLocationSharingChannelIdx = null; + await refreshChannelLocationSharingState(); + if (_hardwareChannelLocationSharingChannelIdx == channelIdx) { + throw StateError('Radio location sharing is still enabled for this channel'); + } + notifyListeners(); + return const ChannelLocationSharingResult( + state: ChannelLocationSharingState( + mode: ChannelLocationSharingMode.hardware, + isSharing: false, + hardwareSupported: true, + isConnected: true, + ), + message: 'Stopped sharing location on this channel.', + ); + } + + _hardwareChannelLocationSharingSupported = false; + _hardwareChannelLocationSharingChannelIdx = null; + notifyListeners(); + return const ChannelLocationSharingResult( + state: ChannelLocationSharingState( + mode: ChannelLocationSharingMode.appFallback, + isSharing: false, + hardwareSupported: false, + isConnected: true, + ), + message: 'Stopped sharing location on this channel.', + ); + } + Future _sendFastLocationUpdate( dynamic position, { required String reason, diff --git a/lib/providers/messages_provider.dart b/lib/providers/messages_provider.dart index 3bb3c16..c4a9c6a 100644 --- a/lib/providers/messages_provider.dart +++ b/lib/providers/messages_provider.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; +import 'package:latlong2/latlong.dart'; import '../models/message.dart'; import '../models/contact.dart'; import '../models/message_contact_location.dart'; @@ -10,6 +11,7 @@ import '../models/path_selection.dart'; import '../models/sar_marker.dart'; import '../models/map_drawing.dart'; import '../services/message_storage_service.dart'; +import '../services/location_tracking_service.dart'; import '../services/notification_service.dart'; import '../utils/sar_message_parser.dart'; import '../utils/drawing_message_parser.dart'; @@ -1808,12 +1810,17 @@ class MessagesProvider with ChangeNotifier { } } enhancedMessage = _resolveSenderNameIfNeeded(enhancedMessage); + final senderLocationSnapshot = _buildSentMessageLocationSnapshot(); // Check for duplicates (shouldn't happen for sent messages, but be safe) if (_findDuplicateMessageIndex(enhancedMessage) != -1) { debugPrint( '⚠️ [MessagesProvider] Duplicate sent message detected, skipping: ${enhancedMessage.id}', ); + if (senderLocationSnapshot != null) { + _messageContactLocations[enhancedMessage.id] = senderLocationSnapshot; + _persistMessages(); + } return; } @@ -1823,6 +1830,9 @@ class MessagesProvider with ChangeNotifier { isRead: true, // Sent messages are always marked as read ); _messages.add(sendingMessage); + if (senderLocationSnapshot != null) { + _messageContactLocations[sendingMessage.id] = senderLocationSnapshot; + } debugPrint(' ✅ Message added to list at index ${_messages.length - 1}'); debugPrint(' Total messages in list: ${_messages.length}'); @@ -1850,6 +1860,20 @@ class MessagesProvider with ChangeNotifier { debugPrint(' ✅ notifyListeners() called - UI should update'); } + MessageContactLocation? _buildSentMessageLocationSnapshot() { + final position = LocationTrackingService().currentPosition; + if (position == null) { + return null; + } + + return MessageContactLocation( + location: LatLng(position.latitude, position.longitude), + source: 'gps', + capturedAt: DateTime.now(), + sourceTimestamp: position.timestamp, + ); + } + /// Register an individual message ID as part of a grouped message void registerGroupedMessageSend( String individualMessageId, diff --git a/lib/screens/contacts_tab.dart b/lib/screens/contacts_tab.dart index 60afcdc..1207f8c 100644 --- a/lib/screens/contacts_tab.dart +++ b/lib/screens/contacts_tab.dart @@ -516,73 +516,174 @@ class _ContactsTabState extends State { messenger.showSnackBar(SnackBar(content: Text(copiedMessage))); } + String _locationSharingErrorMessage(Object error) { + final message = error.toString(); + if (message.startsWith('Bad state: ')) { + return message.substring('Bad state: '.length); + } + return message; + } + + Future _setChannelLocationSharing( + BuildContext context, + Contact channel, + bool enabled, + ) async { + final channelIdx = channel.publicKey.length > 1 ? channel.publicKey[1] : 0; + try { + final result = await context.read().setChannelLocationSharingEnabled( + channelIdx, + enabled, + ); + if (!context.mounted) return; + ToastLogger.success(context, result.message); + } catch (error) { + if (!context.mounted) return; + ToastLogger.error(context, _locationSharingErrorMessage(error)); + } + } + + Future _handleChannelLocationSharingAction( + BuildContext context, + Contact channel, + ) async { + if (channel.isPublicChannel) { + if (!context.mounted) return; + ToastLogger.error( + context, + 'Select a private channel to share your location.', + ); + return; + } + + final channelIdx = channel.publicKey.length > 1 ? channel.publicKey[1] : 0; + if (channelIdx <= 0) { + if (!context.mounted) return; + ToastLogger.error( + context, + 'Select a private channel to share your location.', + ); + return; + } + + try { + final sharingState = await context + .read() + .getChannelLocationSharingState(channelIdx); + if (!context.mounted) return; + await _setChannelLocationSharing(context, channel, !sharingState.isSharing); + } catch (error) { + if (!context.mounted) return; + ToastLogger.error(context, _locationSharingErrorMessage(error)); + } + } + void _showChannelActionSheet(BuildContext context, Contact channel) { final l10n = AppLocalizations.of(context)!; final canExportHashChannelPsk = Channel.isHashChannelName( channel.advName.trim(), ); - final actions = <_ChannelSheetAction>[ - _ChannelSheetAction( - icon: Icons.message_outlined, - label: l10n.messages, - onTap: () async { - Navigator.pop(context); - await _openMessagesForChannel(context, channel); - }, - ), - if (channel.displayLocation != null) - _ChannelSheetAction( - icon: Icons.map_outlined, - label: l10n.viewOnMap, - onTap: () async { - Navigator.pop(context); - _showChannelOnMap(context, channel); - }, - ), - if (canExportHashChannelPsk) - _ChannelSheetAction( - icon: Icons.key_outlined, - label: '${l10n.exportToClipboard} psk_base64', - onTap: () async { - Navigator.pop(context); - await _exportHashChannelPskBase64(context, channel); - }, - ), - _ChannelSheetAction( - icon: Icons.language_rounded, - label: l10n.setRegionScope, - onTap: () async { - Navigator.pop(context); - if (!context.mounted) return; - _showRegionScopeForChannel(context, channel); - }, - ), - if (!channel.isPublicChannel) - _ChannelSheetAction( - icon: Icons.delete_outline_rounded, - label: l10n.deleteChannel, - destructive: true, - onTap: () async { - Navigator.pop(context); - await Future.delayed(Duration.zero); - if (!context.mounted) return; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!context.mounted) return; - _showDeleteChannelDialog(context, channel); - }); - }, - ), - ]; + final channelLocationSharingFuture = + !channel.isPublicChannel && channel.publicKey.length > 1 + ? context + .read() + .getChannelLocationSharingState(channel.publicKey[1]) + : null; showModalBottomSheet( context: context, isScrollControlled: true, backgroundColor: Colors.transparent, - builder: (sheetContext) => _ChannelActionSheet( - channel: channel, - actions: actions, - onClose: () => Navigator.pop(sheetContext), - ), + builder: (sheetContext) { + List<_ChannelSheetAction> buildActions( + ChannelLocationSharingState? sharingState, + ) { + return <_ChannelSheetAction>[ + _ChannelSheetAction( + icon: Icons.message_outlined, + label: l10n.messages, + onTap: () async { + Navigator.pop(context); + await _openMessagesForChannel(context, channel); + }, + ), + if (!channel.isPublicChannel) + _ChannelSheetAction( + icon: sharingState?.isSharing == true + ? Icons.location_off_rounded + : Icons.share_location_rounded, + label: sharingState?.isSharing == true + ? 'Stop sharing my location' + : 'Share my location', + onTap: () async { + Navigator.pop(context); + await _handleChannelLocationSharingAction(context, channel); + }, + ), + if (channel.displayLocation != null) + _ChannelSheetAction( + icon: Icons.map_outlined, + label: l10n.viewOnMap, + onTap: () async { + Navigator.pop(context); + _showChannelOnMap(context, channel); + }, + ), + if (canExportHashChannelPsk) + _ChannelSheetAction( + icon: Icons.key_outlined, + label: '${l10n.exportToClipboard} psk_base64', + onTap: () async { + Navigator.pop(context); + await _exportHashChannelPskBase64(context, channel); + }, + ), + _ChannelSheetAction( + icon: Icons.language_rounded, + label: l10n.setRegionScope, + onTap: () async { + Navigator.pop(context); + if (!context.mounted) return; + _showRegionScopeForChannel(context, channel); + }, + ), + if (!channel.isPublicChannel) + _ChannelSheetAction( + icon: Icons.delete_outline_rounded, + label: l10n.deleteChannel, + destructive: true, + onTap: () async { + Navigator.pop(context); + await Future.delayed(Duration.zero); + if (!context.mounted) return; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!context.mounted) return; + _showDeleteChannelDialog(context, channel); + }); + }, + ), + ]; + } + + Widget buildSheet(ChannelLocationSharingState? sharingState) { + return _ChannelActionSheet( + channel: channel, + actions: buildActions(sharingState), + onClose: () => Navigator.pop(sheetContext), + ); + } + + if (channelLocationSharingFuture == null) { + return buildSheet(null); + } + + return FutureBuilder( + future: channelLocationSharingFuture, + builder: (context, snapshot) { + return buildSheet(snapshot.data); + }, + ); + }, ); } @@ -1820,6 +1921,28 @@ class _ChannelActivityCard extends StatelessWidget { return l10n.daysAgo(diff.inDays); } + Widget _buildChannelLocationSharingMarker( + BuildContext context, + ChannelLocationSharingMode mode, + ) { + final isHardware = mode == ChannelLocationSharingMode.hardware; + const accent = Color(0xFF16A34A); + + return Container( + padding: const EdgeInsets.all(3), + decoration: BoxDecoration( + color: accent, + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 2), + ), + child: Icon( + isHardware ? Icons.public_rounded : Icons.smartphone_rounded, + size: 11, + color: Colors.white, + ), + ); + } + @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; @@ -1828,6 +1951,9 @@ class _ChannelActivityCard extends StatelessWidget { letterSpacing: -0.3, ); final channelIdx = channel.publicKey.length > 1 ? channel.publicKey[1] : 0; + final channelLocationSharingMode = context + .watch() + .channelLocationSharingModeForChannel(channelIdx); final channelMessages = messagesProvider.getMessagesForChannel(channelIdx) ..sort((a, b) => b.sentAt.compareTo(a.sentAt)); final lastActivityAt = messagesProvider.getLastActivityForDestination( @@ -1870,7 +1996,21 @@ class _ChannelActivityCard extends StatelessWidget { child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ - ContactAvatar(contact: channel, radius: 24), + Stack( + clipBehavior: Clip.none, + children: [ + ContactAvatar(contact: channel, radius: 24), + if (channelLocationSharingMode != null) + Positioned( + right: -2, + bottom: -2, + child: _buildChannelLocationSharingMarker( + context, + channelLocationSharingMode, + ), + ), + ], + ), const SizedBox(width: 10), Expanded( child: Column( diff --git a/lib/screens/messages_tab.dart b/lib/screens/messages_tab.dart index 80a7e79..5ded9e5 100644 --- a/lib/screens/messages_tab.dart +++ b/lib/screens/messages_tab.dart @@ -1746,7 +1746,85 @@ class _MessagesTabState extends State { await action(); } + int? _selectedPrivateChannelIdx() { + if (_destinationType != + MessageDestinationPreferences.destinationTypeChannel) { + return null; + } + final recipient = _selectedRecipient; + if (recipient == null || + recipient.isPublicChannel || + recipient.publicKey.length < 2) { + return null; + } + return recipient.publicKey[1]; + } + + String _locationSharingErrorMessage(Object error) { + final message = error.toString(); + if (message.startsWith('Bad state: ')) { + return message.substring('Bad state: '.length); + } + return message; + } + + Future _setSelectedChannelLocationSharing( + int channelIdx, + bool enabled, + ) async { + try { + final result = await context.read().setChannelLocationSharingEnabled( + channelIdx, + enabled, + ); + if (!mounted) return; + ToastLogger.success(context, result.message); + } catch (error) { + if (!mounted) return; + ToastLogger.error(context, _locationSharingErrorMessage(error)); + } + } + + Future _handleChannelLocationSharingAction() async { + final recipient = _selectedRecipient; + if (recipient == null || recipient.isPublicChannel) { + if (!mounted) return; + ToastLogger.error( + context, + 'Select a private channel to share your location.', + ); + return; + } + + final channelIdx = recipient.publicKey.length > 1 ? recipient.publicKey[1] : 0; + if (channelIdx <= 0) { + if (!mounted) return; + ToastLogger.error( + context, + 'Select a private channel to share your location.', + ); + return; + } + + try { + final sharingState = await context + .read() + .getChannelLocationSharingState(channelIdx); + if (!mounted) return; + await _setSelectedChannelLocationSharing(channelIdx, !sharingState.isSharing); + } catch (error) { + if (!mounted) return; + ToastLogger.error(context, _locationSharingErrorMessage(error)); + } + } + void _showComposerActions() { + final privateChannelIdx = _selectedPrivateChannelIdx(); + final locationSharingStateFuture = privateChannelIdx == null + ? null + : context + .read() + .getChannelLocationSharingState(privateChannelIdx); showModalBottomSheet( context: context, showDragHandle: true, @@ -1759,114 +1837,142 @@ class _MessagesTabState extends State { await _runAfterSheetDismissal(sheetContext, action); } - final actions = [ - _ComposerActionTile( - icon: Icons.search_rounded, - title: l10n.searchMessages, - color: const Color(0xFF2B6CB0), - onTap: () => runAction(() async { - _showFilteredMessageSearch(); - }), - ), - _ComposerActionTile( - icon: Icons.add_location_alt_rounded, - title: l10n.sendSarMarker, - color: const Color(0xFFB45309), - onTap: () => runAction(() async { - _showSarDialog(); - }), - ), - if (_voiceSupported) + Widget buildActionsSheet(ChannelLocationSharingState? sharingState) { + final actions = [ _ComposerActionTile( - icon: _isRecording ? Icons.stop_rounded : Icons.mic_rounded, - title: _isRecording ? 'Stop recording' : 'Record voice', - color: const Color(0xFF7C3AED), - enabled: !_isSendingVoice, - busy: _isSendingVoice, - onTap: !_isSendingVoice + icon: Icons.search_rounded, + title: l10n.searchMessages, + color: const Color(0xFF2B6CB0), + onTap: () => runAction(() async { + _showFilteredMessageSearch(); + }), + ), + _ComposerActionTile( + icon: Icons.add_location_alt_rounded, + title: l10n.sendSarMarker, + color: const Color(0xFFB45309), + onTap: () => runAction(() async { + _showSarDialog(); + }), + ), + if (_destinationType == + MessageDestinationPreferences.destinationTypeChannel) + _ComposerActionTile( + icon: sharingState?.isSharing == true + ? Icons.location_off_rounded + : Icons.share_location_rounded, + title: sharingState?.isSharing == true + ? 'Stop sharing my location' + : 'Share my location', + color: const Color(0xFF0F766E), + onTap: () => runAction(() async { + await _handleChannelLocationSharingAction(); + }), + ), + if (_voiceSupported) + _ComposerActionTile( + icon: _isRecording ? Icons.stop_rounded : Icons.mic_rounded, + title: _isRecording ? 'Stop recording' : 'Record voice', + color: const Color(0xFF7C3AED), + enabled: !_isSendingVoice, + busy: _isSendingVoice, + onTap: !_isSendingVoice + ? () => runAction(() async { + if (_isRecording) { + await _stopAndSendVoice(); + } else { + await _startVoiceRecording(); + } + }) + : null, + ), + _ComposerActionTile( + icon: Icons.photo_library_rounded, + title: l10n.sendImageFromGallery, + color: const Color(0xFF0F766E), + enabled: !_isSendingImage, + busy: _isSendingImage, + onTap: !_isSendingImage ? () => runAction(() async { - if (_isRecording) { - await _stopAndSendVoice(); - } else { - await _startVoiceRecording(); - } + await _pickAndSendImage(source: ImageSource.gallery); }) : null, ), - _ComposerActionTile( - icon: Icons.photo_library_rounded, - title: l10n.sendImageFromGallery, - color: const Color(0xFF0F766E), - enabled: !_isSendingImage, - busy: _isSendingImage, - onTap: !_isSendingImage - ? () => runAction(() async { - await _pickAndSendImage(source: ImageSource.gallery); - }) - : null, - ), - _ComposerActionTile( - icon: Icons.camera_alt_rounded, - title: l10n.takePhoto, - color: const Color(0xFF2563EB), - enabled: !_isSendingImage, - busy: _isSendingImage, - onTap: !_isSendingImage - ? () => runAction(() async { - await _pickAndSendImage(source: ImageSource.camera); - }) - : null, - ), - _ComposerActionTile( - icon: Icons.grid_3x3_rounded, - title: l10n.startTictactoe, - color: const Color(0xFFBE185D), - onTap: () => runAction(() async { - await _startTicTacToeGame(); - }), - ), - if (_destinationType == - MessageDestinationPreferences.destinationTypeChannel) _ComposerActionTile( - icon: _channelRegionScopeName != null - ? Icons.language_rounded - : Icons.public_rounded, - title: _channelRegionScopeName != null - ? '${l10n.regionScope}: $_channelRegionScopeName' - : l10n.setRegionScope, - color: const Color(0xFF7C3AED), + icon: Icons.camera_alt_rounded, + title: l10n.takePhoto, + color: const Color(0xFF2563EB), + enabled: !_isSendingImage, + busy: _isSendingImage, + onTap: !_isSendingImage + ? () => runAction(() async { + await _pickAndSendImage(source: ImageSource.camera); + }) + : null, + ), + _ComposerActionTile( + icon: Icons.grid_3x3_rounded, + title: l10n.startTictactoe, + color: const Color(0xFFBE185D), onTap: () => runAction(() async { - _showRegionScopeSheet(); + await _startTicTacToeGame(); }), ), - ]; + if (_destinationType == + MessageDestinationPreferences.destinationTypeChannel) + _ComposerActionTile( + icon: _channelRegionScopeName != null + ? Icons.language_rounded + : Icons.public_rounded, + title: _channelRegionScopeName != null + ? '${l10n.regionScope}: $_channelRegionScopeName' + : l10n.setRegionScope, + color: const Color(0xFF7C3AED), + onTap: () => runAction(() async { + _showRegionScopeSheet(); + }), + ), + ]; - return SafeArea( - child: ConstrainedBox( - constraints: BoxConstraints( - maxHeight: MediaQuery.of(sheetContext).size.height * 0.72, - ), - child: SingleChildScrollView( - padding: const EdgeInsets.fromLTRB(20, 8, 20, 20), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'More actions', - style: theme.textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w700, + return SafeArea( + child: ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(sheetContext).size.height * 0.72, + ), + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'More actions', + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w700, + ), ), - ), - const SizedBox(height: 16), - for (var index = 0; index < actions.length; index++) ...[ - actions[index], - if (index != actions.length - 1) const SizedBox(height: 8), + const SizedBox(height: 16), + for (var index = 0; index < actions.length; index++) ...[ + actions[index], + if (index != actions.length - 1) + const SizedBox(height: 8), + ], ], - ], + ), ), ), - ), + ); + } + + if (locationSharingStateFuture == null) { + return buildActionsSheet(null); + } + + return FutureBuilder( + future: locationSharingStateFuture, + builder: (context, snapshot) { + return buildActionsSheet(snapshot.data); + }, ); }, ); @@ -2027,10 +2133,45 @@ class _MessagesTabState extends State { Widget _buildDestinationAvatar(BuildContext context) { final recipient = _selectedRecipient; if (recipient != null) { - return ContactAvatar( - contact: recipient, - radius: 14, - displayName: _getDestinationLabel(), + final sharingMode = + recipient.isChannel && + !recipient.isPublicChannel && + recipient.publicKey.length > 1 + ? context.watch().channelLocationSharingModeForChannel( + recipient.publicKey[1], + ) + : null; + return Stack( + clipBehavior: Clip.none, + children: [ + ContactAvatar( + contact: recipient, + radius: 14, + displayName: _getDestinationLabel(), + ), + if (sharingMode != null) + Positioned( + right: -3, + bottom: -3, + child: Container( + width: 14, + height: 14, + decoration: BoxDecoration( + color: const Color(0xFF16A34A), + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 1.5), + ), + alignment: Alignment.center, + child: Icon( + sharingMode == ChannelLocationSharingMode.hardware + ? Icons.public_rounded + : Icons.smartphone_rounded, + size: 9, + color: Colors.white, + ), + ), + ), + ], ); } diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 6e17ea2..e7a7833 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -420,6 +420,7 @@ class _SettingsScreenState extends State { Future _setFastLocationUpdatesEnabled(bool enabled) async { await _locationService.setFastLocationUpdatesEnabled(enabled); if (!mounted) return; + context.read().notifyChannelLocationSharingChanged(); setState(() { _fastLocationUpdatesEnabled = _locationService.fastLocationUpdatesEnabled; }); @@ -593,6 +594,7 @@ class _SettingsScreenState extends State { selected < 0 ? null : selected, ); if (!mounted) return; + context.read().notifyChannelLocationSharingChanged(); setState(() { _fastLocationChannelIdx = _locationService.fastLocationChannelIdx; }); diff --git a/lib/widgets/contacts/contact_tile.dart b/lib/widgets/contacts/contact_tile.dart index 21fb74a..5de636d 100644 --- a/lib/widgets/contacts/contact_tile.dart +++ b/lib/widgets/contacts/contact_tile.dart @@ -6,6 +6,7 @@ import 'package:geolocator/geolocator.dart'; import 'package:latlong2/latlong.dart'; import '../../models/contact.dart'; import '../../models/room_login_state.dart'; +import '../../providers/app_provider.dart'; import '../../providers/connection_provider.dart'; import '../../providers/contacts_provider.dart'; import '../../providers/map_provider.dart'; @@ -66,6 +67,28 @@ class ContactTile extends StatelessWidget { .toUpperCase(); } + Widget _buildChannelLocationSharingMarker( + BuildContext context, + ChannelLocationSharingMode mode, + ) { + final isHardware = mode == ChannelLocationSharingMode.hardware; + const accent = Color(0xFF16A34A); + + return Container( + padding: const EdgeInsets.all(3), + decoration: BoxDecoration( + color: accent, + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 2), + ), + child: Icon( + isHardware ? Icons.public_rounded : Icons.smartphone_rounded, + size: 11, + color: Colors.white, + ), + ); + } + @override Widget build(BuildContext context) { final isChannel = contact.type == ContactType.channel; @@ -90,6 +113,7 @@ class ContactTile extends StatelessWidget { // Get room login state if this is a room final connectionProvider = context.watch(); final messagesProvider = context.watch(); + final appProvider = context.watch(); final isPingInProgress = connectionProvider.isPingInProgress( contact.publicKey, ); @@ -134,6 +158,10 @@ class ContactTile extends StatelessWidget { final lastActivityAt = isRoomOrChannel ? messagesProvider.getLastActivityForDestination(contact) : null; + final channelLocationSharingMode = + isChannel && !contact.isPublicChannel && contact.publicKey.length > 1 + ? appProvider.channelLocationSharingModeForChannel(contact.publicKey[1]) + : null; final timeAgoText = _getLocalizedRelativeTime( context, lastActivityAt ?? contact.lastSeenTime, @@ -239,6 +267,15 @@ class ContactTile extends StatelessWidget { ), ), ), + if (isChannel && channelLocationSharingMode != null) + Positioned( + bottom: -2, + right: -2, + child: _buildChannelLocationSharingMarker( + context, + channelLocationSharingMode, + ), + ), if (contact.type == ContactType.room && roomLoginState != null) Positioned( @@ -383,6 +420,68 @@ class ContactTile extends StatelessWidget { _showContactActionSheet(context, contact); } + String _locationSharingErrorMessage(Object error) { + final message = error.toString(); + if (message.startsWith('Bad state: ')) { + return message.substring('Bad state: '.length); + } + return message; + } + + Future _setChannelLocationSharing( + BuildContext context, + Contact contact, + bool enabled, + ) async { + final channelIdx = contact.publicKey.length > 1 ? contact.publicKey[1] : 0; + try { + final result = await context.read().setChannelLocationSharingEnabled( + channelIdx, + enabled, + ); + if (!context.mounted) return; + ToastLogger.success(context, result.message); + } catch (error) { + if (!context.mounted) return; + ToastLogger.error(context, _locationSharingErrorMessage(error)); + } + } + + Future _handleChannelLocationSharingAction( + BuildContext context, + Contact contact, + ) async { + if (contact.isPublicChannel) { + if (!context.mounted) return; + ToastLogger.error( + context, + 'Select a private channel to share your location.', + ); + return; + } + + final channelIdx = contact.publicKey.length > 1 ? contact.publicKey[1] : 0; + if (channelIdx <= 0) { + if (!context.mounted) return; + ToastLogger.error( + context, + 'Select a private channel to share your location.', + ); + return; + } + + try { + final sharingState = await context + .read() + .getChannelLocationSharingState(channelIdx); + if (!context.mounted) return; + await _setChannelLocationSharing(context, contact, !sharingState.isSharing); + } catch (error) { + if (!context.mounted) return; + ToastLogger.error(context, _locationSharingErrorMessage(error)); + } + } + void _showContactActionSheet(BuildContext context, Contact contact) { final l10n = AppLocalizations.of(context)!; final canToggleFavourite = !contact.isChannel; @@ -402,179 +501,220 @@ class ContactTile extends StatelessWidget { final canPreviewSensor = contact.isSensor; final sensorsProvider = context.read(); final isInSensors = sensorsProvider.isWatched(contact.publicKeyHex); - - final primaryActions = <_ContactSheetAction>[ - if (canMessage) - _ContactSheetAction( - icon: Icons.message_outlined, - label: l10n.messages, - onTap: () async { - Navigator.pop(context); - await _openMessagesForContact(context, contact); - }, - ), - if (!contact.isChannel) - _ContactSheetAction( - icon: Icons.share_outlined, - label: l10n.share, - onTap: () async { - Navigator.pop(context); - final connectionProvider = context.read(); - final url = await connectionProvider.exportContactUrl( - contact.publicKey, - ); - if (url != null && context.mounted) { - await Clipboard.setData(ClipboardData(text: url)); - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(l10n.contactLinkCopiedToClipboard)), - ); - } - } else if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(l10n.failedToExportContact)), - ); - } - }, - ), - if (canSetPath) - _ContactSheetAction( - icon: Icons.alt_route, - label: l10n.contactSetPath, - onTap: () async { - Navigator.pop(context); - await _showSetRouteDialog(context, contact); - }, - ), - if (!contact.isPublicChannel) - _ContactSheetAction( - icon: Icons.delete_outline_rounded, - label: l10n.delete, - destructive: true, - onTap: () async { - Navigator.pop(context); - await Future.delayed(Duration.zero); - if (!context.mounted) return; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!context.mounted) return; - if (contact.isChannel) { - _showDeleteChannelDialog(context, contact); - } else { - _showDeleteConfirmation(context, contact); - } - }); - }, - ), - ]; - - final secondaryActions = <_ContactSheetAction>[ - if (contact.displayLocation != null) - _ContactSheetAction( - icon: Icons.map_outlined, - label: l10n.viewOnMap, - onTap: () async { - Navigator.pop(context); - _showContactOnMap(context, contact); - }, - ), - if (contact.type == ContactType.room && !contact.isPublicChannel) - _ContactSheetAction( - icon: Icons.login, - label: - context - .read() - .getRoomLoginState(contact.publicKeyPrefix) - ?.isLoggedIn == - true - ? l10n.reLoginToRoom - : l10n.loginToRoom, - onTap: () async { - Navigator.pop(context); - _showRoomLoginDialog(context, contact); - }, - ), - if (canPreviewSensor) - _ContactSheetAction( - icon: Icons.visibility_outlined, - label: l10n.preview, - onTap: () async { - Navigator.pop(context); - await Future.delayed(Duration.zero); - if (!context.mounted) return; - await _showSensorPreviewView(context, contact); - }, - ), - if (canAddToSensors) - _ContactSheetAction( - icon: isInSensors ? Icons.sensors : Icons.sensors_outlined, - label: isInSensors ? l10n.contactInSensors : l10n.contactAddToSensors, - enabled: !isInSensors, - onTap: () async { - Navigator.pop(context); - await _addContactToSensors(context, contact); - }, - ), - if (!contact.isChannel) - _ContactSheetAction( - icon: Icons.route, - label: l10n.trace, - onTap: () async { - Navigator.pop(context); - _showTraceSheet(context, contact); - }, - ), - if (contact.type == ContactType.repeater) - _ContactSheetAction( - icon: Icons.hub_outlined, - label: 'View Neighbours', - onTap: () async { - Navigator.pop(context); - _showNeighbours(context, contact); - }, - ), - if (contact.type == ContactType.repeater || - contact.type == ContactType.room) - _ContactSheetAction( - icon: Icons.network_ping, - label: 'Ping', - onTap: () async { - Navigator.pop(context); - _pingRelay(context, contact); - }, - ), - if (!contact.isPublicChannel) - _ContactSheetAction( - icon: Icons.edit_outlined, - label: l10n.editName, - onTap: () async { - Navigator.pop(context); - _showNameOverrideDialog(context, contact); - }, - ), - ]; + final channelLocationSharingFuture = + contact.type == ContactType.channel && + !contact.isPublicChannel && + contact.publicKey.length > 1 + ? context + .read() + .getChannelLocationSharingState(contact.publicKey[1]) + : null; showModalBottomSheet( context: context, isScrollControlled: true, backgroundColor: Colors.transparent, - builder: (sheetContext) => _ContactActionSheet( - contact: contact, - primaryActions: primaryActions, - secondaryActions: secondaryActions, - showFavouriteButton: canToggleFavourite, - initialFavourite: contact.isFavourite, - onClose: () => Navigator.pop(sheetContext), - onToggleFavourite: !canToggleFavourite - ? null - : () async { - final toggled = contact.toggleFavourite(); - final connectionProvider = context.read(); - await connectionProvider.addOrUpdateContact(toggled); - if (context.mounted) { - await connectionProvider.getContact(contact.publicKey); - } - }, - ), + builder: (sheetContext) { + Widget buildSheet(ChannelLocationSharingState? sharingState) { + final primaryActions = <_ContactSheetAction>[ + if (canMessage) + _ContactSheetAction( + icon: Icons.message_outlined, + label: l10n.messages, + onTap: () async { + Navigator.pop(context); + await _openMessagesForContact(context, contact); + }, + ), + if (contact.type == ContactType.channel) + _ContactSheetAction( + icon: sharingState?.isSharing == true + ? Icons.location_off_rounded + : Icons.share_location_rounded, + label: sharingState?.isSharing == true + ? 'Stop sharing my location' + : 'Share my location', + onTap: () async { + Navigator.pop(context); + await _handleChannelLocationSharingAction(context, contact); + }, + ), + if (!contact.isChannel) + _ContactSheetAction( + icon: Icons.share_outlined, + label: l10n.share, + onTap: () async { + Navigator.pop(context); + final connectionProvider = context.read(); + final url = await connectionProvider.exportContactUrl( + contact.publicKey, + ); + if (url != null && context.mounted) { + await Clipboard.setData(ClipboardData(text: url)); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(l10n.contactLinkCopiedToClipboard), + ), + ); + } + } else if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(l10n.failedToExportContact)), + ); + } + }, + ), + if (canSetPath) + _ContactSheetAction( + icon: Icons.alt_route, + label: l10n.contactSetPath, + onTap: () async { + Navigator.pop(context); + await _showSetRouteDialog(context, contact); + }, + ), + if (!contact.isPublicChannel) + _ContactSheetAction( + icon: Icons.delete_outline_rounded, + label: l10n.delete, + destructive: true, + onTap: () async { + Navigator.pop(context); + await Future.delayed(Duration.zero); + if (!context.mounted) return; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!context.mounted) return; + if (contact.isChannel) { + _showDeleteChannelDialog(context, contact); + } else { + _showDeleteConfirmation(context, contact); + } + }); + }, + ), + ]; + + final secondaryActions = <_ContactSheetAction>[ + if (contact.displayLocation != null) + _ContactSheetAction( + icon: Icons.map_outlined, + label: l10n.viewOnMap, + onTap: () async { + Navigator.pop(context); + _showContactOnMap(context, contact); + }, + ), + if (contact.type == ContactType.room && !contact.isPublicChannel) + _ContactSheetAction( + icon: Icons.login, + label: + context + .read() + .getRoomLoginState(contact.publicKeyPrefix) + ?.isLoggedIn == + true + ? l10n.reLoginToRoom + : l10n.loginToRoom, + onTap: () async { + Navigator.pop(context); + _showRoomLoginDialog(context, contact); + }, + ), + if (canPreviewSensor) + _ContactSheetAction( + icon: Icons.visibility_outlined, + label: l10n.preview, + onTap: () async { + Navigator.pop(context); + await Future.delayed(Duration.zero); + if (!context.mounted) return; + await _showSensorPreviewView(context, contact); + }, + ), + if (canAddToSensors) + _ContactSheetAction( + icon: isInSensors ? Icons.sensors : Icons.sensors_outlined, + label: isInSensors + ? l10n.contactInSensors + : l10n.contactAddToSensors, + enabled: !isInSensors, + onTap: () async { + Navigator.pop(context); + await _addContactToSensors(context, contact); + }, + ), + if (!contact.isChannel) + _ContactSheetAction( + icon: Icons.route, + label: l10n.trace, + onTap: () async { + Navigator.pop(context); + _showTraceSheet(context, contact); + }, + ), + if (contact.type == ContactType.repeater) + _ContactSheetAction( + icon: Icons.hub_outlined, + label: 'View Neighbours', + onTap: () async { + Navigator.pop(context); + _showNeighbours(context, contact); + }, + ), + if (contact.type == ContactType.repeater || + contact.type == ContactType.room) + _ContactSheetAction( + icon: Icons.network_ping, + label: 'Ping', + onTap: () async { + Navigator.pop(context); + _pingRelay(context, contact); + }, + ), + if (!contact.isPublicChannel) + _ContactSheetAction( + icon: Icons.edit_outlined, + label: l10n.editName, + onTap: () async { + Navigator.pop(context); + _showNameOverrideDialog(context, contact); + }, + ), + ]; + + return _ContactActionSheet( + contact: contact, + primaryActions: primaryActions, + secondaryActions: secondaryActions, + showFavouriteButton: canToggleFavourite, + initialFavourite: contact.isFavourite, + onClose: () => Navigator.pop(sheetContext), + onToggleFavourite: !canToggleFavourite + ? null + : () async { + final toggled = contact.toggleFavourite(); + final connectionProvider = + context.read(); + await connectionProvider.addOrUpdateContact(toggled); + if (context.mounted) { + await connectionProvider.getContact(contact.publicKey); + } + }, + ); + } + + if (channelLocationSharingFuture == null) { + return buildSheet(null); + } + + return FutureBuilder( + future: channelLocationSharingFuture, + builder: (context, snapshot) { + return buildSheet(snapshot.data); + }, + ); + }, ); } diff --git a/lib/widgets/messages/message_bubble.dart b/lib/widgets/messages/message_bubble.dart index 8dccaf0..1ce7bc7 100644 --- a/lib/widgets/messages/message_bubble.dart +++ b/lib/widgets/messages/message_bubble.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter/gestures.dart'; +import 'package:flutter_map/flutter_map.dart' as flutter_map; import 'package:latlong2/latlong.dart'; import 'package:share_plus/share_plus.dart'; import 'package:provider/provider.dart'; @@ -628,7 +629,7 @@ class _MessageBubbleState extends State { recipientName = recipientContact?.advName; } - final senderLocationSnapshot = messagesProvider.getMessageContactLocation( + final messageLocationSnapshot = messagesProvider.getMessageContactLocation( widget.message.id, ); final receptionDetails = messagesProvider.getMessageReceptionDetails( @@ -742,9 +743,9 @@ class _MessageBubbleState extends State { 'Retry result: ${retryResult ?? '-'}', 'Sender key prefix: ${senderPrefixHex ?? '-'}', 'Sender name: ${senderName ?? widget.message.senderName ?? '-'}', - 'Sender location at receipt: ${senderLocationSnapshot?.formattedCoordinates ?? '-'}', - 'Sender location source: ${senderLocationSnapshot?.technicalSourceLabel ?? '-'}', - 'Sender location timestamp: ${senderLocationSnapshot?.sourceTimestamp?.toIso8601String() ?? '-'}', + '${widget.message.isSentMessage ? "Sent from location" : "Sender location at receipt"}: ${messageLocationSnapshot?.formattedCoordinates ?? '-'}', + '${widget.message.isSentMessage ? "Sent from source" : "Sender location source"}: ${messageLocationSnapshot?.technicalSourceLabel ?? '-'}', + '${widget.message.isSentMessage ? "Sent from timestamp" : "Sender location timestamp"}: ${messageLocationSnapshot?.sourceTimestamp?.toIso8601String() ?? '-'}', 'Recipient key prefix: ${recipientPrefixHex ?? '-'}', 'Recipient name: ${recipientName ?? '-'}', 'Drawing flag: ${widget.message.isDrawing}', @@ -907,6 +908,54 @@ class _MessageBubbleState extends State { ), ], ), + if (messageLocationSnapshot != null) ...[ + const SizedBox(height: 12), + _techSection( + sheetContext, + icon: Icons.location_on, + title: AppLocalizations.of(context)!.location, + child: Column( + children: [ + _buildLocationPreviewMap( + sheetContext, + messageLocationSnapshot.location, + ), + const SizedBox(height: 12), + _detailRow( + sheetContext, + label: AppLocalizations.of( + context, + )!.coordinates, + value: messageLocationSnapshot + .formattedCoordinates, + onCopy: () => copyField( + messageLocationSnapshot.formattedCoordinates, + ), + ), + _detailRow( + sheetContext, + label: AppLocalizations.of(context)!.source, + value: messageLocationSnapshot + .technicalSourceLabel, + ), + _detailRow( + sheetContext, + label: AppLocalizations.of(context)!.captured, + value: _formatRfc3339( + messageLocationSnapshot.sourceTimestamp ?? + messageLocationSnapshot.capturedAt, + ), + onCopy: () => copyField( + _formatRfc3339( + messageLocationSnapshot.sourceTimestamp ?? + messageLocationSnapshot.capturedAt, + ), + ), + ), + ], + ), + ), + ], if (widget.message.lastEchoRssiDbm != null || snrDb != null || rssiDbm != null) ...[ @@ -1333,6 +1382,53 @@ class _MessageBubbleState extends State { ); } + Widget _buildLocationPreviewMap(BuildContext context, LatLng location) { + return ClipRRect( + borderRadius: BorderRadius.circular(12), + child: SizedBox( + height: 180, + child: DecoratedBox( + decoration: BoxDecoration( + border: Border.all( + color: Theme.of(context).colorScheme.outlineVariant, + ), + ), + child: flutter_map.FlutterMap( + options: flutter_map.MapOptions( + initialCenter: location, + initialZoom: 15, + interactionOptions: const flutter_map.InteractionOptions( + flags: flutter_map.InteractiveFlag.none, + ), + ), + children: [ + flutter_map.TileLayer( + urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + userAgentPackageName: 'com.meshcore.sar', + ), + flutter_map.MarkerLayer( + markers: [ + flutter_map.Marker( + point: location, + width: 40, + height: 40, + child: Icon( + widget.message.isSentMessage + ? Icons.near_me + : Icons.location_on, + color: Theme.of(context).colorScheme.primary, + size: 32, + ), + ), + ], + ), + ], + ), + ), + ), + ); + } + Widget _techSection( BuildContext context, { required IconData icon, diff --git a/lib/widgets/messages/recipient_selector_sheet.dart b/lib/widgets/messages/recipient_selector_sheet.dart index 92d943a..e5d45ee 100644 --- a/lib/widgets/messages/recipient_selector_sheet.dart +++ b/lib/widgets/messages/recipient_selector_sheet.dart @@ -3,6 +3,7 @@ import 'package:provider/provider.dart'; import '../../l10n/app_localizations.dart'; import '../../models/contact.dart'; +import '../../providers/app_provider.dart'; import '../../providers/messages_provider.dart'; import '../../utils/avatar_label_helper.dart'; import '../common/contact_avatar.dart'; @@ -259,6 +260,19 @@ class _RecipientSelectorSheetState extends State { return _formatRelativeTime(context, lastActivityAt); } + IconData _channelLocationSharingIcon(ChannelLocationSharingMode mode) { + return mode == ChannelLocationSharingMode.hardware + ? Icons.public_rounded + : Icons.smartphone_rounded; + } + + Color _channelLocationSharingColor( + BuildContext context, + ChannelLocationSharingMode mode, + ) { + return const Color(0xFF16A34A); + } + Widget _buildTextSubtitle( BuildContext context, Contact contact, @@ -312,6 +326,9 @@ class _RecipientSelectorSheetState extends State { MessagesProvider? messagesProvider, ) { final previewData = _channelPreviewData(context, channel, messagesProvider); + final sharingMode = context.watch().channelLocationSharingModeForChannel( + channel.publicKey.length > 1 ? channel.publicKey[1] : 0, + ); return _buildRecipientCard( context: context, @@ -319,6 +336,7 @@ class _RecipientSelectorSheetState extends State { contact: channel, title: channel.getLocalizedDisplayName(context), subtitle: _buildChannelSubtitle(context, channel, previewData), + avatarMarkerMode: sharingMode, unreadCount: _unreadFor(channel), isSelected: _isSelected('channel', channel), compact: true, @@ -838,6 +856,7 @@ class _RecipientSelectorSheetState extends State { required Contact contact, required String title, Widget? subtitle, + ChannelLocationSharingMode? avatarMarkerMode, required int unreadCount, required bool isSelected, bool compact = false, @@ -846,6 +865,12 @@ class _RecipientSelectorSheetState extends State { }) { final colorScheme = Theme.of(context).colorScheme; final accentColor = _sectionColor(context, type); + final markerColor = avatarMarkerMode == null + ? accentColor + : _channelLocationSharingColor(context, avatarMarkerMode); + final markerIcon = avatarMarkerMode == null + ? _typeIcon(contact) + : _channelLocationSharingIcon(avatarMarkerMode); return Material( color: Colors.transparent, @@ -886,14 +911,14 @@ class _RecipientSelectorSheetState extends State { color: colorScheme.surface, shape: BoxShape.circle, border: Border.all( - color: accentColor.withValues(alpha: 0.3), + color: markerColor.withValues(alpha: 0.3), ), ), alignment: Alignment.center, child: Icon( - _typeIcon(contact), + markerIcon, size: compact ? 9 : 10, - color: accentColor, + color: markerColor, ), ), ), @@ -945,7 +970,7 @@ class _RecipientSelectorSheetState extends State { ?.copyWith( color: accentColor, fontWeight: FontWeight.w800, - ), + ), ), ), ], diff --git a/test/providers/messages_provider_voice_test.dart b/test/providers/messages_provider_voice_test.dart index 78f285d..a518b45 100644 --- a/test/providers/messages_provider_voice_test.dart +++ b/test/providers/messages_provider_voice_test.dart @@ -1,9 +1,11 @@ import 'dart:typed_data'; +import 'package:geolocator/geolocator.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:meshcore_sar_app/models/contact.dart'; import 'package:meshcore_sar_app/models/message.dart'; import 'package:meshcore_sar_app/models/message_contact_location.dart'; import 'package:meshcore_sar_app/providers/messages_provider.dart'; +import 'package:meshcore_sar_app/services/location_tracking_service.dart'; import 'package:meshcore_sar_app/utils/image_message_parser.dart'; import 'package:meshcore_sar_app/utils/voice_message_parser.dart'; import 'package:latlong2/latlong.dart'; @@ -39,6 +41,7 @@ void main() { group('MessagesProvider voice detection', () { setUp(() { SharedPreferences.setMockInitialValues({}); + LocationTrackingService().currentPosition = null; }); test('marks VE3 envelope messages as voice', () { @@ -131,6 +134,50 @@ void main() { expect(snapshot.location.longitude, closeTo(14.5058, 0.000001)); }); + test('persists current device location for sent messages', () async { + final provider = MessagesProvider(); + LocationTrackingService().currentPosition = Position( + latitude: 46.0569, + longitude: 14.5058, + timestamp: DateTime.fromMillisecondsSinceEpoch(1700000003000), + accuracy: 4.5, + altitude: 310.0, + altitudeAccuracy: 6.0, + heading: 0.0, + headingAccuracy: 0.0, + speed: 0.0, + speedAccuracy: 0.0, + ); + + provider.addSentMessage( + Message( + id: 'sent-1', + messageType: MessageType.contact, + pathLen: 0, + textType: MessageTextType.plain, + senderTimestamp: 1700000003, + text: 'outgoing status', + receivedAt: DateTime.now(), + senderPublicKeyPrefix: Uint8List.fromList([0, 1, 2, 3, 4, 5]), + deliveryStatus: MessageDeliveryStatus.sending, + ), + ); + await Future.delayed(const Duration(milliseconds: 50)); + + final restoredProvider = MessagesProvider(); + await restoredProvider.initialize(); + final snapshot = restoredProvider.getMessageContactLocation('sent-1'); + + expect(snapshot, isNotNull); + expect(snapshot!.source, equals('gps')); + expect(snapshot.location.latitude, closeTo(46.0569, 0.000001)); + expect(snapshot.location.longitude, closeTo(14.5058, 0.000001)); + expect( + snapshot.sourceTimestamp, + DateTime.fromMillisecondsSinceEpoch(1700000003000), + ); + }); + test('dedupes repeated incoming contact messages with same text', () { final provider = MessagesProvider(); final sender = Uint8List.fromList([0, 1, 2, 3, 4, 5]); diff --git a/test/widgets/message_bubble_test.dart b/test/widgets/message_bubble_test.dart index 84c0727..e2a28eb 100644 --- a/test/widgets/message_bubble_test.dart +++ b/test/widgets/message_bubble_test.dart @@ -1,9 +1,11 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart' as flutter_map; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:meshcore_sar_app/l10n/app_localizations.dart'; import 'package:meshcore_sar_app/models/message.dart'; +import 'package:meshcore_sar_app/models/message_contact_location.dart'; import 'package:meshcore_sar_app/providers/app_provider.dart'; import 'package:meshcore_sar_app/providers/channels_provider.dart'; import 'package:meshcore_sar_app/providers/connection_provider.dart'; @@ -15,6 +17,7 @@ import 'package:meshcore_sar_app/providers/voice_provider.dart'; import 'package:meshcore_sar_app/services/voice_codec_service.dart'; import 'package:meshcore_sar_app/services/voice_player_service.dart'; import 'package:meshcore_sar_app/widgets/messages/message_bubble.dart'; +import 'package:latlong2/latlong.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -363,6 +366,50 @@ void main() { await _disposeHarness(tester, harness); } }); + + testWidgets('technical details show a location map for stored snapshots', ( + tester, + ) async { + final harness = await _TestHarness.create(); + try { + final message = Message( + id: 'message-location-details', + messageType: MessageType.contact, + senderPublicKeyPrefix: _prefix(71), + pathLen: 1, + textType: MessageTextType.plain, + senderTimestamp: 1700000000, + text: 'Location details', + receivedAt: DateTime.fromMillisecondsSinceEpoch(1700000000500), + deliveryStatus: MessageDeliveryStatus.received, + ); + + harness.messagesProvider.addMessage( + message, + contactLocationSnapshot: MessageContactLocation( + location: const LatLng(46.0569, 14.5058), + source: 'advert', + capturedAt: DateTime.fromMillisecondsSinceEpoch(1700000000600), + sourceTimestamp: DateTime.fromMillisecondsSinceEpoch(1700000000400), + ), + ); + + await tester.pumpWidget(_buildApp(harness, message)); + await tester.pumpAndSettle(); + + await tester.longPress(find.text('Location details')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Technical details')); + await tester.pumpAndSettle(); + + expect(find.byType(flutter_map.FlutterMap), findsOneWidget); + expect(find.text('46.056900, 14.505800'), findsOneWidget); + expect(find.text('advert'), findsOneWidget); + } finally { + await _disposeHarness(tester, harness); + } + }); } Widget _buildApp(_TestHarness harness, Message message) {