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

@@ -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,179 +501,220 @@ class ContactTile extends StatelessWidget {
final canPreviewSensor = contact.isSensor;
final sensorsProvider = context.read<SensorsProvider>();
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<ConnectionProvider>();
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<void>.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<ConnectionProvider>()
.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<void>.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<AppProvider>()
.getChannelLocationSharingState(contact.publicKey[1])
: null;
showModalBottomSheet<void>(
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<ConnectionProvider>();
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<ConnectionProvider>();
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<void>.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<ConnectionProvider>()
.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<void>.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<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,
),
),
),
@@ -945,7 +970,7 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
?.copyWith(
color: accentColor,
fontWeight: FontWeight.w800,
),
),
),
),
],