Compare commits

..

9 Commits

Author SHA1 Message Date
Janez T
81944bfd3c feat: Refresh generated API snapshots #0 2026-04-05 21:48:19 +02:00
Janez T
43eb857663 Bump iOS build to 133 2026-04-05 20:07:19 +02:00
Janez T
faa396769e feat: Add buzzer alerts toggle 2026-04-05 20:02:54 +02:00
Janez T
1a35b790a7 feat: Add GPS diagnostics #0 2026-04-05 19:58:34 +02:00
Janez T
573921e002 fix: Clarify shared location labels and onboarding copy #123 2026-04-05 19:54:48 +02:00
Janez T
14a41b210e feat: Add manual TCP IP entry 2026-04-05 19:45:16 +02:00
Janez T
56a7fa2fc6 fix: Use location pin icon 2026-04-05 19:39:40 +02:00
Janez T
7f4b4c4560 feat: Add message location details 2026-04-05 19:33:41 +02:00
Janez T
7f7f4aa975 fix: Widen worker charts 2026-04-05 18:01:05 +02:00
24 changed files with 3424 additions and 2005 deletions

View File

@@ -489,7 +489,7 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = 131; CURRENT_PROJECT_VERSION = 133;
DEVELOPMENT_TEAM = JND55328G8; DEVELOPMENT_TEAM = JND55328G8;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
@@ -511,7 +511,7 @@
buildSettings = { buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)"; BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 131; CURRENT_PROJECT_VERSION = 133;
DEVELOPMENT_TEAM = JND55328G8; DEVELOPMENT_TEAM = JND55328G8;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
@@ -530,7 +530,7 @@
buildSettings = { buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)"; BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 131; CURRENT_PROJECT_VERSION = 133;
DEVELOPMENT_TEAM = JND55328G8; DEVELOPMENT_TEAM = JND55328G8;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
@@ -547,7 +547,7 @@
buildSettings = { buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)"; BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 131; CURRENT_PROJECT_VERSION = 133;
DEVELOPMENT_TEAM = JND55328G8; DEVELOPMENT_TEAM = JND55328G8;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
@@ -679,7 +679,7 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = 131; CURRENT_PROJECT_VERSION = 133;
DEVELOPMENT_TEAM = JND55328G8; DEVELOPMENT_TEAM = JND55328G8;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
@@ -702,7 +702,7 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = 131; CURRENT_PROJECT_VERSION = 133;
DEVELOPMENT_TEAM = JND55328G8; DEVELOPMENT_TEAM = JND55328G8;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;

View File

@@ -43,7 +43,7 @@
<key>CFBundleSignature</key> <key>CFBundleSignature</key>
<string>????</string> <string>????</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>131</string> <string>133</string>
<key>LSRequiresIPhoneOS</key> <key>LSRequiresIPhoneOS</key>
<true/> <true/>
<key>ITSAppUsesNonExemptEncryption</key> <key>ITSAppUsesNonExemptEncryption</key>

View File

@@ -5,22 +5,22 @@
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000669"> <testcase classname="fastlane.lanes" name="0: default_platform" time="0.00043">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.917785"> <testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.324798">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="2: build_app" time="130.084682"> <testcase classname="fastlane.lanes" name="2: build_app" time="128.448727">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="203.665249"> <testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="4490.078943">
</testcase> </testcase>

View File

@@ -2317,7 +2317,7 @@
"@wizardOverviewFeature1": { "@wizardOverviewFeature1": {
"description": "Updated onboarding overview feature 1" "description": "Updated onboarding overview feature 1"
}, },
"wizardOverviewFeature2": "Share SAR markers, map drawings, voice clips, and images over the mesh.", "wizardOverviewFeature2": "Share live location to a private channel, plus SAR markers, map drawings, voice clips, and images over the mesh.",
"@wizardOverviewFeature2": { "@wizardOverviewFeature2": {
"description": "Updated onboarding overview feature 2" "description": "Updated onboarding overview feature 2"
}, },
@@ -2458,7 +2458,7 @@
"@wizardMapOpsDescription": { "@wizardMapOpsDescription": {
"description": "Updated onboarding map page description" "description": "Updated onboarding map page description"
}, },
"wizardMapOpsFeature1": "Track your own position, teammate locations, and movement trails on the map.", "wizardMapOpsFeature1": "Track your own position, teammate locations, and share live location to a private channel from the channel menu.",
"@wizardMapOpsFeature1": { "@wizardMapOpsFeature1": {
"description": "Updated onboarding map feature 1" "description": "Updated onboarding map feature 1"
}, },

View File

