mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
feat: Add message location details
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user