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,
|
channels,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum ChannelLocationSharingMode { hardware, appFallback }
|
||||||
|
|
||||||
|
class ChannelLocationSharingState {
|
||||||
|
final ChannelLocationSharingMode mode;
|
||||||
|
final bool isSharing;
|
||||||
|
final bool hardwareSupported;
|
||||||
|
final bool isConnected;
|
||||||
|
|
||||||
|
const ChannelLocationSharingState({
|
||||||
|
required this.mode,
|
||||||
|
required this.isSharing,
|
||||||
|
required this.hardwareSupported,
|
||||||
|
required this.isConnected,
|
||||||
|
});
|
||||||
|
|
||||||
|
bool get usesHardware => mode == ChannelLocationSharingMode.hardware;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ChannelLocationSharingResult {
|
||||||
|
final ChannelLocationSharingState state;
|
||||||
|
final String message;
|
||||||
|
|
||||||
|
const ChannelLocationSharingResult({
|
||||||
|
required this.state,
|
||||||
|
required this.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// Main App Provider - coordinates all other providers
|
/// Main App Provider - coordinates all other providers
|
||||||
class AppProvider with ChangeNotifier {
|
class AppProvider with ChangeNotifier {
|
||||||
static const int _maxDirectPayloadHops = 3;
|
static const int _maxDirectPayloadHops = 3;
|
||||||
@@ -219,6 +247,8 @@ class AppProvider with ChangeNotifier {
|
|||||||
final Map<String, int> _voiceMissingRetryAttempts = {};
|
final Map<String, int> _voiceMissingRetryAttempts = {};
|
||||||
final Map<String, Timer> _imageMissingRetryTimers = {};
|
final Map<String, Timer> _imageMissingRetryTimers = {};
|
||||||
final Map<String, int> _imageMissingRetryAttempts = {};
|
final Map<String, int> _imageMissingRetryAttempts = {};
|
||||||
|
bool _hardwareChannelLocationSharingSupported = false;
|
||||||
|
int? _hardwareChannelLocationSharingChannelIdx;
|
||||||
final FragmentAckWaitRegistry _rawProbeWaiters = FragmentAckWaitRegistry();
|
final FragmentAckWaitRegistry _rawProbeWaiters = FragmentAckWaitRegistry();
|
||||||
final Map<String, Future<bool>> _pendingRawRouteProbes = {};
|
final Map<String, Future<bool>> _pendingRawRouteProbes = {};
|
||||||
final Map<String, Future<bool>> _pendingMediaSwarmFetches = {};
|
final Map<String, Future<bool>> _pendingMediaSwarmFetches = {};
|
||||||
@@ -2544,6 +2574,7 @@ class AppProvider with ChangeNotifier {
|
|||||||
'📍 [AppProvider] Starting location tracking after successful initialization',
|
'📍 [AppProvider] Starting location tracking after successful initialization',
|
||||||
);
|
);
|
||||||
await _startLocationTracking();
|
await _startLocationTracking();
|
||||||
|
await refreshChannelLocationSharingState();
|
||||||
|
|
||||||
// Sync drawing messages with DrawingProvider
|
// Sync drawing messages with DrawingProvider
|
||||||
// This restores any drawings that may be missing from storage
|
// This restores any drawings that may be missing from storage
|
||||||
@@ -2587,6 +2618,7 @@ class AppProvider with ChangeNotifier {
|
|||||||
await connectionProvider.syncChannels(
|
await connectionProvider.syncChannels(
|
||||||
maxChannels: connectionProvider.deviceInfo.maxChannels,
|
maxChannels: connectionProvider.deviceInfo.maxChannels,
|
||||||
);
|
);
|
||||||
|
await refreshChannelLocationSharingState();
|
||||||
|
|
||||||
final messageCount = await connectionProvider.syncAllMessages(
|
final messageCount = await connectionProvider.syncAllMessages(
|
||||||
force: true,
|
force: true,
|
||||||
@@ -2744,6 +2776,229 @@ class AppProvider with ChangeNotifier {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> refreshChannelLocationSharingState() async {
|
||||||
|
if (!connectionProvider.deviceInfo.isConnected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final vars = await connectionProvider.getCustomVars();
|
||||||
|
final hardwareSupported = _supportsHardwareChannelLocationSharing(vars);
|
||||||
|
final rawChannelIdx = hardwareSupported
|
||||||
|
? int.tryParse(vars['fast_gps_channel'] ?? '')
|
||||||
|
: null;
|
||||||
|
final normalizedChannelIdx = rawChannelIdx != null && rawChannelIdx > 0
|
||||||
|
? rawChannelIdx
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (_hardwareChannelLocationSharingSupported == hardwareSupported &&
|
||||||
|
_hardwareChannelLocationSharingChannelIdx == normalizedChannelIdx) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_hardwareChannelLocationSharingSupported = hardwareSupported;
|
||||||
|
_hardwareChannelLocationSharingChannelIdx = normalizedChannelIdx;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
ChannelLocationSharingMode? channelLocationSharingModeForChannel(
|
||||||
|
int channelIdx,
|
||||||
|
) {
|
||||||
|
if (channelIdx <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (_hardwareChannelLocationSharingChannelIdx == channelIdx) {
|
||||||
|
return ChannelLocationSharingMode.hardware;
|
||||||
|
}
|
||||||
|
if (_isAppFallbackLocationSharingActiveForChannel(channelIdx)) {
|
||||||
|
return ChannelLocationSharingMode.appFallback;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
void notifyChannelLocationSharingChanged() {
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isAppFallbackLocationSharingActiveForChannel(int channelIdx) {
|
||||||
|
return locationTrackingService.fastLocationUpdatesEnabled &&
|
||||||
|
locationTrackingService.fastLocationChannelIdx == channelIdx;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _supportsHardwareChannelLocationSharing(Map<String, String> vars) {
|
||||||
|
return vars.containsKey('gps') && vars.containsKey('fast_gps_channel');
|
||||||
|
}
|
||||||
|
|
||||||
|
int? _hardwareChannelLocationSharingIdxFromVars(Map<String, String> vars) {
|
||||||
|
final rawChannelIdx = int.tryParse(vars['fast_gps_channel'] ?? '');
|
||||||
|
return rawChannelIdx != null && rawChannelIdx > 0 ? rawChannelIdx : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _setDeviceCustomVarOrThrow(String key, String value) async {
|
||||||
|
connectionProvider.clearError();
|
||||||
|
await connectionProvider.setCustomVar(key, value);
|
||||||
|
final error = connectionProvider.error;
|
||||||
|
if (error != null) {
|
||||||
|
connectionProvider.clearError();
|
||||||
|
throw StateError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<ChannelLocationSharingState> getChannelLocationSharingState(
|
||||||
|
int channelIdx,
|
||||||
|
) async {
|
||||||
|
if (channelIdx <= 0) {
|
||||||
|
return const ChannelLocationSharingState(
|
||||||
|
mode: ChannelLocationSharingMode.appFallback,
|
||||||
|
isSharing: false,
|
||||||
|
hardwareSupported: false,
|
||||||
|
isConnected: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (connectionProvider.deviceInfo.isConnected) {
|
||||||
|
await refreshChannelLocationSharingState();
|
||||||
|
}
|
||||||
|
|
||||||
|
final sharingMode = channelLocationSharingModeForChannel(channelIdx);
|
||||||
|
|
||||||
|
return ChannelLocationSharingState(
|
||||||
|
mode: sharingMode ??
|
||||||
|
(_hardwareChannelLocationSharingSupported
|
||||||
|
? ChannelLocationSharingMode.hardware
|
||||||
|
: ChannelLocationSharingMode.appFallback),
|
||||||
|
isSharing: sharingMode != null,
|
||||||
|
hardwareSupported: _hardwareChannelLocationSharingSupported,
|
||||||
|
isConnected: connectionProvider.deviceInfo.isConnected,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<ChannelLocationSharingResult> setChannelLocationSharingEnabled(
|
||||||
|
int channelIdx,
|
||||||
|
bool enabled,
|
||||||
|
) async {
|
||||||
|
if (channelIdx <= 0) {
|
||||||
|
throw ArgumentError.value(
|
||||||
|
channelIdx,
|
||||||
|
'channelIdx',
|
||||||
|
'Location sharing requires a non-public channel',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!connectionProvider.deviceInfo.isConnected) {
|
||||||
|
throw StateError('Connect to a device first');
|
||||||
|
}
|
||||||
|
|
||||||
|
final vars = await connectionProvider.getCustomVars();
|
||||||
|
final hardwareSupported = _supportsHardwareChannelLocationSharing(vars);
|
||||||
|
if (enabled) {
|
||||||
|
if (hardwareSupported) {
|
||||||
|
await _setDeviceCustomVarOrThrow('gps', '1');
|
||||||
|
await _setDeviceCustomVarOrThrow(
|
||||||
|
'fast_gps_channel',
|
||||||
|
channelIdx.toString(),
|
||||||
|
);
|
||||||
|
_hardwareChannelLocationSharingSupported = true;
|
||||||
|
_hardwareChannelLocationSharingChannelIdx = channelIdx;
|
||||||
|
await locationTrackingService.updateFastLocationChannelIdx(null);
|
||||||
|
await locationTrackingService.setFastLocationUpdatesEnabled(false);
|
||||||
|
await refreshChannelLocationSharingState();
|
||||||
|
if (_hardwareChannelLocationSharingChannelIdx != channelIdx) {
|
||||||
|
throw StateError('Radio location sharing did not enable for this channel');
|
||||||
|
}
|
||||||
|
notifyListeners();
|
||||||
|
return const ChannelLocationSharingResult(
|
||||||
|
state: ChannelLocationSharingState(
|
||||||
|
mode: ChannelLocationSharingMode.hardware,
|
||||||
|
isSharing: true,
|
||||||
|
hardwareSupported: true,
|
||||||
|
isConnected: true,
|
||||||
|
),
|
||||||
|
message: 'Sharing location on this channel from the radio.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
_hardwareChannelLocationSharingSupported = false;
|
||||||
|
_hardwareChannelLocationSharingChannelIdx = null;
|
||||||
|
final previousEnabled = locationTrackingService.fastLocationUpdatesEnabled;
|
||||||
|
final previousChannelIdx = locationTrackingService.fastLocationChannelIdx;
|
||||||
|
try {
|
||||||
|
await locationTrackingService.updateFastLocationChannelIdx(channelIdx);
|
||||||
|
await locationTrackingService.setFastLocationUpdatesEnabled(true);
|
||||||
|
if (!locationTrackingService.isTracking) {
|
||||||
|
final started = await locationTrackingService.startTracking();
|
||||||
|
if (!started) {
|
||||||
|
throw StateError('Phone location tracking could not be started');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
await locationTrackingService.updateFastLocationChannelIdx(
|
||||||
|
previousChannelIdx,
|
||||||
|
);
|
||||||
|
await locationTrackingService.setFastLocationUpdatesEnabled(
|
||||||
|
previousEnabled,
|
||||||
|
);
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
notifyListeners();
|
||||||
|
return const ChannelLocationSharingResult(
|
||||||
|
state: ChannelLocationSharingState(
|
||||||
|
mode: ChannelLocationSharingMode.appFallback,
|
||||||
|
isSharing: true,
|
||||||
|
hardwareSupported: false,
|
||||||
|
isConnected: true,
|
||||||
|
),
|
||||||
|
message: 'Sharing location on this channel from the phone.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final activeHardwareChannelIdx = _hardwareChannelLocationSharingIdxFromVars(
|
||||||
|
vars,
|
||||||
|
);
|
||||||
|
final shouldAttemptHardwareStop =
|
||||||
|
hardwareSupported ||
|
||||||
|
_hardwareChannelLocationSharingSupported ||
|
||||||
|
activeHardwareChannelIdx == channelIdx ||
|
||||||
|
_hardwareChannelLocationSharingChannelIdx == channelIdx;
|
||||||
|
|
||||||
|
if (locationTrackingService.fastLocationChannelIdx == channelIdx) {
|
||||||
|
await locationTrackingService.updateFastLocationChannelIdx(null);
|
||||||
|
await locationTrackingService.setFastLocationUpdatesEnabled(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldAttemptHardwareStop) {
|
||||||
|
await _setDeviceCustomVarOrThrow('fast_gps_channel', '-1');
|
||||||
|
_hardwareChannelLocationSharingSupported = true;
|
||||||
|
_hardwareChannelLocationSharingChannelIdx = null;
|
||||||
|
await refreshChannelLocationSharingState();
|
||||||
|
if (_hardwareChannelLocationSharingChannelIdx == channelIdx) {
|
||||||
|
throw StateError('Radio location sharing is still enabled for this channel');
|
||||||
|
}
|
||||||
|
notifyListeners();
|
||||||
|
return const ChannelLocationSharingResult(
|
||||||
|
state: ChannelLocationSharingState(
|
||||||
|
mode: ChannelLocationSharingMode.hardware,
|
||||||
|
isSharing: false,
|
||||||
|
hardwareSupported: true,
|
||||||
|
isConnected: true,
|
||||||
|
),
|
||||||
|
message: 'Stopped sharing location on this channel.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
_hardwareChannelLocationSharingSupported = false;
|
||||||
|
_hardwareChannelLocationSharingChannelIdx = null;
|
||||||
|
notifyListeners();
|
||||||
|
return const ChannelLocationSharingResult(
|
||||||
|
state: ChannelLocationSharingState(
|
||||||
|
mode: ChannelLocationSharingMode.appFallback,
|
||||||
|
isSharing: false,
|
||||||
|
hardwareSupported: false,
|
||||||
|
isConnected: true,
|
||||||
|
),
|
||||||
|
message: 'Stopped sharing location on this channel.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _sendFastLocationUpdate(
|
Future<void> _sendFastLocationUpdate(
|
||||||
dynamic position, {
|
dynamic position, {
|
||||||
required String reason,
|
required String reason,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:latlong2/latlong.dart';
|
||||||
import '../models/message.dart';
|
import '../models/message.dart';
|
||||||
import '../models/contact.dart';
|
import '../models/contact.dart';
|
||||||
import '../models/message_contact_location.dart';
|
import '../models/message_contact_location.dart';
|
||||||
@@ -10,6 +11,7 @@ import '../models/path_selection.dart';
|
|||||||
import '../models/sar_marker.dart';
|
import '../models/sar_marker.dart';
|
||||||
import '../models/map_drawing.dart';
|
import '../models/map_drawing.dart';
|
||||||
import '../services/message_storage_service.dart';
|
import '../services/message_storage_service.dart';
|
||||||
|
import '../services/location_tracking_service.dart';
|
||||||
import '../services/notification_service.dart';
|
import '../services/notification_service.dart';
|
||||||
import '../utils/sar_message_parser.dart';
|
import '../utils/sar_message_parser.dart';
|
||||||
import '../utils/drawing_message_parser.dart';
|
import '../utils/drawing_message_parser.dart';
|
||||||
@@ -1808,12 +1810,17 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
enhancedMessage = _resolveSenderNameIfNeeded(enhancedMessage);
|
enhancedMessage = _resolveSenderNameIfNeeded(enhancedMessage);
|
||||||
|
final senderLocationSnapshot = _buildSentMessageLocationSnapshot();
|
||||||
|
|
||||||
// Check for duplicates (shouldn't happen for sent messages, but be safe)
|
// Check for duplicates (shouldn't happen for sent messages, but be safe)
|
||||||
if (_findDuplicateMessageIndex(enhancedMessage) != -1) {
|
if (_findDuplicateMessageIndex(enhancedMessage) != -1) {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'⚠️ [MessagesProvider] Duplicate sent message detected, skipping: ${enhancedMessage.id}',
|
'⚠️ [MessagesProvider] Duplicate sent message detected, skipping: ${enhancedMessage.id}',
|
||||||
);
|
);
|
||||||
|
if (senderLocationSnapshot != null) {
|
||||||
|
_messageContactLocations[enhancedMessage.id] = senderLocationSnapshot;
|
||||||
|
_persistMessages();
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1823,6 +1830,9 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
isRead: true, // Sent messages are always marked as read
|
isRead: true, // Sent messages are always marked as read
|
||||||
);
|
);
|
||||||
_messages.add(sendingMessage);
|
_messages.add(sendingMessage);
|
||||||
|
if (senderLocationSnapshot != null) {
|
||||||
|
_messageContactLocations[sendingMessage.id] = senderLocationSnapshot;
|
||||||
|
}
|
||||||
debugPrint(' ✅ Message added to list at index ${_messages.length - 1}');
|
debugPrint(' ✅ Message added to list at index ${_messages.length - 1}');
|
||||||
debugPrint(' Total messages in list: ${_messages.length}');
|
debugPrint(' Total messages in list: ${_messages.length}');
|
||||||
|
|
||||||
@@ -1850,6 +1860,20 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
debugPrint(' ✅ notifyListeners() called - UI should update');
|
debugPrint(' ✅ notifyListeners() called - UI should update');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
MessageContactLocation? _buildSentMessageLocationSnapshot() {
|
||||||
|
final position = LocationTrackingService().currentPosition;
|
||||||
|
if (position == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return MessageContactLocation(
|
||||||
|
location: LatLng(position.latitude, position.longitude),
|
||||||
|
source: 'gps',
|
||||||
|
capturedAt: DateTime.now(),
|
||||||
|
sourceTimestamp: position.timestamp,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Register an individual message ID as part of a grouped message
|
/// Register an individual message ID as part of a grouped message
|
||||||
void registerGroupedMessageSend(
|
void registerGroupedMessageSend(
|
||||||
String individualMessageId,
|
String individualMessageId,
|
||||||
|
|||||||
@@ -516,12 +516,89 @@ class _ContactsTabState extends State<ContactsTab> {
|
|||||||
messenger.showSnackBar(SnackBar(content: Text(copiedMessage)));
|
messenger.showSnackBar(SnackBar(content: Text(copiedMessage)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _locationSharingErrorMessage(Object error) {
|
||||||
|
final message = error.toString();
|
||||||
|
if (message.startsWith('Bad state: ')) {
|
||||||
|
return message.substring('Bad state: '.length);
|
||||||
|
}
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _setChannelLocationSharing(
|
||||||
|
BuildContext context,
|
||||||
|
Contact channel,
|
||||||
|
bool enabled,
|
||||||
|
) async {
|
||||||
|
final channelIdx = channel.publicKey.length > 1 ? channel.publicKey[1] : 0;
|
||||||
|
try {
|
||||||
|
final result = await context.read<AppProvider>().setChannelLocationSharingEnabled(
|
||||||
|
channelIdx,
|
||||||
|
enabled,
|
||||||
|
);
|
||||||
|
if (!context.mounted) return;
|
||||||
|
ToastLogger.success(context, result.message);
|
||||||
|
} catch (error) {
|
||||||
|
if (!context.mounted) return;
|
||||||
|
ToastLogger.error(context, _locationSharingErrorMessage(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _handleChannelLocationSharingAction(
|
||||||
|
BuildContext context,
|
||||||
|
Contact channel,
|
||||||
|
) async {
|
||||||
|
if (channel.isPublicChannel) {
|
||||||
|
if (!context.mounted) return;
|
||||||
|
ToastLogger.error(
|
||||||
|
context,
|
||||||
|
'Select a private channel to share your location.',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final channelIdx = channel.publicKey.length > 1 ? channel.publicKey[1] : 0;
|
||||||
|
if (channelIdx <= 0) {
|
||||||
|
if (!context.mounted) return;
|
||||||
|
ToastLogger.error(
|
||||||
|
context,
|
||||||
|
'Select a private channel to share your location.',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final sharingState = await context
|
||||||
|
.read<AppProvider>()
|
||||||
|
.getChannelLocationSharingState(channelIdx);
|
||||||
|
if (!context.mounted) return;
|
||||||
|
await _setChannelLocationSharing(context, channel, !sharingState.isSharing);
|
||||||
|
} catch (error) {
|
||||||
|
if (!context.mounted) return;
|
||||||
|
ToastLogger.error(context, _locationSharingErrorMessage(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void _showChannelActionSheet(BuildContext context, Contact channel) {
|
void _showChannelActionSheet(BuildContext context, Contact channel) {
|
||||||
final l10n = AppLocalizations.of(context)!;
|
final l10n = AppLocalizations.of(context)!;
|
||||||
final canExportHashChannelPsk = Channel.isHashChannelName(
|
final canExportHashChannelPsk = Channel.isHashChannelName(
|
||||||
channel.advName.trim(),
|
channel.advName.trim(),
|
||||||
);
|
);
|
||||||
final actions = <_ChannelSheetAction>[
|
final channelLocationSharingFuture =
|
||||||
|
!channel.isPublicChannel && channel.publicKey.length > 1
|
||||||
|
? context
|
||||||
|
.read<AppProvider>()
|
||||||
|
.getChannelLocationSharingState(channel.publicKey[1])
|
||||||
|
: null;
|
||||||
|
|
||||||
|
showModalBottomSheet<void>(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
builder: (sheetContext) {
|
||||||
|
List<_ChannelSheetAction> buildActions(
|
||||||
|
ChannelLocationSharingState? sharingState,
|
||||||
|
) {
|
||||||
|
return <_ChannelSheetAction>[
|
||||||
_ChannelSheetAction(
|
_ChannelSheetAction(
|
||||||
icon: Icons.message_outlined,
|
icon: Icons.message_outlined,
|
||||||
label: l10n.messages,
|
label: l10n.messages,
|
||||||
@@ -530,6 +607,19 @@ class _ContactsTabState extends State<ContactsTab> {
|
|||||||
await _openMessagesForChannel(context, channel);
|
await _openMessagesForChannel(context, channel);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
if (!channel.isPublicChannel)
|
||||||
|
_ChannelSheetAction(
|
||||||
|
icon: sharingState?.isSharing == true
|
||||||
|
? Icons.location_off_rounded
|
||||||
|
: Icons.share_location_rounded,
|
||||||
|
label: sharingState?.isSharing == true
|
||||||
|
? 'Stop sharing my location'
|
||||||
|
: 'Share my location',
|
||||||
|
onTap: () async {
|
||||||
|
Navigator.pop(context);
|
||||||
|
await _handleChannelLocationSharingAction(context, channel);
|
||||||
|
},
|
||||||
|
),
|
||||||
if (channel.displayLocation != null)
|
if (channel.displayLocation != null)
|
||||||
_ChannelSheetAction(
|
_ChannelSheetAction(
|
||||||
icon: Icons.map_outlined,
|
icon: Icons.map_outlined,
|
||||||
@@ -573,16 +663,27 @@ class _ContactsTabState extends State<ContactsTab> {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
}
|
||||||
|
|
||||||
showModalBottomSheet<void>(
|
Widget buildSheet(ChannelLocationSharingState? sharingState) {
|
||||||
context: context,
|
return _ChannelActionSheet(
|
||||||
isScrollControlled: true,
|
|
||||||
backgroundColor: Colors.transparent,
|
|
||||||
builder: (sheetContext) => _ChannelActionSheet(
|
|
||||||
channel: channel,
|
channel: channel,
|
||||||
actions: actions,
|
actions: buildActions(sharingState),
|
||||||
onClose: () => Navigator.pop(sheetContext),
|
onClose: () => Navigator.pop(sheetContext),
|
||||||
),
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (channelLocationSharingFuture == null) {
|
||||||
|
return buildSheet(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return FutureBuilder<ChannelLocationSharingState>(
|
||||||
|
future: channelLocationSharingFuture,
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
return buildSheet(snapshot.data);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1820,6 +1921,28 @@ class _ChannelActivityCard extends StatelessWidget {
|
|||||||
return l10n.daysAgo(diff.inDays);
|
return l10n.daysAgo(diff.inDays);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildChannelLocationSharingMarker(
|
||||||
|
BuildContext context,
|
||||||
|
ChannelLocationSharingMode mode,
|
||||||
|
) {
|
||||||
|
final isHardware = mode == ChannelLocationSharingMode.hardware;
|
||||||
|
const accent = Color(0xFF16A34A);
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(3),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: accent,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(color: Colors.white, width: 2),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
isHardware ? Icons.public_rounded : Icons.smartphone_rounded,
|
||||||
|
size: 11,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
@@ -1828,6 +1951,9 @@ class _ChannelActivityCard extends StatelessWidget {
|
|||||||
letterSpacing: -0.3,
|
letterSpacing: -0.3,
|
||||||
);
|
);
|
||||||
final channelIdx = channel.publicKey.length > 1 ? channel.publicKey[1] : 0;
|
final channelIdx = channel.publicKey.length > 1 ? channel.publicKey[1] : 0;
|
||||||
|
final channelLocationSharingMode = context
|
||||||
|
.watch<AppProvider>()
|
||||||
|
.channelLocationSharingModeForChannel(channelIdx);
|
||||||
final channelMessages = messagesProvider.getMessagesForChannel(channelIdx)
|
final channelMessages = messagesProvider.getMessagesForChannel(channelIdx)
|
||||||
..sort((a, b) => b.sentAt.compareTo(a.sentAt));
|
..sort((a, b) => b.sentAt.compareTo(a.sentAt));
|
||||||
final lastActivityAt = messagesProvider.getLastActivityForDestination(
|
final lastActivityAt = messagesProvider.getLastActivityForDestination(
|
||||||
@@ -1869,8 +1995,22 @@ class _ChannelActivityCard extends StatelessWidget {
|
|||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||||
child: Row(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Stack(
|
||||||
|
clipBehavior: Clip.none,
|
||||||
children: [
|
children: [
|
||||||
ContactAvatar(contact: channel, radius: 24),
|
ContactAvatar(contact: channel, radius: 24),
|
||||||
|
if (channelLocationSharingMode != null)
|
||||||
|
Positioned(
|
||||||
|
right: -2,
|
||||||
|
bottom: -2,
|
||||||
|
child: _buildChannelLocationSharingMarker(
|
||||||
|
context,
|
||||||
|
channelLocationSharingMode,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
const SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
|
|||||||
@@ -1746,7 +1746,85 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
await action();
|
await action();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int? _selectedPrivateChannelIdx() {
|
||||||
|
if (_destinationType !=
|
||||||
|
MessageDestinationPreferences.destinationTypeChannel) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final recipient = _selectedRecipient;
|
||||||
|
if (recipient == null ||
|
||||||
|
recipient.isPublicChannel ||
|
||||||
|
recipient.publicKey.length < 2) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return recipient.publicKey[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
String _locationSharingErrorMessage(Object error) {
|
||||||
|
final message = error.toString();
|
||||||
|
if (message.startsWith('Bad state: ')) {
|
||||||
|
return message.substring('Bad state: '.length);
|
||||||
|
}
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _setSelectedChannelLocationSharing(
|
||||||
|
int channelIdx,
|
||||||
|
bool enabled,
|
||||||
|
) async {
|
||||||
|
try {
|
||||||
|
final result = await context.read<AppProvider>().setChannelLocationSharingEnabled(
|
||||||
|
channelIdx,
|
||||||
|
enabled,
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
ToastLogger.success(context, result.message);
|
||||||
|
} catch (error) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ToastLogger.error(context, _locationSharingErrorMessage(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _handleChannelLocationSharingAction() async {
|
||||||
|
final recipient = _selectedRecipient;
|
||||||
|
if (recipient == null || recipient.isPublicChannel) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ToastLogger.error(
|
||||||
|
context,
|
||||||
|
'Select a private channel to share your location.',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final channelIdx = recipient.publicKey.length > 1 ? recipient.publicKey[1] : 0;
|
||||||
|
if (channelIdx <= 0) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ToastLogger.error(
|
||||||
|
context,
|
||||||
|
'Select a private channel to share your location.',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final sharingState = await context
|
||||||
|
.read<AppProvider>()
|
||||||
|
.getChannelLocationSharingState(channelIdx);
|
||||||
|
if (!mounted) return;
|
||||||
|
await _setSelectedChannelLocationSharing(channelIdx, !sharingState.isSharing);
|
||||||
|
} catch (error) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ToastLogger.error(context, _locationSharingErrorMessage(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void _showComposerActions() {
|
void _showComposerActions() {
|
||||||
|
final privateChannelIdx = _selectedPrivateChannelIdx();
|
||||||
|
final locationSharingStateFuture = privateChannelIdx == null
|
||||||
|
? null
|
||||||
|
: context
|
||||||
|
.read<AppProvider>()
|
||||||
|
.getChannelLocationSharingState(privateChannelIdx);
|
||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
showDragHandle: true,
|
showDragHandle: true,
|
||||||
@@ -1759,6 +1837,7 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
await _runAfterSheetDismissal(sheetContext, action);
|
await _runAfterSheetDismissal(sheetContext, action);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget buildActionsSheet(ChannelLocationSharingState? sharingState) {
|
||||||
final actions = <Widget>[
|
final actions = <Widget>[
|
||||||
_ComposerActionTile(
|
_ComposerActionTile(
|
||||||
icon: Icons.search_rounded,
|
icon: Icons.search_rounded,
|
||||||
@@ -1776,6 +1855,20 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
_showSarDialog();
|
_showSarDialog();
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
if (_destinationType ==
|
||||||
|
MessageDestinationPreferences.destinationTypeChannel)
|
||||||
|
_ComposerActionTile(
|
||||||
|
icon: sharingState?.isSharing == true
|
||||||
|
? Icons.location_off_rounded
|
||||||
|
: Icons.share_location_rounded,
|
||||||
|
title: sharingState?.isSharing == true
|
||||||
|
? 'Stop sharing my location'
|
||||||
|
: 'Share my location',
|
||||||
|
color: const Color(0xFF0F766E),
|
||||||
|
onTap: () => runAction(() async {
|
||||||
|
await _handleChannelLocationSharingAction();
|
||||||
|
}),
|
||||||
|
),
|
||||||
if (_voiceSupported)
|
if (_voiceSupported)
|
||||||
_ComposerActionTile(
|
_ComposerActionTile(
|
||||||
icon: _isRecording ? Icons.stop_rounded : Icons.mic_rounded,
|
icon: _isRecording ? Icons.stop_rounded : Icons.mic_rounded,
|
||||||
@@ -1861,13 +1954,26 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
for (var index = 0; index < actions.length; index++) ...[
|
for (var index = 0; index < actions.length; index++) ...[
|
||||||
actions[index],
|
actions[index],
|
||||||
if (index != actions.length - 1) const SizedBox(height: 8),
|
if (index != actions.length - 1)
|
||||||
|
const SizedBox(height: 8),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (locationSharingStateFuture == null) {
|
||||||
|
return buildActionsSheet(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return FutureBuilder<ChannelLocationSharingState>(
|
||||||
|
future: locationSharingStateFuture,
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
return buildActionsSheet(snapshot.data);
|
||||||
|
},
|
||||||
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2027,10 +2133,45 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
Widget _buildDestinationAvatar(BuildContext context) {
|
Widget _buildDestinationAvatar(BuildContext context) {
|
||||||
final recipient = _selectedRecipient;
|
final recipient = _selectedRecipient;
|
||||||
if (recipient != null) {
|
if (recipient != null) {
|
||||||
return ContactAvatar(
|
final sharingMode =
|
||||||
|
recipient.isChannel &&
|
||||||
|
!recipient.isPublicChannel &&
|
||||||
|
recipient.publicKey.length > 1
|
||||||
|
? context.watch<AppProvider>().channelLocationSharingModeForChannel(
|
||||||
|
recipient.publicKey[1],
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
return Stack(
|
||||||
|
clipBehavior: Clip.none,
|
||||||
|
children: [
|
||||||
|
ContactAvatar(
|
||||||
contact: recipient,
|
contact: recipient,
|
||||||
radius: 14,
|
radius: 14,
|
||||||
displayName: _getDestinationLabel(),
|
displayName: _getDestinationLabel(),
|
||||||
|
),
|
||||||
|
if (sharingMode != null)
|
||||||
|
Positioned(
|
||||||
|
right: -3,
|
||||||
|
bottom: -3,
|
||||||
|
child: Container(
|
||||||
|
width: 14,
|
||||||
|
height: 14,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFF16A34A),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(color: Colors.white, width: 1.5),
|
||||||
|
),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: Icon(
|
||||||
|
sharingMode == ChannelLocationSharingMode.hardware
|
||||||
|
? Icons.public_rounded
|
||||||
|
: Icons.smartphone_rounded,
|
||||||
|
size: 9,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -420,6 +420,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
Future<void> _setFastLocationUpdatesEnabled(bool enabled) async {
|
Future<void> _setFastLocationUpdatesEnabled(bool enabled) async {
|
||||||
await _locationService.setFastLocationUpdatesEnabled(enabled);
|
await _locationService.setFastLocationUpdatesEnabled(enabled);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
context.read<AppProvider>().notifyChannelLocationSharingChanged();
|
||||||
setState(() {
|
setState(() {
|
||||||
_fastLocationUpdatesEnabled = _locationService.fastLocationUpdatesEnabled;
|
_fastLocationUpdatesEnabled = _locationService.fastLocationUpdatesEnabled;
|
||||||
});
|
});
|
||||||
@@ -593,6 +594,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
selected < 0 ? null : selected,
|
selected < 0 ? null : selected,
|
||||||
);
|
);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
context.read<AppProvider>().notifyChannelLocationSharingChanged();
|
||||||
setState(() {
|
setState(() {
|
||||||
_fastLocationChannelIdx = _locationService.fastLocationChannelIdx;
|
_fastLocationChannelIdx = _locationService.fastLocationChannelIdx;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import 'package:geolocator/geolocator.dart';
|
|||||||
import 'package:latlong2/latlong.dart';
|
import 'package:latlong2/latlong.dart';
|
||||||
import '../../models/contact.dart';
|
import '../../models/contact.dart';
|
||||||
import '../../models/room_login_state.dart';
|
import '../../models/room_login_state.dart';
|
||||||
|
import '../../providers/app_provider.dart';
|
||||||
import '../../providers/connection_provider.dart';
|
import '../../providers/connection_provider.dart';
|
||||||
import '../../providers/contacts_provider.dart';
|
import '../../providers/contacts_provider.dart';
|
||||||
import '../../providers/map_provider.dart';
|
import '../../providers/map_provider.dart';
|
||||||
@@ -66,6 +67,28 @@ class ContactTile extends StatelessWidget {
|
|||||||
.toUpperCase();
|
.toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildChannelLocationSharingMarker(
|
||||||
|
BuildContext context,
|
||||||
|
ChannelLocationSharingMode mode,
|
||||||
|
) {
|
||||||
|
final isHardware = mode == ChannelLocationSharingMode.hardware;
|
||||||
|
const accent = Color(0xFF16A34A);
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(3),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: accent,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(color: Colors.white, width: 2),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
isHardware ? Icons.public_rounded : Icons.smartphone_rounded,
|
||||||
|
size: 11,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final isChannel = contact.type == ContactType.channel;
|
final isChannel = contact.type == ContactType.channel;
|
||||||
@@ -90,6 +113,7 @@ class ContactTile extends StatelessWidget {
|
|||||||
// Get room login state if this is a room
|
// Get room login state if this is a room
|
||||||
final connectionProvider = context.watch<ConnectionProvider>();
|
final connectionProvider = context.watch<ConnectionProvider>();
|
||||||
final messagesProvider = context.watch<MessagesProvider>();
|
final messagesProvider = context.watch<MessagesProvider>();
|
||||||
|
final appProvider = context.watch<AppProvider>();
|
||||||
final isPingInProgress = connectionProvider.isPingInProgress(
|
final isPingInProgress = connectionProvider.isPingInProgress(
|
||||||
contact.publicKey,
|
contact.publicKey,
|
||||||
);
|
);
|
||||||
@@ -134,6 +158,10 @@ class ContactTile extends StatelessWidget {
|
|||||||
final lastActivityAt = isRoomOrChannel
|
final lastActivityAt = isRoomOrChannel
|
||||||
? messagesProvider.getLastActivityForDestination(contact)
|
? messagesProvider.getLastActivityForDestination(contact)
|
||||||
: null;
|
: null;
|
||||||
|
final channelLocationSharingMode =
|
||||||
|
isChannel && !contact.isPublicChannel && contact.publicKey.length > 1
|
||||||
|
? appProvider.channelLocationSharingModeForChannel(contact.publicKey[1])
|
||||||
|
: null;
|
||||||
final timeAgoText = _getLocalizedRelativeTime(
|
final timeAgoText = _getLocalizedRelativeTime(
|
||||||
context,
|
context,
|
||||||
lastActivityAt ?? contact.lastSeenTime,
|
lastActivityAt ?? contact.lastSeenTime,
|
||||||
@@ -239,6 +267,15 @@ class ContactTile extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (isChannel && channelLocationSharingMode != null)
|
||||||
|
Positioned(
|
||||||
|
bottom: -2,
|
||||||
|
right: -2,
|
||||||
|
child: _buildChannelLocationSharingMarker(
|
||||||
|
context,
|
||||||
|
channelLocationSharingMode,
|
||||||
|
),
|
||||||
|
),
|
||||||
if (contact.type == ContactType.room &&
|
if (contact.type == ContactType.room &&
|
||||||
roomLoginState != null)
|
roomLoginState != null)
|
||||||
Positioned(
|
Positioned(
|
||||||
@@ -383,6 +420,68 @@ class ContactTile extends StatelessWidget {
|
|||||||
_showContactActionSheet(context, contact);
|
_showContactActionSheet(context, contact);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _locationSharingErrorMessage(Object error) {
|
||||||
|
final message = error.toString();
|
||||||
|
if (message.startsWith('Bad state: ')) {
|
||||||
|
return message.substring('Bad state: '.length);
|
||||||
|
}
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _setChannelLocationSharing(
|
||||||
|
BuildContext context,
|
||||||
|
Contact contact,
|
||||||
|
bool enabled,
|
||||||
|
) async {
|
||||||
|
final channelIdx = contact.publicKey.length > 1 ? contact.publicKey[1] : 0;
|
||||||
|
try {
|
||||||
|
final result = await context.read<AppProvider>().setChannelLocationSharingEnabled(
|
||||||
|
channelIdx,
|
||||||
|
enabled,
|
||||||
|
);
|
||||||
|
if (!context.mounted) return;
|
||||||
|
ToastLogger.success(context, result.message);
|
||||||
|
} catch (error) {
|
||||||
|
if (!context.mounted) return;
|
||||||
|
ToastLogger.error(context, _locationSharingErrorMessage(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _handleChannelLocationSharingAction(
|
||||||
|
BuildContext context,
|
||||||
|
Contact contact,
|
||||||
|
) async {
|
||||||
|
if (contact.isPublicChannel) {
|
||||||
|
if (!context.mounted) return;
|
||||||
|
ToastLogger.error(
|
||||||
|
context,
|
||||||
|
'Select a private channel to share your location.',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final channelIdx = contact.publicKey.length > 1 ? contact.publicKey[1] : 0;
|
||||||
|
if (channelIdx <= 0) {
|
||||||
|
if (!context.mounted) return;
|
||||||
|
ToastLogger.error(
|
||||||
|
context,
|
||||||
|
'Select a private channel to share your location.',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final sharingState = await context
|
||||||
|
.read<AppProvider>()
|
||||||
|
.getChannelLocationSharingState(channelIdx);
|
||||||
|
if (!context.mounted) return;
|
||||||
|
await _setChannelLocationSharing(context, contact, !sharingState.isSharing);
|
||||||
|
} catch (error) {
|
||||||
|
if (!context.mounted) return;
|
||||||
|
ToastLogger.error(context, _locationSharingErrorMessage(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void _showContactActionSheet(BuildContext context, Contact contact) {
|
void _showContactActionSheet(BuildContext context, Contact contact) {
|
||||||
final l10n = AppLocalizations.of(context)!;
|
final l10n = AppLocalizations.of(context)!;
|
||||||
final canToggleFavourite = !contact.isChannel;
|
final canToggleFavourite = !contact.isChannel;
|
||||||
@@ -402,7 +501,21 @@ class ContactTile extends StatelessWidget {
|
|||||||
final canPreviewSensor = contact.isSensor;
|
final canPreviewSensor = contact.isSensor;
|
||||||
final sensorsProvider = context.read<SensorsProvider>();
|
final sensorsProvider = context.read<SensorsProvider>();
|
||||||
final isInSensors = sensorsProvider.isWatched(contact.publicKeyHex);
|
final isInSensors = sensorsProvider.isWatched(contact.publicKeyHex);
|
||||||
|
final channelLocationSharingFuture =
|
||||||
|
contact.type == ContactType.channel &&
|
||||||
|
!contact.isPublicChannel &&
|
||||||
|
contact.publicKey.length > 1
|
||||||
|
? context
|
||||||
|
.read<AppProvider>()
|
||||||
|
.getChannelLocationSharingState(contact.publicKey[1])
|
||||||
|
: null;
|
||||||
|
|
||||||
|
showModalBottomSheet<void>(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
builder: (sheetContext) {
|
||||||
|
Widget buildSheet(ChannelLocationSharingState? sharingState) {
|
||||||
final primaryActions = <_ContactSheetAction>[
|
final primaryActions = <_ContactSheetAction>[
|
||||||
if (canMessage)
|
if (canMessage)
|
||||||
_ContactSheetAction(
|
_ContactSheetAction(
|
||||||
@@ -413,6 +526,19 @@ class ContactTile extends StatelessWidget {
|
|||||||
await _openMessagesForContact(context, contact);
|
await _openMessagesForContact(context, contact);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
if (contact.type == ContactType.channel)
|
||||||
|
_ContactSheetAction(
|
||||||
|
icon: sharingState?.isSharing == true
|
||||||
|
? Icons.location_off_rounded
|
||||||
|
: Icons.share_location_rounded,
|
||||||
|
label: sharingState?.isSharing == true
|
||||||
|
? 'Stop sharing my location'
|
||||||
|
: 'Share my location',
|
||||||
|
onTap: () async {
|
||||||
|
Navigator.pop(context);
|
||||||
|
await _handleChannelLocationSharingAction(context, contact);
|
||||||
|
},
|
||||||
|
),
|
||||||
if (!contact.isChannel)
|
if (!contact.isChannel)
|
||||||
_ContactSheetAction(
|
_ContactSheetAction(
|
||||||
icon: Icons.share_outlined,
|
icon: Icons.share_outlined,
|
||||||
@@ -427,7 +553,9 @@ class ContactTile extends StatelessWidget {
|
|||||||
await Clipboard.setData(ClipboardData(text: url));
|
await Clipboard.setData(ClipboardData(text: url));
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(content: Text(l10n.contactLinkCopiedToClipboard)),
|
SnackBar(
|
||||||
|
content: Text(l10n.contactLinkCopiedToClipboard),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else if (context.mounted) {
|
} else if (context.mounted) {
|
||||||
@@ -507,7 +635,9 @@ class ContactTile extends StatelessWidget {
|
|||||||
if (canAddToSensors)
|
if (canAddToSensors)
|
||||||
_ContactSheetAction(
|
_ContactSheetAction(
|
||||||
icon: isInSensors ? Icons.sensors : Icons.sensors_outlined,
|
icon: isInSensors ? Icons.sensors : Icons.sensors_outlined,
|
||||||
label: isInSensors ? l10n.contactInSensors : l10n.contactAddToSensors,
|
label: isInSensors
|
||||||
|
? l10n.contactInSensors
|
||||||
|
: l10n.contactAddToSensors,
|
||||||
enabled: !isInSensors,
|
enabled: !isInSensors,
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
@@ -553,11 +683,7 @@ class ContactTile extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
showModalBottomSheet<void>(
|
return _ContactActionSheet(
|
||||||
context: context,
|
|
||||||
isScrollControlled: true,
|
|
||||||
backgroundColor: Colors.transparent,
|
|
||||||
builder: (sheetContext) => _ContactActionSheet(
|
|
||||||
contact: contact,
|
contact: contact,
|
||||||
primaryActions: primaryActions,
|
primaryActions: primaryActions,
|
||||||
secondaryActions: secondaryActions,
|
secondaryActions: secondaryActions,
|
||||||
@@ -568,13 +694,27 @@ class ContactTile extends StatelessWidget {
|
|||||||
? null
|
? null
|
||||||
: () async {
|
: () async {
|
||||||
final toggled = contact.toggleFavourite();
|
final toggled = contact.toggleFavourite();
|
||||||
final connectionProvider = context.read<ConnectionProvider>();
|
final connectionProvider =
|
||||||
|
context.read<ConnectionProvider>();
|
||||||
await connectionProvider.addOrUpdateContact(toggled);
|
await connectionProvider.addOrUpdateContact(toggled);
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
await connectionProvider.getContact(contact.publicKey);
|
await connectionProvider.getContact(contact.publicKey);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
),
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (channelLocationSharingFuture == null) {
|
||||||
|
return buildSheet(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return FutureBuilder<ChannelLocationSharingState>(
|
||||||
|
future: channelLocationSharingFuture,
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
return buildSheet(snapshot.data);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter/gestures.dart';
|
import 'package:flutter/gestures.dart';
|
||||||
|
import 'package:flutter_map/flutter_map.dart' as flutter_map;
|
||||||
import 'package:latlong2/latlong.dart';
|
import 'package:latlong2/latlong.dart';
|
||||||
import 'package:share_plus/share_plus.dart';
|
import 'package:share_plus/share_plus.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
@@ -628,7 +629,7 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
recipientName = recipientContact?.advName;
|
recipientName = recipientContact?.advName;
|
||||||
}
|
}
|
||||||
|
|
||||||
final senderLocationSnapshot = messagesProvider.getMessageContactLocation(
|
final messageLocationSnapshot = messagesProvider.getMessageContactLocation(
|
||||||
widget.message.id,
|
widget.message.id,
|
||||||
);
|
);
|
||||||
final receptionDetails = messagesProvider.getMessageReceptionDetails(
|
final receptionDetails = messagesProvider.getMessageReceptionDetails(
|
||||||
@@ -742,9 +743,9 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
'Retry result: ${retryResult ?? '-'}',
|
'Retry result: ${retryResult ?? '-'}',
|
||||||
'Sender key prefix: ${senderPrefixHex ?? '-'}',
|
'Sender key prefix: ${senderPrefixHex ?? '-'}',
|
||||||
'Sender name: ${senderName ?? widget.message.senderName ?? '-'}',
|
'Sender name: ${senderName ?? widget.message.senderName ?? '-'}',
|
||||||
'Sender location at receipt: ${senderLocationSnapshot?.formattedCoordinates ?? '-'}',
|
'${widget.message.isSentMessage ? "Sent from location" : "Sender location at receipt"}: ${messageLocationSnapshot?.formattedCoordinates ?? '-'}',
|
||||||
'Sender location source: ${senderLocationSnapshot?.technicalSourceLabel ?? '-'}',
|
'${widget.message.isSentMessage ? "Sent from source" : "Sender location source"}: ${messageLocationSnapshot?.technicalSourceLabel ?? '-'}',
|
||||||
'Sender location timestamp: ${senderLocationSnapshot?.sourceTimestamp?.toIso8601String() ?? '-'}',
|
'${widget.message.isSentMessage ? "Sent from timestamp" : "Sender location timestamp"}: ${messageLocationSnapshot?.sourceTimestamp?.toIso8601String() ?? '-'}',
|
||||||
'Recipient key prefix: ${recipientPrefixHex ?? '-'}',
|
'Recipient key prefix: ${recipientPrefixHex ?? '-'}',
|
||||||
'Recipient name: ${recipientName ?? '-'}',
|
'Recipient name: ${recipientName ?? '-'}',
|
||||||
'Drawing flag: ${widget.message.isDrawing}',
|
'Drawing flag: ${widget.message.isDrawing}',
|
||||||
@@ -907,6 +908,54 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
if (messageLocationSnapshot != null) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_techSection(
|
||||||
|
sheetContext,
|
||||||
|
icon: Icons.location_on,
|
||||||
|
title: AppLocalizations.of(context)!.location,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
_buildLocationPreviewMap(
|
||||||
|
sheetContext,
|
||||||
|
messageLocationSnapshot.location,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_detailRow(
|
||||||
|
sheetContext,
|
||||||
|
label: AppLocalizations.of(
|
||||||
|
context,
|
||||||
|
)!.coordinates,
|
||||||
|
value: messageLocationSnapshot
|
||||||
|
.formattedCoordinates,
|
||||||
|
onCopy: () => copyField(
|
||||||
|
messageLocationSnapshot.formattedCoordinates,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_detailRow(
|
||||||
|
sheetContext,
|
||||||
|
label: AppLocalizations.of(context)!.source,
|
||||||
|
value: messageLocationSnapshot
|
||||||
|
.technicalSourceLabel,
|
||||||
|
),
|
||||||
|
_detailRow(
|
||||||
|
sheetContext,
|
||||||
|
label: AppLocalizations.of(context)!.captured,
|
||||||
|
value: _formatRfc3339(
|
||||||
|
messageLocationSnapshot.sourceTimestamp ??
|
||||||
|
messageLocationSnapshot.capturedAt,
|
||||||
|
),
|
||||||
|
onCopy: () => copyField(
|
||||||
|
_formatRfc3339(
|
||||||
|
messageLocationSnapshot.sourceTimestamp ??
|
||||||
|
messageLocationSnapshot.capturedAt,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
if (widget.message.lastEchoRssiDbm != null ||
|
if (widget.message.lastEchoRssiDbm != null ||
|
||||||
snrDb != null ||
|
snrDb != null ||
|
||||||
rssiDbm != null) ...[
|
rssiDbm != null) ...[
|
||||||
@@ -1333,6 +1382,53 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildLocationPreviewMap(BuildContext context, LatLng location) {
|
||||||
|
return ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: SizedBox(
|
||||||
|
height: 180,
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(
|
||||||
|
color: Theme.of(context).colorScheme.outlineVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: flutter_map.FlutterMap(
|
||||||
|
options: flutter_map.MapOptions(
|
||||||
|
initialCenter: location,
|
||||||
|
initialZoom: 15,
|
||||||
|
interactionOptions: const flutter_map.InteractionOptions(
|
||||||
|
flags: flutter_map.InteractiveFlag.none,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
children: [
|
||||||
|
flutter_map.TileLayer(
|
||||||
|
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||||
|
userAgentPackageName: 'com.meshcore.sar',
|
||||||
|
),
|
||||||
|
flutter_map.MarkerLayer(
|
||||||
|
markers: [
|
||||||
|
flutter_map.Marker(
|
||||||
|
point: location,
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
child: Icon(
|
||||||
|
widget.message.isSentMessage
|
||||||
|
? Icons.near_me
|
||||||
|
: Icons.location_on,
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
size: 32,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _techSection(
|
Widget _techSection(
|
||||||
BuildContext context, {
|
BuildContext context, {
|
||||||
required IconData icon,
|
required IconData icon,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:provider/provider.dart';
|
|||||||
|
|
||||||
import '../../l10n/app_localizations.dart';
|
import '../../l10n/app_localizations.dart';
|
||||||
import '../../models/contact.dart';
|
import '../../models/contact.dart';
|
||||||
|
import '../../providers/app_provider.dart';
|
||||||
import '../../providers/messages_provider.dart';
|
import '../../providers/messages_provider.dart';
|
||||||
import '../../utils/avatar_label_helper.dart';
|
import '../../utils/avatar_label_helper.dart';
|
||||||
import '../common/contact_avatar.dart';
|
import '../common/contact_avatar.dart';
|
||||||
@@ -259,6 +260,19 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
|
|||||||
return _formatRelativeTime(context, lastActivityAt);
|
return _formatRelativeTime(context, lastActivityAt);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
IconData _channelLocationSharingIcon(ChannelLocationSharingMode mode) {
|
||||||
|
return mode == ChannelLocationSharingMode.hardware
|
||||||
|
? Icons.public_rounded
|
||||||
|
: Icons.smartphone_rounded;
|
||||||
|
}
|
||||||
|
|
||||||
|
Color _channelLocationSharingColor(
|
||||||
|
BuildContext context,
|
||||||
|
ChannelLocationSharingMode mode,
|
||||||
|
) {
|
||||||
|
return const Color(0xFF16A34A);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildTextSubtitle(
|
Widget _buildTextSubtitle(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
Contact contact,
|
Contact contact,
|
||||||
@@ -312,6 +326,9 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
|
|||||||
MessagesProvider? messagesProvider,
|
MessagesProvider? messagesProvider,
|
||||||
) {
|
) {
|
||||||
final previewData = _channelPreviewData(context, channel, messagesProvider);
|
final previewData = _channelPreviewData(context, channel, messagesProvider);
|
||||||
|
final sharingMode = context.watch<AppProvider>().channelLocationSharingModeForChannel(
|
||||||
|
channel.publicKey.length > 1 ? channel.publicKey[1] : 0,
|
||||||
|
);
|
||||||
|
|
||||||
return _buildRecipientCard(
|
return _buildRecipientCard(
|
||||||
context: context,
|
context: context,
|
||||||
@@ -319,6 +336,7 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
|
|||||||
contact: channel,
|
contact: channel,
|
||||||
title: channel.getLocalizedDisplayName(context),
|
title: channel.getLocalizedDisplayName(context),
|
||||||
subtitle: _buildChannelSubtitle(context, channel, previewData),
|
subtitle: _buildChannelSubtitle(context, channel, previewData),
|
||||||
|
avatarMarkerMode: sharingMode,
|
||||||
unreadCount: _unreadFor(channel),
|
unreadCount: _unreadFor(channel),
|
||||||
isSelected: _isSelected('channel', channel),
|
isSelected: _isSelected('channel', channel),
|
||||||
compact: true,
|
compact: true,
|
||||||
@@ -838,6 +856,7 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
|
|||||||
required Contact contact,
|
required Contact contact,
|
||||||
required String title,
|
required String title,
|
||||||
Widget? subtitle,
|
Widget? subtitle,
|
||||||
|
ChannelLocationSharingMode? avatarMarkerMode,
|
||||||
required int unreadCount,
|
required int unreadCount,
|
||||||
required bool isSelected,
|
required bool isSelected,
|
||||||
bool compact = false,
|
bool compact = false,
|
||||||
@@ -846,6 +865,12 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
|
|||||||
}) {
|
}) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final accentColor = _sectionColor(context, type);
|
final accentColor = _sectionColor(context, type);
|
||||||
|
final markerColor = avatarMarkerMode == null
|
||||||
|
? accentColor
|
||||||
|
: _channelLocationSharingColor(context, avatarMarkerMode);
|
||||||
|
final markerIcon = avatarMarkerMode == null
|
||||||
|
? _typeIcon(contact)
|
||||||
|
: _channelLocationSharingIcon(avatarMarkerMode);
|
||||||
|
|
||||||
return Material(
|
return Material(
|
||||||
color: Colors.transparent,
|
color: Colors.transparent,
|
||||||
@@ -886,14 +911,14 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
|
|||||||
color: colorScheme.surface,
|
color: colorScheme.surface,
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: accentColor.withValues(alpha: 0.3),
|
color: markerColor.withValues(alpha: 0.3),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
child: Icon(
|
child: Icon(
|
||||||
_typeIcon(contact),
|
markerIcon,
|
||||||
size: compact ? 9 : 10,
|
size: compact ? 9 : 10,
|
||||||
color: accentColor,
|
color: markerColor,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
import 'package:geolocator/geolocator.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:meshcore_sar_app/models/contact.dart';
|
import 'package:meshcore_sar_app/models/contact.dart';
|
||||||
import 'package:meshcore_sar_app/models/message.dart';
|
import 'package:meshcore_sar_app/models/message.dart';
|
||||||
import 'package:meshcore_sar_app/models/message_contact_location.dart';
|
import 'package:meshcore_sar_app/models/message_contact_location.dart';
|
||||||
import 'package:meshcore_sar_app/providers/messages_provider.dart';
|
import 'package:meshcore_sar_app/providers/messages_provider.dart';
|
||||||
|
import 'package:meshcore_sar_app/services/location_tracking_service.dart';
|
||||||
import 'package:meshcore_sar_app/utils/image_message_parser.dart';
|
import 'package:meshcore_sar_app/utils/image_message_parser.dart';
|
||||||
import 'package:meshcore_sar_app/utils/voice_message_parser.dart';
|
import 'package:meshcore_sar_app/utils/voice_message_parser.dart';
|
||||||
import 'package:latlong2/latlong.dart';
|
import 'package:latlong2/latlong.dart';
|
||||||
@@ -39,6 +41,7 @@ void main() {
|
|||||||
group('MessagesProvider voice detection', () {
|
group('MessagesProvider voice detection', () {
|
||||||
setUp(() {
|
setUp(() {
|
||||||
SharedPreferences.setMockInitialValues({});
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
LocationTrackingService().currentPosition = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
test('marks VE3 envelope messages as voice', () {
|
test('marks VE3 envelope messages as voice', () {
|
||||||
@@ -131,6 +134,50 @@ void main() {
|
|||||||
expect(snapshot.location.longitude, closeTo(14.5058, 0.000001));
|
expect(snapshot.location.longitude, closeTo(14.5058, 0.000001));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('persists current device location for sent messages', () async {
|
||||||
|
final provider = MessagesProvider();
|
||||||
|
LocationTrackingService().currentPosition = Position(
|
||||||
|
latitude: 46.0569,
|
||||||
|
longitude: 14.5058,
|
||||||
|
timestamp: DateTime.fromMillisecondsSinceEpoch(1700000003000),
|
||||||
|
accuracy: 4.5,
|
||||||
|
altitude: 310.0,
|
||||||
|
altitudeAccuracy: 6.0,
|
||||||
|
heading: 0.0,
|
||||||
|
headingAccuracy: 0.0,
|
||||||
|
speed: 0.0,
|
||||||
|
speedAccuracy: 0.0,
|
||||||
|
);
|
||||||
|
|
||||||
|
provider.addSentMessage(
|
||||||
|
Message(
|
||||||
|
id: 'sent-1',
|
||||||
|
messageType: MessageType.contact,
|
||||||
|
pathLen: 0,
|
||||||
|
textType: MessageTextType.plain,
|
||||||
|
senderTimestamp: 1700000003,
|
||||||
|
text: 'outgoing status',
|
||||||
|
receivedAt: DateTime.now(),
|
||||||
|
senderPublicKeyPrefix: Uint8List.fromList([0, 1, 2, 3, 4, 5]),
|
||||||
|
deliveryStatus: MessageDeliveryStatus.sending,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||||
|
|
||||||
|
final restoredProvider = MessagesProvider();
|
||||||
|
await restoredProvider.initialize();
|
||||||
|
final snapshot = restoredProvider.getMessageContactLocation('sent-1');
|
||||||
|
|
||||||
|
expect(snapshot, isNotNull);
|
||||||
|
expect(snapshot!.source, equals('gps'));
|
||||||
|
expect(snapshot.location.latitude, closeTo(46.0569, 0.000001));
|
||||||
|
expect(snapshot.location.longitude, closeTo(14.5058, 0.000001));
|
||||||
|
expect(
|
||||||
|
snapshot.sourceTimestamp,
|
||||||
|
DateTime.fromMillisecondsSinceEpoch(1700000003000),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('dedupes repeated incoming contact messages with same text', () {
|
test('dedupes repeated incoming contact messages with same text', () {
|
||||||
final provider = MessagesProvider();
|
final provider = MessagesProvider();
|
||||||
final sender = Uint8List.fromList([0, 1, 2, 3, 4, 5]);
|
final sender = Uint8List.fromList([0, 1, 2, 3, 4, 5]);
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import 'package:flutter/gestures.dart';
|
import 'package:flutter/gestures.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_map/flutter_map.dart' as flutter_map;
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:meshcore_sar_app/l10n/app_localizations.dart';
|
import 'package:meshcore_sar_app/l10n/app_localizations.dart';
|
||||||
import 'package:meshcore_sar_app/models/message.dart';
|
import 'package:meshcore_sar_app/models/message.dart';
|
||||||
|
import 'package:meshcore_sar_app/models/message_contact_location.dart';
|
||||||
import 'package:meshcore_sar_app/providers/app_provider.dart';
|
import 'package:meshcore_sar_app/providers/app_provider.dart';
|
||||||
import 'package:meshcore_sar_app/providers/channels_provider.dart';
|
import 'package:meshcore_sar_app/providers/channels_provider.dart';
|
||||||
import 'package:meshcore_sar_app/providers/connection_provider.dart';
|
import 'package:meshcore_sar_app/providers/connection_provider.dart';
|
||||||
@@ -15,6 +17,7 @@ import 'package:meshcore_sar_app/providers/voice_provider.dart';
|
|||||||
import 'package:meshcore_sar_app/services/voice_codec_service.dart';
|
import 'package:meshcore_sar_app/services/voice_codec_service.dart';
|
||||||
import 'package:meshcore_sar_app/services/voice_player_service.dart';
|
import 'package:meshcore_sar_app/services/voice_player_service.dart';
|
||||||
import 'package:meshcore_sar_app/widgets/messages/message_bubble.dart';
|
import 'package:meshcore_sar_app/widgets/messages/message_bubble.dart';
|
||||||
|
import 'package:latlong2/latlong.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
@@ -363,6 +366,50 @@ void main() {
|
|||||||
await _disposeHarness(tester, harness);
|
await _disposeHarness(tester, harness);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('technical details show a location map for stored snapshots', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final harness = await _TestHarness.create();
|
||||||
|
try {
|
||||||
|
final message = Message(
|
||||||
|
id: 'message-location-details',
|
||||||
|
messageType: MessageType.contact,
|
||||||
|
senderPublicKeyPrefix: _prefix(71),
|
||||||
|
pathLen: 1,
|
||||||
|
textType: MessageTextType.plain,
|
||||||
|
senderTimestamp: 1700000000,
|
||||||
|
text: 'Location details',
|
||||||
|
receivedAt: DateTime.fromMillisecondsSinceEpoch(1700000000500),
|
||||||
|
deliveryStatus: MessageDeliveryStatus.received,
|
||||||
|
);
|
||||||
|
|
||||||
|
harness.messagesProvider.addMessage(
|
||||||
|
message,
|
||||||
|
contactLocationSnapshot: MessageContactLocation(
|
||||||
|
location: const LatLng(46.0569, 14.5058),
|
||||||
|
source: 'advert',
|
||||||
|
capturedAt: DateTime.fromMillisecondsSinceEpoch(1700000000600),
|
||||||
|
sourceTimestamp: DateTime.fromMillisecondsSinceEpoch(1700000000400),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(_buildApp(harness, message));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.longPress(find.text('Location details'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.text('Technical details'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byType(flutter_map.FlutterMap), findsOneWidget);
|
||||||
|
expect(find.text('46.056900, 14.505800'), findsOneWidget);
|
||||||
|
expect(find.text('advert'), findsOneWidget);
|
||||||
|
} finally {
|
||||||
|
await _disposeHarness(tester, harness);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildApp(_TestHarness harness, Message message) {
|
Widget _buildApp(_TestHarness harness, Message message) {
|
||||||
|
|||||||
Reference in New Issue
Block a user