@@ -3267,7 +3267,7 @@ abstract class AppLocalizations {
/// Updated onboarding overview feature 2 /// Updated onboarding overview feature 2
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Share SAR markers, map drawings, voice clips, and images over the mesh.'** /// **'Share live location to a private channel, plus SAR markers, map drawings, voice clips, and images over the mesh.'**
String get wizardOverviewFeature2; String get wizardOverviewFeature2;
/// Updated onboarding overview feature 3 /// Updated onboarding overview feature 3
@@ -3459,7 +3459,7 @@ abstract class AppLocalizations {
/// Updated onboarding map feature 1 /// Updated onboarding map feature 1
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Track your own position, teammate locations, and movement trails on the map.'** /// **'Track your own position, teammate locations, and share live location to a private channel from the channel menu.'**
String get wizardMapOpsFeature1; String get wizardMapOpsFeature1;
/// Updated onboarding map feature 2 /// Updated onboarding map feature 2

View File

@@ -1743,7 +1743,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get wizardOverviewFeature2 => String get wizardOverviewFeature2 =>
'Share SAR markers, map drawings, voice clips, and images over the mesh.'; 'Share live location to a private channel, plus SAR markers, map drawings, voice clips, and images over the mesh.';
@override @override
String get wizardOverviewFeature3 => String get wizardOverviewFeature3 =>
@@ -1860,7 +1860,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get wizardMapOpsFeature1 => String get wizardMapOpsFeature1 =>
'Track your own position, teammate locations, and movement trails on the map.'; 'Track your own position, teammate locations, and share live location to a private channel from the channel menu.';
@override @override
String get wizardMapOpsFeature2 => String get wizardMapOpsFeature2 =>

View File

@@ -15,10 +15,14 @@ class MessageContactLocation {
String get technicalSourceLabel { String get technicalSourceLabel {
switch (source) { switch (source) {
case 'shared':
return 'shared location';
case 'gps':
return 'shared location';
case 'telemetry': case 'telemetry':
return 'telemetry'; return 'live telemetry';
case 'advert': case 'advert':
return 'advert'; return 'shared advert';
default: default:
return source; return source;
} }

View File

@@ -118,6 +118,34 @@ enum ContactsTabSection {
channels, channels,
} }
enum ChannelLocationSharingMode { hardware, appFallback }
class ChannelLocationSharingState {
final ChannelLocationSharingMode mode;
final bool isSharing;
final bool hardwareSupported;
final bool isConnected;
const ChannelLocationSharingState({
required this.mode,
required this.isSharing,
required this.hardwareSupported,
required this.isConnected,
});
bool get usesHardware => mode == ChannelLocationSharingMode.hardware;
}
class ChannelLocationSharingResult {
final ChannelLocationSharingState state;
final String message;
const ChannelLocationSharingResult({
required this.state,
required this.message,
});
}
/// Main App Provider - coordinates all other providers /// Main App Provider - coordinates all other providers
class AppProvider with ChangeNotifier { class AppProvider with ChangeNotifier {
static const int _maxDirectPayloadHops = 3; static const int _maxDirectPayloadHops = 3;
@@ -219,6 +247,8 @@ class AppProvider with ChangeNotifier {
final Map<String, int> _voiceMissingRetryAttempts = {}; final Map<String, int> _voiceMissingRetryAttempts = {};
final Map<String, Timer> _imageMissingRetryTimers = {}; final Map<String, Timer> _imageMissingRetryTimers = {};
final Map<String, int> _imageMissingRetryAttempts = {}; final Map<String, int> _imageMissingRetryAttempts = {};
bool _hardwareChannelLocationSharingSupported = false;
int? _hardwareChannelLocationSharingChannelIdx;
final FragmentAckWaitRegistry _rawProbeWaiters = FragmentAckWaitRegistry(); final FragmentAckWaitRegistry _rawProbeWaiters = FragmentAckWaitRegistry();
final Map<String, Future<bool>> _pendingRawRouteProbes = {}; final Map<String, Future<bool>> _pendingRawRouteProbes = {};
final Map<String, Future<bool>> _pendingMediaSwarmFetches = {}; final Map<String, Future<bool>> _pendingMediaSwarmFetches = {};
@@ -2544,6 +2574,7 @@ class AppProvider with ChangeNotifier {
'📍 [AppProvider] Starting location tracking after successful initialization', '📍 [AppProvider] Starting location tracking after successful initialization',
); );
await _startLocationTracking(); await _startLocationTracking();
await refreshChannelLocationSharingState();
// Sync drawing messages with DrawingProvider // Sync drawing messages with DrawingProvider
// This restores any drawings that may be missing from storage // This restores any drawings that may be missing from storage
@@ -2587,6 +2618,7 @@ class AppProvider with ChangeNotifier {
await connectionProvider.syncChannels( await connectionProvider.syncChannels(
maxChannels: connectionProvider.deviceInfo.maxChannels, maxChannels: connectionProvider.deviceInfo.maxChannels,
); );
await refreshChannelLocationSharingState();
final messageCount = await connectionProvider.syncAllMessages( final messageCount = await connectionProvider.syncAllMessages(
force: true, force: true,
@@ -2744,6 +2776,229 @@ class AppProvider with ChangeNotifier {
]); ]);
} }
Future<void> refreshChannelLocationSharingState() async {
if (!connectionProvider.deviceInfo.isConnected) {
return;
}
final vars = await connectionProvider.getCustomVars();
final hardwareSupported = _supportsHardwareChannelLocationSharing(vars);
final rawChannelIdx = hardwareSupported
? int.tryParse(vars['fast_gps_channel'] ?? '')
: null;
final normalizedChannelIdx = rawChannelIdx != null && rawChannelIdx > 0
? rawChannelIdx
: null;
if (_hardwareChannelLocationSharingSupported == hardwareSupported &&
_hardwareChannelLocationSharingChannelIdx == normalizedChannelIdx) {
return;
}
_hardwareChannelLocationSharingSupported = hardwareSupported;
_hardwareChannelLocationSharingChannelIdx = normalizedChannelIdx;
notifyListeners();
}
ChannelLocationSharingMode? channelLocationSharingModeForChannel(
int channelIdx,
) {
if (channelIdx <= 0) {
return null;
}
if (_hardwareChannelLocationSharingChannelIdx == channelIdx) {
return ChannelLocationSharingMode.hardware;
}
if (_isAppFallbackLocationSharingActiveForChannel(channelIdx)) {
return ChannelLocationSharingMode.appFallback;
}
return null;
}
void notifyChannelLocationSharingChanged() {
notifyListeners();
}
bool _isAppFallbackLocationSharingActiveForChannel(int channelIdx) {
return locationTrackingService.fastLocationUpdatesEnabled &&
locationTrackingService.fastLocationChannelIdx == channelIdx;
}
bool _supportsHardwareChannelLocationSharing(Map<String, String> vars) {
return vars.containsKey('gps') && vars.containsKey('fast_gps_channel');
}
int? _hardwareChannelLocationSharingIdxFromVars(Map<String, String> vars) {
final rawChannelIdx = int.tryParse(vars['fast_gps_channel'] ?? '');
return rawChannelIdx != null && rawChannelIdx > 0 ? rawChannelIdx : null;
}
Future<void> _setDeviceCustomVarOrThrow(String key, String value) async {
connectionProvider.clearError();
await connectionProvider.setCustomVar(key, value);
final error = connectionProvider.error;
if (error != null) {
connectionProvider.clearError();
throw StateError(error);
}
}
Future<ChannelLocationSharingState> getChannelLocationSharingState(
int channelIdx,
) async {
if (channelIdx <= 0) {
return const ChannelLocationSharingState(
mode: ChannelLocationSharingMode.appFallback,
isSharing: false,
hardwareSupported: false,
isConnected: false,
);
}
if (connectionProvider.deviceInfo.isConnected) {
await refreshChannelLocationSharingState();
}
final sharingMode = channelLocationSharingModeForChannel(channelIdx);
return ChannelLocationSharingState(
mode: sharingMode ??
(_hardwareChannelLocationSharingSupported
? ChannelLocationSharingMode.hardware
: ChannelLocationSharingMode.appFallback),
isSharing: sharingMode != null,
hardwareSupported: _hardwareChannelLocationSharingSupported,
isConnected: connectionProvider.deviceInfo.isConnected,
);
}
Future<ChannelLocationSharingResult> setChannelLocationSharingEnabled(
int channelIdx,
bool enabled,
) async {
if (channelIdx <= 0) {
throw ArgumentError.value(
channelIdx,
'channelIdx',
'Location sharing requires a non-public channel',
);
}
if (!connectionProvider.deviceInfo.isConnected) {
throw StateError('Connect to a device first');
}
final vars = await connectionProvider.getCustomVars();
final hardwareSupported = _supportsHardwareChannelLocationSharing(vars);
if (enabled) {
if (hardwareSupported) {
await _setDeviceCustomVarOrThrow('gps', '1');
await _setDeviceCustomVarOrThrow(
'fast_gps_channel',
channelIdx.toString(),
);
_hardwareChannelLocationSharingSupported = true;
_hardwareChannelLocationSharingChannelIdx = channelIdx;
await locationTrackingService.updateFastLocationChannelIdx(null);
await locationTrackingService.setFastLocationUpdatesEnabled(false);
await refreshChannelLocationSharingState();
if (_hardwareChannelLocationSharingChannelIdx != channelIdx) {
throw StateError('Radio location sharing did not enable for this channel');
}
notifyListeners();
return const ChannelLocationSharingResult(
state: ChannelLocationSharingState(
mode: ChannelLocationSharingMode.hardware,
isSharing: true,
hardwareSupported: true,
isConnected: true,
),
message: 'Sharing location on this channel from the radio.',
);
}
_hardwareChannelLocationSharingSupported = false;
_hardwareChannelLocationSharingChannelIdx = null;
final previousEnabled = locationTrackingService.fastLocationUpdatesEnabled;
final previousChannelIdx = locationTrackingService.fastLocationChannelIdx;
try {
await locationTrackingService.updateFastLocationChannelIdx(channelIdx);
await locationTrackingService.setFastLocationUpdatesEnabled(true);
if (!locationTrackingService.isTracking) {
final started = await locationTrackingService.startTracking();
if (!started) {
throw StateError('Phone location tracking could not be started');
}
}
} catch (e) {
await locationTrackingService.updateFastLocationChannelIdx(
previousChannelIdx,
);
await locationTrackingService.setFastLocationUpdatesEnabled(
previousEnabled,
);
rethrow;
}
notifyListeners();
return const ChannelLocationSharingResult(
state: ChannelLocationSharingState(
mode: ChannelLocationSharingMode.appFallback,
isSharing: true,
hardwareSupported: false,
isConnected: true,
),
message: 'Sharing location on this channel from the phone.',
);
}
final activeHardwareChannelIdx = _hardwareChannelLocationSharingIdxFromVars(
vars,
);
final shouldAttemptHardwareStop =
hardwareSupported ||
_hardwareChannelLocationSharingSupported ||
activeHardwareChannelIdx == channelIdx ||
_hardwareChannelLocationSharingChannelIdx == channelIdx;
if (locationTrackingService.fastLocationChannelIdx == channelIdx) {
await locationTrackingService.updateFastLocationChannelIdx(null);
await locationTrackingService.setFastLocationUpdatesEnabled(false);
}
if (shouldAttemptHardwareStop) {
await _setDeviceCustomVarOrThrow('fast_gps_channel', '-1');
_hardwareChannelLocationSharingSupported = true;
_hardwareChannelLocationSharingChannelIdx = null;
await refreshChannelLocationSharingState();
if (_hardwareChannelLocationSharingChannelIdx == channelIdx) {
throw StateError('Radio location sharing is still enabled for this channel');
}
notifyListeners();
return const ChannelLocationSharingResult(
state: ChannelLocationSharingState(
mode: ChannelLocationSharingMode.hardware,
isSharing: false,
hardwareSupported: true,
isConnected: true,
),
message: 'Stopped sharing location on this channel.',
);
}
_hardwareChannelLocationSharingSupported = false;
_hardwareChannelLocationSharingChannelIdx = null;
notifyListeners();
return const ChannelLocationSharingResult(
state: ChannelLocationSharingState(
mode: ChannelLocationSharingMode.appFallback,
isSharing: false,
hardwareSupported: false,
isConnected: true,
),
message: 'Stopped sharing location on this channel.',
);
}
Future<void> _sendFastLocationUpdate( Future<void> _sendFastLocationUpdate(
dynamic position, { dynamic position, {
required String reason, required String reason,

View File

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

View File

@@ -516,12 +516,89 @@ class _ContactsTabState extends State<ContactsTab> {
messenger.showSnackBar(SnackBar(content: Text(copiedMessage))); messenger.showSnackBar(SnackBar(content: Text(copiedMessage)));
} }
String _locationSharingErrorMessage(Object error) {
final message = error.toString();
if (message.startsWith('Bad state: ')) {
return message.substring('Bad state: '.length);
}
return message;
}
Future<void> _setChannelLocationSharing(
BuildContext context,
Contact channel,
bool enabled,
) async {
final channelIdx = channel.publicKey.length > 1 ? channel.publicKey[1] : 0;
try {
final result = await context.read<AppProvider>().setChannelLocationSharingEnabled(
channelIdx,
enabled,
);
if (!context.mounted) return;
ToastLogger.success(context, result.message);
} catch (error) {
if (!context.mounted) return;
ToastLogger.error(context, _locationSharingErrorMessage(error));
}
}
Future<void> _handleChannelLocationSharingAction(
BuildContext context,
Contact channel,
) async {
if (channel.isPublicChannel) {
if (!context.mounted) return;
ToastLogger.error(
context,
'Select a private channel to share your location.',
);
return;
}
final channelIdx = channel.publicKey.length > 1 ? channel.publicKey[1] : 0;
if (channelIdx <= 0) {
if (!context.mounted) return;
ToastLogger.error(
context,
'Select a private channel to share your location.',
);
return;
}
try {
final sharingState = await context
.read<AppProvider>()
.getChannelLocationSharingState(channelIdx);
if (!context.mounted) return;
await _setChannelLocationSharing(context, channel, !sharingState.isSharing);
} catch (error) {
if (!context.mounted) return;
ToastLogger.error(context, _locationSharingErrorMessage(error));
}
}
void _showChannelActionSheet(BuildContext context, Contact channel) { void _showChannelActionSheet(BuildContext context, Contact channel) {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
final canExportHashChannelPsk = Channel.isHashChannelName( final canExportHashChannelPsk = Channel.isHashChannelName(
channel.advName.trim(), channel.advName.trim(),
); );
final actions = <_ChannelSheetAction>[ final channelLocationSharingFuture =
!channel.isPublicChannel && channel.publicKey.length > 1
? context
.read<AppProvider>()
.getChannelLocationSharingState(channel.publicKey[1])
: null;
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (sheetContext) {
List<_ChannelSheetAction> buildActions(
ChannelLocationSharingState? sharingState,
) {
return <_ChannelSheetAction>[
_ChannelSheetAction( _ChannelSheetAction(
icon: Icons.message_outlined, icon: Icons.message_outlined,
label: l10n.messages, label: l10n.messages,
@@ -530,6 +607,19 @@ class _ContactsTabState extends State<ContactsTab> {
await _openMessagesForChannel(context, channel); await _openMessagesForChannel(context, channel);
}, },
), ),
if (!channel.isPublicChannel)
_ChannelSheetAction(
icon: sharingState?.isSharing == true
? Icons.location_off_rounded
: Icons.share_location_rounded,
label: sharingState?.isSharing == true
? 'Stop sharing my location'
: 'Share my location',
onTap: () async {
Navigator.pop(context);
await _handleChannelLocationSharingAction(context, channel);
},
),
if (channel.displayLocation != null) if (channel.displayLocation != null)
_ChannelSheetAction( _ChannelSheetAction(
icon: Icons.map_outlined, icon: Icons.map_outlined,
@@ -573,16 +663,27 @@ class _ContactsTabState extends State<ContactsTab> {
}, },
), ),
]; ];
}
showModalBottomSheet<void>( Widget buildSheet(ChannelLocationSharingState? sharingState) {
context: context, return _ChannelActionSheet(
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (sheetContext) => _ChannelActionSheet(
channel: channel, channel: channel,
actions: actions, actions: buildActions(sharingState),
onClose: () => Navigator.pop(sheetContext), onClose: () => Navigator.pop(sheetContext),
), );
}
if (channelLocationSharingFuture == null) {
return buildSheet(null);
}
return FutureBuilder<ChannelLocationSharingState>(
future: channelLocationSharingFuture,
builder: (context, snapshot) {
return buildSheet(snapshot.data);
},
);
},
); );
} }
@@ -1820,6 +1921,27 @@ class _ChannelActivityCard extends StatelessWidget {
return l10n.daysAgo(diff.inDays); return l10n.daysAgo(diff.inDays);
} }
Widget _buildChannelLocationSharingMarker(
BuildContext context,
ChannelLocationSharingMode mode,
) {
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(
Icons.location_pin,
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 +1950,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 +1994,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(

File diff suppressed because it is too large Load Diff

View File

@@ -1746,7 +1746,85 @@ class _MessagesTabState extends State<MessagesTab> {
await action(); await action();
} }
int? _selectedPrivateChannelIdx() {
if (_destinationType !=
MessageDestinationPreferences.destinationTypeChannel) {
return null;
}
final recipient = _selectedRecipient;
if (recipient == null ||
recipient.isPublicChannel ||
recipient.publicKey.length < 2) {
return null;
}
return recipient.publicKey[1];
}
String _locationSharingErrorMessage(Object error) {
final message = error.toString();
if (message.startsWith('Bad state: ')) {
return message.substring('Bad state: '.length);
}
return message;
}
Future<void> _setSelectedChannelLocationSharing(
int channelIdx,
bool enabled,
) async {
try {
final result = await context.read<AppProvider>().setChannelLocationSharingEnabled(
channelIdx,
enabled,
);
if (!mounted) return;
ToastLogger.success(context, result.message);
} catch (error) {
if (!mounted) return;
ToastLogger.error(context, _locationSharingErrorMessage(error));
}
}
Future<void> _handleChannelLocationSharingAction() async {
final recipient = _selectedRecipient;
if (recipient == null || recipient.isPublicChannel) {
if (!mounted) return;
ToastLogger.error(
context,
'Select a private channel to share your location.',
);
return;
}
final channelIdx = recipient.publicKey.length > 1 ? recipient.publicKey[1] : 0;
if (channelIdx <= 0) {
if (!mounted) return;
ToastLogger.error(
context,
'Select a private channel to share your location.',
);
return;
}
try {
final sharingState = await context
.read<AppProvider>()
.getChannelLocationSharingState(channelIdx);
if (!mounted) return;
await _setSelectedChannelLocationSharing(channelIdx, !sharingState.isSharing);
} catch (error) {
if (!mounted) return;
ToastLogger.error(context, _locationSharingErrorMessage(error));
}
}
void _showComposerActions() { void _showComposerActions() {
final privateChannelIdx = _selectedPrivateChannelIdx();
final locationSharingStateFuture = privateChannelIdx == null
? null
: context
.read<AppProvider>()
.getChannelLocationSharingState(privateChannelIdx);
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
showDragHandle: true, showDragHandle: true,
@@ -1759,6 +1837,7 @@ class _MessagesTabState extends State<MessagesTab> {
await _runAfterSheetDismissal(sheetContext, action); await _runAfterSheetDismissal(sheetContext, action);
} }
Widget buildActionsSheet(ChannelLocationSharingState? sharingState) {
final actions = <Widget>[ final actions = <Widget>[
_ComposerActionTile( _ComposerActionTile(
icon: Icons.search_rounded, icon: Icons.search_rounded,
@@ -1776,6 +1855,20 @@ class _MessagesTabState extends State<MessagesTab> {
_showSarDialog(); _showSarDialog();
}), }),
), ),
if (_destinationType ==
MessageDestinationPreferences.destinationTypeChannel)
_ComposerActionTile(
icon: sharingState?.isSharing == true
? Icons.location_off_rounded
: Icons.share_location_rounded,
title: sharingState?.isSharing == true
? 'Stop sharing my location'
: 'Share my location',
color: const Color(0xFF0F766E),
onTap: () => runAction(() async {
await _handleChannelLocationSharingAction();
}),
),
if (_voiceSupported) if (_voiceSupported)
_ComposerActionTile( _ComposerActionTile(
icon: _isRecording ? Icons.stop_rounded : Icons.mic_rounded, icon: _isRecording ? Icons.stop_rounded : Icons.mic_rounded,
@@ -1861,13 +1954,26 @@ class _MessagesTabState extends State<MessagesTab> {
const SizedBox(height: 16), const SizedBox(height: 16),
for (var index = 0; index < actions.length; index++) ...[ for (var index = 0; index < actions.length; index++) ...[
actions[index], actions[index],
if (index != actions.length - 1) const SizedBox(height: 8), if (index != actions.length - 1)
const SizedBox(height: 8),
], ],
], ],
), ),
), ),
), ),
); );
}
if (locationSharingStateFuture == null) {
return buildActionsSheet(null);
}
return FutureBuilder<ChannelLocationSharingState>(
future: locationSharingStateFuture,
builder: (context, snapshot) {
return buildActionsSheet(snapshot.data);
},
);
}, },
); );
} }
@@ -2027,10 +2133,43 @@ 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(
Icons.location_pin,
size: 9,
color: Colors.white,
),
),
),
],
); );
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,5 @@
import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@@ -119,7 +121,9 @@ Future<bool> showConnectionDialogFlow(
/// Connection Dialog with tabs for BLE devices and Network servers /// Connection Dialog with tabs for BLE devices and Network servers
class ConnectionDialog extends StatefulWidget { class ConnectionDialog extends StatefulWidget {
const ConnectionDialog({super.key}); final NetworkScannerService? networkScanner;
const ConnectionDialog({super.key, this.networkScanner});
@override @override
State<ConnectionDialog> createState() => _ConnectionDialogState(); State<ConnectionDialog> createState() => _ConnectionDialogState();
@@ -129,7 +133,7 @@ class _ConnectionDialogState extends State<ConnectionDialog>
with SingleTickerProviderStateMixin { with SingleTickerProviderStateMixin {
late TabController _tabController; late TabController _tabController;
late final ConnectionProvider _connectionProvider; late final ConnectionProvider _connectionProvider;
final NetworkScannerService _networkScanner = NetworkScannerService(); late final NetworkScannerService _networkScanner;
final List<DiscoveredServer> _discoveredServers = []; final List<DiscoveredServer> _discoveredServers = [];
int _scannedCount = 0; int _scannedCount = 0;
int _totalToScan = 0; int _totalToScan = 0;
@@ -162,6 +166,7 @@ class _ConnectionDialogState extends State<ConnectionDialog>
context, context,
listen: false, listen: false,
); );
_networkScanner = widget.networkScanner ?? NetworkScannerService();
_networkScanner.onServerDiscovered = (server) { _networkScanner.onServerDiscovered = (server) {
if (!mounted) return; if (!mounted) return;
@@ -334,6 +339,9 @@ class _ConnectionDialogState extends State<ConnectionDialog>
required IconData icon, required IconData icon,
required String message, required String message,
required VoidCallback onRefresh, required VoidCallback onRefresh,
IconData? secondaryActionIcon,
String? secondaryActionTooltip,
VoidCallback? onSecondaryAction,
}) { }) {
final theme = Theme.of(context); final theme = Theme.of(context);
return Container( return Container(
@@ -355,6 +363,18 @@ class _ConnectionDialogState extends State<ConnectionDialog>
), ),
), ),
), ),
Row(
mainAxisSize: MainAxisSize.min,
children: [
if (secondaryActionIcon != null)
IconButton(
tooltip: secondaryActionTooltip,
icon: Icon(
secondaryActionIcon,
color: theme.colorScheme.onPrimaryContainer,
),
onPressed: onSecondaryAction,
),
IconButton( IconButton(
icon: Icon( icon: Icon(
Icons.refresh_rounded, Icons.refresh_rounded,
@@ -364,9 +384,56 @@ class _ConnectionDialogState extends State<ConnectionDialog>
), ),
], ],
), ),
],
),
); );
} }
Future<String?> _promptForManualTcpHost() async {
return showDialog<String>(
context: context,
builder: (dialogContext) => _ManualTcpHostDialog(
initialHost: _connectionProvider.tcpHost,
),
);
}
Future<void> _connectManualTcpHost() async {
final host = await _promptForManualTcpHost();
if (host == null || !mounted) {
return;
}
final serverKey = '$host:${NetworkScannerService.defaultPort}';
final connectionProvider = context.read<ConnectionProvider>();
setState(() {
_connectingToServerKey = serverKey;
});
try {
final success = await connectionProvider.connectTcp(
host,
NetworkScannerService.defaultPort,
);
if (!success) {
throw Exception(
connectionProvider.error ??
'Failed to connect to $host:${NetworkScannerService.defaultPort}',
);
}
_closeOnSuccessfulConnection();
} catch (error) {
if (!mounted) {
return;
}
setState(() {
_connectingToServerKey = null;
});
_showConnectionError(error);
}
}
Widget _buildErrorBanner(String message) { Widget _buildErrorBanner(String message) {
final theme = Theme.of(context); final theme = Theme.of(context);
return Container( return Container(
@@ -404,9 +471,12 @@ class _ConnectionDialogState extends State<ConnectionDialog>
required VoidCallback onAction, required VoidCallback onAction,
}) { }) {
final theme = Theme.of(context); final theme = Theme.of(context);
return Center( return LayoutBuilder(
child: Padding( builder: (context, constraints) => SingleChildScrollView(
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(24),
child: ConstrainedBox(
constraints: BoxConstraints(minHeight: constraints.maxHeight),
child: Center(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
@@ -442,6 +512,8 @@ class _ConnectionDialogState extends State<ConnectionDialog>
], ],
), ),
), ),
),
),
); );
} }
@@ -620,6 +692,11 @@ class _ConnectionDialogState extends State<ConnectionDialog>
message: showingCachedResults message: showingCachedResults
? 'Showing cached results. Tap refresh to rescan.' ? 'Showing cached results. Tap refresh to rescan.'
: 'Scanning local network for MeshCore WiFi devices on port 5000', : 'Scanning local network for MeshCore WiFi devices on port 5000',
secondaryActionIcon: Icons.add_rounded,
secondaryActionTooltip: 'Add IP address',
onSecondaryAction: _connectingToServerKey != null
? null
: _connectManualTcpHost,
onRefresh: _startNetworkScan, onRefresh: _startNetworkScan,
), ),
if (_networkScanner.isScanning) if (_networkScanner.isScanning)
@@ -768,6 +845,82 @@ class _ConnectionDialogState extends State<ConnectionDialog>
} }
} }
class _ManualTcpHostDialog extends StatefulWidget {
final String? initialHost;
const _ManualTcpHostDialog({this.initialHost});
@override
State<_ManualTcpHostDialog> createState() => _ManualTcpHostDialogState();
}
class _ManualTcpHostDialogState extends State<_ManualTcpHostDialog> {
late final TextEditingController _controller;
String? _errorText;
@override
void initState() {
super.initState();
_controller = TextEditingController(text: widget.initialHost);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _submit() {
final host = _controller.text.trim();
final parsedAddress = InternetAddress.tryParse(host);
if (parsedAddress == null) {
setState(() {
_errorText = 'Enter a valid IP address';
});
return;
}
Navigator.of(context).pop(parsedAddress.address);
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Connect by IP address'),
content: TextField(
controller: _controller,
autofocus: true,
keyboardType: TextInputType.url,
decoration: InputDecoration(
labelText: 'IP address',
hintText: '192.168.1.42',
helperText: 'Uses TCP port 5000',
border: const OutlineInputBorder(),
errorText: _errorText,
),
onChanged: (_) {
if (_errorText == null) {
return;
}
setState(() {
_errorText = null;
});
},
onSubmitted: (_) => _submit(),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
FilledButton(
onPressed: _submit,
child: const Text('Connect'),
),
],
);
}
}
typedef _TransportCardBuilder = typedef _TransportCardBuilder =
Widget Function({ Widget Function({
required IconData icon, required IconData icon,

View File

@@ -6,6 +6,7 @@ import 'package:geolocator/geolocator.dart';
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
import '../../models/contact.dart'; import '../../models/contact.dart';
import '../../models/room_login_state.dart'; import '../../models/room_login_state.dart';
import '../../providers/app_provider.dart';
import '../../providers/connection_provider.dart'; import '../../providers/connection_provider.dart';
import '../../providers/contacts_provider.dart'; import '../../providers/contacts_provider.dart';
import '../../providers/map_provider.dart'; import '../../providers/map_provider.dart';
@@ -66,6 +67,27 @@ class ContactTile extends StatelessWidget {
.toUpperCase(); .toUpperCase();
} }
Widget _buildChannelLocationSharingMarker(
BuildContext context,
ChannelLocationSharingMode mode,
) {
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(
Icons.location_pin,
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 +112,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 +157,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 +266,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 +419,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 +500,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 +525,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 +552,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 +634,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 +682,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 +693,27 @@ class ContactTile extends StatelessWidget {
? null ? null
: () async { : () async {
final toggled = contact.toggleFavourite(); final toggled = contact.toggleFavourite();
final connectionProvider = context.read<ConnectionProvider>(); final connectionProvider =
context.read<ConnectionProvider>();
await connectionProvider.addOrUpdateContact(toggled); await connectionProvider.addOrUpdateContact(toggled);
if (context.mounted) { if (context.mounted) {
await connectionProvider.getContact(contact.publicKey); await connectionProvider.getContact(contact.publicKey);
} }
}, },
), );
}
if (channelLocationSharingFuture == null) {
return buildSheet(null);
}
return FutureBuilder<ChannelLocationSharingState>(
future: channelLocationSharingFuture,
builder: (context, snapshot) {
return buildSheet(snapshot.data);
},
);
},
); );
} }

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
import 'package:flutter_map/flutter_map.dart' as flutter_map;
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
import 'package:share_plus/share_plus.dart'; import 'package:share_plus/share_plus.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@@ -21,7 +22,9 @@ import '../../providers/image_provider.dart' as ip;
import '../../providers/app_provider.dart'; import '../../providers/app_provider.dart';
import '../drawing_minimap_preview.dart'; import '../drawing_minimap_preview.dart';
import '../../models/ble_packet_log.dart'; import '../../models/ble_packet_log.dart';
import '../../models/message_contact_location.dart';
import '../../services/sar_template_service.dart'; import '../../services/sar_template_service.dart';
import '../../services/location_tracking_service.dart';
import '../../utils/toast_logger.dart'; import '../../utils/toast_logger.dart';
import '../../utils/sar_message_parser.dart'; import '../../utils/sar_message_parser.dart';
import '../../utils/key_comparison.dart'; import '../../utils/key_comparison.dart';
@@ -420,6 +423,8 @@ class _MessageBubbleState extends State<MessageBubble> {
} }
void _showMessageOptions(BuildContext context) { void _showMessageOptions(BuildContext context) {
final parentContext = context;
final l10n = AppLocalizations.of(context)!;
final connectionProvider = context.read<ConnectionProvider>(); final connectionProvider = context.read<ConnectionProvider>();
final selfPublicKey = connectionProvider.deviceInfo.publicKey; final selfPublicKey = connectionProvider.deviceInfo.publicKey;
final isOwnMessage = final isOwnMessage =
@@ -432,7 +437,7 @@ class _MessageBubbleState extends State<MessageBubble> {
shape: const RoundedRectangleBorder( shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)), borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
), ),
builder: (context) => Padding( builder: (sheetContext) => Padding(
padding: const EdgeInsets.symmetric(vertical: 16), padding: const EdgeInsets.symmetric(vertical: 16),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@@ -440,29 +445,29 @@ class _MessageBubbleState extends State<MessageBubble> {
if (!isOwnMessage && widget.onReply != null) if (!isOwnMessage && widget.onReply != null)
ListTile( ListTile(
leading: Icon(Icons.reply), leading: Icon(Icons.reply),
title: Text(AppLocalizations.of(context)!.reply), title: Text(l10n.reply),
onTap: () { onTap: () {
Navigator.pop(context); Navigator.pop(sheetContext);
widget.onReply?.call(); widget.onReply?.call();
}, },
), ),
// Copy text option // Copy text option
ListTile( ListTile(
leading: Icon(Icons.copy), leading: Icon(Icons.copy),
title: Text(AppLocalizations.of(context)!.copyText), title: Text(l10n.copyText),
onTap: () { onTap: () {
Clipboard.setData(ClipboardData(text: widget.message.text)); Clipboard.setData(ClipboardData(text: widget.message.text));
Navigator.pop(context); Navigator.pop(sheetContext);
ToastLogger.success( ToastLogger.success(
context, parentContext,
AppLocalizations.of(context)!.textCopiedToClipboard, l10n.textCopiedToClipboard,
); );
}, },
), ),
// Save as Template option (only for SAR markers without existing template) // Save as Template option (only for SAR markers without existing template)
if (widget.message.isSarMarker) if (widget.message.isSarMarker)
Builder( Builder(
builder: (context) { builder: (_) {
// Extract emoji from SAR message // Extract emoji from SAR message
final sarInfo = SarMessageParser.parse(widget.message.text); final sarInfo = SarMessageParser.parse(widget.message.text);
if (sarInfo == null || sarInfo.emoji.isEmpty) { if (sarInfo == null || sarInfo.emoji.isEmpty) {
@@ -481,10 +486,10 @@ class _MessageBubbleState extends State<MessageBubble> {
return ListTile( return ListTile(
leading: Icon(Icons.bookmark_add), leading: Icon(Icons.bookmark_add),
title: Text(AppLocalizations.of(context)!.saveAsTemplate), title: Text(l10n.saveAsTemplate),
onTap: () { onTap: () {
Navigator.pop(context); Navigator.pop(sheetContext);
_saveAsTemplate(context); _saveAsTemplate(parentContext);
}, },
); );
}, },
@@ -494,49 +499,49 @@ class _MessageBubbleState extends State<MessageBubble> {
widget.message.sarGpsCoordinates != null) widget.message.sarGpsCoordinates != null)
ListTile( ListTile(
leading: Icon(Icons.share_location), leading: Icon(Icons.share_location),
title: Text(AppLocalizations.of(context)!.shareLocation), title: Text(l10n.shareLocation),
onTap: () { onTap: () {
Navigator.pop(context); Navigator.pop(sheetContext);
_shareLocation(context); _shareLocation(parentContext);
}, },
), ),
// Navigate to drawing option (only for drawing messages) // Navigate to drawing option (only for drawing messages)
if (widget.message.isDrawing && widget.message.drawingId != null) if (widget.message.isDrawing && widget.message.drawingId != null)
ListTile( ListTile(
leading: Icon(Icons.map), leading: Icon(Icons.map),
title: Text(AppLocalizations.of(context)!.navigateToDrawing), title: Text(l10n.navigateToDrawing),
onTap: () { onTap: () {
Navigator.pop(context); Navigator.pop(sheetContext);
_navigateToDrawing(context); _navigateToDrawing(parentContext);
}, },
), ),
// Copy coordinates option (only for drawing messages) // Copy coordinates option (only for drawing messages)
if (widget.message.isDrawing && widget.message.drawingId != null) if (widget.message.isDrawing && widget.message.drawingId != null)
ListTile( ListTile(
leading: Icon(Icons.copy), leading: Icon(Icons.copy),
title: Text(AppLocalizations.of(context)!.copyCoordinates), title: Text(l10n.copyCoordinates),
onTap: () { onTap: () {
Navigator.pop(context); Navigator.pop(sheetContext);
_copyDrawingCoordinates(context); _copyDrawingCoordinates(parentContext);
}, },
), ),
// Hide from map option (only for drawing messages) // Hide from map option (only for drawing messages)
if (widget.message.isDrawing && widget.message.drawingId != null) if (widget.message.isDrawing && widget.message.drawingId != null)
ListTile( ListTile(
leading: Icon(Icons.visibility_off), leading: Icon(Icons.visibility_off),
title: Text(AppLocalizations.of(context)!.hideFromMap), title: Text(l10n.hideFromMap),
onTap: () { onTap: () {
Navigator.pop(context); Navigator.pop(sheetContext);
_hideDrawingFromMap(context); _hideDrawingFromMap(parentContext);
}, },
), ),
// Technical details option // Technical details option
ListTile( ListTile(
leading: Icon(Icons.data_object), leading: Icon(Icons.data_object),
title: Text(AppLocalizations.of(context)!.technicalDetails), title: Text(l10n.technicalDetails),
onTap: () { onTap: () {
Navigator.pop(context); Navigator.pop(sheetContext);
_showTechnicalDetails(context); _showTechnicalDetails(parentContext);
}, },
), ),
if (!isOwnMessage && if (!isOwnMessage &&
@@ -544,22 +549,22 @@ class _MessageBubbleState extends State<MessageBubble> {
widget.message.pathLen < 255) widget.message.pathLen < 255)
ListTile( ListTile(
leading: Icon(Icons.route), leading: Icon(Icons.route),
title: Text(AppLocalizations.of(context)!.trace), title: Text(l10n.trace),
onTap: () { onTap: () {
Navigator.pop(context); Navigator.pop(sheetContext);
_showTraceSheet(context); _showTraceSheet(parentContext);
}, },
), ),
// Delete message option // Delete message option
ListTile( ListTile(
leading: Icon(Icons.delete, color: Colors.red), leading: Icon(Icons.delete, color: Colors.red),
title: Text( title: Text(
AppLocalizations.of(context)!.delete, l10n.delete,
style: const TextStyle(color: Colors.red), style: const TextStyle(color: Colors.red),
), ),
onTap: () { onTap: () {
Navigator.pop(context); Navigator.pop(sheetContext);
_showDeleteConfirmation(context); _showDeleteConfirmation(parentContext);
}, },
), ),
], ],
@@ -581,6 +586,7 @@ class _MessageBubbleState extends State<MessageBubble> {
} }
void _showTechnicalDetails(BuildContext context) { void _showTechnicalDetails(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final connectionProvider = context.read<ConnectionProvider>(); final connectionProvider = context.read<ConnectionProvider>();
final radioBw = connectionProvider.deviceInfo.radioBw; final radioBw = connectionProvider.deviceInfo.radioBw;
final radioSf = connectionProvider.deviceInfo.radioSf; final radioSf = connectionProvider.deviceInfo.radioSf;
@@ -595,6 +601,7 @@ class _MessageBubbleState extends State<MessageBubble> {
widget.message.isFromSelf(selfPublicKey); widget.message.isFromSelf(selfPublicKey);
String? senderName; String? senderName;
Contact? senderContact;
if (widget.message.senderPublicKeyPrefix != null) { if (widget.message.senderPublicKeyPrefix != null) {
final senderKeyHex = widget.message.senderPublicKeyPrefix! final senderKeyHex = widget.message.senderPublicKeyPrefix!
.sublist( .sublist(
@@ -605,7 +612,7 @@ class _MessageBubbleState extends State<MessageBubble> {
) )
.map((b) => b.toRadixString(16).padLeft(2, '0')) .map((b) => b.toRadixString(16).padLeft(2, '0'))
.join(''); .join('');
final senderContact = contactsProvider.contacts senderContact = contactsProvider.contacts
.where((c) => c.publicKeyHex.startsWith(senderKeyHex)) .where((c) => c.publicKeyHex.startsWith(senderKeyHex))
.firstOrNull; .firstOrNull;
senderName = senderContact?.advName; senderName = senderContact?.advName;
@@ -628,7 +635,7 @@ class _MessageBubbleState extends State<MessageBubble> {
recipientName = recipientContact?.advName; recipientName = recipientContact?.advName;
} }
final senderLocationSnapshot = messagesProvider.getMessageContactLocation( final storedLocationSnapshot = messagesProvider.getMessageContactLocation(
widget.message.id, widget.message.id,
); );
final receptionDetails = messagesProvider.getMessageReceptionDetails( final receptionDetails = messagesProvider.getMessageReceptionDetails(
@@ -709,6 +716,12 @@ class _MessageBubbleState extends State<MessageBubble> {
final routeMetadata = context final routeMetadata = context
.read<MessagesProvider>() .read<MessagesProvider>()
.getMessageRouteMetadata(widget.message.id); .getMessageRouteMetadata(widget.message.id);
final messageLocationSnapshot = _resolveDetailsLocationSnapshot(
storedSnapshot: storedLocationSnapshot,
senderContact: senderContact,
connectionProvider: connectionProvider,
isOwnMessage: isOwnMessage,
);
final rawLines = <String>[ final rawLines = <String>[
'Message ID: ${widget.message.id}', 'Message ID: ${widget.message.id}',
@@ -742,9 +755,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}',
@@ -833,12 +846,9 @@ class _MessageBubbleState extends State<MessageBubble> {
} }
} }
void copyField(String value) { void copyField(BuildContext feedbackContext, String value) {
Clipboard.setData(ClipboardData(text: value)); Clipboard.setData(ClipboardData(text: value));
ToastLogger.success( ToastLogger.success(feedbackContext, l10n.textCopiedToClipboard);
context,
AppLocalizations.of(context)!.textCopiedToClipboard,
);
} }
showModalBottomSheet( showModalBottomSheet(
@@ -860,14 +870,14 @@ class _MessageBubbleState extends State<MessageBubble> {
children: [ children: [
Expanded( Expanded(
child: Text( child: Text(
AppLocalizations.of(context)!.messageTechnicalDetails, l10n.messageTechnicalDetails,
style: Theme.of(sheetContext).textTheme.titleLarge, style: Theme.of(sheetContext).textTheme.titleLarge,
), ),
), ),
IconButton( IconButton(
onPressed: () => Navigator.pop(sheetContext), onPressed: () => Navigator.pop(sheetContext),
icon: Icon(Icons.close), icon: Icon(Icons.close),
tooltip: AppLocalizations.of(context)!.close, tooltip: l10n.close,
), ),
], ],
), ),
@@ -907,6 +917,54 @@ class _MessageBubbleState extends State<MessageBubble> {
), ),
], ],
), ),
if (messageLocationSnapshot != null) ...[
const SizedBox(height: 12),
_techSection(
sheetContext,
icon: Icons.location_on,
title: l10n.location,
child: Column(
children: [
_buildLocationPreviewMap(
sheetContext,
messageLocationSnapshot.location,
),
const SizedBox(height: 12),
_detailRow(
sheetContext,
label: l10n.coordinates,
value: messageLocationSnapshot
.formattedCoordinates,
onCopy: () => copyField(
sheetContext,
messageLocationSnapshot.formattedCoordinates,
),
),
_detailRow(
sheetContext,
label: l10n.source,
value: messageLocationSnapshot
.technicalSourceLabel,
),
_detailRow(
sheetContext,
label: l10n.captured,
value: _formatRfc3339(
messageLocationSnapshot.sourceTimestamp ??
messageLocationSnapshot.capturedAt,
),
onCopy: () => copyField(
sheetContext,
_formatRfc3339(
messageLocationSnapshot.sourceTimestamp ??
messageLocationSnapshot.capturedAt,
),
),
),
],
),
),
],
if (widget.message.lastEchoRssiDbm != null || if (widget.message.lastEchoRssiDbm != null ||
snrDb != null || snrDb != null ||
rssiDbm != null) ...[ rssiDbm != null) ...[
@@ -914,13 +972,13 @@ class _MessageBubbleState extends State<MessageBubble> {
_techSection( _techSection(
sheetContext, sheetContext,
icon: Icons.network_check, icon: Icons.network_check,
title: AppLocalizations.of(context)!.linkQuality, title: l10n.linkQuality,
child: Column( child: Column(
children: [ children: [
if (rssiDbm != null) if (rssiDbm != null)
_signalRow( _signalRow(
sheetContext, sheetContext,
label: AppLocalizations.of(context)!.rssi, label: l10n.rssi,
valueLabel: '$rssiDbm dBm', valueLabel: '$rssiDbm dBm',
normalized: normalized:
((rssiDbm.toDouble() + 120.0) / 70.0) ((rssiDbm.toDouble() + 120.0) / 70.0)
@@ -935,7 +993,7 @@ class _MessageBubbleState extends State<MessageBubble> {
SizedBox(height: 8), SizedBox(height: 8),
_signalRow( _signalRow(
sheetContext, sheetContext,
label: AppLocalizations.of(context)!.snr, label: l10n.snr,
valueLabel: '${snrDb.toStringAsFixed(1)} dB', valueLabel: '${snrDb.toStringAsFixed(1)} dB',
normalized: ((snrDb + 20.0) / 40.0).clamp( normalized: ((snrDb + 20.0) / 40.0).clamp(
0.0, 0.0,
@@ -956,39 +1014,34 @@ class _MessageBubbleState extends State<MessageBubble> {
_techSection( _techSection(
sheetContext, sheetContext,
icon: Icons.tune, icon: Icons.tune,
title: AppLocalizations.of(context)!.delivery, title: l10n.delivery,
child: Column( child: Column(
children: [ children: [
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of(context)!.status, label: l10n.status,
value: widget.message.deliveryStatus.name, value: widget.message.deliveryStatus.name,
), ),
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of( label: l10n.receivedRfc3339,
context,
)!.receivedRfc3339,
value: _formatRfc3339(widget.message.receivedAt), value: _formatRfc3339(widget.message.receivedAt),
onCopy: () => copyField( onCopy: () => copyField(
sheetContext,
_formatRfc3339(widget.message.receivedAt), _formatRfc3339(widget.message.receivedAt),
), ),
), ),
if (widget.message.expectedAckTag != null) if (widget.message.expectedAckTag != null)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of( label: l10n.expectedAckTag,
context,
)!.expectedAckTag,
value: widget.message.expectedAckTag! value: widget.message.expectedAckTag!
.toString(), .toString(),
), ),
if (receptionDetails?.senderToReceiptMs != null) if (receptionDetails?.senderToReceiptMs != null)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of( label: l10n.senderToReceipt,
context,
)!.senderToReceipt,
value: _formatDurationMs( value: _formatDurationMs(
receptionDetails!.senderToReceiptMs!, receptionDetails!.senderToReceiptMs!,
), ),
@@ -996,9 +1049,7 @@ class _MessageBubbleState extends State<MessageBubble> {
if (receptionDetails?.estimatedTransmitMs != null) if (receptionDetails?.estimatedTransmitMs != null)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of( label: l10n.estimatedTx,
context,
)!.estimatedTx,
value: _formatDurationMs( value: _formatDurationMs(
receptionDetails!.estimatedTransmitMs!, receptionDetails!.estimatedTransmitMs!,
), ),
@@ -1006,9 +1057,7 @@ class _MessageBubbleState extends State<MessageBubble> {
if (receptionDetails?.postTransmitDelayMs != null) if (receptionDetails?.postTransmitDelayMs != null)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of( label: l10n.posttxDelay,
context,
)!.posttxDelay,
value: _formatDurationMs( value: _formatDurationMs(
receptionDetails!.postTransmitDelayMs!, receptionDetails!.postTransmitDelayMs!,
), ),
@@ -1016,87 +1065,80 @@ class _MessageBubbleState extends State<MessageBubble> {
if (widget.receivedCopies > 1) if (widget.receivedCopies > 1)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of( label: l10n.receivedCopies,
context,
)!.receivedCopies,
value: '${widget.receivedCopies}', value: '${widget.receivedCopies}',
), ),
if (widget.message.suggestedTimeoutMs != null) if (widget.message.suggestedTimeoutMs != null)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of(context)!.ackTimeout, label: l10n.ackTimeout,
value: value:
'${widget.message.suggestedTimeoutMs} ms', '${widget.message.suggestedTimeoutMs} ms',
), ),
if (retryCause != null) if (retryCause != null)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of(context)!.retryCause, label: l10n.retryCause,
value: retryCause, value: retryCause,
), ),
if (retryMode != null) if (retryMode != null)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of(context)!.retryMode, label: l10n.retryMode,
value: retryMode, value: retryMode,
), ),
if (widget.message.roundTripTimeMs != null) if (widget.message.roundTripTimeMs != null)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of(context)!.roundTrip, label: l10n.roundTrip,
value: '${widget.message.roundTripTimeMs} ms', value: '${widget.message.roundTripTimeMs} ms',
), ),
if (widget.message.retryAttempt > 0) if (widget.message.retryAttempt > 0)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of( label: l10n.retryAttempt,
context,
)!.retryAttempt,
value: '${widget.message.retryAttempt}/4', value: '${widget.message.retryAttempt}/4',
), ),
if (widget.message.lastRetryAt != null) if (widget.message.lastRetryAt != null)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of(context)!.lastRetry, label: l10n.lastRetry,
value: _formatRfc3339( value: _formatRfc3339(
widget.message.lastRetryAt!, widget.message.lastRetryAt!,
), ),
onCopy: () => copyField( onCopy: () => copyField(
sheetContext,
_formatRfc3339(widget.message.lastRetryAt!), _formatRfc3339(widget.message.lastRetryAt!),
), ),
), ),
if (widget.message.usedFloodFallback) if (widget.message.usedFloodFallback)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of( label: l10n.floodFallback,
context, value: l10n.yes,
)!.floodFallback,
value: AppLocalizations.of(context)!.yes,
), ),
if (routeMetadata?.canonicalPath if (routeMetadata?.canonicalPath
case final routePath?) case final routePath?)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of( label: l10n.selectedPath,
context,
)!.selectedPath,
value: routePath, value: routePath,
onCopy: () => copyField(routePath), onCopy: () =>
copyField(sheetContext, routePath),
), ),
if (retryResult != null) if (retryResult != null)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of( label: l10n.retryResult,
context,
)!.retryResult,
value: retryResult, value: retryResult,
), ),
if (packetPathHex != null) if (packetPathHex != null)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of(context)!.pathBytes, label: l10n.pathBytes,
value: packetPathHex, value: packetPathHex,
onCopy: () => copyField(packetPathHex), onCopy: () =>
copyField(sheetContext, packetPathHex),
), ),
], ],
), ),
@@ -1105,18 +1147,19 @@ class _MessageBubbleState extends State<MessageBubble> {
_techSection( _techSection(
sheetContext, sheetContext,
icon: Icons.badge, icon: Icons.badge,
title: AppLocalizations.of(context)!.identity, title: l10n.identity,
child: Column( child: Column(
children: [ children: [
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of(context)!.messageId, label: l10n.messageId,
value: widget.message.id, value: widget.message.id,
onCopy: () => copyField(widget.message.id), onCopy: () =>
copyField(sheetContext, widget.message.id),
), ),
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of(context)!.sender, label: l10n.sender,
value: value:
senderName ?? senderName ??
widget.message.senderName ?? widget.message.senderName ??
@@ -1125,24 +1168,26 @@ class _MessageBubbleState extends State<MessageBubble> {
if (senderPrefixHex != null) if (senderPrefixHex != null)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of(context)!.senderKey, label: l10n.senderKey,
value: senderPrefixHex, value: senderPrefixHex,
onCopy: () => copyField(senderPrefixHex), onCopy: () =>
copyField(sheetContext, senderPrefixHex),
), ),
if (recipientName != null) if (recipientName != null)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of(context)!.recipient, label: l10n.recipient,
value: recipientName, value: recipientName,
), ),
if (recipientPrefixHex != null) if (recipientPrefixHex != null)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of( label: l10n.recipientKey,
context,
)!.recipientKey,
value: recipientPrefixHex, value: recipientPrefixHex,
onCopy: () => copyField(recipientPrefixHex), onCopy: () => copyField(
sheetContext,
recipientPrefixHex,
),
), ),
], ],
), ),
@@ -1152,53 +1197,47 @@ class _MessageBubbleState extends State<MessageBubble> {
_techSection( _techSection(
sheetContext, sheetContext,
icon: Icons.graphic_eq, icon: Icons.graphic_eq,
title: AppLocalizations.of(context)!.voice, title: l10n.voice,
child: Column( child: Column(
children: [ children: [
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of(context)!.voiceId, label: l10n.voiceId,
value: widget.message.voiceId ?? '-', value: widget.message.voiceId ?? '-',
), ),
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of(context)!.envelope, label: l10n.envelope,
value: envelope != null value: envelope != null
? 'VE3 compact' ? 'VE3 compact'
: AppLocalizations.of(context)!.unknown, : l10n.unknown,
), ),
if (voiceSession != null) if (voiceSession != null)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of( label: l10n.sessionProgress,
context,
)!.sessionProgress,
value: value:
'${voiceSession.receivedCount}/${voiceSession.total} segments', '${voiceSession.receivedCount}/${voiceSession.total} segments',
), ),
if (voiceSession != null) if (voiceSession != null)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of(context)!.complete, label: l10n.complete,
value: voiceSession.isComplete value: voiceSession.isComplete
? AppLocalizations.of(context)!.yes ? l10n.yes
: AppLocalizations.of(context)!.no, : l10n.no,
), ),
if (transferDetails != null) if (transferDetails != null)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of( label: l10n.transfers,
context,
)!.transfers,
value: '${transferDetails.totalTransfers}', value: '${transferDetails.totalTransfers}',
), ),
if (transferDetails != null && if (transferDetails != null &&
transferDetails.downloaders.isNotEmpty) transferDetails.downloaders.isNotEmpty)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of( label: l10n.downloadedBy,
context,
)!.downloadedBy,
value: _formatDownloaderSummary( value: _formatDownloaderSummary(
transferDetails, transferDetails,
), ),
@@ -1206,9 +1245,7 @@ class _MessageBubbleState extends State<MessageBubble> {
if (voiceTxEstimate > Duration.zero) if (voiceTxEstimate > Duration.zero)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of( label: l10n.estimatedTx,
context,
)!.estimatedTx,
value: voiceTxEstimate.inSeconds < 60 value: voiceTxEstimate.inSeconds < 60
? '~${voiceTxEstimate.inSeconds}s' ? '~${voiceTxEstimate.inSeconds}s'
: '~${voiceTxEstimate.inMinutes}m ${voiceTxEstimate.inSeconds % 60}s', : '~${voiceTxEstimate.inMinutes}m ${voiceTxEstimate.inSeconds % 60}s',
@@ -1222,28 +1259,28 @@ class _MessageBubbleState extends State<MessageBubble> {
_techSection( _techSection(
sheetContext, sheetContext,
icon: Icons.image_outlined, icon: Icons.image_outlined,
title: AppLocalizations.of(context)!.image, title: l10n.image,
child: Column( child: Column(
children: [ children: [
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of(context)!.envelope, label: l10n.envelope,
value: 'IE1', value: 'IE1',
), ),
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of(context)!.format, label: l10n.format,
value: imageEnvelope.format.label, value: imageEnvelope.format.label,
), ),
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of(context)!.dimensions, label: l10n.dimensions,
value: value:
'${imageEnvelope.width}×${imageEnvelope.height}', '${imageEnvelope.width}×${imageEnvelope.height}',
), ),
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of(context)!.segments, label: l10n.segments,
value: imageSession != null value: imageSession != null
? '${imageSession.receivedCount}/${imageSession.total}' ? '${imageSession.receivedCount}/${imageSession.total}'
: '${imageEnvelope.total}', : '${imageEnvelope.total}',
@@ -1251,26 +1288,22 @@ class _MessageBubbleState extends State<MessageBubble> {
if (imageSession != null) if (imageSession != null)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of(context)!.complete, label: l10n.complete,
value: imageSession.isComplete value: imageSession.isComplete
? AppLocalizations.of(context)!.yes ? l10n.yes
: AppLocalizations.of(context)!.no, : l10n.no,
), ),
if (transferDetails != null) if (transferDetails != null)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of( label: l10n.transfers,
context,
)!.transfers,
value: '${transferDetails.totalTransfers}', value: '${transferDetails.totalTransfers}',
), ),
if (transferDetails != null && if (transferDetails != null &&
transferDetails.downloaders.isNotEmpty) transferDetails.downloaders.isNotEmpty)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of( label: l10n.downloadedBy,
context,
)!.downloadedBy,
value: _formatDownloaderSummary( value: _formatDownloaderSummary(
transferDetails, transferDetails,
), ),
@@ -1278,9 +1311,7 @@ class _MessageBubbleState extends State<MessageBubble> {
if (imageTxEstimate > Duration.zero) if (imageTxEstimate > Duration.zero)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of( label: l10n.estimatedTx,
context,
)!.estimatedTx,
value: imageTxEstimate.inSeconds < 60 value: imageTxEstimate.inSeconds < 60
? '~${imageTxEstimate.inSeconds}s' ? '~${imageTxEstimate.inSeconds}s'
: '~${imageTxEstimate.inMinutes}m ${imageTxEstimate.inSeconds % 60}s', : '~${imageTxEstimate.inMinutes}m ${imageTxEstimate.inSeconds % 60}s',
@@ -1295,7 +1326,7 @@ class _MessageBubbleState extends State<MessageBubble> {
dense: true, dense: true,
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
title: Text( title: Text(
AppLocalizations.of(context)!.rawDump, l10n.rawDump,
style: const TextStyle( style: const TextStyle(
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
@@ -1333,6 +1364,108 @@ 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,
),
),
],
),
],
),
),
),
);
}
MessageContactLocation? _resolveDetailsLocationSnapshot({
required MessageContactLocation? storedSnapshot,
required Contact? senderContact,
required ConnectionProvider connectionProvider,
required bool isOwnMessage,
}) {
if (storedSnapshot != null) {
return storedSnapshot;
}
if (isOwnMessage) {
final advLat = connectionProvider.deviceInfo.advLat;
final advLon = connectionProvider.deviceInfo.advLon;
if (advLat != null &&
advLon != null &&
(advLat != 0 || advLon != 0)) {
return MessageContactLocation(
location: LatLng(advLat / 1e6, advLon / 1e6),
source: 'shared',
capturedAt: widget.message.sentAt,
sourceTimestamp: widget.message.sentAt,
);
}
final currentPosition = LocationTrackingService().currentPosition;
if (currentPosition != null) {
return MessageContactLocation(
location: LatLng(
currentPosition.latitude,
currentPosition.longitude,
),
source: 'shared',
capturedAt: currentPosition.timestamp,
sourceTimestamp: currentPosition.timestamp,
);
}
}
final contactLocation = senderContact?.displayLocation;
if (contactLocation != null) {
final source =
senderContact?.telemetry?.gpsLocation != null
? 'telemetry'
: 'advert';
return MessageContactLocation(
location: contactLocation,
source: source,
capturedAt: senderContact?.locationUpdateTime ?? widget.message.receivedAt,
sourceTimestamp: senderContact?.locationUpdateTime,
);
}
return null;
}
Widget _techSection( Widget _techSection(
BuildContext context, { BuildContext context, {
required IconData icon, required IconData icon,

View File

@@ -3,6 +3,7 @@ import 'package:provider/provider.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
import '../../models/contact.dart'; import '../../models/contact.dart';
import '../../providers/app_provider.dart';
import '../../providers/messages_provider.dart'; import '../../providers/messages_provider.dart';
import '../../utils/avatar_label_helper.dart'; import '../../utils/avatar_label_helper.dart';
import '../common/contact_avatar.dart'; import '../common/contact_avatar.dart';
@@ -259,6 +260,21 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
return _formatRelativeTime(context, lastActivityAt); return _formatRelativeTime(context, lastActivityAt);
} }
IconData _channelLocationSharingIcon(ChannelLocationSharingMode mode) {
switch (mode) {
case ChannelLocationSharingMode.hardware:
case ChannelLocationSharingMode.appFallback:
return Icons.location_pin;
}
}
Color _channelLocationSharingColor(
BuildContext context,
ChannelLocationSharingMode mode,
) {
return const Color(0xFF16A34A);
}
Widget _buildTextSubtitle( Widget _buildTextSubtitle(
BuildContext context, BuildContext context,
Contact contact, Contact contact,
@@ -312,6 +328,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 +338,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 +858,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 +867,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 +913,14 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
color: colorScheme.surface, color: colorScheme.surface,
shape: BoxShape.circle, shape: BoxShape.circle,
border: Border.all( border: Border.all(
color: accentColor.withValues(alpha: 0.3), color: markerColor.withValues(alpha: 0.3),
), ),
), ),
alignment: Alignment.center, alignment: Alignment.center,
child: Icon( child: Icon(
_typeIcon(contact), markerIcon,
size: compact ? 9 : 10, size: compact ? 9 : 10,
color: accentColor, color: markerColor,
), ),
), ),
), ),

