feat: Add message location details

This commit is contained in:
Janez T
2026-04-05 19:33:41 +02:00
parent 7f7f4aa975
commit 7f4b4c4560
10 changed files with 1249 additions and 332 deletions

View File

@@ -118,6 +118,34 @@ enum ContactsTabSection {
channels, 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 /// Main App Provider - coordinates all other providers
class AppProvider with ChangeNotifier { class AppProvider with ChangeNotifier {
static const int _maxDirectPayloadHops = 3; static const int _maxDirectPayloadHops = 3;
@@ -219,6 +247,8 @@ class AppProvider with ChangeNotifier {
final Map<String, int> _voiceMissingRetryAttempts = {}; final Map<String, int> _voiceMissingRetryAttempts = {};
final Map<String, Timer> _imageMissingRetryTimers = {}; final Map<String, Timer> _imageMissingRetryTimers = {};
final Map<String, int> _imageMissingRetryAttempts = {}; final Map<String, int> _imageMissingRetryAttempts = {};
bool _hardwareChannelLocationSharingSupported = false;
int? _hardwareChannelLocationSharingChannelIdx;
final FragmentAckWaitRegistry _rawProbeWaiters = FragmentAckWaitRegistry(); final FragmentAckWaitRegistry _rawProbeWaiters = FragmentAckWaitRegistry();
final Map<String, Future<bool>> _pendingRawRouteProbes = {}; final Map<String, Future<bool>> _pendingRawRouteProbes = {};
final Map<String, Future<bool>> _pendingMediaSwarmFetches = {}; final Map<String, Future<bool>> _pendingMediaSwarmFetches = {};
@@ -2544,6 +2574,7 @@ class AppProvider with ChangeNotifier {
'📍 [AppProvider] Starting location tracking after successful initialization', '📍 [AppProvider] Starting location tracking after successful initialization',
); );
await _startLocationTracking(); await _startLocationTracking();
await refreshChannelLocationSharingState();
// Sync drawing messages with DrawingProvider // Sync drawing messages with DrawingProvider
// This restores any drawings that may be missing from storage // This restores any drawings that may be missing from storage
@@ -2587,6 +2618,7 @@ class AppProvider with ChangeNotifier {
await connectionProvider.syncChannels( await connectionProvider.syncChannels(
maxChannels: connectionProvider.deviceInfo.maxChannels, maxChannels: connectionProvider.deviceInfo.maxChannels,
); );
await refreshChannelLocationSharingState();
final messageCount = await connectionProvider.syncAllMessages( final messageCount = await connectionProvider.syncAllMessages(
force: true, force: true,
@@ -2744,6 +2776,229 @@ class AppProvider with ChangeNotifier {
]); ]);
} }
Future<void> 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<String, String> vars) {
return vars.containsKey('gps') && vars.containsKey('fast_gps_channel');
}
int? _hardwareChannelLocationSharingIdxFromVars(Map<String, String> vars) {
final rawChannelIdx = int.tryParse(vars['fast_gps_channel'] ?? '');
return rawChannelIdx != null && rawChannelIdx > 0 ? rawChannelIdx : null;
}
Future<void> _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<ChannelLocationSharingState> 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<ChannelLocationSharingResult> 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<void> _sendFastLocationUpdate( Future<void> _sendFastLocationUpdate(
dynamic position, { dynamic position, {
required String reason, required String reason,

View File

@@ -1,5 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:latlong2/latlong.dart';
import '../models/message.dart'; import '../models/message.dart';
import '../models/contact.dart'; import '../models/contact.dart';
import '../models/message_contact_location.dart'; import '../models/message_contact_location.dart';
@@ -10,6 +11,7 @@ import '../models/path_selection.dart';
import '../models/sar_marker.dart'; import '../models/sar_marker.dart';
import '../models/map_drawing.dart'; import '../models/map_drawing.dart';
import '../services/message_storage_service.dart'; import '../services/message_storage_service.dart';
import '../services/location_tracking_service.dart';
import '../services/notification_service.dart'; import '../services/notification_service.dart';
import '../utils/sar_message_parser.dart'; import '../utils/sar_message_parser.dart';
import '../utils/drawing_message_parser.dart'; import '../utils/drawing_message_parser.dart';
@@ -1808,12 +1810,17 @@ class MessagesProvider with ChangeNotifier {
} }
} }
enhancedMessage = _resolveSenderNameIfNeeded(enhancedMessage); enhancedMessage = _resolveSenderNameIfNeeded(enhancedMessage);
final senderLocationSnapshot = _buildSentMessageLocationSnapshot();
// Check for duplicates (shouldn't happen for sent messages, but be safe) // Check for duplicates (shouldn't happen for sent messages, but be safe)
if (_findDuplicateMessageIndex(enhancedMessage) != -1) { if (_findDuplicateMessageIndex(enhancedMessage) != -1) {
debugPrint( debugPrint(
'⚠️ [MessagesProvider] Duplicate sent message detected, skipping: ${enhancedMessage.id}', '⚠️ [MessagesProvider] Duplicate sent message detected, skipping: ${enhancedMessage.id}',
); );
if (senderLocationSnapshot != null) {
_messageContactLocations[enhancedMessage.id] = senderLocationSnapshot;
_persistMessages();
}
return; return;
} }
@@ -1823,6 +1830,9 @@ class MessagesProvider with ChangeNotifier {
isRead: true, // Sent messages are always marked as read isRead: true, // Sent messages are always marked as read
); );
_messages.add(sendingMessage); _messages.add(sendingMessage);
if (senderLocationSnapshot != null) {
_messageContactLocations[sendingMessage.id] = senderLocationSnapshot;
}
debugPrint(' ✅ Message added to list at index ${_messages.length - 1}'); debugPrint(' ✅ Message added to list at index ${_messages.length - 1}');
debugPrint(' Total messages in list: ${_messages.length}'); debugPrint(' Total messages in list: ${_messages.length}');
@@ -1850,6 +1860,20 @@ class MessagesProvider with ChangeNotifier {
debugPrint(' ✅ notifyListeners() called - UI should update'); 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 /// Register an individual message ID as part of a grouped message
void registerGroupedMessageSend( void registerGroupedMessageSend(
String individualMessageId, String individualMessageId,

View File

@@ -516,12 +516,89 @@ class _ContactsTabState extends State<ContactsTab> {
messenger.showSnackBar(SnackBar(content: Text(copiedMessage))); 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<void> _setChannelLocationSharing(
BuildContext context,
Contact channel,
bool enabled,
) async {
final channelIdx = channel.publicKey.length > 1 ? channel.publicKey[1] : 0;
try {
final result = await context.read<AppProvider>().setChannelLocationSharingEnabled(
channelIdx,
enabled,
);
if (!context.mounted) return;
ToastLogger.success(context, result.message);
} catch (error) {
if (!context.mounted) return;
ToastLogger.error(context, _locationSharingErrorMessage(error));
}
}
Future<void> _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<AppProvider>()
.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) { void _showChannelActionSheet(BuildContext context, Contact channel) {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
final canExportHashChannelPsk = Channel.isHashChannelName( final canExportHashChannelPsk = Channel.isHashChannelName(
channel.advName.trim(), channel.advName.trim(),
); );
final actions = <_ChannelSheetAction>[ final channelLocationSharingFuture =
!channel.isPublicChannel && channel.publicKey.length > 1
? context
.read<AppProvider>()
.getChannelLocationSharingState(channel.publicKey[1])
: null;
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (sheetContext) {
List<_ChannelSheetAction> buildActions(
ChannelLocationSharingState? sharingState,
) {
return <_ChannelSheetAction>[
_ChannelSheetAction( _ChannelSheetAction(
icon: Icons.message_outlined, icon: Icons.message_outlined,
label: l10n.messages, label: l10n.messages,
@@ -530,6 +607,19 @@ class _ContactsTabState extends State<ContactsTab> {
await _openMessagesForChannel(context, channel); 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) if (channel.displayLocation != null)
_ChannelSheetAction( _ChannelSheetAction(
icon: Icons.map_outlined, icon: Icons.map_outlined,
@@ -573,16 +663,27 @@ class _ContactsTabState extends State<ContactsTab> {
}, },
), ),
]; ];
}
showModalBottomSheet<void>( Widget buildSheet(ChannelLocationSharingState? sharingState) {
context: context, return _ChannelActionSheet(
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (sheetContext) => _ChannelActionSheet(
channel: channel, channel: channel,
actions: actions, actions: buildActions(sharingState),
onClose: () => Navigator.pop(sheetContext), onClose: () => Navigator.pop(sheetContext),
), );
}
if (channelLocationSharingFuture == null) {
return buildSheet(null);
}
return FutureBuilder<ChannelLocationSharingState>(
future: channelLocationSharingFuture,
builder: (context, snapshot) {
return buildSheet(snapshot.data);
},
);
},
); );
} }
@@ -1820,6 +1921,28 @@ class _ChannelActivityCard extends StatelessWidget {
return l10n.daysAgo(diff.inDays); 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
@@ -1828,6 +1951,9 @@ class _ChannelActivityCard extends StatelessWidget {
letterSpacing: -0.3, letterSpacing: -0.3,
); );
final channelIdx = channel.publicKey.length > 1 ? channel.publicKey[1] : 0; final channelIdx = channel.publicKey.length > 1 ? channel.publicKey[1] : 0;
final channelLocationSharingMode = context
.watch<AppProvider>()
.channelLocationSharingModeForChannel(channelIdx);
final channelMessages = messagesProvider.getMessagesForChannel(channelIdx) final channelMessages = messagesProvider.getMessagesForChannel(channelIdx)
..sort((a, b) => b.sentAt.compareTo(a.sentAt)); ..sort((a, b) => b.sentAt.compareTo(a.sentAt));
final lastActivityAt = messagesProvider.getLastActivityForDestination( final lastActivityAt = messagesProvider.getLastActivityForDestination(
@@ -1869,8 +1995,22 @@ class _ChannelActivityCard extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [
Stack(
clipBehavior: Clip.none,
children: [ children: [
ContactAvatar(contact: channel, radius: 24), ContactAvatar(contact: channel, radius: 24),
if (channelLocationSharingMode != null)
Positioned(
right: -2,
bottom: -2,
child: _buildChannelLocationSharingMarker(
context,
channelLocationSharingMode,
),
),
],
),
const SizedBox(width: 10), const SizedBox(width: 10),
Expanded( Expanded(
child: Column( child: Column(

View File

@@ -1746,7 +1746,85 @@ class _MessagesTabState extends State<MessagesTab> {
await action(); 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<void> _setSelectedChannelLocationSharing(
int channelIdx,
bool enabled,
) async {
try {
final result = await context.read<AppProvider>().setChannelLocationSharingEnabled(
channelIdx,
enabled,
);
if (!mounted) return;
ToastLogger.success(context, result.message);
} catch (error) {
if (!mounted) return;
ToastLogger.error(context, _locationSharingErrorMessage(error));
}
}
Future<void> _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<AppProvider>()
.getChannelLocationSharingState(channelIdx);
if (!mounted) return;
await _setSelectedChannelLocationSharing(channelIdx, !sharingState.isSharing);
} catch (error) {
if (!mounted) return;
ToastLogger.error(context, _locationSharingErrorMessage(error));
}
}
void _showComposerActions() { void _showComposerActions() {
final privateChannelIdx = _selectedPrivateChannelIdx();
final locationSharingStateFuture = privateChannelIdx == null
? null
: context
.read<AppProvider>()
.getChannelLocationSharingState(privateChannelIdx);
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
showDragHandle: true, showDragHandle: true,
@@ -1759,6 +1837,7 @@ class _MessagesTabState extends State<MessagesTab> {
await _runAfterSheetDismissal(sheetContext, action); await _runAfterSheetDismissal(sheetContext, action);
} }
Widget buildActionsSheet(ChannelLocationSharingState? sharingState) {
final actions = <Widget>[ final actions = <Widget>[
_ComposerActionTile( _ComposerActionTile(
icon: Icons.search_rounded, icon: Icons.search_rounded,
@@ -1776,6 +1855,20 @@ class _MessagesTabState extends State<MessagesTab> {
_showSarDialog(); _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) if (_voiceSupported)
_ComposerActionTile( _ComposerActionTile(
icon: _isRecording ? Icons.stop_rounded : Icons.mic_rounded, icon: _isRecording ? Icons.stop_rounded : Icons.mic_rounded,
@@ -1861,13 +1954,26 @@ class _MessagesTabState extends State<MessagesTab> {
const SizedBox(height: 16), const SizedBox(height: 16),
for (var index = 0; index < actions.length; index++) ...[ for (var index = 0; index < actions.length; index++) ...[
actions[index], actions[index],
if (index != actions.length - 1) const SizedBox(height: 8), if (index != actions.length - 1)
const SizedBox(height: 8),
], ],
], ],
), ),
), ),
), ),
); );
}
if (locationSharingStateFuture == null) {
return buildActionsSheet(null);
}
return FutureBuilder<ChannelLocationSharingState>(
future: locationSharingStateFuture,
builder: (context, snapshot) {
return buildActionsSheet(snapshot.data);
},
);
}, },
); );
} }
@@ -2027,10 +2133,45 @@ class _MessagesTabState extends State<MessagesTab> {
Widget _buildDestinationAvatar(BuildContext context) { Widget _buildDestinationAvatar(BuildContext context) {
final recipient = _selectedRecipient; final recipient = _selectedRecipient;
if (recipient != null) { if (recipient != null) {
return ContactAvatar( final sharingMode =
recipient.isChannel &&
!recipient.isPublicChannel &&
recipient.publicKey.length > 1
? context.watch<AppProvider>().channelLocationSharingModeForChannel(
recipient.publicKey[1],
)
: null;
return Stack(
clipBehavior: Clip.none,
children: [
ContactAvatar(
contact: recipient, contact: recipient,
radius: 14, radius: 14,
displayName: _getDestinationLabel(), 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,
),
),
),
],
); );
} }

View File

@@ -420,6 +420,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
Future<void> _setFastLocationUpdatesEnabled(bool enabled) async { Future<void> _setFastLocationUpdatesEnabled(bool enabled) async {
await _locationService.setFastLocationUpdatesEnabled(enabled); await _locationService.setFastLocationUpdatesEnabled(enabled);
if (!mounted) return; if (!mounted) return;
context.read<AppProvider>().notifyChannelLocationSharingChanged();
setState(() { setState(() {
_fastLocationUpdatesEnabled = _locationService.fastLocationUpdatesEnabled; _fastLocationUpdatesEnabled = _locationService.fastLocationUpdatesEnabled;
}); });
@@ -593,6 +594,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
selected < 0 ? null : selected, selected < 0 ? null : selected,
); );
if (!mounted) return; if (!mounted) return;
context.read<AppProvider>().notifyChannelLocationSharingChanged();
setState(() { setState(() {
_fastLocationChannelIdx = _locationService.fastLocationChannelIdx; _fastLocationChannelIdx = _locationService.fastLocationChannelIdx;
}); });

View File

@@ -6,6 +6,7 @@ import 'package:geolocator/geolocator.dart';
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
import '../../models/contact.dart'; import '../../models/contact.dart';
import '../../models/room_login_state.dart'; import '../../models/room_login_state.dart';
import '../../providers/app_provider.dart';
import '../../providers/connection_provider.dart'; import '../../providers/connection_provider.dart';
import '../../providers/contacts_provider.dart'; import '../../providers/contacts_provider.dart';
import '../../providers/map_provider.dart'; import '../../providers/map_provider.dart';
@@ -66,6 +67,28 @@ class ContactTile extends StatelessWidget {
.toUpperCase(); .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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isChannel = contact.type == ContactType.channel; final isChannel = contact.type == ContactType.channel;
@@ -90,6 +113,7 @@ class ContactTile extends StatelessWidget {
// Get room login state if this is a room // Get room login state if this is a room
final connectionProvider = context.watch<ConnectionProvider>(); final connectionProvider = context.watch<ConnectionProvider>();
final messagesProvider = context.watch<MessagesProvider>(); final messagesProvider = context.watch<MessagesProvider>();
final appProvider = context.watch<AppProvider>();
final isPingInProgress = connectionProvider.isPingInProgress( final isPingInProgress = connectionProvider.isPingInProgress(
contact.publicKey, contact.publicKey,
); );
@@ -134,6 +158,10 @@ class ContactTile extends StatelessWidget {
final lastActivityAt = isRoomOrChannel final lastActivityAt = isRoomOrChannel
? messagesProvider.getLastActivityForDestination(contact) ? messagesProvider.getLastActivityForDestination(contact)
: null; : null;
final channelLocationSharingMode =
isChannel && !contact.isPublicChannel && contact.publicKey.length > 1
? appProvider.channelLocationSharingModeForChannel(contact.publicKey[1])
: null;
final timeAgoText = _getLocalizedRelativeTime( final timeAgoText = _getLocalizedRelativeTime(
context, context,
lastActivityAt ?? contact.lastSeenTime, 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 && if (contact.type == ContactType.room &&
roomLoginState != null) roomLoginState != null)
Positioned( Positioned(
@@ -383,6 +420,68 @@ class ContactTile extends StatelessWidget {
_showContactActionSheet(context, contact); _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<void> _setChannelLocationSharing(
BuildContext context,
Contact contact,
bool enabled,
) async {
final channelIdx = contact.publicKey.length > 1 ? contact.publicKey[1] : 0;
try {
final result = await context.read<AppProvider>().setChannelLocationSharingEnabled(
channelIdx,
enabled,
);
if (!context.mounted) return;
ToastLogger.success(context, result.message);
} catch (error) {
if (!context.mounted) return;
ToastLogger.error(context, _locationSharingErrorMessage(error));
}
}
Future<void> _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<AppProvider>()
.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) { void _showContactActionSheet(BuildContext context, Contact contact) {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
final canToggleFavourite = !contact.isChannel; final canToggleFavourite = !contact.isChannel;
@@ -402,7 +501,21 @@ class ContactTile extends StatelessWidget {
final canPreviewSensor = contact.isSensor; final canPreviewSensor = contact.isSensor;
final sensorsProvider = context.read<SensorsProvider>(); final sensorsProvider = context.read<SensorsProvider>();
final isInSensors = sensorsProvider.isWatched(contact.publicKeyHex); final isInSensors = sensorsProvider.isWatched(contact.publicKeyHex);
final channelLocationSharingFuture =
contact.type == ContactType.channel &&
!contact.isPublicChannel &&
contact.publicKey.length > 1
? context
.read<AppProvider>()
.getChannelLocationSharingState(contact.publicKey[1])
: null;
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (sheetContext) {
Widget buildSheet(ChannelLocationSharingState? sharingState) {
final primaryActions = <_ContactSheetAction>[ final primaryActions = <_ContactSheetAction>[
if (canMessage) if (canMessage)
_ContactSheetAction( _ContactSheetAction(
@@ -413,6 +526,19 @@ class ContactTile extends StatelessWidget {
await _openMessagesForContact(context, contact); 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) if (!contact.isChannel)
_ContactSheetAction( _ContactSheetAction(
icon: Icons.share_outlined, icon: Icons.share_outlined,
@@ -427,7 +553,9 @@ class ContactTile extends StatelessWidget {
await Clipboard.setData(ClipboardData(text: url)); await Clipboard.setData(ClipboardData(text: url));
if (context.mounted) { if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.contactLinkCopiedToClipboard)), SnackBar(
content: Text(l10n.contactLinkCopiedToClipboard),
),
); );
} }
} else if (context.mounted) { } else if (context.mounted) {
@@ -507,7 +635,9 @@ class ContactTile extends StatelessWidget {
if (canAddToSensors) if (canAddToSensors)
_ContactSheetAction( _ContactSheetAction(
icon: isInSensors ? Icons.sensors : Icons.sensors_outlined, icon: isInSensors ? Icons.sensors : Icons.sensors_outlined,
label: isInSensors ? l10n.contactInSensors : l10n.contactAddToSensors, label: isInSensors
? l10n.contactInSensors
: l10n.contactAddToSensors,
enabled: !isInSensors, enabled: !isInSensors,
onTap: () async { onTap: () async {
Navigator.pop(context); Navigator.pop(context);
@@ -553,11 +683,7 @@ class ContactTile extends StatelessWidget {
), ),
]; ];
showModalBottomSheet<void>( return _ContactActionSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (sheetContext) => _ContactActionSheet(
contact: contact, contact: contact,
primaryActions: primaryActions, primaryActions: primaryActions,
secondaryActions: secondaryActions, secondaryActions: secondaryActions,
@@ -568,13 +694,27 @@ class ContactTile extends StatelessWidget {
? null ? null
: () async { : () async {
final toggled = contact.toggleFavourite(); final toggled = contact.toggleFavourite();
final connectionProvider = context.read<ConnectionProvider>(); final connectionProvider =
context.read<ConnectionProvider>();
await connectionProvider.addOrUpdateContact(toggled); await connectionProvider.addOrUpdateContact(toggled);
if (context.mounted) { if (context.mounted) {
await connectionProvider.getContact(contact.publicKey); await connectionProvider.getContact(contact.publicKey);
} }
}, },
), );
}
if (channelLocationSharingFuture == null) {
return buildSheet(null);
}
return FutureBuilder<ChannelLocationSharingState>(
future: channelLocationSharingFuture,
builder: (context, snapshot) {
return buildSheet(snapshot.data);
},
);
},
); );
} }

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
import 'package:flutter_map/flutter_map.dart' as flutter_map;
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
import 'package:share_plus/share_plus.dart'; import 'package:share_plus/share_plus.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@@ -628,7 +629,7 @@ class _MessageBubbleState extends State<MessageBubble> {
recipientName = recipientContact?.advName; recipientName = recipientContact?.advName;
} }
final senderLocationSnapshot = messagesProvider.getMessageContactLocation( final messageLocationSnapshot = messagesProvider.getMessageContactLocation(
widget.message.id, widget.message.id,
); );
final receptionDetails = messagesProvider.getMessageReceptionDetails( final receptionDetails = messagesProvider.getMessageReceptionDetails(
@@ -742,9 +743,9 @@ class _MessageBubbleState extends State<MessageBubble> {
'Retry result: ${retryResult ?? '-'}', 'Retry result: ${retryResult ?? '-'}',
'Sender key prefix: ${senderPrefixHex ?? '-'}', 'Sender key prefix: ${senderPrefixHex ?? '-'}',
'Sender name: ${senderName ?? widget.message.senderName ?? '-'}', 'Sender name: ${senderName ?? widget.message.senderName ?? '-'}',
'Sender location at receipt: ${senderLocationSnapshot?.formattedCoordinates ?? '-'}', '${widget.message.isSentMessage ? "Sent from location" : "Sender location at receipt"}: ${messageLocationSnapshot?.formattedCoordinates ?? '-'}',
'Sender location source: ${senderLocationSnapshot?.technicalSourceLabel ?? '-'}', '${widget.message.isSentMessage ? "Sent from source" : "Sender location source"}: ${messageLocationSnapshot?.technicalSourceLabel ?? '-'}',
'Sender location timestamp: ${senderLocationSnapshot?.sourceTimestamp?.toIso8601String() ?? '-'}', '${widget.message.isSentMessage ? "Sent from timestamp" : "Sender location timestamp"}: ${messageLocationSnapshot?.sourceTimestamp?.toIso8601String() ?? '-'}',
'Recipient key prefix: ${recipientPrefixHex ?? '-'}', 'Recipient key prefix: ${recipientPrefixHex ?? '-'}',
'Recipient name: ${recipientName ?? '-'}', 'Recipient name: ${recipientName ?? '-'}',
'Drawing flag: ${widget.message.isDrawing}', 'Drawing flag: ${widget.message.isDrawing}',
@@ -907,6 +908,54 @@ class _MessageBubbleState extends State<MessageBubble> {
), ),
], ],
), ),
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 || if (widget.message.lastEchoRssiDbm != null ||
snrDb != null || snrDb != null ||
rssiDbm != null) ...[ rssiDbm != null) ...[
@@ -1333,6 +1382,53 @@ class _MessageBubbleState extends State<MessageBubble> {
); );
} }
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( Widget _techSection(
BuildContext context, { BuildContext context, {
required IconData icon, required IconData icon,

View File

@@ -3,6 +3,7 @@ import 'package:provider/provider.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
import '../../models/contact.dart'; import '../../models/contact.dart';
import '../../providers/app_provider.dart';
import '../../providers/messages_provider.dart'; import '../../providers/messages_provider.dart';
import '../../utils/avatar_label_helper.dart'; import '../../utils/avatar_label_helper.dart';
import '../common/contact_avatar.dart'; import '../common/contact_avatar.dart';
@@ -259,6 +260,19 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
return _formatRelativeTime(context, lastActivityAt); 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( Widget _buildTextSubtitle(
BuildContext context, BuildContext context,
Contact contact, Contact contact,
@@ -312,6 +326,9 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
MessagesProvider? messagesProvider, MessagesProvider? messagesProvider,
) { ) {
final previewData = _channelPreviewData(context, channel, messagesProvider); final previewData = _channelPreviewData(context, channel, messagesProvider);
final sharingMode = context.watch<AppProvider>().channelLocationSharingModeForChannel(
channel.publicKey.length > 1 ? channel.publicKey[1] : 0,
);
return _buildRecipientCard( return _buildRecipientCard(
context: context, context: context,
@@ -319,6 +336,7 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
contact: channel, contact: channel,
title: channel.getLocalizedDisplayName(context), title: channel.getLocalizedDisplayName(context),
subtitle: _buildChannelSubtitle(context, channel, previewData), subtitle: _buildChannelSubtitle(context, channel, previewData),
avatarMarkerMode: sharingMode,
unreadCount: _unreadFor(channel), unreadCount: _unreadFor(channel),
isSelected: _isSelected('channel', channel), isSelected: _isSelected('channel', channel),
compact: true, compact: true,
@@ -838,6 +856,7 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
required Contact contact, required Contact contact,
required String title, required String title,
Widget? subtitle, Widget? subtitle,
ChannelLocationSharingMode? avatarMarkerMode,
required int unreadCount, required int unreadCount,
required bool isSelected, required bool isSelected,
bool compact = false, bool compact = false,
@@ -846,6 +865,12 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
}) { }) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final accentColor = _sectionColor(context, type); final accentColor = _sectionColor(context, type);
final markerColor = avatarMarkerMode == null
? accentColor
: _channelLocationSharingColor(context, avatarMarkerMode);
final markerIcon = avatarMarkerMode == null
? _typeIcon(contact)
: _channelLocationSharingIcon(avatarMarkerMode);
return Material( return Material(
color: Colors.transparent, color: Colors.transparent,
@@ -886,14 +911,14 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
color: colorScheme.surface, color: colorScheme.surface,
shape: BoxShape.circle, shape: BoxShape.circle,
border: Border.all( border: Border.all(
color: accentColor.withValues(alpha: 0.3), color: markerColor.withValues(alpha: 0.3),
), ),
), ),
alignment: Alignment.center, alignment: Alignment.center,
child: Icon( child: Icon(
_typeIcon(contact), markerIcon,
size: compact ? 9 : 10, size: compact ? 9 : 10,
color: accentColor, color: markerColor,
), ),
), ),
), ),

View File

@@ -1,9 +1,11 @@
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:geolocator/geolocator.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/models/contact.dart'; import 'package:meshcore_sar_app/models/contact.dart';
import 'package:meshcore_sar_app/models/message.dart'; import 'package:meshcore_sar_app/models/message.dart';
import 'package:meshcore_sar_app/models/message_contact_location.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/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/image_message_parser.dart';
import 'package:meshcore_sar_app/utils/voice_message_parser.dart'; import 'package:meshcore_sar_app/utils/voice_message_parser.dart';
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
@@ -39,6 +41,7 @@ void main() {
group('MessagesProvider voice detection', () { group('MessagesProvider voice detection', () {
setUp(() { setUp(() {
SharedPreferences.setMockInitialValues({}); SharedPreferences.setMockInitialValues({});
LocationTrackingService().currentPosition = null;
}); });
test('marks VE3 envelope messages as voice', () { test('marks VE3 envelope messages as voice', () {
@@ -131,6 +134,50 @@ void main() {
expect(snapshot.location.longitude, closeTo(14.5058, 0.000001)); 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<void>.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', () { test('dedupes repeated incoming contact messages with same text', () {
final provider = MessagesProvider(); final provider = MessagesProvider();
final sender = Uint8List.fromList([0, 1, 2, 3, 4, 5]); final sender = Uint8List.fromList([0, 1, 2, 3, 4, 5]);

View File

@@ -1,9 +1,11 @@
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart' as flutter_map;
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/l10n/app_localizations.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.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/app_provider.dart';
import 'package:meshcore_sar_app/providers/channels_provider.dart'; import 'package:meshcore_sar_app/providers/channels_provider.dart';
import 'package:meshcore_sar_app/providers/connection_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_codec_service.dart';
import 'package:meshcore_sar_app/services/voice_player_service.dart'; import 'package:meshcore_sar_app/services/voice_player_service.dart';
import 'package:meshcore_sar_app/widgets/messages/message_bubble.dart'; import 'package:meshcore_sar_app/widgets/messages/message_bubble.dart';
import 'package:latlong2/latlong.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
@@ -363,6 +366,50 @@ void main() {
await _disposeHarness(tester, harness); 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) { Widget _buildApp(_TestHarness harness, Message message) {