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,
}
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<String, int> _voiceMissingRetryAttempts = {};
final Map<String, Timer> _imageMissingRetryTimers = {};
final Map<String, int> _imageMissingRetryAttempts = {};
bool _hardwareChannelLocationSharingSupported = false;
int? _hardwareChannelLocationSharingChannelIdx;
final FragmentAckWaitRegistry _rawProbeWaiters = FragmentAckWaitRegistry();
final Map<String, Future<bool>> _pendingRawRouteProbes = {};
final Map<String, Future<bool>> _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<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(
dynamic position, {
required String reason,

View File

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

View File

@@ -516,12 +516,89 @@ class _ContactsTabState extends State<ContactsTab> {
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) {
final l10n = AppLocalizations.of(context)!;
final canExportHashChannelPsk = Channel.isHashChannelName(
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(
icon: Icons.message_outlined,
label: l10n.messages,
@@ -530,6 +607,19 @@ class _ContactsTabState extends State<ContactsTab> {
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,
@@ -573,16 +663,27 @@ class _ContactsTabState extends State<ContactsTab> {
},
),
];
}
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (sheetContext) => _ChannelActionSheet(
Widget buildSheet(ChannelLocationSharingState? sharingState) {
return _ChannelActionSheet(
channel: channel,
actions: actions,
actions: buildActions(sharingState),
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);
}
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<AppProvider>()
.channelLocationSharingModeForChannel(channelIdx);
final channelMessages = messagesProvider.getMessagesForChannel(channelIdx)
..sort((a, b) => b.sentAt.compareTo(a.sentAt));
final lastActivityAt = messagesProvider.getLastActivityForDestination(
@@ -1869,8 +1995,22 @@ class _ChannelActivityCard extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
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(

View File

@@ -1746,7 +1746,85 @@ class _MessagesTabState extends State<MessagesTab> {
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() {
final privateChannelIdx = _selectedPrivateChannelIdx();
final locationSharingStateFuture = privateChannelIdx == null
? null
: context
.read<AppProvider>()
.getChannelLocationSharingState(privateChannelIdx);
showModalBottomSheet(
context: context,
showDragHandle: true,
@@ -1759,6 +1837,7 @@ class _MessagesTabState extends State<MessagesTab> {
await _runAfterSheetDismissal(sheetContext, action);
}
Widget buildActionsSheet(ChannelLocationSharingState? sharingState) {
final actions = <Widget>[
_ComposerActionTile(
icon: Icons.search_rounded,
@@ -1776,6 +1855,20 @@ class _MessagesTabState extends State<MessagesTab> {
_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,
@@ -1861,13 +1954,26 @@ class _MessagesTabState extends State<MessagesTab> {
const SizedBox(height: 16),
for (var index = 0; index < actions.length; 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) {
final recipient = _selectedRecipient;
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,
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,
),
),
),
],
);
}

View File

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

View File

@@ -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<ConnectionProvider>();
final messagesProvider = context.watch<MessagesProvider>();
final appProvider = context.watch<AppProvider>();
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<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) {
final l10n = AppLocalizations.of(context)!;
final canToggleFavourite = !contact.isChannel;
@@ -402,7 +501,21 @@ class ContactTile extends StatelessWidget {
final canPreviewSensor = contact.isSensor;
final sensorsProvider = context.read<SensorsProvider>();
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>[
if (canMessage)
_ContactSheetAction(
@@ -413,6 +526,19 @@ class ContactTile extends StatelessWidget {
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,
@@ -427,7 +553,9 @@ class ContactTile extends StatelessWidget {
await Clipboard.setData(ClipboardData(text: url));
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.contactLinkCopiedToClipboard)),
SnackBar(
content: Text(l10n.contactLinkCopiedToClipboard),
),
);
}
} else if (context.mounted) {
@@ -507,7 +635,9 @@ class ContactTile extends StatelessWidget {
if (canAddToSensors)
_ContactSheetAction(
icon: isInSensors ? Icons.sensors : Icons.sensors_outlined,
label: isInSensors ? l10n.contactInSensors : l10n.contactAddToSensors,
label: isInSensors
? l10n.contactInSensors
: l10n.contactAddToSensors,
enabled: !isInSensors,
onTap: () async {
Navigator.pop(context);
@@ -553,11 +683,7 @@ class ContactTile extends StatelessWidget {
),
];
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (sheetContext) => _ContactActionSheet(
return _ContactActionSheet(
contact: contact,
primaryActions: primaryActions,
secondaryActions: secondaryActions,
@@ -568,13 +694,27 @@ class ContactTile extends StatelessWidget {
? null
: () async {
final toggled = contact.toggleFavourite();
final connectionProvider = context.read<ConnectionProvider>();
final connectionProvider =
context.read<ConnectionProvider>();
await connectionProvider.addOrUpdateContact(toggled);
if (context.mounted) {
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/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<MessageBubble> {
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<MessageBubble> {
'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<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 ||
snrDb != 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(
BuildContext context, {
required IconData icon,

View File

@@ -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<RecipientSelectorSheet> {
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<RecipientSelectorSheet> {
MessagesProvider? messagesProvider,
) {
final previewData = _channelPreviewData(context, channel, messagesProvider);
final sharingMode = context.watch<AppProvider>().channelLocationSharingModeForChannel(
channel.publicKey.length > 1 ? channel.publicKey[1] : 0,
);
return _buildRecipientCard(
context: context,
@@ -319,6 +336,7 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
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<RecipientSelectorSheet> {
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<RecipientSelectorSheet> {
}) {
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<RecipientSelectorSheet> {
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,
),
),
),

View File

@@ -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<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', () {
final provider = MessagesProvider();
final sender = Uint8List.fromList([0, 1, 2, 3, 4, 5]);

View File

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