View File

@@ -11,62 +11,47 @@ class TrafficStatsReportingSection extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
SwitchListTile( SwitchListTile(
secondary: const Icon(Icons.cloud_upload_outlined), dense: true,
title: const Text('Anonymous RX stats reporting'), secondary: const Icon(Icons.cloud_upload_outlined, size: 20),
title: const Text('Anonymous RX stats'),
subtitle: const Text( subtitle: const Text(
'Upload RX live-traffic packet type and path mode totals to the fixed Cloudflare worker every 5 minutes.', 'Upload packet totals every 5 min',
), ),
value: service.isEnabled, value: service.isEnabled,
onChanged: (value) async { onChanged: (value) async {
await service.setEnabled(value); await service.setEnabled(value);
}, },
), ),
if (service.isEnabled)
Padding( Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), padding: const EdgeInsets.fromLTRB(16, 4, 16, 8),
child: DecoratedBox( child: Row(
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: theme.colorScheme.outlineVariant.withValues(alpha: 0.5),
),
),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Expanded(
'Upload status', child: Text(
style: theme.textTheme.titleSmall,
),
const SizedBox(height: 8),
Text(
_statusText(service), _statusText(service),
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 6),
Text(
'Ingest URL: ${TrafficStatsReportingService.ingestUri}',
style: theme.textTheme.bodySmall?.copyWith( style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant, color: colorScheme.onSurfaceVariant,
),
), ),
), ),
const SizedBox(height: 8),
TextButton.icon( TextButton.icon(
onPressed: _openStatsDashboard, onPressed: _openStatsDashboard,
icon: const Icon(Icons.open_in_new), icon: const Icon(Icons.open_in_new, size: 16),
label: const Text('View public stats'), label: const Text('View'),
style: TextButton.styleFrom(
visualDensity: VisualDensity.compact,
textStyle: theme.textTheme.labelSmall,
),
), ),
], ],
), ),
), ),
),
),
], ],
); );
} }
@@ -79,21 +64,15 @@ class TrafficStatsReportingSection extends StatelessWidget {
} }
static String _statusText(TrafficStatsReportingService service) { static String _statusText(TrafficStatsReportingService service) {
final buffer = StringBuffer(); final parts = <String>[];
buffer.write('Pending uploads: ${service.pendingUploadCount}'); parts.add('Pending: ${service.pendingUploadCount}');
if (service.lastSuccessAt != null) { if (service.lastSuccessAt != null) {
buffer.write( parts.add('Sent: ${_formatDateTime(service.lastSuccessAt!.toLocal())}');
'\nLast sent: ${_formatDateTime(service.lastSuccessAt!.toLocal())}',
);
} else {
buffer.write('\nLast sent: Never');
} }
if (service.lastError != null && service.lastError!.isNotEmpty) { if (service.lastError != null && service.lastError!.isNotEmpty) {
buffer.write('\nLast error: ${service.lastError}'); parts.add('Error: ${service.lastError}');
} else {
buffer.write('\nLast error: None');
} }
return buffer.toString(); return parts.join(' · ');
} }
static String _formatDateTime(DateTime value) { static String _formatDateTime(DateTime value) {
@@ -101,7 +80,6 @@ class TrafficStatsReportingSection extends StatelessWidget {
final day = value.day.toString().padLeft(2, '0'); final day = value.day.toString().padLeft(2, '0');
final hour = value.hour.toString().padLeft(2, '0'); final hour = value.hour.toString().padLeft(2, '0');
final minute = value.minute.toString().padLeft(2, '0'); final minute = value.minute.toString().padLeft(2, '0');
final second = value.second.toString().padLeft(2, '0'); return '${value.year}-$month-$day $hour:$minute';
return '${value.year}-$month-$day $hour:$minute:$second';
} }
} }

View File

@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts # In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix. # of the product and file versions while build-number is used as the build suffix.
version: 2026.0403.1+45 version: 2026.0405.1+47
environment: environment:
sdk: ^3.9.2 sdk: ^3.9.2

View File

@@ -1,9 +1,11 @@
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:geolocator/geolocator.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/models/contact.dart'; import 'package:meshcore_sar_app/models/contact.dart';
import 'package:meshcore_sar_app/models/message.dart'; import 'package:meshcore_sar_app/models/message.dart';
import 'package:meshcore_sar_app/models/message_contact_location.dart'; import 'package:meshcore_sar_app/models/message_contact_location.dart';
import 'package:meshcore_sar_app/providers/messages_provider.dart'; import 'package:meshcore_sar_app/providers/messages_provider.dart';
import 'package:meshcore_sar_app/services/location_tracking_service.dart';
import 'package:meshcore_sar_app/utils/image_message_parser.dart'; import 'package:meshcore_sar_app/utils/image_message_parser.dart';
import 'package:meshcore_sar_app/utils/voice_message_parser.dart'; import 'package:meshcore_sar_app/utils/voice_message_parser.dart';
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
@@ -39,6 +41,7 @@ void main() {
group('MessagesProvider voice detection', () { group('MessagesProvider voice detection', () {
setUp(() { setUp(() {
SharedPreferences.setMockInitialValues({}); SharedPreferences.setMockInitialValues({});
LocationTrackingService().currentPosition = null;
}); });
test('marks VE3 envelope messages as voice', () { test('marks VE3 envelope messages as voice', () {
@@ -131,6 +134,50 @@ void main() {
expect(snapshot.location.longitude, closeTo(14.5058, 0.000001)); expect(snapshot.location.longitude, closeTo(14.5058, 0.000001));
}); });
test('persists current device location for sent messages', () async {
final provider = MessagesProvider();
LocationTrackingService().currentPosition = Position(
latitude: 46.0569,
longitude: 14.5058,
timestamp: DateTime.fromMillisecondsSinceEpoch(1700000003000),
accuracy: 4.5,
altitude: 310.0,
altitudeAccuracy: 6.0,
heading: 0.0,
headingAccuracy: 0.0,
speed: 0.0,
speedAccuracy: 0.0,
);
provider.addSentMessage(
Message(
id: 'sent-1',
messageType: MessageType.contact,
pathLen: 0,
textType: MessageTextType.plain,
senderTimestamp: 1700000003,
text: 'outgoing status',
receivedAt: DateTime.now(),
senderPublicKeyPrefix: Uint8List.fromList([0, 1, 2, 3, 4, 5]),
deliveryStatus: MessageDeliveryStatus.sending,
),
);
await Future<void>.delayed(const Duration(milliseconds: 50));
final restoredProvider = MessagesProvider();
await restoredProvider.initialize();
final snapshot = restoredProvider.getMessageContactLocation('sent-1');
expect(snapshot, isNotNull);
expect(snapshot!.source, equals('gps'));
expect(snapshot.location.latitude, closeTo(46.0569, 0.000001));
expect(snapshot.location.longitude, closeTo(14.5058, 0.000001));
expect(
snapshot.sourceTimestamp,
DateTime.fromMillisecondsSinceEpoch(1700000003000),
);
});
test('dedupes repeated incoming contact messages with same text', () { test('dedupes repeated incoming contact messages with same text', () {
final provider = MessagesProvider(); final provider = MessagesProvider();
final sender = Uint8List.fromList([0, 1, 2, 3, 4, 5]); final sender = Uint8List.fromList([0, 1, 2, 3, 4, 5]);

View File

@@ -3,6 +3,7 @@ import 'package:flutter_blue_plus/flutter_blue_plus.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/providers/connection_provider.dart'; import 'package:meshcore_sar_app/providers/connection_provider.dart';
import 'package:meshcore_sar_app/services/network_scanner_service.dart';
import 'package:meshcore_sar_app/widgets/connection_dialog.dart'; import 'package:meshcore_sar_app/widgets/connection_dialog.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@@ -53,6 +54,51 @@ class _ConnectableFakeConnectionProvider extends ConnectionProvider {
} }
} }
class _TcpConnectableFakeConnectionProvider extends ConnectionProvider {
int connectTcpCalls = 0;
String? connectedHost;
int? connectedPort;
@override
List<ScannedDevice> get scannedDevices => const [];
@override
String? get error => null;
@override
Future<bool> connectTcp(String host, int port) async {
connectTcpCalls += 1;
connectedHost = host;
connectedPort = port;
return true;
}
}
class _FakeNetworkScannerService extends NetworkScannerService {
int scanCalls = 0;
@override
bool get isScanning => false;
@override
bool get hasCachedResults => false;
@override
List<DiscoveredServer> get cachedServers => const [];
@override
Future<List<DiscoveredServer>> scan({int? port}) async {
scanCalls += 1;
return const [];
}
@override
void clearCache() {}
@override
void stopScan() {}
}
void main() { void main() {
testWidgets('BLE scan waits for explicit user action', (tester) async { testWidgets('BLE scan waits for explicit user action', (tester) async {
final connectionProvider = _FakeConnectionProvider(); final connectionProvider = _FakeConnectionProvider();
@@ -126,4 +172,59 @@ void main() {
expect(connectionProvider.connectCalls, 1); expect(connectionProvider.connectCalls, 1);
expect(find.byType(ConnectionDialog), findsNothing); expect(find.byType(ConnectionDialog), findsNothing);
}); });
testWidgets('manual TCP connect accepts an IP address from the network tab', (
tester,
) async {
final connectionProvider = _TcpConnectableFakeConnectionProvider();
final networkScanner = _FakeNetworkScannerService();
await tester.pumpWidget(
ChangeNotifierProvider<ConnectionProvider>.value(
value: connectionProvider,
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Builder(
builder: (context) => Scaffold(
body: Center(
child: FilledButton(
onPressed: () {
showModalBottomSheet<Object?>(
context: context,
isScrollControlled: true,
builder: (_) => ConnectionDialog(
networkScanner: networkScanner,
),
);
},
child: const Text('Open'),
),
),
),
),
),
),
);
await tester.tap(find.text('Open'));
await tester.pumpAndSettle();
await tester.tap(find.text('Network'));
await tester.pumpAndSettle();
expect(networkScanner.scanCalls, 1);
await tester.tap(find.byTooltip('Add IP address'));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), '192.168.1.42');
await tester.tap(find.widgetWithText(FilledButton, 'Connect'));
await tester.pumpAndSettle();
expect(connectionProvider.connectTcpCalls, 1);
expect(connectionProvider.connectedHost, '192.168.1.42');
expect(connectionProvider.connectedPort, NetworkScannerService.defaultPort);
expect(find.byType(ConnectionDialog), findsNothing);
});
} }

View File

@@ -1,9 +1,12 @@
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:geolocator/geolocator.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';
@@ -12,9 +15,11 @@ import 'package:meshcore_sar_app/providers/drawing_provider.dart';
import 'package:meshcore_sar_app/providers/image_provider.dart' as ip; import 'package:meshcore_sar_app/providers/image_provider.dart' as ip;
import 'package:meshcore_sar_app/providers/messages_provider.dart'; import 'package:meshcore_sar_app/providers/messages_provider.dart';
import 'package:meshcore_sar_app/providers/voice_provider.dart'; import 'package:meshcore_sar_app/providers/voice_provider.dart';
import 'package:meshcore_sar_app/services/location_tracking_service.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';
@@ -25,6 +30,7 @@ void main() {
setUp(() { setUp(() {
SharedPreferences.setMockInitialValues({}); SharedPreferences.setMockInitialValues({});
LocationTrackingService().currentPosition = null;
launchedUrls.clear(); launchedUrls.clear();
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler( .setMockMethodCallHandler(
@@ -363,6 +369,99 @@ 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('shared advert'), findsOneWidget);
} finally {
await _disposeHarness(tester, harness);
}
});
testWidgets(
'technical details show a location map for sent channel messages using live position fallback',
(tester) async {
final harness = await _TestHarness.create();
try {
LocationTrackingService().currentPosition = Position(
latitude: 46.0569,
longitude: 14.5058,
timestamp: DateTime.fromMillisecondsSinceEpoch(1700000003000),
accuracy: 6.0,
altitude: 300.0,
altitudeAccuracy: 6.0,
heading: 0.0,
headingAccuracy: 0.0,
speed: 0.0,
speedAccuracy: 0.0,
);
final message = Message(
id: 'channel-live-fallback',
messageType: MessageType.channel,
senderPublicKeyPrefix: _prefix(81),
channelIdx: 0,
pathLen: 0,
textType: MessageTextType.plain,
senderTimestamp: 1700000000,
text: 'Channel fallback',
receivedAt: DateTime.fromMillisecondsSinceEpoch(1700000000500),
deliveryStatus: MessageDeliveryStatus.sent,
);
await tester.pumpWidget(_buildApp(harness, message));
await tester.pumpAndSettle();
await tester.longPress(find.text('Channel fallback'));
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('shared location'), findsOneWidget);
} finally {
await _disposeHarness(tester, harness);
}
},
);
} }
Widget _buildApp(_TestHarness harness, Message message) { Widget _buildApp(_TestHarness harness, Message message) {

View File

@@ -450,15 +450,40 @@ export function DashboardShell() {
// --- Composition stacked area chart --- // --- Composition stacked area chart ---
const MIN_CHART_W = 320;
const COMPOSITION_CHART_H = 236;
function useResponsiveChartWidth(minWidth = MIN_CHART_W) {
const [element, setElement] = useState<HTMLDivElement | null>(null);
const [width, setWidth] = useState(minWidth);
useEffect(() => {
if (!element) return;
const sync = () => setWidth(Math.max(element.clientWidth, minWidth));
sync();
if (typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(sync);
observer.observe(element);
return () => { observer.disconnect(); };
}, [element, minWidth]);
return { setElement, width };
}
function CompositionChart({ points }: { points: CompositionPoint[] }) { function CompositionChart({ points }: { points: CompositionPoint[] }) {
const [hover, setHover] = useState<number | null>(null); const [hover, setHover] = useState<number | null>(null);
const { setElement, width } = useResponsiveChartWidth();
const count = points.length; const count = points.length;
if (count === 0) return null; if (count === 0) return null;
const maxVal = Math.max(...points.map((p) => p.human + p.overhead), 1); const maxVal = Math.max(...points.map((p) => p.human + p.overhead), 1);
const innerW = 100;
const innerH = 188;
const pad = { top: 16, right: 16, bottom: 32, left: 48 }; const pad = { top: 16, right: 16, bottom: 32, left: 48 };
const innerW = Math.max(width - pad.left - pad.right, 240);
const innerH = COMPOSITION_CHART_H - pad.top - pad.bottom;
const xs = points.map((_, i) => i / Math.max(count - 1, 1)); const xs = points.map((_, i) => i / Math.max(count - 1, 1));
@@ -473,11 +498,10 @@ function CompositionChart({ points }: { points: CompositionPoint[] }) {
const gridLines = niceGridLines(maxVal, 3); const gridLines = niceGridLines(maxVal, 3);
return ( return (
<div className="relative select-none"> <div ref={setElement} className="relative select-none">
<svg <svg
viewBox={`${-pad.left} ${-pad.top} ${innerW + pad.left + pad.right} ${innerH + pad.top + pad.bottom}`} viewBox={`${-pad.left} ${-pad.top} ${innerW + pad.left + pad.right} ${innerH + pad.top + pad.bottom}`}
className="h-auto max-h-[280px] w-full" className="h-[236px] w-full"
preserveAspectRatio="xMidYMid meet"
onMouseLeave={() => setHover(null)} onMouseLeave={() => setHover(null)}
> >
<defs> <defs>
@@ -569,6 +593,7 @@ const CHART_PAD = { top: 20, right: 16, bottom: 32, left: 48 };
function TrafficChart({ points, maxValue: _rawMax }: { points: ChartPoint[]; maxValue: number }) { function TrafficChart({ points, maxValue: _rawMax }: { points: ChartPoint[]; maxValue: number }) {
const [hover, setHover] = useState<number | null>(null); const [hover, setHover] = useState<number | null>(null);
const { setElement, width } = useResponsiveChartWidth();
const count = points.length; const count = points.length;
if (count === 0) return null; if (count === 0) return null;
@@ -579,7 +604,7 @@ function TrafficChart({ points, maxValue: _rawMax }: { points: ChartPoint[]; max
})); }));
const maxValue = Math.max(...normalized.map((p) => p.perNode), 1); const maxValue = Math.max(...normalized.map((p) => p.perNode), 1);
const innerW = 100; const innerW = Math.max(width - CHART_PAD.left - CHART_PAD.right, 240);
const innerH = CHART_H - CHART_PAD.top - CHART_PAD.bottom; const innerH = CHART_H - CHART_PAD.top - CHART_PAD.bottom;
const peakIdx = normalized.reduce((best, p, i) => (p.perNode > normalized[best].perNode ? i : best), 0); const peakIdx = normalized.reduce((best, p, i) => (p.perNode > normalized[best].perNode ? i : best), 0);
@@ -599,11 +624,10 @@ function TrafficChart({ points, maxValue: _rawMax }: { points: ChartPoint[]; max
.filter((_, i) => i % labelStep === 0 || i === count - 1); .filter((_, i) => i % labelStep === 0 || i === count - 1);
return ( return (
<div className="relative select-none"> <div ref={setElement} className="relative select-none">
<svg <svg
viewBox={`${-CHART_PAD.left} ${-CHART_PAD.top} ${innerW + CHART_PAD.left + CHART_PAD.right} ${CHART_H}`} viewBox={`${-CHART_PAD.left} ${-CHART_PAD.top} ${innerW + CHART_PAD.left + CHART_PAD.right} ${CHART_H}`}
className="h-auto max-h-[280px] w-full" className="h-[240px] w-full"
preserveAspectRatio="xMidYMid meet"
onMouseLeave={() => setHover(null)} onMouseLeave={() => setHover(null)}
> >
<defs> <defs>

View File

@@ -20,7 +20,7 @@ import "../index.css";
<slot /> <slot />
<script> <script>
const mq = window.matchMedia("(prefers-color-scheme: dark)"); const mq = window.matchMedia("(prefers-color-scheme: dark)");
function apply(e) { document.documentElement.classList.toggle("dark", e.matches); } function apply(e: MediaQueryList | MediaQueryListEvent) { document.documentElement.classList.toggle("dark", e.matches); }
apply(mq); apply(mq);
mq.addEventListener("change", apply); mq.addEventListener("change", apply);
</script> </script>